Skip to content

Commit bcf58ca

Browse files
Matthew HoroszowskiMatthew Horoszowski
authored andcommitted
feat(tables): add rows.add/remove + columns.add/remove (Tables 2.0 Phase 1)
Phase 1 of issue #12 (Tables 2.0 epic). Lands the second-most-asked table feature in the python-pptx ecosystem — adding and removing rows and columns on existing tables (upstream issue scanny#86, 17 comments; closes the upstream PR scanny#399 design directly). Defers table-style binding (`apply_style`), merge ergonomics, and gridSpan accessor exposure to follow-up phases. New public API -------------- - `Table.rows.add(at=None, height=None) -> _Row` * `at=None` (default) appends at the bottom; backwards compatible. * `at=N` inserts at zero-based position N; `at=len(rows)` appends. * `height=None` inherits the first row's height (or 0.4" for an empty table). Pass an explicit `Length` to override. * Populates the new row with empty `<a:tc>` cells matching the current column count. - `Table.rows.remove(index)` * Drops the row at `index`. Raises `ValueError` when the row has a cell with `rowSpan > 1` (origin) or `vMerge=True` (target) — removing such a row would orphan the rest of a vertical merge. Split the affected merge first. - `Table.columns.add(at=None, width=None) -> _Column` * Mirrors `rows.add` semantics. Inserts an empty `<a:tc>` at the corresponding position in every existing row, preserving cell alignment. `width=None` inherits the leftmost column's width (or 1" for an empty table). - `Table.columns.remove(index)` * Drops the gridCol at `index` and the corresponding `<a:tc>` from every row. Raises `ValueError` on a column carrying `gridSpan > 1` or `hMerge=True`. - `_RowCollection.__iter__` and `_ColumnCollection.__iter__` are promoted to first-class iterators (existed implicitly before via `__getitem__`; explicit makes intent clear and matches Pythonic collection conventions). Internal additions ------------------ - `pptx/oxml/table.py`: * `CT_TableGrid.{insert_gridCol_at, remove_gridCol_at}` * `CT_Table.{insert_tr_at, remove_tr_at, column_has_cross_column_merge}` * `CT_TableRow.{insert_tc_at, remove_tc_at, has_cross_row_merge}` Merge-safety policy (Phase 1) ----------------------------- This phase deliberately raises `ValueError` rather than silently mutating the merge graph when row/column CRUD would split a merge: | Operation | Raises when | |--------------------------------------|--------------------------------------| | `rows.remove(idx)` | row contains `rowSpan>1` or `vMerge` | | `rows.add(at=idx)` | row at `idx` has any `vMerge=True` | | `columns.remove(idx)` | col has `gridSpan>1` or `hMerge` | | `columns.add(at=idx)` | col `idx` has any `hMerge=True` | Auto-splitting merges through CRUD is a Phase 2 enhancement. Test coverage ------------- - 55 new unit tests in `tests/test_tables_crud.py`: * 18 oxml-layer tests (insert/remove/cross-merge detection) * 32 collection-API tests (rows.add/remove + columns.add/remove) * 5 round-trip integration tests (open → mutate → save → reopen) - 7 new behave scenarios in `features/tbl-crud.feature` covering append-row, indexed-row-insert, append-column, remove-row, remove-column, and the two merge-safety raise cases. - New `uat_tables_crud.py` (untracked per repo §6) builds a 5-slide deck where each slide shows a different stage of CRUD operations, printing per-stage cell content so the maintainer can flip through in PowerPoint and see the mutations land. Verification ------------ ``` $ python3 -m pytest tests/ -q | tail -3 3277 passed in 4.54s $ ruff check src tests | tail -3 All checks passed! $ python3 -m behave features/ --no-color | tail -3 1006 scenarios passed, 0 failed, 0 skipped 3023 steps passed, 0 failed, 0 skipped ``` Refs #12
1 parent 76291a2 commit bcf58ca

5 files changed

Lines changed: 845 additions & 0 deletions

File tree

