Skip to content

Commit 05b01dc

Browse files
Matthew HoroszowskiMatthew Horoszowski
authored andcommitted
feat(tables): row_count/column_count/dimensions + sizing round-trip lock — Tables 2.0 Phase 4 (closes epic)
Issue: #12 (Phase 4 — closing PR) Closes the Tables 2.0 epic. Phase 1 (PR #37) shipped row/column add/remove. Phase 2 (PR #40) shipped the table style API. Phase 3 (PR #41) shipped range-style merge_cells/split_cells with Cell inspection accessors. This PR closes the last two unchecked sub-features on issue #12: ergonomic count properties on Table, and a regression-lock on per-row height / per-column width round-trip preservation. Public surface added (additive — no existing API changed): `Table` (read-only convenience properties) - `Table.row_count` — number of rows; equivalent to `len(table.rows)` but doesn't instantiate the `_RowCollection`. - `Table.column_count` — number of columns; equivalent to `len(table.columns)` but doesn't instantiate the `_ColumnCollection`. - `Table.dimensions` — `(row_count, column_count)` tuple. Symmetrical with `TcRange.dimensions`; rows-first per the dominant 2D-array convention. Round-trip regression-lock for per-row height / per-column width - `_Row.height` and `_Column.width` setters already wrote to `<a:tr>/@h` and `<a:gridCol>/@w` correctly, and a smoke test confirmed round-trip through save+reload already worked. This PR pins that down with five regression tests covering single-row height, single-column width, mixed-height row sets, mixed-width column sets, and count-property preservation through round-trip — so any future change that breaks EMU preservation fails CI immediately. Out of scope (deferred to follow-ups, not omitted from issue #12) - NumPy-style `table[r, c]` indexing — `table.cell(r, c)` is canonical; adding `__getitem__` would conflict with `_RowCollection.__getitem__` on `table.rows[i]` and confuse the data model. - Bulk getter helpers (`row_heights` / `column_widths` returning lists) — list-comprehension is one line; no API value-add. - Constraint or auto-fit logic — PowerPoint's job at render time. Tests - 22 new pytest cases in `tests/test_tables_phase4.py` covering each new count property (including reactivity to `.rows.add()` / `.columns.remove()` etc.), the read-only setter checks, the round-trip preservation matrix, and Phase-1/2/3 regression checks. Full suite: `3426 passed`. - 7 new behave scenarios in `features/tbl-sizing.feature`: row_count, column_count, dimensions, count-after-add, count-after-remove, height round-trip via stream, width round-trip via stream. Full behave: `1036 scenarios passed, 0 failed` (baseline 1029 + 7 new). - Ruff: `ruff check src tests` → All checks passed; `ruff format --check` → no diff. UAT - `uat_tables_phase4.py` (untracked per CLAUDE.md §6) at repo root. Builds two tables — a 4×4 with explicit graduated row heights and column widths, and a 3×4 that's grown into a 4×4 by using the count properties to drive the loop. All round-trip assertions pass programmatically; visual UAT in PowerPoint or Keynote pending maintainer signoff. Issue #12 closes with this merge — all eight sub-features now shipped. Closes #12
1 parent 1aea816 commit 05b01dc

4 files changed

Lines changed: 354 additions & 0 deletions

File tree

