diff options
Diffstat (limited to '.venv/lib/python3.12/site-packages/pptx/oxml/table.py')
-rw-r--r-- | .venv/lib/python3.12/site-packages/pptx/oxml/table.py | 588 |
1 files changed, 588 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/pptx/oxml/table.py b/.venv/lib/python3.12/site-packages/pptx/oxml/table.py new file mode 100644 index 00000000..cd3e9ebc --- /dev/null +++ b/.venv/lib/python3.12/site-packages/pptx/oxml/table.py @@ -0,0 +1,588 @@ +"""Custom element classes for table-related XML elements""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, Iterator, cast + +from pptx.enum.text import MSO_VERTICAL_ANCHOR +from pptx.oxml import parse_xml +from pptx.oxml.dml.fill import CT_GradientFillProperties +from pptx.oxml.ns import nsdecls +from pptx.oxml.simpletypes import ST_Coordinate, ST_Coordinate32, XsdBoolean, XsdInt +from pptx.oxml.text import CT_TextBody +from pptx.oxml.xmlchemy import ( + BaseOxmlElement, + Choice, + OneAndOnlyOne, + OptionalAttribute, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, + ZeroOrOneChoice, +) +from pptx.util import Emu, lazyproperty + +if TYPE_CHECKING: + from pptx.util import Length + + +class CT_Table(BaseOxmlElement): + """`a:tbl` custom element class""" + + get_or_add_tblPr: Callable[[], CT_TableProperties] + tr_lst: list[CT_TableRow] + _add_tr: Callable[..., CT_TableRow] + + _tag_seq = ("a:tblPr", "a:tblGrid", "a:tr") + tblPr: CT_TableProperties | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:tblPr", successors=_tag_seq[1:] + ) + tblGrid: CT_TableGrid = OneAndOnlyOne("a:tblGrid") # pyright: ignore[reportAssignmentType] + tr = ZeroOrMore("a:tr", successors=_tag_seq[3:]) + del _tag_seq + + def add_tr(self, height: Length) -> CT_TableRow: + """Return a newly created `a:tr` child element having its `h` attribute set to `height`.""" + return self._add_tr(h=height) + + @property + def bandCol(self) -> bool: + return self._get_boolean_property("bandCol") + + @bandCol.setter + def bandCol(self, value: bool): + self._set_boolean_property("bandCol", value) + + @property + def bandRow(self) -> bool: + return self._get_boolean_property("bandRow") + + @bandRow.setter + def bandRow(self, value: bool): + self._set_boolean_property("bandRow", value) + + @property + def firstCol(self) -> bool: + return self._get_boolean_property("firstCol") + + @firstCol.setter + def firstCol(self, value: bool): + self._set_boolean_property("firstCol", value) + + @property + def firstRow(self) -> bool: + return self._get_boolean_property("firstRow") + + @firstRow.setter + def firstRow(self, value: bool): + self._set_boolean_property("firstRow", value) + + def iter_tcs(self) -> Iterator[CT_TableCell]: + """Generate each `a:tc` element in this tbl. + + `a:tc` elements are generated left-to-right, top-to-bottom. + """ + return (tc for tr in self.tr_lst for tc in tr.tc_lst) + + @property + def lastCol(self) -> bool: + return self._get_boolean_property("lastCol") + + @lastCol.setter + def lastCol(self, value: bool): + self._set_boolean_property("lastCol", value) + + @property + def lastRow(self) -> bool: + return self._get_boolean_property("lastRow") + + @lastRow.setter + def lastRow(self, value: bool): + self._set_boolean_property("lastRow", value) + + @classmethod + def new_tbl( + cls, rows: int, cols: int, width: int, height: int, tableStyleId: str | None = None + ) -> CT_Table: + """Return a new `p:tbl` element tree.""" + # working hypothesis is this is the default table style GUID + if tableStyleId is None: + tableStyleId = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" + + xml = cls._tbl_tmpl() % (tableStyleId) + tbl = cast(CT_Table, parse_xml(xml)) + + # add specified number of rows and columns + rowheight = height // rows + colwidth = width // cols + + for col in range(cols): + # adjust width of last col to absorb any div error + if col == cols - 1: + colwidth = width - ((cols - 1) * colwidth) + tbl.tblGrid.add_gridCol(width=Emu(colwidth)) + + for row in range(rows): + # adjust height of last row to absorb any div error + if row == rows - 1: + rowheight = height - ((rows - 1) * rowheight) + tr = tbl.add_tr(height=Emu(rowheight)) + for col in range(cols): + tr.add_tc() + + return tbl + + def tc(self, row_idx: int, col_idx: int) -> CT_TableCell: + """Return `a:tc` element at `row_idx`, `col_idx`.""" + return self.tr_lst[row_idx].tc_lst[col_idx] + + def _get_boolean_property(self, propname: str) -> bool: + """Generalized getter for the boolean properties on the `a:tblPr` child element. + + Defaults to False if `propname` attribute is missing or `a:tblPr` element itself is not + present. + """ + tblPr = self.tblPr + if tblPr is None: + return False + propval = getattr(tblPr, propname) + return {True: True, False: False, None: False}[propval] + + def _set_boolean_property(self, propname: str, value: bool) -> None: + """Generalized setter for boolean properties on the `a:tblPr` child element. + + Sets `propname` attribute appropriately based on `value`. If `value` is True, the + attribute is set to "1"; a tblPr child element is added if necessary. If `value` is False, + the `propname` attribute is removed if present, allowing its default value of False to be + its effective value. + """ + if value not in (True, False): + raise ValueError("assigned value must be either True or False, got %s" % value) + tblPr = self.get_or_add_tblPr() + setattr(tblPr, propname, value) + + @classmethod + def _tbl_tmpl(cls): + return ( + "<a:tbl %s>\n" + ' <a:tblPr firstRow="1" bandRow="1">\n' + " <a:tableStyleId>%s</a:tableStyleId>\n" + " </a:tblPr>\n" + " <a:tblGrid/>\n" + "</a:tbl>" % (nsdecls("a"), "%s") + ) + + +class CT_TableCell(BaseOxmlElement): + """`a:tc` custom element class""" + + get_or_add_tcPr: Callable[[], CT_TableCellProperties] + get_or_add_txBody: Callable[[], CT_TextBody] + + _tag_seq = ("a:txBody", "a:tcPr", "a:extLst") + txBody: CT_TextBody | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:txBody", successors=_tag_seq[1:] + ) + tcPr: CT_TableCellProperties | None = ZeroOrOne( # pyright: ignore[reportAssignmentType] + "a:tcPr", successors=_tag_seq[2:] + ) + del _tag_seq + + gridSpan: int = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "gridSpan", XsdInt, default=1 + ) + rowSpan: int = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "rowSpan", XsdInt, default=1 + ) + hMerge: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "hMerge", XsdBoolean, default=False + ) + vMerge: bool = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "vMerge", XsdBoolean, default=False + ) + + @property + def anchor(self) -> MSO_VERTICAL_ANCHOR | None: + """String held in `anchor` attribute of `a:tcPr` child element of this `a:tc` element.""" + if self.tcPr is None: + return None + return self.tcPr.anchor + + @anchor.setter + def anchor(self, anchor_enum_idx: MSO_VERTICAL_ANCHOR | None): + """Set value of anchor attribute on `a:tcPr` child element.""" + if anchor_enum_idx is None and self.tcPr is None: + return + tcPr = self.get_or_add_tcPr() + tcPr.anchor = anchor_enum_idx + + def append_ps_from(self, spanned_tc: CT_TableCell): + """Append `a:p` elements taken from `spanned_tc`. + + Any non-empty paragraph elements in `spanned_tc` are removed and appended to the + text-frame of this cell. If `spanned_tc` is left with no content after this process, a + single empty `a:p` element is added to ensure the cell is compliant with the spec. + """ + source_txBody = spanned_tc.get_or_add_txBody() + target_txBody = self.get_or_add_txBody() + + # ---if source is empty, there's nothing to do--- + if source_txBody.is_empty: + return + + # ---a single empty paragraph in target is overwritten--- + if target_txBody.is_empty: + target_txBody.clear_content() + + for p in source_txBody.p_lst: + target_txBody.append(p) + + # ---neither source nor target can be left without ps--- + source_txBody.unclear_content() + target_txBody.unclear_content() + + @property + def col_idx(self) -> int: + """Offset of this cell's column in its table.""" + # ---tc elements come before any others in `a:tr` element--- + return cast(CT_TableRow, self.getparent()).index(self) + + @property + def is_merge_origin(self) -> bool: + """True if cell is top-left in merged cell range.""" + if self.gridSpan > 1 and not self.vMerge: + return True + return self.rowSpan > 1 and not self.hMerge + + @property + def is_spanned(self) -> bool: + """True if cell is in merged cell range but not merge origin cell.""" + return self.hMerge or self.vMerge + + @property + def marT(self) -> Length: + """Top margin for this cell. + + This value is stored in the `marT` attribute of the `a:tcPr` child element of this `a:tc`. + + Read/write. If the attribute is not present, the default value `45720` (0.05 inches) is + returned for top and bottom; `91440` (0.10 inches) is the default for left and right. + Assigning |None| to any `marX` property clears that attribute from the element, + effectively setting it to the default value. + """ + return self._get_marX("marT", Emu(45720)) + + @marT.setter + def marT(self, value: Length | None): + self._set_marX("marT", value) + + @property + def marR(self) -> Length: + """Right margin value represented in `marR` attribute.""" + return self._get_marX("marR", Emu(91440)) + + @marR.setter + def marR(self, value: Length | None): + self._set_marX("marR", value) + + @property + def marB(self) -> Length: + """Bottom margin value represented in `marB` attribute.""" + return self._get_marX("marB", Emu(45720)) + + @marB.setter + def marB(self, value: Length | None): + self._set_marX("marB", value) + + @property + def marL(self) -> Length: + """Left margin value represented in `marL` attribute.""" + return self._get_marX("marL", Emu(91440)) + + @marL.setter + def marL(self, value: Length | None): + self._set_marX("marL", value) + + @classmethod + def new(cls) -> CT_TableCell: + """Return a new `a:tc` element subtree.""" + return cast( + CT_TableCell, + parse_xml( + f"<a:tc {nsdecls('a')}>\n" + f" <a:txBody>\n" + f" <a:bodyPr/>\n" + f" <a:lstStyle/>\n" + f" <a:p/>\n" + f" </a:txBody>\n" + f" <a:tcPr/>\n" + f"</a:tc>" + ), + ) + + @property + def row_idx(self) -> int: + """Offset of this cell's row in its table.""" + return cast(CT_TableRow, self.getparent()).row_idx + + @property + def tbl(self) -> CT_Table: + """Table element this cell belongs to.""" + return cast(CT_Table, self.xpath("ancestor::a:tbl")[0]) + + @property + def text(self) -> str: # pyright: ignore[reportIncompatibleMethodOverride] + """str text contained in cell""" + # ---note this shadows lxml _Element.text--- + txBody = self.txBody + if txBody is None: + return "" + return "\n".join([p.text for p in txBody.p_lst]) + + def _get_marX(self, attr_name: str, default: Length) -> Length: + """Generalized method to get margin values.""" + if self.tcPr is None: + return Emu(default) + return Emu(int(self.tcPr.get(attr_name, default))) + + def _new_txBody(self) -> CT_TextBody: + return CT_TextBody.new_a_txBody() + + def _set_marX(self, marX: str, value: Length | None) -> None: + """Set value of marX attribute on `a:tcPr` child element. + + If `marX` is |None|, the marX attribute is removed. `marX` is a string, one of `('marL', + 'marR', 'marT', 'marB')`. + """ + if value is None and self.tcPr is None: + return + tcPr = self.get_or_add_tcPr() + setattr(tcPr, marX, value) + + +class CT_TableCellProperties(BaseOxmlElement): + """`a:tcPr` custom element class""" + + eg_fillProperties = ZeroOrOneChoice( + ( + Choice("a:noFill"), + Choice("a:solidFill"), + Choice("a:gradFill"), + Choice("a:blipFill"), + Choice("a:pattFill"), + Choice("a:grpFill"), + ), + successors=("a:headers", "a:extLst"), + ) + anchor: MSO_VERTICAL_ANCHOR | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "anchor", MSO_VERTICAL_ANCHOR + ) + marL: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "marL", ST_Coordinate32 + ) + marR: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "marR", ST_Coordinate32 + ) + marT: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "marT", ST_Coordinate32 + ) + marB: Length | None = OptionalAttribute( # pyright: ignore[reportAssignmentType] + "marB", ST_Coordinate32 + ) + + def _new_gradFill(self): + return CT_GradientFillProperties.new_gradFill() + + +class CT_TableCol(BaseOxmlElement): + """`a:gridCol` custom element class.""" + + w: Length = RequiredAttribute("w", ST_Coordinate) # pyright: ignore[reportAssignmentType] + + +class CT_TableGrid(BaseOxmlElement): + """`a:tblGrid` custom element class.""" + + gridCol_lst: list[CT_TableCol] + _add_gridCol: Callable[..., CT_TableCol] + + gridCol = ZeroOrMore("a:gridCol") + + def add_gridCol(self, width: Length) -> CT_TableCol: + """A newly appended `a:gridCol` child element having its `w` attribute set to `width`.""" + return self._add_gridCol(w=width) + + +class CT_TableProperties(BaseOxmlElement): + """`a:tblPr` custom element class.""" + + bandRow = OptionalAttribute("bandRow", XsdBoolean, default=False) + bandCol = OptionalAttribute("bandCol", XsdBoolean, default=False) + firstRow = OptionalAttribute("firstRow", XsdBoolean, default=False) + firstCol = OptionalAttribute("firstCol", XsdBoolean, default=False) + lastRow = OptionalAttribute("lastRow", XsdBoolean, default=False) + lastCol = OptionalAttribute("lastCol", XsdBoolean, default=False) + + +class CT_TableRow(BaseOxmlElement): + """`a:tr` custom element class.""" + + tc_lst: list[CT_TableCell] + _add_tc: Callable[[], CT_TableCell] + + tc = ZeroOrMore("a:tc", successors=("a:extLst",)) + h: Length = RequiredAttribute("h", ST_Coordinate) # pyright: ignore[reportAssignmentType] + + def add_tc(self) -> CT_TableCell: + """A newly added minimal valid `a:tc` child element.""" + return self._add_tc() + + @property + def row_idx(self) -> int: + """Offset of this row in its table.""" + return cast(CT_Table, self.getparent()).tr_lst.index(self) + + def _new_tc(self): + return CT_TableCell.new() + + +class TcRange(object): + """A 2D block of `a:tc` cell elements in a table. + + This object assumes the structure of the underlying table does not change during its lifetime. + Structural changes in this context would be insertion or removal of rows or columns. + + The client is expected to create, use, and then abandon an instance in the context of a single + user operation that is known to have no structural side-effects of this type. + """ + + def __init__(self, tc: CT_TableCell, other_tc: CT_TableCell): + self._tc = tc + self._other_tc = other_tc + + @classmethod + def from_merge_origin(cls, tc: CT_TableCell): + """Return instance created from merge-origin tc element.""" + other_tc = tc.tbl.tc( + tc.row_idx + tc.rowSpan - 1, # ---other_row_idx + tc.col_idx + tc.gridSpan - 1, # ---other_col_idx + ) + return cls(tc, other_tc) + + @lazyproperty + def contains_merged_cell(self) -> bool: + """True if one or more cells in range are part of a merged cell.""" + for tc in self.iter_tcs(): + if tc.gridSpan > 1: + return True + if tc.rowSpan > 1: + return True + if tc.hMerge: + return True + if tc.vMerge: + return True + return False + + @lazyproperty + def dimensions(self) -> tuple[int, int]: + """(row_count, col_count) pair describing size of range.""" + _, _, width, height = self._extents + return height, width + + @lazyproperty + def in_same_table(self): + """True if both cells provided to constructor are in same table.""" + if self._tc.tbl is self._other_tc.tbl: + return True + return False + + def iter_except_left_col_tcs(self): + """Generate each `a:tc` element not in leftmost column of range.""" + for tr in self._tbl.tr_lst[self._top : self._bottom]: + for tc in tr.tc_lst[self._left + 1 : self._right]: + yield tc + + def iter_except_top_row_tcs(self): + """Generate each `a:tc` element in non-first rows of range.""" + for tr in self._tbl.tr_lst[self._top + 1 : self._bottom]: + for tc in tr.tc_lst[self._left : self._right]: + yield tc + + def iter_left_col_tcs(self): + """Generate each `a:tc` element in leftmost column of range.""" + col_idx = self._left + for tr in self._tbl.tr_lst[self._top : self._bottom]: + yield tr.tc_lst[col_idx] + + def iter_tcs(self): + """Generate each `a:tc` element in this range. + + Cell elements are generated left-to-right, top-to-bottom. + """ + return ( + tc + for tr in self._tbl.tr_lst[self._top : self._bottom] + for tc in tr.tc_lst[self._left : self._right] + ) + + def iter_top_row_tcs(self): + """Generate each `a:tc` element in topmost row of range.""" + tr = self._tbl.tr_lst[self._top] + for tc in tr.tc_lst[self._left : self._right]: + yield tc + + def move_content_to_origin(self): + """Move all paragraphs in range to origin cell.""" + tcs = list(self.iter_tcs()) + origin_tc = tcs[0] + for spanned_tc in tcs[1:]: + origin_tc.append_ps_from(spanned_tc) + + @lazyproperty + def _bottom(self): + """Index of row following last row of range""" + _, top, _, height = self._extents + return top + height + + @lazyproperty + def _extents(self) -> tuple[int, int, int, int]: + """A (left, top, width, height) tuple describing range extents. + + Note this is normalized to accommodate the various orderings of the corner cells provided + on construction, which may be in any of four configurations such as (top-left, + bottom-right), (bottom-left, top-right), etc. + """ + + def start_and_size(idx: int, other_idx: int) -> tuple[int, int]: + """Return beginning and length of range based on two indexes.""" + return min(idx, other_idx), abs(idx - other_idx) + 1 + + tc, other_tc = self._tc, self._other_tc + + left, width = start_and_size(tc.col_idx, other_tc.col_idx) + top, height = start_and_size(tc.row_idx, other_tc.row_idx) + + return left, top, width, height + + @lazyproperty + def _left(self): + """Index of leftmost column in range.""" + left, _, _, _ = self._extents + return left + + @lazyproperty + def _right(self): + """Index of column following the last column in range.""" + left, _, width, _ = self._extents + return left + width + + @lazyproperty + def _tbl(self): + """`a:tbl` element containing this cell range.""" + return self._tc.tbl + + @lazyproperty + def _top(self): + """Index of topmost row in range.""" + _, top, _, _ = self._extents + return top |