diff --git a/features/steps/tables.py b/features/steps/tables.py new file mode 100644 index 000000000..60aa5082c --- /dev/null +++ b/features/steps/tables.py @@ -0,0 +1,123 @@ +"""Gherkin step implementations for Table row/column CRUD (issue #12 Phase 1).""" + +from __future__ import annotations + +import pytest +from behave import given, then, when + +from pptx import Presentation +from pptx.util import Inches + + +# given =================================================== + + +def _seed_table(context, rows: int, cols: int): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_table(rows, cols, Inches(1), Inches(1), Inches(6), Inches(2)) + context.prs = prs + context.table_ = shape.table + + +@given("a 2x3 table on a fresh slide") +def given_a_2x3_table(context): + _seed_table(context, 2, 3) + + +@given("a 3x2 table on a fresh slide") +def given_a_3x2_table(context): + _seed_table(context, 3, 2) + + +@given("a 2x2 table on a fresh slide") +def given_a_2x2_table(context): + _seed_table(context, 2, 2) + + +@given("a 3x2 table on a fresh slide with a vertical merge between rows 0 and 1") +def given_3x2_with_vmerge(context): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_table(3, 2, Inches(1), Inches(1), Inches(6), Inches(2)) + table = shape.table + table.cell(0, 0).merge(table.cell(1, 0)) + context.prs = prs + context.table_ = table + + +@given("a 2x3 table on a fresh slide with a horizontal merge between columns 0 and 1") +def given_2x3_with_hmerge(context): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_table(2, 3, Inches(1), Inches(1), Inches(6), Inches(2)) + table = shape.table + table.cell(0, 0).merge(table.cell(0, 1)) + context.prs = prs + context.table_ = table + + +# when ==================================================== + + +@when("I call table.rows.add()") +def when_table_rows_add(context): + context.new_row = context.table_.rows.add() + + +@when("I call table.rows.add(at={at:d})") +def when_table_rows_add_at(context, at): + context.new_row = context.table_.rows.add(at=at) + + +@when("I call table.columns.add()") +def when_table_columns_add(context): + context.new_column = context.table_.columns.add() + + +@when("I call table.rows.remove({idx:d})") +def when_table_rows_remove(context, idx): + context.table_.rows.remove(idx) + + +@when("I call table.columns.remove({idx:d})") +def when_table_columns_remove(context, idx): + context.table_.columns.remove(idx) + + +# then ==================================================== + + +@then("the table has {n:d} rows") +def then_table_has_n_rows(context, n): + assert len(context.table_.rows) == n, f"expected {n} rows, got {len(context.table_.rows)}" + + +@then("the table has {n:d} columns") +def then_table_has_n_columns(context, n): + assert len(context.table_.columns) == n, ( + f"expected {n} columns, got {len(context.table_.columns)}" + ) + + +@then("the new row is at index {idx:d}") +def then_new_row_is_at_index(context, idx): + assert context.table_.rows[idx]._tr is context.new_row._tr + + +@then("every row has {n:d} cells") +def then_every_row_has_n_cells(context, n): + for row in context.table_.rows: + assert len(row.cells) == n + + +@then("calling table.rows.remove({idx:d}) raises ValueError") +def then_rows_remove_raises_value_error(context, idx): + with pytest.raises(ValueError): + context.table_.rows.remove(idx) + + +@then("calling table.columns.remove({idx:d}) raises ValueError") +def then_columns_remove_raises_value_error(context, idx): + with pytest.raises(ValueError): + context.table_.columns.remove(idx) diff --git a/features/tbl-crud.feature b/features/tbl-crud.feature new file mode 100644 index 000000000..faab91f4b --- /dev/null +++ b/features/tbl-crud.feature @@ -0,0 +1,48 @@ +Feature: Table row/column CRUD — add and remove + In order to assemble tables programmatically without lxml hacks + As a developer using python-pptx + I need to add and remove rows and columns on existing tables + + + Scenario: Append a row to a table + Given a 2x3 table on a fresh slide + When I call table.rows.add() + Then the table has 3 rows + And the table has 3 columns + + + Scenario: Insert a row at index 1 + Given a 2x3 table on a fresh slide + When I call table.rows.add(at=1) + Then the table has 3 rows + And the new row is at index 1 + + + Scenario: Append a column to a table + Given a 2x3 table on a fresh slide + When I call table.columns.add() + Then the table has 4 columns + And every row has 4 cells + + + Scenario: Remove a row by index + Given a 3x2 table on a fresh slide + When I call table.rows.remove(1) + Then the table has 2 rows + + + Scenario: Remove a column by index + Given a 2x3 table on a fresh slide + When I call table.columns.remove(1) + Then the table has 2 columns + And every row has 2 cells + + + Scenario: Removing a row with vertical merge raises ValueError + Given a 3x2 table on a fresh slide with a vertical merge between rows 0 and 1 + Then calling table.rows.remove(0) raises ValueError + + + Scenario: Removing a column with horizontal merge raises ValueError + Given a 2x3 table on a fresh slide with a horizontal merge between columns 0 and 1 + Then calling table.columns.remove(0) raises ValueError diff --git a/src/pptx/oxml/table.py b/src/pptx/oxml/table.py index 3e44de35d..a642eafb4 100644 --- a/src/pptx/oxml/table.py +++ b/src/pptx/oxml/table.py @@ -45,6 +45,29 @@ 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) + def insert_tr_at(self, idx: int, height: Length) -> CT_TableRow: + """Insert a new `a:tr` at zero-based position `idx` with no cells. + + Caller is responsible for populating the new row with `add_tc()` calls + to match the table's column count. `idx` may equal `len(self.tr_lst)` + to append. Raises `IndexError` if `idx` is out of range. + """ + if idx < 0 or idx > len(self.tr_lst): + raise IndexError("row index out of range") + new_tr = self.add_tr(height) + if idx < len(self.tr_lst) - 1: + self.tr_lst[idx].addprevious(new_tr) + return new_tr + + def remove_tr_at(self, idx: int) -> None: + """Remove the `a:tr` at zero-based position `idx`. + + Raises `IndexError` if `idx` is out of range. + """ + if idx < 0 or idx >= len(self.tr_lst): + raise IndexError("row index out of range") + self.remove(self.tr_lst[idx]) + @property def bandCol(self) -> bool: return self._get_boolean_property("bandCol") @@ -136,6 +159,21 @@ 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 column_has_cross_column_merge(self, col_idx: int) -> bool: + """True if column `col_idx` contains a cell in a multi-column merge. + + Specifically: any tc at this column with `gridSpan > 1` (origin of + horizontal merge), or any tc with `hMerge=True` (target). Used to + decide whether `Table.columns.remove(col_idx)` is safe. + """ + for tr in self.tr_lst: + if col_idx >= len(tr.tc_lst): + continue + tc = tr.tc_lst[col_idx] + if tc.gridSpan > 1 or tc.hMerge: + return True + return False + def _get_boolean_property(self, propname: str) -> bool: """Generalized getter for the boolean properties on the `a:tblPr` child element. @@ -439,6 +477,28 @@ 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) + def insert_gridCol_at(self, idx: int, width: Length) -> CT_TableCol: + """Insert a new `a:gridCol` at zero-based position `idx`. + + `idx` may equal `len(self.gridCol_lst)` to append. Raises + `IndexError` if `idx` is out of range. + """ + if idx < 0 or idx > len(self.gridCol_lst): + raise IndexError("column index out of range") + new_gridCol = self.add_gridCol(width) + if idx < len(self.gridCol_lst) - 1: + self.gridCol_lst[idx].addprevious(new_gridCol) + return new_gridCol + + def remove_gridCol_at(self, idx: int) -> None: + """Remove the `a:gridCol` at zero-based position `idx`. + + Raises `IndexError` if `idx` is out of range. + """ + if idx < 0 or idx >= len(self.gridCol_lst): + raise IndexError("column index out of range") + self.remove(self.gridCol_lst[idx]) + class CT_TableProperties(BaseOxmlElement): """`a:tblPr` custom element class.""" @@ -464,6 +524,40 @@ def add_tc(self) -> CT_TableCell: """A newly added minimal valid `a:tc` child element.""" return self._add_tc() + def insert_tc_at(self, idx: int) -> CT_TableCell: + """Insert a new minimal valid `a:tc` child element at zero-based position `idx`. + + `idx` may equal `len(self.tc_lst)` to append. Raises `IndexError` if + `idx` is out of range. + """ + if idx < 0 or idx > len(self.tc_lst): + raise IndexError("cell index out of range") + new_tc = self.add_tc() + if idx < len(self.tc_lst) - 1: + self.tc_lst[idx].addprevious(new_tc) + return new_tc + + def remove_tc_at(self, idx: int) -> None: + """Remove the `a:tc` at zero-based position `idx`. + + Raises `IndexError` if `idx` is out of range. + """ + if idx < 0 or idx >= len(self.tc_lst): + raise IndexError("cell index out of range") + self.remove(self.tc_lst[idx]) + + @property + def has_cross_row_merge(self) -> bool: + """True if this row contains a cell that participates in a multi-row merge. + + Specifically: any `` with `rowSpan > 1` (origin of vertical + merge), or any `` with `vMerge=True` (target of vertical + merge anchored elsewhere). Used to decide whether + `Table.rows.remove(idx)` is safe — removing a row that crosses a + vertical merge would orphan the rest of the merge. + """ + return any(tc.rowSpan > 1 or tc.vMerge for tc in self.tc_lst) + @property def row_idx(self) -> int: """Offset of this row in its table.""" diff --git a/src/pptx/table.py b/src/pptx/table.py index df3637a3f..cb7f34215 100644 --- a/src/pptx/table.py +++ b/src/pptx/table.py @@ -535,10 +535,74 @@ def __getitem__(self, idx: int): raise IndexError(msg) return _Column(self._tbl.tblGrid.gridCol_lst[idx], self) + def __iter__(self) -> Iterator[_Column]: + """Generate each |_Column| in left-to-right order.""" + return (_Column(gc, self) for gc in self._tbl.tblGrid.gridCol_lst) + def __len__(self): """Supports len() function (e.g. 'len(columns) == 1').""" return len(self._tbl.tblGrid.gridCol_lst) + def add(self, at: int | None = None, width: Length | None = None) -> _Column: + """Insert a new column and return its |_Column| proxy. + + When `at` is |None| (default), the new column is appended to the + right edge. When `at` is an integer, the new column is inserted + at that zero-based position; `at` may equal ``len(self)`` to + append explicitly. Negative indices are not supported. + + When `width` is |None| (default), the new column inherits the + width of the leftmost column (or 1 inch for an empty table); pass + an explicit |Length| (e.g. ``Inches(1.5)``) to override. + + An empty `` is added at the corresponding position in every + existing row, preserving row-cell alignment. Cell content in + existing columns is left untouched. + + Raises |IndexError| if `at` is out of range and |ValueError| if + the insertion column would split a cross-column merge. + """ + if at is not None and (at < 0 or at > len(self)): + raise IndexError("column index out of range") + if width is None: + existing = self._tbl.tblGrid.gridCol_lst + width = Emu(existing[0].w) if existing else Emu(914400) # ---1 inch + idx = at if at is not None else len(self) + # ---inserting at idx must not split an existing horizontal merge that + # spans across the boundary at column `idx`--- + if 0 < idx < len(self): + for tr in self._tbl.tr_lst: + if idx < len(tr.tc_lst) and tr.tc_lst[idx].hMerge: + raise ValueError( + "cannot insert column at index %d — would split a " + "horizontal merge; split the affected merge first" % idx + ) + new_gridCol = self._tbl.tblGrid.insert_gridCol_at(idx, width) + for tr in self._tbl.tr_lst: + tr.insert_tc_at(idx) + self._parent.notify_width_changed() + return _Column(new_gridCol, self) + + def remove(self, index: int) -> None: + """Remove the column at `index`. + + Raises |IndexError| if `index` is out of range and |ValueError| + if the column participates in a multi-column merge (the column + contains a cell with `gridSpan > 1` or `hMerge=True`). Split the + affected merge before calling. + """ + if index < 0 or index >= len(self): + raise IndexError("column index out of range") + if self._tbl.column_has_cross_column_merge(index): + raise ValueError( + "cannot remove column %d containing a cross-column merge; " + "split affected merges before removing the column" % index + ) + self._tbl.tblGrid.remove_gridCol_at(index) + for tr in self._tbl.tr_lst: + tr.remove_tc_at(index) + self._parent.notify_width_changed() + def notify_width_changed(self): """Called by a column when its width changes. Pass along to parent.""" self._parent.notify_width_changed() @@ -559,10 +623,72 @@ def __getitem__(self, idx: int) -> _Row: raise IndexError(msg) return _Row(self._tbl.tr_lst[idx], self) + def __iter__(self) -> Iterator[_Row]: + """Generate each |_Row| in top-to-bottom order.""" + return (_Row(tr, self) for tr in self._tbl.tr_lst) + def __len__(self): """Supports len() function (e.g. 'len(rows) == 1').""" return len(self._tbl.tr_lst) + def add(self, at: int | None = None, height: Length | None = None) -> _Row: + """Insert a new row and return its |_Row| proxy. + + When `at` is |None| (default), the new row is appended at the + bottom. When `at` is an integer, the new row is inserted at that + zero-based position; `at` may equal ``len(self)`` to append + explicitly. Negative indices are not supported. + + When `height` is |None| (default), the new row inherits the + height of the first row (or 0.4 inch for an empty table); pass an + explicit |Length| (e.g. ``Inches(0.5)``) to override. + + The new row is populated with empty `` cells matching the + table's current column count. Existing cell content is untouched. + + Raises |IndexError| if `at` is out of range and |ValueError| if + the insertion row would split a cross-row merge. + """ + if at is not None and (at < 0 or at > len(self)): + raise IndexError("row index out of range") + if height is None: + existing = self._tbl.tr_lst + height = Emu(existing[0].h) if existing else Emu(370840) # ---~0.4 inch + idx = at if at is not None else len(self) + # ---inserting at idx must not split an existing vertical merge that + # spans across the boundary at row `idx`--- + if 0 < idx < len(self): + for tc in self._tbl.tr_lst[idx].tc_lst: + if tc.vMerge: + raise ValueError( + "cannot insert row at index %d — would split a " + "vertical merge; split the affected merge first" % idx + ) + new_tr = self._tbl.insert_tr_at(idx, height) if at is not None else self._tbl.add_tr(height) + col_count = len(self._tbl.tblGrid.gridCol_lst) + for _ in range(col_count): + new_tr.add_tc() + self._parent.notify_height_changed() + return _Row(new_tr, self) + + def remove(self, index: int) -> None: + """Remove the row at `index`. + + Raises |IndexError| if `index` is out of range and |ValueError| + if the row participates in a multi-row merge (the row contains a + cell with `rowSpan > 1` or `vMerge=True`). Split the affected + merge before calling. + """ + if index < 0 or index >= len(self): + raise IndexError("row index out of range") + if self._tbl.tr_lst[index].has_cross_row_merge: + raise ValueError( + "cannot remove row %d containing a cross-row merge; split " + "affected merges before removing the row" % index + ) + self._tbl.remove_tr_at(index) + self._parent.notify_height_changed() + def notify_height_changed(self): """Called by a row when its height changes. Pass along to parent.""" self._parent.notify_height_changed() diff --git a/tests/test_tables_crud.py b/tests/test_tables_crud.py new file mode 100644 index 000000000..cc42e1edf --- /dev/null +++ b/tests/test_tables_crud.py @@ -0,0 +1,454 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for Tables 2.0 Phase 1 — row/column CRUD. + +Covers: + +- New oxml helpers `CT_TableGrid.{insert_gridCol_at, remove_gridCol_at}`, + `CT_Table.insert_tr_at` / `remove_tr_at`, `CT_TableRow.{insert_tc_at, + remove_tc_at, has_cross_row_merge}`, `CT_Table.column_has_cross_column_merge`. +- Public API `Table.rows.add(at, height)`, `Table.rows.remove(index)`, + `Table.columns.add(at, width)`, `Table.columns.remove(index)`. +- Round-trip integration: open → mutate → save → reopen. +- Anti-criteria: removing a row/column that participates in a multi-row / + multi-column merge raises ValueError; inserts that would split a merge + also raise. + +Issue: https://github.com/MHoroszowski/python-pptx/issues/12 (Phase 1). +""" + +from __future__ import annotations + +import io + +import pytest + +from pptx import Presentation +from pptx.oxml.table import CT_Table, CT_TableGrid, CT_TableRow +from pptx.table import Table, _Column, _Row +from pptx.util import Emu, Inches + +from .unitutil.cxml import element + +# --------------------------------------------------------------------------- +# OXML LAYER — CT_TableGrid / CT_Table / CT_TableRow new helpers +# --------------------------------------------------------------------------- + + +class DescribeCT_TableGrid_NewHelpers(object): + """Unit-test suite for `CT_TableGrid.{insert_gridCol_at, remove_gridCol_at}`.""" + + @pytest.mark.parametrize( + ("idx", "expected_position"), + [(0, 0), (1, 1), (2, 2)], # ---head, middle, append + ) + def it_can_insert_a_gridCol_at_a_specific_index(self, idx, expected_position): + tblGrid = element("a:tblGrid/(a:gridCol{w=100},a:gridCol{w=200})") + assert isinstance(tblGrid, CT_TableGrid) + + new_col = tblGrid.insert_gridCol_at(idx, Emu(999)) + + assert tblGrid.gridCol_lst[expected_position] is new_col + assert new_col.w == 999 + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_insert_out_of_range(self, bad_idx): + tblGrid = element("a:tblGrid/a:gridCol{w=100}") + with pytest.raises(IndexError): + tblGrid.insert_gridCol_at(bad_idx, Emu(999)) + + def it_can_remove_a_gridCol_at_an_index(self): + tblGrid = element("a:tblGrid/(a:gridCol{w=100},a:gridCol{w=200},a:gridCol{w=300})") + + tblGrid.remove_gridCol_at(1) + + assert [g.w for g in tblGrid.gridCol_lst] == [100, 300] + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_remove_out_of_range(self, bad_idx): + tblGrid = element("a:tblGrid/a:gridCol{w=100}") + with pytest.raises(IndexError): + tblGrid.remove_gridCol_at(bad_idx) + + +class DescribeCT_Table_NewHelpers(object): + """Unit-test suite for new `CT_Table` row insert/remove + cross-col merge helpers.""" + + def it_can_insert_a_tr_at_a_specific_index(self): + tbl = element( + "a:tbl/(a:tblGrid,a:tr{h=100}/a:tc/a:txBody/a:p,a:tr{h=200}/a:tc/a:txBody/a:p)" + ) + assert isinstance(tbl, CT_Table) + + new_tr = tbl.insert_tr_at(1, Emu(999)) + + assert tbl.tr_lst[1] is new_tr + assert new_tr.h == 999 + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_insert_tr_out_of_range(self, bad_idx): + tbl = element("a:tbl/(a:tblGrid,a:tr{h=100}/a:tc/a:txBody/a:p)") + with pytest.raises(IndexError): + tbl.insert_tr_at(bad_idx, Emu(999)) + + def it_can_remove_a_tr_at_an_index(self): + tbl = element( + "a:tbl/(a:tblGrid,a:tr{h=100}/a:tc/a:txBody/a:p," + "a:tr{h=200}/a:tc/a:txBody/a:p,a:tr{h=300}/a:tc/a:txBody/a:p)" + ) + + tbl.remove_tr_at(1) + + assert [tr.h for tr in tbl.tr_lst] == [100, 300] + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_remove_tr_out_of_range(self, bad_idx): + tbl = element("a:tbl/(a:tblGrid,a:tr{h=100}/a:tc/a:txBody/a:p)") + with pytest.raises(IndexError): + tbl.remove_tr_at(bad_idx) + + def it_detects_cross_column_merge_via_gridSpan(self): + tbl = element( + "a:tbl/(a:tblGrid/(a:gridCol{w=1},a:gridCol{w=2})," + "a:tr{h=10}/(a:tc{gridSpan=2}/a:txBody/a:p,a:tc{hMerge=1}/a:txBody/a:p))" + ) + assert tbl.column_has_cross_column_merge(0) is True + assert tbl.column_has_cross_column_merge(1) is True + + def it_does_not_flag_unmerged_columns(self): + tbl = element( + "a:tbl/(a:tblGrid/(a:gridCol{w=1},a:gridCol{w=2})," + "a:tr{h=10}/(a:tc/a:txBody/a:p,a:tc/a:txBody/a:p))" + ) + assert tbl.column_has_cross_column_merge(0) is False + assert tbl.column_has_cross_column_merge(1) is False + + +class DescribeCT_TableRow_NewHelpers(object): + """Unit-test suite for `CT_TableRow.{insert_tc_at, remove_tc_at, has_cross_row_merge}`.""" + + def it_can_insert_a_tc_at_a_specific_index(self): + tr = element("a:tr{h=10}/(a:tc/a:txBody/a:p,a:tc/a:txBody/a:p)") + assert isinstance(tr, CT_TableRow) + + new_tc = tr.insert_tc_at(1) + + assert tr.tc_lst[1] is new_tc + assert len(tr.tc_lst) == 3 + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_insert_tc_out_of_range(self, bad_idx): + tr = element("a:tr{h=10}/a:tc/a:txBody/a:p") + with pytest.raises(IndexError): + tr.insert_tc_at(bad_idx) + + def it_can_remove_a_tc_at_an_index(self): + tr = element("a:tr{h=10}/(a:tc/a:txBody/a:p,a:tc/a:txBody/a:p,a:tc/a:txBody/a:p)") + + tr.remove_tc_at(1) + + assert len(tr.tc_lst) == 2 + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_remove_tc_out_of_range(self, bad_idx): + tr = element("a:tr{h=10}/a:tc/a:txBody/a:p") + with pytest.raises(IndexError): + tr.remove_tc_at(bad_idx) + + def it_detects_cross_row_merge_via_rowSpan(self): + tr = element("a:tr{h=10}/(a:tc{rowSpan=2}/a:txBody/a:p,a:tc/a:txBody/a:p)") + assert tr.has_cross_row_merge is True + + def it_detects_cross_row_merge_via_vMerge(self): + tr = element("a:tr{h=10}/(a:tc{vMerge=1}/a:txBody/a:p,a:tc/a:txBody/a:p)") + assert tr.has_cross_row_merge is True + + def it_does_not_flag_a_row_without_vertical_merges(self): + tr = element("a:tr{h=10}/(a:tc{gridSpan=2}/a:txBody/a:p,a:tc{hMerge=1}/a:txBody/a:p)") + assert tr.has_cross_row_merge is False + + +# --------------------------------------------------------------------------- +# Integration — `Table.rows` / `Table.columns` add/remove on a real table +# --------------------------------------------------------------------------- + + +def _new_table(rows: int, cols: int) -> Table: + """Return a fresh table with `rows` × `cols` empty cells, on a fresh slide.""" + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_table(rows, cols, Inches(1), Inches(1), Inches(6), Inches(2)) + return shape.table + + +def _round_trip_table(table: Table) -> Table: + """Save the table's presentation to bytes, reopen, and return the table.""" + prs = table._graphic_frame.part.package.presentation_part.presentation + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + reopened = Presentation(buf) + # ---first table on the first slide--- + for shape in reopened.slides[0].shapes: + if shape.has_table: + return shape.table + raise AssertionError("no table found in round-tripped slide") + + +class DescribeRowAdd(object): + """`Table.rows.add(at, height)` — row insertion behavior.""" + + def it_appends_a_row_when_at_is_None(self): + table = _new_table(2, 3) + + new_row = table.rows.add() + + assert isinstance(new_row, _Row) + assert len(table.rows) == 3 + # ---new row is the last row--- + assert table.rows[2]._tr is new_row._tr + + def it_can_insert_a_row_at_the_head(self): + table = _new_table(2, 3) + + new_row = table.rows.add(at=0) + + assert len(table.rows) == 3 + assert table.rows[0]._tr is new_row._tr + + def it_can_insert_a_row_at_a_middle_index(self): + table = _new_table(3, 3) + + new_row = table.rows.add(at=1) + + assert len(table.rows) == 4 + assert table.rows[1]._tr is new_row._tr + + def it_populates_the_new_row_with_empty_cells_matching_column_count(self): + table = _new_table(2, 4) + + table.rows.add() + + new_row = table.rows[2] + assert len(new_row.cells) == 4 + for cell in new_row.cells: + assert cell.text == "" + + def it_inherits_height_from_the_first_row_when_height_is_None(self): + table = _new_table(2, 3) + first_row_h = table.rows[0].height + + table.rows.add() + + assert table.rows[2].height == first_row_h + + def it_uses_explicit_height_when_supplied(self): + table = _new_table(2, 3) + + table.rows.add(height=Emu(123456)) + + assert table.rows[2].height == 123456 + + @pytest.mark.parametrize("bad_at", [-1, 99]) + def but_it_raises_on_at_out_of_range(self, bad_at): + table = _new_table(2, 3) + with pytest.raises(IndexError): + table.rows.add(at=bad_at) + + +class DescribeRowRemove(object): + """`Table.rows.remove(index)` — row removal behavior.""" + + def it_can_remove_a_row(self): + table = _new_table(3, 2) + table.cell(1, 0).text = "row 1 cell 0" + before_kept = table.cell(0, 0).text # ---row 0 (will keep) + also_kept = table.cell(2, 0).text # ---row 2 (will keep) + + table.rows.remove(1) + + assert len(table.rows) == 2 + assert table.cell(0, 0).text == before_kept + assert table.cell(1, 0).text == also_kept + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_index_out_of_range(self, bad_idx): + table = _new_table(2, 2) + with pytest.raises(IndexError): + table.rows.remove(bad_idx) + + def but_it_raises_on_row_with_cross_row_merge_origin(self): + """Removing a row whose cell originates a vertical merge would orphan.""" + table = _new_table(3, 2) + # ---merge (0,0) into (1,0): origin at row 0 with rowSpan=2 + table.cell(0, 0).merge(table.cell(1, 0)) + + with pytest.raises(ValueError): + table.rows.remove(0) + + def but_it_raises_on_row_that_is_a_vertical_merge_target(self): + table = _new_table(3, 2) + # ---origin at row 0 with rowSpan=2; row 1 is the target (vMerge=True) + table.cell(0, 0).merge(table.cell(1, 0)) + + with pytest.raises(ValueError): + table.rows.remove(1) + + +class DescribeColumnAdd(object): + """`Table.columns.add(at, width)` — column insertion behavior.""" + + def it_appends_a_column_when_at_is_None(self): + table = _new_table(2, 3) + + new_column = table.columns.add() + + assert isinstance(new_column, _Column) + assert len(table.columns) == 4 + + def it_can_insert_a_column_at_the_head(self): + table = _new_table(2, 3) + + new_column = table.columns.add(at=0) + + assert len(table.columns) == 4 + assert table.columns[0]._gridCol is new_column._gridCol + + def it_inserts_an_empty_cell_in_every_existing_row(self): + table = _new_table(3, 2) + table.cell(0, 0).text = "kept" + table.cell(0, 1).text = "kept-too" + + table.columns.add(at=1) + + assert len(table.columns) == 3 + for r in range(3): + assert len(table.rows[r].cells) == 3 + assert table.cell(0, 0).text == "kept" + assert table.cell(0, 1).text == "" # ---newly inserted, empty + assert table.cell(0, 2).text == "kept-too" + + def it_inherits_width_from_the_first_column_when_width_is_None(self): + table = _new_table(2, 3) + first_col_w = table.columns[0].width + + table.columns.add() + + assert table.columns[3].width == first_col_w + + def it_uses_explicit_width_when_supplied(self): + table = _new_table(2, 3) + + table.columns.add(width=Emu(987654)) + + assert table.columns[3].width == 987654 + + @pytest.mark.parametrize("bad_at", [-1, 99]) + def but_it_raises_on_at_out_of_range(self, bad_at): + table = _new_table(2, 3) + with pytest.raises(IndexError): + table.columns.add(at=bad_at) + + +class DescribeColumnRemove(object): + """`Table.columns.remove(index)` — column removal behavior.""" + + def it_can_remove_a_column(self): + table = _new_table(2, 3) + table.cell(0, 0).text = "keep-A" + table.cell(0, 2).text = "keep-C" + + table.columns.remove(1) + + assert len(table.columns) == 2 + for r in range(2): + assert len(table.rows[r].cells) == 2 + assert table.cell(0, 0).text == "keep-A" + assert table.cell(0, 1).text == "keep-C" + + @pytest.mark.parametrize("bad_idx", [-1, 99]) + def but_it_raises_on_index_out_of_range(self, bad_idx): + table = _new_table(2, 2) + with pytest.raises(IndexError): + table.columns.remove(bad_idx) + + def but_it_raises_on_column_with_cross_column_merge_origin(self): + """Removing column 0 of a horizontally-merged pair orphans (1,*).""" + table = _new_table(2, 3) + table.cell(0, 0).merge(table.cell(0, 1)) + + with pytest.raises(ValueError): + table.columns.remove(0) + + def but_it_raises_on_column_that_is_a_horizontal_merge_target(self): + table = _new_table(2, 3) + table.cell(0, 0).merge(table.cell(0, 1)) + + with pytest.raises(ValueError): + table.columns.remove(1) + + +# --------------------------------------------------------------------------- +# Round-trip integration — open → mutate → save → reopen +# --------------------------------------------------------------------------- + + +class DescribeTablesRoundTrip(object): + """Save → reopen preservation of row/column CRUD operations.""" + + def it_round_trips_a_row_append(self): + table = _new_table(2, 3) + table.cell(0, 0).text = "header-A" + + table.rows.add() + rt = _round_trip_table(table) + + assert len(rt.rows) == 3 + assert rt.cell(0, 0).text == "header-A" + + def it_round_trips_a_column_append(self): + table = _new_table(2, 2) + table.cell(0, 0).text = "kept" + + table.columns.add() + rt = _round_trip_table(table) + + assert len(rt.columns) == 3 + assert rt.cell(0, 0).text == "kept" + + def it_round_trips_a_row_removal(self): + table = _new_table(3, 2) + table.cell(0, 0).text = "row0" + table.cell(2, 0).text = "row2" + + table.rows.remove(1) + rt = _round_trip_table(table) + + assert len(rt.rows) == 2 + assert rt.cell(0, 0).text == "row0" + assert rt.cell(1, 0).text == "row2" + + def it_round_trips_a_column_removal(self): + table = _new_table(2, 3) + table.cell(0, 0).text = "A" + table.cell(0, 2).text = "C" + + table.columns.remove(1) + rt = _round_trip_table(table) + + assert len(rt.columns) == 2 + assert rt.cell(0, 0).text == "A" + assert rt.cell(0, 1).text == "C" + + def it_round_trips_an_indexed_row_insert(self): + table = _new_table(2, 2) + table.cell(0, 0).text = "first" + table.cell(1, 0).text = "last" + + table.rows.add(at=1) + rt = _round_trip_table(table) + + assert len(rt.rows) == 3 + assert rt.cell(0, 0).text == "first" + assert rt.cell(1, 0).text == "" # ---inserted empty + assert rt.cell(2, 0).text == "last"