features/steps/tbl_sizing.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Gherkin step implementations for Table sizing & ergonomics (issue #12 Phase 4)."""
2+
3+
from __future__ import annotations
4+
5+
import io
6+
7+
from behave import given, then, when
8+
9+
from pptx import Presentation
10+
from pptx.util import Emu, Inches
11+
12+
13+
# given ===================================================
14+
15+
16+
@given("a 3x4 table on a fresh slide")
17+
def given_a_3x4_table(context):
18+
prs = Presentation()
19+
slide = prs.slides.add_slide(prs.slide_layouts[6])
20+
shape = slide.shapes.add_table(3, 4, Inches(1), Inches(1), Inches(6), Inches(2))
21+
context.prs = prs
22+
context.table_ = shape.table
23+
24+
25+
# when ====================================================
26+
27+
28+
@when("I add a row to the table")
29+
def when_add_row(context):
30+
context.table_.rows.add()
31+
32+
33+
@when("I remove column {idx:d} from the table")
34+
def when_remove_column(context, idx):
35+
context.table_.columns.remove(idx)
36+
37+
38+
@when("I set row {idx:d} height to {emu:d} EMU")
39+
def when_set_row_height(context, idx, emu):
40+
context.table_.rows[idx].height = Emu(emu)
41+
42+
43+
@when("I set column {idx:d} width to {emu:d} EMU")
44+
def when_set_column_width(context, idx, emu):
45+
context.table_.columns[idx].width = Emu(emu)
46+
47+
48+
# the "save and reload via stream" step is shared with tbl_styles.py — reuse
49+
50+
51+
# then ====================================================
52+
53+
54+
@then("table.row_count is {n:d}")
55+
def then_row_count(context, n):
56+
assert context.table_.row_count == n, (context.table_.row_count, n)
57+
58+
59+
@then("table.column_count is {n:d}")
60+
def then_column_count(context, n):
61+
assert context.table_.column_count == n, (context.table_.column_count, n)
62+
63+
64+
@then("table.dimensions is ({rows:d}, {cols:d})")
65+
def then_dimensions(context, rows, cols):
66+
assert context.table_.dimensions == (rows, cols), (context.table_.dimensions, rows, cols)
67+
68+
69+
@then("the reloaded row {idx:d} height is {emu:d}")
70+
def then_reloaded_row_height(context, idx, emu):
71+
actual = context.table_reloaded.rows[idx].height
72+
assert actual == emu, (actual, emu)
73+
74+
75+
@then("the reloaded column {idx:d} width is {emu:d}")
76+
def then_reloaded_column_width(context, idx, emu):
77+
actual = context.table_reloaded.columns[idx].width
78+
assert actual == emu, (actual, emu)

features/tbl-sizing.feature

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
Feature: Table sizing & ergonomics — row_count / column_count / dimensions + sizing round-trip
2+
In order to inspect a table's shape and rely on persistent row heights and column widths
3+
As a developer using python-pptx
4+
I need read-only count properties on Table and round-trip preservation of explicit sizes
5+
6+
7+
Scenario: row_count returns the number of rows
8+
Given a 3x4 table on a fresh slide
9+
Then table.row_count is 3
10+
11+
12+
Scenario: column_count returns the number of columns
13+
Given a 3x4 table on a fresh slide
14+
Then table.column_count is 4
15+
16+
17+
Scenario: dimensions returns a (rows, cols) tuple
18+
Given a 3x4 table on a fresh slide
19+
Then table.dimensions is (3, 4)
20+
21+
22+
Scenario: row_count updates after rows.add()
23+
Given a 3x4 table on a fresh slide
24+
When I add a row to the table
25+
Then table.row_count is 4
26+
27+
28+
Scenario: column_count updates after columns.remove()
29+
Given a 3x4 table on a fresh slide
30+
When I remove column 0 from the table
31+
Then table.column_count is 3
32+
33+
34+
Scenario: Row height round-trips through save/reload
35+
Given a 3x4 table on a fresh slide
36+
When I set row 0 height to 500000 EMU
37+
And I save and reload the presentation via stream
38+
Then the reloaded row 0 height is 500000
39+
40+
41+
Scenario: Column width round-trips through save/reload
42+
Given a 3x4 table on a fresh slide
43+
When I set column 1 width to 1000000 EMU
44+
And I save and reload the presentation via stream
45+
Then the reloaded column 1 width is 1000000

src/pptx/table.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,33 @@ def vert_banding(self) -> bool:
166166
def vert_banding(self, value: bool):
167167
self._tbl.bandCol = value
168168

169+
@property
170+
def row_count(self) -> int:
171+
"""Number of rows in this table.
172+
173+
Read-only. Equivalent to ``len(table.rows)`` but doesn't instantiate
174+
the |_RowCollection|.
175+
"""
176+
return len(self._tbl.tr_lst)
177+
178+
@property
179+
def column_count(self) -> int:
180+
"""Number of columns in this table.
181+
182+
Read-only. Equivalent to ``len(table.columns)`` but doesn't
183+
instantiate the |_ColumnCollection|.
184+
"""
185+
return len(self._tbl.tblGrid.gridCol_lst)
186+
187+
@property
188+
def dimensions(self) -> tuple[int, int]:
189+
"""``(row_count, column_count)`` pair describing this table's shape.
190+
191+
Read-only. Symmetrical with ``TcRange.dimensions``; rows-first order
192+
matches the dominant 2D-array convention.
193+
"""
194+
return (self.row_count, self.column_count)
195+
169196
def merge_cells(self, row_range, col_range) -> "_Cell":
170197
"""Merge a rectangular block of cells into a single merged cell.
171198

tests/test_tables_phase4.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# pyright: reportPrivateUsage=false
2+
3+
"""Unit-test suite for Tables 2.0 Phase 4 — sizing & ergonomics, closing the epic.
4+
5+
Covers:
6+
7+
- Read-only count properties on |Table|: ``row_count``, ``column_count``,
8+
``dimensions`` — convenience accessors that don't instantiate the
9+
full |_RowCollection| / |_ColumnCollection|.
10+
- Per-row height and per-column width round-trip preservation through
11+
save/reload (regression-lock — the underlying setters already work,
12+
but no test pinned that down before now).
13+
- Anti-criteria: Phase 1/2/3 surfaces unaffected.
14+
15+
Issue: https://github.com/MHoroszowski/python-pptx/issues/12 (Phase 4, closing PR).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import io
21+
22+
import pytest
23+
24+
from pptx import Presentation
25+
from pptx.util import Emu, Inches
26+
27+
# ---------------------------------------------------------------------------
28+
# Fixtures
29+
# ---------------------------------------------------------------------------
30+
31+
32+
def _make_table(rows: int, cols: int):
33+
prs = Presentation()
34+
slide = prs.slides.add_slide(prs.slide_layouts[6])
35+
gf = slide.shapes.add_table(rows, cols, Inches(1), Inches(1), Inches(6), Inches(2))
36+
return prs, gf.table
37+
38+
39+
@pytest.fixture
40+
def t3x4():
41+
_, t = _make_table(3, 4)
42+
return t
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# Table.row_count / column_count / dimensions
47+
# ---------------------------------------------------------------------------
48+
49+
50+
class DescribeTable_CountProperties(object):
51+
"""Unit-test suite for `row_count`, `column_count`, `dimensions`."""
52+
53+
def it_reports_row_count_for_a_3x4_table(self, t3x4):
54+
assert t3x4.row_count == 3
55+
56+
def it_reports_column_count_for_a_3x4_table(self, t3x4):
57+
assert t3x4.column_count == 4
58+
59+
def it_reports_dimensions_as_rows_cols_tuple(self, t3x4):
60+
assert t3x4.dimensions == (3, 4)
61+
62+
def it_matches_len_of_rows_and_columns_collections(self, t3x4):
63+
assert t3x4.row_count == len(t3x4.rows)
64+
assert t3x4.column_count == len(t3x4.columns)
65+
66+
def it_increments_row_count_after_rows_add(self, t3x4):
67+
t3x4.rows.add()
68+
assert t3x4.row_count == 4
69+
assert t3x4.dimensions == (4, 4)
70+
71+
def it_decrements_row_count_after_rows_remove(self, t3x4):
72+
t3x4.rows.remove(1)
73+
assert t3x4.row_count == 2
74+
assert t3x4.dimensions == (2, 4)
75+
76+
def it_increments_column_count_after_columns_add(self, t3x4):
77+
t3x4.columns.add()
78+
assert t3x4.column_count == 5
79+
assert t3x4.dimensions == (3, 5)
80+
81+
def it_decrements_column_count_after_columns_remove(self, t3x4):
82+
t3x4.columns.remove(0)
83+
assert t3x4.column_count == 3
84+
assert t3x4.dimensions == (3, 3)
85+
86+
@pytest.mark.parametrize("attr", ["row_count", "column_count", "dimensions"])
87+
def it_is_read_only_no_setter(self, t3x4, attr):
88+
with pytest.raises(AttributeError):
89+
setattr(t3x4, attr, 99)
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# Per-row height / per-column width — round-trip regression-lock
94+
# ---------------------------------------------------------------------------
95+
96+
97+
class DescribeSizing_RoundTrip(object):
98+
"""Save → reload preserves explicit row heights and column widths."""
99+
100+
def it_preserves_a_single_row_height_through_save_and_reload(self):
101+
prs, t = _make_table(3, 3)
102+
t.rows[0].height = Emu(500000)
103+
104+
buf = io.BytesIO()
105+
prs.save(buf)
106+
buf.seek(0)
107+
108+
prs2 = Presentation(buf)
109+
t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table
110+
assert t2.rows[0].height == 500000
111+
112+
def it_preserves_a_single_column_width_through_save_and_reload(self):
113+
prs, t = _make_table(3, 3)
114+
t.columns[1].width = Emu(1000000)
115+
116+
buf = io.BytesIO()
117+
prs.save(buf)
118+
buf.seek(0)
119+
120+
prs2 = Presentation(buf)
121+
t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table
122+
assert t2.columns[1].width == 1000000
123+
124+
def it_preserves_mixed_row_heights(self):
125+
prs, t = _make_table(3, 3)
126+
heights = [Emu(300000), Emu(600000), Emu(900000)]
127+
for idx, h in enumerate(heights):
128+
t.rows[idx].height = h
129+
130+
buf = io.BytesIO()
131+
prs.save(buf)
132+
buf.seek(0)
133+
134+
prs2 = Presentation(buf)
135+
t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table
136+
assert [t2.rows[i].height for i in range(3)] == [300000, 600000, 900000]
137+
138+
def it_preserves_mixed_column_widths(self):
139+
prs, t = _make_table(2, 4)
140+
widths = [Emu(400000), Emu(800000), Emu(1200000), Emu(1600000)]
141+
for idx, w in enumerate(widths):
142+
t.columns[idx].width = w
143+
144+
buf = io.BytesIO()
145+
prs.save(buf)
146+
buf.seek(0)
147+
148+
prs2 = Presentation(buf)
149+
t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table
150+
assert [t2.columns[i].width for i in range(4)] == [400000, 800000, 1200000, 1600000]
151+
152+
def it_preserves_count_properties_through_round_trip(self):
153+
prs, t = _make_table(4, 5)
154+
155+
buf = io.BytesIO()
156+
prs.save(buf)
157+
buf.seek(0)
158+
159+
prs2 = Presentation(buf)
160+
t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table
161+
assert t2.row_count == 4
162+
assert t2.column_count == 5
163+
assert t2.dimensions == (4, 5)
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# Anti / Regression
168+
# ---------------------------------------------------------------------------
169+
170+
171+
class DescribePhase4_Regression(object):
172+
"""Anti-criteria: existing surfaces unaffected by Phase 4 additions."""
173+
174+
def it_keeps_phase1_rows_add_remove_working(self, t3x4):
175+
t3x4.rows.add()
176+
assert t3x4.row_count == 4
177+
t3x4.rows.remove(0)
178+
assert t3x4.row_count == 3
179+
180+
def it_keeps_phase1_columns_add_remove_working(self, t3x4):
181+
t3x4.columns.add()
182+
assert t3x4.column_count == 5
183+
t3x4.columns.remove(0)
184+
assert t3x4.column_count == 4
185+
186+
def it_keeps_phase2_style_api_working(self, t3x4):
187+
assert t3x4.style_id == "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
188+
t3x4.apply_style("No Style, No Grid")
189+
assert t3x4.style_name == "No Style, No Grid"
190+
191+
def it_keeps_phase3_merge_api_working(self, t3x4):
192+
t3x4.merge_cells((0, 1), (0, 2))
193+
assert t3x4.cell(0, 0).grid_span == 3
194+
assert t3x4.cell(0, 0).row_span == 2
195+
t3x4.split_cells((0, 1), (0, 2))
196+
assert t3x4.cell(0, 0).grid_span == 1
197+
198+
def it_keeps_existing_Row_height_setter_working(self, t3x4):
199+
t3x4.rows[0].height = Emu(500000)
200+
assert t3x4.rows[0].height == 500000
201+
202+
def it_keeps_existing_Column_width_setter_working(self, t3x4):
203+
t3x4.columns[0].width = Emu(800000)
204+
assert t3x4.columns[0].width == 800000

0 commit comments

Comments
 (0)