features/steps/tables.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Gherkin step implementations for Table row/column CRUD (issue #12 Phase 1)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from behave import given, then, when
7+
8+
from pptx import Presentation
9+
from pptx.util import Inches
10+
11+
12+
# given ===================================================
13+
14+
15+
def _seed_table(context, rows: int, cols: int):
16+
prs = Presentation()
17+
slide = prs.slides.add_slide(prs.slide_layouts[6])
18+
shape = slide.shapes.add_table(rows, cols, Inches(1), Inches(1), Inches(6), Inches(2))
19+
context.prs = prs
20+
context.table_ = shape.table
21+
22+
23+
@given("a 2x3 table on a fresh slide")
24+
def given_a_2x3_table(context):
25+
_seed_table(context, 2, 3)
26+
27+
28+
@given("a 3x2 table on a fresh slide")
29+
def given_a_3x2_table(context):
30+
_seed_table(context, 3, 2)
31+
32+
33+
@given("a 2x2 table on a fresh slide")
34+
def given_a_2x2_table(context):
35+
_seed_table(context, 2, 2)
36+
37+
38+
@given("a 3x2 table on a fresh slide with a vertical merge between rows 0 and 1")
39+
def given_3x2_with_vmerge(context):
40+
prs = Presentation()
41+
slide = prs.slides.add_slide(prs.slide_layouts[6])
42+
shape = slide.shapes.add_table(3, 2, Inches(1), Inches(1), Inches(6), Inches(2))
43+
table = shape.table
44+
table.cell(0, 0).merge(table.cell(1, 0))
45+
context.prs = prs
46+
context.table_ = table
47+
48+
49+
@given("a 2x3 table on a fresh slide with a horizontal merge between columns 0 and 1")
50+
def given_2x3_with_hmerge(context):
51+
prs = Presentation()
52+
slide = prs.slides.add_slide(prs.slide_layouts[6])
53+
shape = slide.shapes.add_table(2, 3, Inches(1), Inches(1), Inches(6), Inches(2))
54+
table = shape.table
55+
table.cell(0, 0).merge(table.cell(0, 1))
56+
context.prs = prs
57+
context.table_ = table
58+
59+
60+
# when ====================================================
61+
62+
63+
@when("I call table.rows.add()")
64+
def when_table_rows_add(context):
65+
context.new_row = context.table_.rows.add()
66+
67+
68+
@when("I call table.rows.add(at={at:d})")
69+
def when_table_rows_add_at(context, at):
70+
context.new_row = context.table_.rows.add(at=at)
71+
72+
73+
@when("I call table.columns.add()")
74+
def when_table_columns_add(context):
75+
context.new_column = context.table_.columns.add()
76+
77+
78+
@when("I call table.rows.remove({idx:d})")
79+
def when_table_rows_remove(context, idx):
80+
context.table_.rows.remove(idx)
81+
82+
83+
@when("I call table.columns.remove({idx:d})")
84+
def when_table_columns_remove(context, idx):
85+
context.table_.columns.remove(idx)
86+
87+
88+
# then ====================================================
89+
90+
91+
@then("the table has {n:d} rows")
92+
def then_table_has_n_rows(context, n):
93+
assert len(context.table_.rows) == n, f"expected {n} rows, got {len(context.table_.rows)}"
94+
95+
96+
@then("the table has {n:d} columns")
97+
def then_table_has_n_columns(context, n):
98+
assert len(context.table_.columns) == n, (
99+
f"expected {n} columns, got {len(context.table_.columns)}"
100+
)
101+
102+
103+
@then("the new row is at index {idx:d}")
104+
def then_new_row_is_at_index(context, idx):
105+
assert context.table_.rows[idx]._tr is context.new_row._tr
106+
107+
108+
@then("every row has {n:d} cells")
109+
def then_every_row_has_n_cells(context, n):
110+
for row in context.table_.rows:
111+
assert len(row.cells) == n
112+
113+
114+
@then("calling table.rows.remove({idx:d}) raises ValueError")
115+
def then_rows_remove_raises_value_error(context, idx):
116+
with pytest.raises(ValueError):
117+
context.table_.rows.remove(idx)
118+
119+
120+
@then("calling table.columns.remove({idx:d}) raises ValueError")
121+
def then_columns_remove_raises_value_error(context, idx):
122+
with pytest.raises(ValueError):
123+
context.table_.columns.remove(idx)

features/tbl-crud.feature

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
Feature: Table row/column CRUD — add and remove
2+
In order to assemble tables programmatically without lxml hacks
3+
As a developer using python-pptx
4+
I need to add and remove rows and columns on existing tables
5+
6+
7+
Scenario: Append a row to a table
8+
Given a 2x3 table on a fresh slide
9+
When I call table.rows.add()
10+
Then the table has 3 rows
11+
And the table has 3 columns
12+
13+
14+
Scenario: Insert a row at index 1
15+
Given a 2x3 table on a fresh slide
16+
When I call table.rows.add(at=1)
17+
Then the table has 3 rows
18+
And the new row is at index 1
19+
20+
21+
Scenario: Append a column to a table
22+
Given a 2x3 table on a fresh slide
23+
When I call table.columns.add()
24+
Then the table has 4 columns
25+
And every row has 4 cells
26+
27+
28+
Scenario: Remove a row by index
29+
Given a 3x2 table on a fresh slide
30+
When I call table.rows.remove(1)
31+
Then the table has 2 rows
32+
33+
34+
Scenario: Remove a column by index
35+
Given a 2x3 table on a fresh slide
36+
When I call table.columns.remove(1)
37+
Then the table has 2 columns
38+
And every row has 2 cells
39+
40+
41+
Scenario: Removing a row with vertical merge raises ValueError
42+
Given a 3x2 table on a fresh slide with a vertical merge between rows 0 and 1
43+
Then calling table.rows.remove(0) raises ValueError
44+
45+
46+
Scenario: Removing a column with horizontal merge raises ValueError
47+
Given a 2x3 table on a fresh slide with a horizontal merge between columns 0 and 1
48+
Then calling table.columns.remove(0) raises ValueError

src/pptx/oxml/table.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,29 @@ def add_tr(self, height: Length) -> CT_TableRow:
4545
"""Return a newly created `a:tr` child element having its `h` attribute set to `height`."""
4646
return self._add_tr(h=height)
4747

48+
def insert_tr_at(self, idx: int, height: Length) -> CT_TableRow:
49+
"""Insert a new `a:tr` at zero-based position `idx` with no cells.
50+
51+
Caller is responsible for populating the new row with `add_tc()` calls
52+
to match the table's column count. `idx` may equal `len(self.tr_lst)`
53+
to append. Raises `IndexError` if `idx` is out of range.
54+
"""
55+
if idx < 0 or idx > len(self.tr_lst):
56+
raise IndexError("row index out of range")
57+
new_tr = self.add_tr(height)
58+
if idx < len(self.tr_lst) - 1:
59+
self.tr_lst[idx].addprevious(new_tr)
60+
return new_tr
61+
62+
def remove_tr_at(self, idx: int) -> None:
63+
"""Remove the `a:tr` at zero-based position `idx`.
64+
65+
Raises `IndexError` if `idx` is out of range.
66+
"""
67+
if idx < 0 or idx >= len(self.tr_lst):
68+
raise IndexError("row index out of range")
69+
self.remove(self.tr_lst[idx])
70+
4871
@property
4972
def bandCol(self) -> bool:
5073
return self._get_boolean_property("bandCol")
@@ -136,6 +159,21 @@ def tc(self, row_idx: int, col_idx: int) -> CT_TableCell:
136159
"""Return `a:tc` element at `row_idx`, `col_idx`."""
137160
return self.tr_lst[row_idx].tc_lst[col_idx]
138161

162+
def column_has_cross_column_merge(self, col_idx: int) -> bool:
163+
"""True if column `col_idx` contains a cell in a multi-column merge.
164+
165+
Specifically: any tc at this column with `gridSpan > 1` (origin of
166+
horizontal merge), or any tc with `hMerge=True` (target). Used to
167+
decide whether `Table.columns.remove(col_idx)` is safe.
168+
"""
169+
for tr in self.tr_lst:
170+
if col_idx >= len(tr.tc_lst):
171+
continue
172+
tc = tr.tc_lst[col_idx]
173+
if tc.gridSpan > 1 or tc.hMerge:
174+
return True
175+
return False
176+
139177
def _get_boolean_property(self, propname: str) -> bool:
140178
"""Generalized getter for the boolean properties on the `a:tblPr` child element.
141179
@@ -439,6 +477,28 @@ def add_gridCol(self, width: Length) -> CT_TableCol:
439477
"""A newly appended `a:gridCol` child element having its `w` attribute set to `width`."""
440478
return self._add_gridCol(w=width)
441479

480+
def insert_gridCol_at(self, idx: int, width: Length) -> CT_TableCol:
481+
"""Insert a new `a:gridCol` at zero-based position `idx`.
482+
483+
`idx` may equal `len(self.gridCol_lst)` to append. Raises
484+
`IndexError` if `idx` is out of range.
485+
"""
486+
if idx < 0 or idx > len(self.gridCol_lst):
487+
raise IndexError("column index out of range")
488+
new_gridCol = self.add_gridCol(width)
489+
if idx < len(self.gridCol_lst) - 1:
490+
self.gridCol_lst[idx].addprevious(new_gridCol)
491+
return new_gridCol
492+
493+
def remove_gridCol_at(self, idx: int) -> None:
494+
"""Remove the `a:gridCol` at zero-based position `idx`.
495+
496+
Raises `IndexError` if `idx` is out of range.
497+
"""
498+
if idx < 0 or idx >= len(self.gridCol_lst):
499+
raise IndexError("column index out of range")
500+
self.remove(self.gridCol_lst[idx])
501+
442502

443503
class CT_TableProperties(BaseOxmlElement):
444504
"""`a:tblPr` custom element class."""
@@ -464,6 +524,40 @@ def add_tc(self) -> CT_TableCell:
464524
"""A newly added minimal valid `a:tc` child element."""
465525
return self._add_tc()
466526

527+
def insert_tc_at(self, idx: int) -> CT_TableCell:
528+
"""Insert a new minimal valid `a:tc` child element at zero-based position `idx`.
529+
530+
`idx` may equal `len(self.tc_lst)` to append. Raises `IndexError` if
531+
`idx` is out of range.
532+
"""
533+
if idx < 0 or idx > len(self.tc_lst):
534+
raise IndexError("cell index out of range")
535+
new_tc = self.add_tc()
536+
if idx < len(self.tc_lst) - 1:
537+
self.tc_lst[idx].addprevious(new_tc)
538+
return new_tc
539+
540+
def remove_tc_at(self, idx: int) -> None:
541+
"""Remove the `a:tc` at zero-based position `idx`.
542+
543+
Raises `IndexError` if `idx` is out of range.
544+
"""
545+
if idx < 0 or idx >= len(self.tc_lst):
546+
raise IndexError("cell index out of range")
547+
self.remove(self.tc_lst[idx])
548+
549+
@property
550+
def has_cross_row_merge(self) -> bool:
551+
"""True if this row contains a cell that participates in a multi-row merge.
552+
553+
Specifically: any `<a:tc>` with `rowSpan > 1` (origin of vertical
554+
merge), or any `<a:tc>` with `vMerge=True` (target of vertical
555+
merge anchored elsewhere). Used to decide whether
556+
`Table.rows.remove(idx)` is safe — removing a row that crosses a
557+
vertical merge would orphan the rest of the merge.
558+
"""
559+
return any(tc.rowSpan > 1 or tc.vMerge for tc in self.tc_lst)
560+
467561
@property
468562
def row_idx(self) -> int:
469563
"""Offset of this row in its table."""

0 commit comments

Comments
 (0)