Skip to content

Commit 1aea816

Browse files
authored
Merge pull request #41 from MHoroszowski/feature/tables-phase3
feat(tables): merge robustness — Table.merge_cells/split_cells + Cell inspection accessors (Tables 2.0 Phase 3)
2 parents 5655acc + 5d079cd commit 1aea816

4 files changed

Lines changed: 735 additions & 0 deletions

File tree

features/steps/tbl_merge.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Gherkin step implementations for Table merge robustness (issue #12 Phase 3)."""
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+
@given("a 3x3 table on a fresh slide")
16+
def given_a_3x3_table(context):
17+
prs = Presentation()
18+
slide = prs.slides.add_slide(prs.slide_layouts[6])
19+
shape = slide.shapes.add_table(3, 3, Inches(1), Inches(1), Inches(6), Inches(2))
20+
context.prs = prs
21+
context.table_ = shape.table
22+
23+
24+
# when ====================================================
25+
26+
27+
@when("I call table.merge_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d})")
28+
def when_merge_cells(context, r1, r2, c1, c2):
29+
context.table_.merge_cells((r1, r2), (c1, c2))
30+
31+
32+
@when(
33+
"I call table.merge_cells with range({r_start:d},{r_stop:d}) "
34+
"and range({c_start:d},{c_stop:d})"
35+
)
36+
def when_merge_cells_with_range(context, r_start, r_stop, c_start, c_stop):
37+
context.table_.merge_cells(range(r_start, r_stop), range(c_start, c_stop))
38+
39+
40+
@when("I call table.split_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d})")
41+
def when_split_cells(context, r1, r2, c1, c2):
42+
context.table_.split_cells((r1, r2), (c1, c2))
43+
44+
45+
# then ====================================================
46+
47+
48+
@then("cell ({r:d},{c:d}) has gridSpan={gs:d} and rowSpan={rs:d}")
49+
def then_cell_has_dimensions(context, r, c, gs, rs):
50+
cell = context.table_.cell(r, c)
51+
assert cell.grid_span == gs, (cell.grid_span, gs)
52+
assert cell.row_span == rs, (cell.row_span, rs)
53+
54+
55+
@then("cell ({r:d},{c:d}) is_merge_origin is {expected:S}")
56+
def then_cell_is_merge_origin(context, r, c, expected):
57+
actual = context.table_.cell(r, c).is_merge_origin
58+
want = expected == "True"
59+
assert actual is want, (actual, expected)
60+
61+
62+
@then("cell ({r:d},{c:d}) hMerge is {expected:S}")
63+
def then_cell_hMerge(context, r, c, expected):
64+
actual = context.table_.cell(r, c).h_merge
65+
want = expected == "True"
66+
assert actual is want, (actual, expected)
67+
68+
69+
@then("cell ({r:d},{c:d}) vMerge is {expected:S}")
70+
def then_cell_vMerge(context, r, c, expected):
71+
actual = context.table_.cell(r, c).v_merge
72+
want = expected == "True"
73+
assert actual is want, (actual, expected)
74+
75+
76+
@then("calling table.merge_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d}) raises ValueError")
77+
def then_merge_cells_raises(context, r1, r2, c1, c2):
78+
with pytest.raises(ValueError):
79+
context.table_.merge_cells((r1, r2), (c1, c2))
80+
81+
82+
@then("calling table.split_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d}) raises ValueError")
83+
def then_split_cells_raises(context, r1, r2, c1, c2):
84+
with pytest.raises(ValueError):
85+
context.table_.split_cells((r1, r2), (c1, c2))

features/tbl-merge.feature

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
Feature: Table merge robustness — range-style merge_cells / split_cells
2+
In order to assemble tables with block merges programmatically
3+
As a developer using python-pptx
4+
I need range-style idempotent Table.merge_cells / Table.split_cells, and read-only inspection of gridSpan/rowSpan/hMerge/vMerge
5+
6+
7+
Scenario: Range merge a 2x3 block
8+
Given a 3x3 table on a fresh slide
9+
When I call table.merge_cells row=(0,1) col=(0,2)
10+
Then cell (0,0) has gridSpan=3 and rowSpan=2
11+
And cell (0,0) is_merge_origin is True
12+
And cell (1,1) hMerge is True
13+
And cell (1,1) vMerge is True
14+
15+
16+
Scenario: Range merge is idempotent on exact re-merge
17+
Given a 3x3 table on a fresh slide
18+
When I call table.merge_cells row=(0,1) col=(0,2)
19+
And I call table.merge_cells row=(0,1) col=(0,2)
20+
Then cell (0,0) has gridSpan=3 and rowSpan=2
21+
22+
23+
Scenario: Range merge accepts Python range objects
24+
Given a 3x3 table on a fresh slide
25+
When I call table.merge_cells with range(0,2) and range(0,3)
26+
Then cell (0,0) has gridSpan=3 and rowSpan=2
27+
28+
29+
Scenario: Range merge raises on partial overlap
30+
Given a 3x3 table on a fresh slide
31+
When I call table.merge_cells row=(0,0) col=(0,1)
32+
Then calling table.merge_cells row=(0,1) col=(0,1) raises ValueError
33+
34+
35+
Scenario: Single-cell range merge is a no-op
36+
Given a 3x3 table on a fresh slide
37+
When I call table.merge_cells row=(0,0) col=(0,0)
38+
Then cell (0,0) has gridSpan=1 and rowSpan=1
39+
40+
41+
Scenario: Range split unmerges a block
42+
Given a 3x3 table on a fresh slide
43+
When I call table.merge_cells row=(0,1) col=(0,2)
44+
And I call table.split_cells row=(0,1) col=(0,2)
45+
Then cell (0,0) has gridSpan=1 and rowSpan=1
46+
And cell (1,1) hMerge is False
47+
And cell (1,1) vMerge is False
48+
49+
50+
Scenario: Range split is idempotent on un-merged ranges
51+
Given a 3x3 table on a fresh slide
52+
When I call table.split_cells row=(0,2) col=(0,2)
53+
Then cell (0,0) has gridSpan=1 and rowSpan=1
54+
55+
56+
Scenario: Range split raises when merge crosses range boundary
57+
Given a 3x3 table on a fresh slide
58+
When I call table.merge_cells row=(0,1) col=(0,2)
59+
Then calling table.split_cells row=(0,0) col=(0,1) raises ValueError

src/pptx/table.py

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

169+
def merge_cells(self, row_range, col_range) -> "_Cell":
170+
"""Merge a rectangular block of cells into a single merged cell.
171+
172+
``row_range`` and ``col_range`` accept either:
173+
174+
- a 2-tuple ``(start, end)`` interpreted as **inclusive** indices —
175+
``(0, 1)`` covers rows 0 and 1.
176+
- a Python ``range`` object — half-open per Python convention; ``range(0, 2)``
177+
covers rows 0 and 1.
178+
179+
The order within each range is irrelevant: ``(2, 0)`` is the same as ``(0, 2)``.
180+
181+
Idempotent: if the entire requested range is already merged exactly
182+
as a single block with the same origin and dimensions, the call is
183+
a no-op and returns the existing merge-origin cell. Calling on a
184+
single-cell range that is not merged is also a no-op (no merge is
185+
needed for one cell).
186+
187+
Raises |ValueError| if the requested range partially overlaps an
188+
existing merge with different boundaries — the caller is expected
189+
to ``split_cells`` that overlap first.
190+
191+
Returns the |_Cell| at the merge origin (top-left of the merged
192+
block).
193+
"""
194+
top, bottom = _normalize_range(row_range)
195+
left, right = _normalize_range(col_range)
196+
197+
origin_tc = self._tbl.tc(top, left)
198+
bottom_right_tc = self._tbl.tc(bottom, right)
199+
200+
# ---single-cell range (no merge needed); return cell as-is---
201+
if top == bottom and left == right:
202+
return _Cell(origin_tc, self)
203+
204+
target_row_count = bottom - top + 1
205+
target_col_count = right - left + 1
206+
207+
# ---idempotency check: already merged exactly this way?---
208+
if (
209+
origin_tc.is_merge_origin
210+
and origin_tc.rowSpan == target_row_count
211+
and origin_tc.gridSpan == target_col_count
212+
):
213+
return _Cell(origin_tc, self)
214+
215+
tc_range = TcRange(origin_tc, bottom_right_tc)
216+
if tc_range.contains_merged_cell:
217+
raise ValueError(
218+
"merge_cells range partially overlaps an existing merge; "
219+
"call split_cells on the overlap first"
220+
)
221+
222+
tc_range.move_content_to_origin()
223+
224+
for tc in tc_range.iter_top_row_tcs():
225+
tc.rowSpan = target_row_count
226+
for tc in tc_range.iter_left_col_tcs():
227+
tc.gridSpan = target_col_count
228+
for tc in tc_range.iter_except_left_col_tcs():
229+
tc.hMerge = True
230+
for tc in tc_range.iter_except_top_row_tcs():
231+
tc.vMerge = True
232+
233+
return _Cell(origin_tc, self)
234+
235+
def split_cells(self, row_range, col_range) -> None:
236+
"""Split (un-merge) any merges fully contained in this range.
237+
238+
``row_range`` and ``col_range`` follow the same shape rules as
239+
:meth:`merge_cells` — tuples are inclusive, ``range`` objects are
240+
half-open.
241+
242+
Idempotent: cells in the range that aren't part of a merge are
243+
skipped silently. The order within each range is irrelevant.
244+
245+
Raises |ValueError| if a merge in the range extends *beyond* the
246+
range boundary — splitting it would orphan the rest of the merge,
247+
so the caller must widen the range to include the full merge or
248+
call this on the full merge directly.
249+
"""
250+
top, bottom = _normalize_range(row_range)
251+
left, right = _normalize_range(col_range)
252+
253+
# ---first pass: validate every merge that intersects the range
254+
# ---is FULLY contained (origin + extent inside [top..bottom, left..right])---
255+
for r in range(top, bottom + 1):
256+
for c in range(left, right + 1):
257+
tc = self._tbl.tc(r, c)
258+
if tc.is_merge_origin:
259+
if r + tc.rowSpan - 1 > bottom or c + tc.gridSpan - 1 > right:
260+
raise ValueError(
261+
"merge at (%d, %d) extends outside split range; "
262+
"widen the range or call split_cells on the full merge" % (r, c)
263+
)
264+
elif tc.hMerge or tc.vMerge:
265+
# ---spanned cell whose origin is OUTSIDE the range = boundary cross---
266+
# ---walk back to origin to verify---
267+
origin_r, origin_c = _find_merge_origin(self._tbl, r, c)
268+
if origin_r < top or origin_c < left:
269+
raise ValueError(
270+
"merge containing (%d, %d) starts outside split range; "
271+
"widen the range or call split_cells on the full merge" % (r, c)
272+
)
273+
274+
# ---second pass: split each merge-origin in range (idempotent on non-merges)---
275+
for r in range(top, bottom + 1):
276+
for c in range(left, right + 1):
277+
tc = self._tbl.tc(r, c)
278+
if not tc.is_merge_origin:
279+
continue
280+
tc_range = TcRange.from_merge_origin(tc)
281+
for inner_tc in tc_range.iter_tcs():
282+
inner_tc.rowSpan = 1
283+
inner_tc.gridSpan = 1
284+
inner_tc.hMerge = False
285+
inner_tc.vMerge = False
286+
169287
@property
170288
def style_id(self) -> str | None:
171289
"""The GUID identifying this table's built-in style, or |None|.
@@ -246,6 +364,52 @@ def _looks_like_guid(value: str) -> bool:
246364
return bool(_GUID_RE.match(value))
247365

248366

367+
def _normalize_range(rng) -> tuple[int, int]:
368+
"""Normalize a `merge_cells`/`split_cells` range argument to `(low, high)` inclusive.
369+
370+
Accepts a 2-tuple (interpreted as inclusive `(start, end)`) or a Python
371+
`range` object (half-open per Python convention). Order within either
372+
form is irrelevant — `(2, 0)` becomes `(0, 2)`. Raises `TypeError` on
373+
other input shapes.
374+
"""
375+
if isinstance(rng, range):
376+
# ---half-open: range(0, 2) covers 0..1 inclusive---
377+
if rng.step != 1:
378+
raise ValueError("range step must be 1, got %r" % rng.step)
379+
if len(rng) == 0:
380+
raise ValueError("range is empty: %r" % rng)
381+
low, high = rng.start, rng.stop - 1
382+
elif isinstance(rng, tuple) and len(rng) == 2:
383+
a, b = rng
384+
low, high = (a, b) if a <= b else (b, a)
385+
else:
386+
raise TypeError(
387+
"range argument must be a 2-tuple (inclusive) or a range object, got %r" % (rng,)
388+
)
389+
if low < 0 or high < 0:
390+
raise ValueError("range indices must be non-negative")
391+
return low, high
392+
393+
394+
def _find_merge_origin(tbl, row_idx: int, col_idx: int) -> tuple[int, int]:
395+
"""Walk back from a spanned cell to the (row, col) of its merge origin.
396+
397+
A spanned cell carries `hMerge=True` and/or `vMerge=True` and its origin
398+
sits at some `(r0, c0)` where `r0 <= row_idx` and `c0 <= col_idx`. The
399+
origin's `rowSpan`/`gridSpan` covers (row_idx, col_idx). We scan
400+
leftward until `hMerge` is False, then upward until `vMerge` is False —
401+
that lands on the origin in two passes.
402+
"""
403+
r, c = row_idx, col_idx
404+
# ---scan left through hMerge cells---
405+
while c > 0 and tbl.tc(r, c).hMerge:
406+
c -= 1
407+
# ---scan up through vMerge cells---
408+
while r > 0 and tbl.tc(r, c).vMerge:
409+
r -= 1
410+
return r, c
411+
412+
249413
class _BorderEdge:
250414
"""Adapter providing a `LineFormat`-compatible interface for one edge of a cell border.
251415
@@ -348,6 +512,51 @@ def fill(self) -> FillFormat:
348512
tcPr = self._tc.get_or_add_tcPr()
349513
return FillFormat.from_fill_parent(tcPr)
350514

515+
@property
516+
def grid_span(self) -> int:
517+
"""Number of grid columns this cell spans (1 if not a horizontal merge origin).
518+
519+
Read-only. Mirrors the underlying ``a:tc/@gridSpan`` attribute. A
520+
merge-origin cell that spans ``N`` columns reports ``N``; a spanned
521+
(non-origin) cell reports 1 even when it is part of a merge — the
522+
merge origin holds the dimension; spanned cells carry ``h_merge`` /
523+
``v_merge`` instead. Use this together with ``row_span`` /
524+
``h_merge`` / ``v_merge`` to inspect any cell's merge state without
525+
relying on `is_merge_origin` heuristics.
526+
"""
527+
return self._tc.gridSpan
528+
529+
@property
530+
def row_span(self) -> int:
531+
"""Number of grid rows this cell spans (1 if not a vertical merge origin).
532+
533+
Read-only. Mirrors the underlying ``a:tc/@rowSpan`` attribute. Same
534+
contract as ``grid_span`` but for rows. See ``grid_span`` docstring.
535+
"""
536+
return self._tc.rowSpan
537+
538+
@property
539+
def h_merge(self) -> bool:
540+
"""True if this cell is part of a horizontal merge but is NOT the origin.
541+
542+
Read-only. Mirrors the underlying ``a:tc/@hMerge`` attribute.
543+
Always |False| on the merge-origin cell of a horizontal merge —
544+
only the spanned cells (those to the right of the origin) carry
545+
``hMerge=True`` in the underlying XML.
546+
"""
547+
return self._tc.hMerge
548+
549+
@property
550+
def v_merge(self) -> bool:
551+
"""True if this cell is part of a vertical merge but is NOT the origin.
552+
553+
Read-only. Mirrors the underlying ``a:tc/@vMerge`` attribute.
554+
Always |False| on the merge-origin cell of a vertical merge —
555+
only the spanned cells (those below the origin) carry
556+
``vMerge=True`` in the underlying XML.
557+
"""
558+
return self._tc.vMerge
559+
351560
@property
352561
def is_merge_origin(self) -> bool:
353562
"""True if this cell is the top-left grid cell in a merged cell."""

0 commit comments

Comments
 (0)