Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions features/steps/tbl_sizing.py
Original file line number Diff line number Diff line change
@@ -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)
45 changes: 45 additions & 0 deletions features/tbl-sizing.feature
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions src/pptx/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
204 changes: 204 additions & 0 deletions tests/test_tables_phase4.py
Original file line number Diff line number Diff line change
@@ -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
Loading