From 05b01dca275647b7424bc6a40a252178db131d14 Mon Sep 17 00:00:00 2001 From: Matthew Horoszowski Date: Fri, 8 May 2026 13:08:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(tables):=20row=5Fcount/column=5Fcount/dime?= =?UTF-8?q?nsions=20+=20sizing=20round-trip=20lock=20=E2=80=94=20Tables=20?= =?UTF-8?q?2.0=20Phase=204=20(closes=20epic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: https://github.com/MHoroszowski/python-pptx/issues/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 `/@h` and `/@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 --- features/steps/tbl_sizing.py | 78 ++++++++++++++ features/tbl-sizing.feature | 45 ++++++++ src/pptx/table.py | 27 +++++ tests/test_tables_phase4.py | 204 +++++++++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 features/steps/tbl_sizing.py create mode 100644 features/tbl-sizing.feature create mode 100644 tests/test_tables_phase4.py diff --git a/features/steps/tbl_sizing.py b/features/steps/tbl_sizing.py new file mode 100644 index 000000000..bf64f4519 --- /dev/null +++ b/features/steps/tbl_sizing.py @@ -0,0 +1,78 @@ +"""Gherkin step implementations for Table sizing & ergonomics (issue #12 Phase 4).""" + +from __future__ import annotations + +import io + +from behave import given, then, when + +from pptx import Presentation +from pptx.util import Emu, Inches + + +# given =================================================== + + +@given("a 3x4 table on a fresh slide") +def given_a_3x4_table(context): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_table(3, 4, Inches(1), Inches(1), Inches(6), Inches(2)) + context.prs = prs + context.table_ = shape.table + + +# when ==================================================== + + +@when("I add a row to the table") +def when_add_row(context): + context.table_.rows.add() + + +@when("I remove column {idx:d} from the table") +def when_remove_column(context, idx): + context.table_.columns.remove(idx) + + +@when("I set row {idx:d} height to {emu:d} EMU") +def when_set_row_height(context, idx, emu): + context.table_.rows[idx].height = Emu(emu) + + +@when("I set column {idx:d} width to {emu:d} EMU") +def when_set_column_width(context, idx, emu): + context.table_.columns[idx].width = Emu(emu) + + +# the "save and reload via stream" step is shared with tbl_styles.py — reuse + + +# then ==================================================== + + +@then("table.row_count is {n:d}") +def then_row_count(context, n): + assert context.table_.row_count == n, (context.table_.row_count, n) + + +@then("table.column_count is {n:d}") +def then_column_count(context, n): + assert context.table_.column_count == n, (context.table_.column_count, n) + + +@then("table.dimensions is ({rows:d}, {cols:d})") +def then_dimensions(context, rows, cols): + assert context.table_.dimensions == (rows, cols), (context.table_.dimensions, rows, cols) + + +@then("the reloaded row {idx:d} height is {emu:d}") +def then_reloaded_row_height(context, idx, emu): + actual = context.table_reloaded.rows[idx].height + assert actual == emu, (actual, emu) + + +@then("the reloaded column {idx:d} width is {emu:d}") +def then_reloaded_column_width(context, idx, emu): + actual = context.table_reloaded.columns[idx].width + assert actual == emu, (actual, emu) diff --git a/features/tbl-sizing.feature b/features/tbl-sizing.feature new file mode 100644 index 000000000..f99ee6070 --- /dev/null +++ b/features/tbl-sizing.feature @@ -0,0 +1,45 @@ +Feature: Table sizing & ergonomics — row_count / column_count / dimensions + sizing round-trip + In order to inspect a table's shape and rely on persistent row heights and column widths + As a developer using python-pptx + I need read-only count properties on Table and round-trip preservation of explicit sizes + + + Scenario: row_count returns the number of rows + Given a 3x4 table on a fresh slide + Then table.row_count is 3 + + + Scenario: column_count returns the number of columns + Given a 3x4 table on a fresh slide + Then table.column_count is 4 + + + Scenario: dimensions returns a (rows, cols) tuple + Given a 3x4 table on a fresh slide + Then table.dimensions is (3, 4) + + + Scenario: row_count updates after rows.add() + Given a 3x4 table on a fresh slide + When I add a row to the table + Then table.row_count is 4 + + + Scenario: column_count updates after columns.remove() + Given a 3x4 table on a fresh slide + When I remove column 0 from the table + Then table.column_count is 3 + + + Scenario: Row height round-trips through save/reload + Given a 3x4 table on a fresh slide + When I set row 0 height to 500000 EMU + And I save and reload the presentation via stream + Then the reloaded row 0 height is 500000 + + + Scenario: Column width round-trips through save/reload + Given a 3x4 table on a fresh slide + When I set column 1 width to 1000000 EMU + And I save and reload the presentation via stream + Then the reloaded column 1 width is 1000000 diff --git a/src/pptx/table.py b/src/pptx/table.py index ce8519f04..23512994b 100644 --- a/src/pptx/table.py +++ b/src/pptx/table.py @@ -166,6 +166,33 @@ def vert_banding(self) -> bool: def vert_banding(self, value: bool): self._tbl.bandCol = value + @property + def row_count(self) -> int: + """Number of rows in this table. + + Read-only. Equivalent to ``len(table.rows)`` but doesn't instantiate + the |_RowCollection|. + """ + return len(self._tbl.tr_lst) + + @property + def column_count(self) -> int: + """Number of columns in this table. + + Read-only. Equivalent to ``len(table.columns)`` but doesn't + instantiate the |_ColumnCollection|. + """ + return len(self._tbl.tblGrid.gridCol_lst) + + @property + def dimensions(self) -> tuple[int, int]: + """``(row_count, column_count)`` pair describing this table's shape. + + Read-only. Symmetrical with ``TcRange.dimensions``; rows-first order + matches the dominant 2D-array convention. + """ + return (self.row_count, self.column_count) + def merge_cells(self, row_range, col_range) -> "_Cell": """Merge a rectangular block of cells into a single merged cell. diff --git a/tests/test_tables_phase4.py b/tests/test_tables_phase4.py new file mode 100644 index 000000000..4d0b2c8ce --- /dev/null +++ b/tests/test_tables_phase4.py @@ -0,0 +1,204 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for Tables 2.0 Phase 4 — sizing & ergonomics, closing the epic. + +Covers: + +- Read-only count properties on |Table|: ``row_count``, ``column_count``, + ``dimensions`` — convenience accessors that don't instantiate the + full |_RowCollection| / |_ColumnCollection|. +- Per-row height and per-column width round-trip preservation through + save/reload (regression-lock — the underlying setters already work, + but no test pinned that down before now). +- Anti-criteria: Phase 1/2/3 surfaces unaffected. + +Issue: https://github.com/MHoroszowski/python-pptx/issues/12 (Phase 4, closing PR). +""" + +from __future__ import annotations + +import io + +import pytest + +from pptx import Presentation +from pptx.util import Emu, Inches + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_table(rows: int, cols: int): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + gf = slide.shapes.add_table(rows, cols, Inches(1), Inches(1), Inches(6), Inches(2)) + return prs, gf.table + + +@pytest.fixture +def t3x4(): + _, t = _make_table(3, 4) + return t + + +# --------------------------------------------------------------------------- +# Table.row_count / column_count / dimensions +# --------------------------------------------------------------------------- + + +class DescribeTable_CountProperties(object): + """Unit-test suite for `row_count`, `column_count`, `dimensions`.""" + + def it_reports_row_count_for_a_3x4_table(self, t3x4): + assert t3x4.row_count == 3 + + def it_reports_column_count_for_a_3x4_table(self, t3x4): + assert t3x4.column_count == 4 + + def it_reports_dimensions_as_rows_cols_tuple(self, t3x4): + assert t3x4.dimensions == (3, 4) + + def it_matches_len_of_rows_and_columns_collections(self, t3x4): + assert t3x4.row_count == len(t3x4.rows) + assert t3x4.column_count == len(t3x4.columns) + + def it_increments_row_count_after_rows_add(self, t3x4): + t3x4.rows.add() + assert t3x4.row_count == 4 + assert t3x4.dimensions == (4, 4) + + def it_decrements_row_count_after_rows_remove(self, t3x4): + t3x4.rows.remove(1) + assert t3x4.row_count == 2 + assert t3x4.dimensions == (2, 4) + + def it_increments_column_count_after_columns_add(self, t3x4): + t3x4.columns.add() + assert t3x4.column_count == 5 + assert t3x4.dimensions == (3, 5) + + def it_decrements_column_count_after_columns_remove(self, t3x4): + t3x4.columns.remove(0) + assert t3x4.column_count == 3 + assert t3x4.dimensions == (3, 3) + + @pytest.mark.parametrize("attr", ["row_count", "column_count", "dimensions"]) + def it_is_read_only_no_setter(self, t3x4, attr): + with pytest.raises(AttributeError): + setattr(t3x4, attr, 99) + + +# --------------------------------------------------------------------------- +# Per-row height / per-column width — round-trip regression-lock +# --------------------------------------------------------------------------- + + +class DescribeSizing_RoundTrip(object): + """Save → reload preserves explicit row heights and column widths.""" + + def it_preserves_a_single_row_height_through_save_and_reload(self): + prs, t = _make_table(3, 3) + t.rows[0].height = Emu(500000) + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + prs2 = Presentation(buf) + t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table + assert t2.rows[0].height == 500000 + + def it_preserves_a_single_column_width_through_save_and_reload(self): + prs, t = _make_table(3, 3) + t.columns[1].width = Emu(1000000) + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + prs2 = Presentation(buf) + t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table + assert t2.columns[1].width == 1000000 + + def it_preserves_mixed_row_heights(self): + prs, t = _make_table(3, 3) + heights = [Emu(300000), Emu(600000), Emu(900000)] + for idx, h in enumerate(heights): + t.rows[idx].height = h + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + prs2 = Presentation(buf) + t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table + assert [t2.rows[i].height for i in range(3)] == [300000, 600000, 900000] + + def it_preserves_mixed_column_widths(self): + prs, t = _make_table(2, 4) + widths = [Emu(400000), Emu(800000), Emu(1200000), Emu(1600000)] + for idx, w in enumerate(widths): + t.columns[idx].width = w + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + prs2 = Presentation(buf) + t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table + assert [t2.columns[i].width for i in range(4)] == [400000, 800000, 1200000, 1600000] + + def it_preserves_count_properties_through_round_trip(self): + prs, t = _make_table(4, 5) + + buf = io.BytesIO() + prs.save(buf) + buf.seek(0) + + prs2 = Presentation(buf) + t2 = next(shp for shp in prs2.slides[0].shapes if shp.has_table).table + assert t2.row_count == 4 + assert t2.column_count == 5 + assert t2.dimensions == (4, 5) + + +# --------------------------------------------------------------------------- +# Anti / Regression +# --------------------------------------------------------------------------- + + +class DescribePhase4_Regression(object): + """Anti-criteria: existing surfaces unaffected by Phase 4 additions.""" + + def it_keeps_phase1_rows_add_remove_working(self, t3x4): + t3x4.rows.add() + assert t3x4.row_count == 4 + t3x4.rows.remove(0) + assert t3x4.row_count == 3 + + def it_keeps_phase1_columns_add_remove_working(self, t3x4): + t3x4.columns.add() + assert t3x4.column_count == 5 + t3x4.columns.remove(0) + assert t3x4.column_count == 4 + + def it_keeps_phase2_style_api_working(self, t3x4): + assert t3x4.style_id == "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" + t3x4.apply_style("No Style, No Grid") + assert t3x4.style_name == "No Style, No Grid" + + def it_keeps_phase3_merge_api_working(self, t3x4): + t3x4.merge_cells((0, 1), (0, 2)) + assert t3x4.cell(0, 0).grid_span == 3 + assert t3x4.cell(0, 0).row_span == 2 + t3x4.split_cells((0, 1), (0, 2)) + assert t3x4.cell(0, 0).grid_span == 1 + + def it_keeps_existing_Row_height_setter_working(self, t3x4): + t3x4.rows[0].height = Emu(500000) + assert t3x4.rows[0].height == 500000 + + def it_keeps_existing_Column_width_setter_working(self, t3x4): + t3x4.columns[0].width = Emu(800000) + assert t3x4.columns[0].width == 800000