diff --git a/features/steps/tbl_merge.py b/features/steps/tbl_merge.py new file mode 100644 index 000000000..194020786 --- /dev/null +++ b/features/steps/tbl_merge.py @@ -0,0 +1,85 @@ +"""Gherkin step implementations for Table merge robustness (issue #12 Phase 3).""" + +from __future__ import annotations + +import pytest +from behave import given, then, when + +from pptx import Presentation +from pptx.util import Inches + + +# given =================================================== + + +@given("a 3x3 table on a fresh slide") +def given_a_3x3_table(context): + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + shape = slide.shapes.add_table(3, 3, Inches(1), Inches(1), Inches(6), Inches(2)) + context.prs = prs + context.table_ = shape.table + + +# when ==================================================== + + +@when("I call table.merge_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d})") +def when_merge_cells(context, r1, r2, c1, c2): + context.table_.merge_cells((r1, r2), (c1, c2)) + + +@when( + "I call table.merge_cells with range({r_start:d},{r_stop:d}) " + "and range({c_start:d},{c_stop:d})" +) +def when_merge_cells_with_range(context, r_start, r_stop, c_start, c_stop): + context.table_.merge_cells(range(r_start, r_stop), range(c_start, c_stop)) + + +@when("I call table.split_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d})") +def when_split_cells(context, r1, r2, c1, c2): + context.table_.split_cells((r1, r2), (c1, c2)) + + +# then ==================================================== + + +@then("cell ({r:d},{c:d}) has gridSpan={gs:d} and rowSpan={rs:d}") +def then_cell_has_dimensions(context, r, c, gs, rs): + cell = context.table_.cell(r, c) + assert cell.grid_span == gs, (cell.grid_span, gs) + assert cell.row_span == rs, (cell.row_span, rs) + + +@then("cell ({r:d},{c:d}) is_merge_origin is {expected:S}") +def then_cell_is_merge_origin(context, r, c, expected): + actual = context.table_.cell(r, c).is_merge_origin + want = expected == "True" + assert actual is want, (actual, expected) + + +@then("cell ({r:d},{c:d}) hMerge is {expected:S}") +def then_cell_hMerge(context, r, c, expected): + actual = context.table_.cell(r, c).h_merge + want = expected == "True" + assert actual is want, (actual, expected) + + +@then("cell ({r:d},{c:d}) vMerge is {expected:S}") +def then_cell_vMerge(context, r, c, expected): + actual = context.table_.cell(r, c).v_merge + want = expected == "True" + assert actual is want, (actual, expected) + + +@then("calling table.merge_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d}) raises ValueError") +def then_merge_cells_raises(context, r1, r2, c1, c2): + with pytest.raises(ValueError): + context.table_.merge_cells((r1, r2), (c1, c2)) + + +@then("calling table.split_cells row=({r1:d},{r2:d}) col=({c1:d},{c2:d}) raises ValueError") +def then_split_cells_raises(context, r1, r2, c1, c2): + with pytest.raises(ValueError): + context.table_.split_cells((r1, r2), (c1, c2)) diff --git a/features/tbl-merge.feature b/features/tbl-merge.feature new file mode 100644 index 000000000..804caf52e --- /dev/null +++ b/features/tbl-merge.feature @@ -0,0 +1,59 @@ +Feature: Table merge robustness — range-style merge_cells / split_cells + In order to assemble tables with block merges programmatically + As a developer using python-pptx + I need range-style idempotent Table.merge_cells / Table.split_cells, and read-only inspection of gridSpan/rowSpan/hMerge/vMerge + + + Scenario: Range merge a 2x3 block + Given a 3x3 table on a fresh slide + When I call table.merge_cells row=(0,1) col=(0,2) + Then cell (0,0) has gridSpan=3 and rowSpan=2 + And cell (0,0) is_merge_origin is True + And cell (1,1) hMerge is True + And cell (1,1) vMerge is True + + + Scenario: Range merge is idempotent on exact re-merge + Given a 3x3 table on a fresh slide + When I call table.merge_cells row=(0,1) col=(0,2) + And I call table.merge_cells row=(0,1) col=(0,2) + Then cell (0,0) has gridSpan=3 and rowSpan=2 + + + Scenario: Range merge accepts Python range objects + Given a 3x3 table on a fresh slide + When I call table.merge_cells with range(0,2) and range(0,3) + Then cell (0,0) has gridSpan=3 and rowSpan=2 + + + Scenario: Range merge raises on partial overlap + Given a 3x3 table on a fresh slide + When I call table.merge_cells row=(0,0) col=(0,1) + Then calling table.merge_cells row=(0,1) col=(0,1) raises ValueError + + + Scenario: Single-cell range merge is a no-op + Given a 3x3 table on a fresh slide + When I call table.merge_cells row=(0,0) col=(0,0) + Then cell (0,0) has gridSpan=1 and rowSpan=1 + + + Scenario: Range split unmerges a block + Given a 3x3 table on a fresh slide + When I call table.merge_cells row=(0,1) col=(0,2) + And I call table.split_cells row=(0,1) col=(0,2) + Then cell (0,0) has gridSpan=1 and rowSpan=1 + And cell (1,1) hMerge is False + And cell (1,1) vMerge is False + + + Scenario: Range split is idempotent on un-merged ranges + Given a 3x3 table on a fresh slide + When I call table.split_cells row=(0,2) col=(0,2) + Then cell (0,0) has gridSpan=1 and rowSpan=1 + + + Scenario: Range split raises when merge crosses range boundary + Given a 3x3 table on a fresh slide + When I call table.merge_cells row=(0,1) col=(0,2) + Then calling table.split_cells row=(0,0) col=(0,1) raises ValueError diff --git a/src/pptx/table.py b/src/pptx/table.py index be2194a29..ce8519f04 100644 --- a/src/pptx/table.py +++ b/src/pptx/table.py @@ -166,6 +166,124 @@ def vert_banding(self) -> bool: def vert_banding(self, value: bool): self._tbl.bandCol = value + def merge_cells(self, row_range, col_range) -> "_Cell": + """Merge a rectangular block of cells into a single merged cell. + + ``row_range`` and ``col_range`` accept either: + + - a 2-tuple ``(start, end)`` interpreted as **inclusive** indices — + ``(0, 1)`` covers rows 0 and 1. + - a Python ``range`` object — half-open per Python convention; ``range(0, 2)`` + covers rows 0 and 1. + + The order within each range is irrelevant: ``(2, 0)`` is the same as ``(0, 2)``. + + Idempotent: if the entire requested range is already merged exactly + as a single block with the same origin and dimensions, the call is + a no-op and returns the existing merge-origin cell. Calling on a + single-cell range that is not merged is also a no-op (no merge is + needed for one cell). + + Raises |ValueError| if the requested range partially overlaps an + existing merge with different boundaries — the caller is expected + to ``split_cells`` that overlap first. + + Returns the |_Cell| at the merge origin (top-left of the merged + block). + """ + top, bottom = _normalize_range(row_range) + left, right = _normalize_range(col_range) + + origin_tc = self._tbl.tc(top, left) + bottom_right_tc = self._tbl.tc(bottom, right) + + # ---single-cell range (no merge needed); return cell as-is--- + if top == bottom and left == right: + return _Cell(origin_tc, self) + + target_row_count = bottom - top + 1 + target_col_count = right - left + 1 + + # ---idempotency check: already merged exactly this way?--- + if ( + origin_tc.is_merge_origin + and origin_tc.rowSpan == target_row_count + and origin_tc.gridSpan == target_col_count + ): + return _Cell(origin_tc, self) + + tc_range = TcRange(origin_tc, bottom_right_tc) + if tc_range.contains_merged_cell: + raise ValueError( + "merge_cells range partially overlaps an existing merge; " + "call split_cells on the overlap first" + ) + + tc_range.move_content_to_origin() + + for tc in tc_range.iter_top_row_tcs(): + tc.rowSpan = target_row_count + for tc in tc_range.iter_left_col_tcs(): + tc.gridSpan = target_col_count + for tc in tc_range.iter_except_left_col_tcs(): + tc.hMerge = True + for tc in tc_range.iter_except_top_row_tcs(): + tc.vMerge = True + + return _Cell(origin_tc, self) + + def split_cells(self, row_range, col_range) -> None: + """Split (un-merge) any merges fully contained in this range. + + ``row_range`` and ``col_range`` follow the same shape rules as + :meth:`merge_cells` — tuples are inclusive, ``range`` objects are + half-open. + + Idempotent: cells in the range that aren't part of a merge are + skipped silently. The order within each range is irrelevant. + + Raises |ValueError| if a merge in the range extends *beyond* the + range boundary — splitting it would orphan the rest of the merge, + so the caller must widen the range to include the full merge or + call this on the full merge directly. + """ + top, bottom = _normalize_range(row_range) + left, right = _normalize_range(col_range) + + # ---first pass: validate every merge that intersects the range + # ---is FULLY contained (origin + extent inside [top..bottom, left..right])--- + for r in range(top, bottom + 1): + for c in range(left, right + 1): + tc = self._tbl.tc(r, c) + if tc.is_merge_origin: + if r + tc.rowSpan - 1 > bottom or c + tc.gridSpan - 1 > right: + raise ValueError( + "merge at (%d, %d) extends outside split range; " + "widen the range or call split_cells on the full merge" % (r, c) + ) + elif tc.hMerge or tc.vMerge: + # ---spanned cell whose origin is OUTSIDE the range = boundary cross--- + # ---walk back to origin to verify--- + origin_r, origin_c = _find_merge_origin(self._tbl, r, c) + if origin_r < top or origin_c < left: + raise ValueError( + "merge containing (%d, %d) starts outside split range; " + "widen the range or call split_cells on the full merge" % (r, c) + ) + + # ---second pass: split each merge-origin in range (idempotent on non-merges)--- + for r in range(top, bottom + 1): + for c in range(left, right + 1): + tc = self._tbl.tc(r, c) + if not tc.is_merge_origin: + continue + tc_range = TcRange.from_merge_origin(tc) + for inner_tc in tc_range.iter_tcs(): + inner_tc.rowSpan = 1 + inner_tc.gridSpan = 1 + inner_tc.hMerge = False + inner_tc.vMerge = False + @property def style_id(self) -> str | None: """The GUID identifying this table's built-in style, or |None|. @@ -246,6 +364,52 @@ def _looks_like_guid(value: str) -> bool: return bool(_GUID_RE.match(value)) +def _normalize_range(rng) -> tuple[int, int]: + """Normalize a `merge_cells`/`split_cells` range argument to `(low, high)` inclusive. + + Accepts a 2-tuple (interpreted as inclusive `(start, end)`) or a Python + `range` object (half-open per Python convention). Order within either + form is irrelevant — `(2, 0)` becomes `(0, 2)`. Raises `TypeError` on + other input shapes. + """ + if isinstance(rng, range): + # ---half-open: range(0, 2) covers 0..1 inclusive--- + if rng.step != 1: + raise ValueError("range step must be 1, got %r" % rng.step) + if len(rng) == 0: + raise ValueError("range is empty: %r" % rng) + low, high = rng.start, rng.stop - 1 + elif isinstance(rng, tuple) and len(rng) == 2: + a, b = rng + low, high = (a, b) if a <= b else (b, a) + else: + raise TypeError( + "range argument must be a 2-tuple (inclusive) or a range object, got %r" % (rng,) + ) + if low < 0 or high < 0: + raise ValueError("range indices must be non-negative") + return low, high + + +def _find_merge_origin(tbl, row_idx: int, col_idx: int) -> tuple[int, int]: + """Walk back from a spanned cell to the (row, col) of its merge origin. + + A spanned cell carries `hMerge=True` and/or `vMerge=True` and its origin + sits at some `(r0, c0)` where `r0 <= row_idx` and `c0 <= col_idx`. The + origin's `rowSpan`/`gridSpan` covers (row_idx, col_idx). We scan + leftward until `hMerge` is False, then upward until `vMerge` is False — + that lands on the origin in two passes. + """ + r, c = row_idx, col_idx + # ---scan left through hMerge cells--- + while c > 0 and tbl.tc(r, c).hMerge: + c -= 1 + # ---scan up through vMerge cells--- + while r > 0 and tbl.tc(r, c).vMerge: + r -= 1 + return r, c + + class _BorderEdge: """Adapter providing a `LineFormat`-compatible interface for one edge of a cell border. @@ -348,6 +512,51 @@ def fill(self) -> FillFormat: tcPr = self._tc.get_or_add_tcPr() return FillFormat.from_fill_parent(tcPr) + @property + def grid_span(self) -> int: + """Number of grid columns this cell spans (1 if not a horizontal merge origin). + + Read-only. Mirrors the underlying ``a:tc/@gridSpan`` attribute. A + merge-origin cell that spans ``N`` columns reports ``N``; a spanned + (non-origin) cell reports 1 even when it is part of a merge — the + merge origin holds the dimension; spanned cells carry ``h_merge`` / + ``v_merge`` instead. Use this together with ``row_span`` / + ``h_merge`` / ``v_merge`` to inspect any cell's merge state without + relying on `is_merge_origin` heuristics. + """ + return self._tc.gridSpan + + @property + def row_span(self) -> int: + """Number of grid rows this cell spans (1 if not a vertical merge origin). + + Read-only. Mirrors the underlying ``a:tc/@rowSpan`` attribute. Same + contract as ``grid_span`` but for rows. See ``grid_span`` docstring. + """ + return self._tc.rowSpan + + @property + def h_merge(self) -> bool: + """True if this cell is part of a horizontal merge but is NOT the origin. + + Read-only. Mirrors the underlying ``a:tc/@hMerge`` attribute. + Always |False| on the merge-origin cell of a horizontal merge — + only the spanned cells (those to the right of the origin) carry + ``hMerge=True`` in the underlying XML. + """ + return self._tc.hMerge + + @property + def v_merge(self) -> bool: + """True if this cell is part of a vertical merge but is NOT the origin. + + Read-only. Mirrors the underlying ``a:tc/@vMerge`` attribute. + Always |False| on the merge-origin cell of a vertical merge — + only the spanned cells (those below the origin) carry + ``vMerge=True`` in the underlying XML. + """ + return self._tc.vMerge + @property def is_merge_origin(self) -> bool: """True if this cell is the top-left grid cell in a merged cell.""" diff --git a/tests/test_tables_phase3.py b/tests/test_tables_phase3.py new file mode 100644 index 000000000..a968d68b4 --- /dev/null +++ b/tests/test_tables_phase3.py @@ -0,0 +1,382 @@ +# pyright: reportPrivateUsage=false + +"""Unit-test suite for Tables 2.0 Phase 3 — merge robustness API. + +Covers: + +- Read-only inspection accessors on |_Cell|: ``gridSpan``, ``rowSpan``, + ``hMerge``, ``vMerge`` — mirror the underlying ``a:tc`` attrs and let + callers inspect any cell's merge state without `is_merge_origin` + heuristics. +- Range-style merge: |Table|.merge_cells(row_range, col_range) — idempotent + on already-merged-exactly-this-way regions, no-op on single-cell ranges, + raises |ValueError| on partial overlap with a different-shape merge. +- Range-style split: |Table|.split_cells(row_range, col_range) — + idempotent on un-merged ranges, raises |ValueError| when a merge + extends beyond the requested range boundary. +- Range-arg shape: tuples (inclusive) and Python ``range`` objects + (half-open) are both accepted; order within either form is irrelevant. +- Round-trip: a merge applied via merge_cells survives save/reload. +- Anti: existing |Cell|.merge / |Cell|.split unchanged; Phase-2 style API + unaffected by Phase-3 additions. + +Issue: https://github.com/MHoroszowski/python-pptx/issues/12 (Phase 3). +""" + +from __future__ import annotations + +import io + +import pytest + +from pptx import Presentation +from pptx.table import _Cell, _normalize_range +from pptx.util import 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 t3x3(): + _, t = _make_table(3, 3) + return t + + +@pytest.fixture +def t4x4(): + _, t = _make_table(4, 4) + return t + + +# --------------------------------------------------------------------------- +# Read-only inspection accessors — _Cell.grid_span / rowSpan / hMerge / vMerge +# --------------------------------------------------------------------------- + + +class DescribeCell_InspectionAccessors(object): + """Unit-test suite for `_Cell.grid_span`, `rowSpan`, `hMerge`, `vMerge`.""" + + def it_returns_default_gridSpan_1_for_unmerged_cell(self, t3x3): + assert t3x3.cell(0, 0).grid_span == 1 + + def it_returns_default_rowSpan_1_for_unmerged_cell(self, t3x3): + assert t3x3.cell(0, 0).row_span == 1 + + def it_returns_False_hMerge_for_unmerged_cell(self, t3x3): + assert t3x3.cell(0, 0).h_merge is False + + def it_returns_False_vMerge_for_unmerged_cell(self, t3x3): + assert t3x3.cell(0, 0).v_merge is False + + def it_reports_gridSpan_on_merge_origin_after_merge(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) # 2 rows x 3 cols + assert t3x3.cell(0, 0).grid_span == 3 + + def it_reports_rowSpan_on_merge_origin_after_merge(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) # 2 rows x 3 cols + assert t3x3.cell(0, 0).row_span == 2 + + def it_reports_hMerge_True_on_horizontally_spanned_cell(self, t3x3): + t3x3.merge_cells((0, 0), (0, 2)) # 1 row x 3 cols + assert t3x3.cell(0, 1).h_merge is True + assert t3x3.cell(0, 2).h_merge is True + + def it_reports_vMerge_True_on_vertically_spanned_cell(self, t3x3): + t3x3.merge_cells((0, 2), (0, 0)) # 3 rows x 1 col + assert t3x3.cell(1, 0).v_merge is True + assert t3x3.cell(2, 0).v_merge is True + + def it_reports_both_hMerge_and_vMerge_on_inner_spanned_cell_of_block(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) # 2x3 block + # ---inner spanned (1,2) is both horizontally and vertically spanned--- + c = t3x3.cell(1, 2) + assert c.h_merge is True + assert c.v_merge is True + + def it_resets_all_attrs_after_split(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) + t3x3.split_cells((0, 1), (0, 2)) + for r in range(2): + for c in range(3): + cell = t3x3.cell(r, c) + assert cell.grid_span == 1 + assert cell.row_span == 1 + assert cell.h_merge is False + assert cell.v_merge is False + + @pytest.mark.parametrize("attr", ["grid_span", "row_span", "h_merge", "v_merge"]) + def it_is_read_only_no_setter(self, t3x3, attr): + cell = t3x3.cell(0, 0) + with pytest.raises(AttributeError): + setattr(cell, attr, 99) + + +# --------------------------------------------------------------------------- +# Table.merge_cells +# --------------------------------------------------------------------------- + + +class DescribeTable_merge_cells(object): + """Unit-test suite for `Table.merge_cells`.""" + + def it_merges_a_2x3_block(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) + + origin = t3x3.cell(0, 0) + assert origin.is_merge_origin is True + assert origin.grid_span == 3 + assert origin.row_span == 2 + + def it_returns_the_merge_origin_Cell(self, t3x3): + result = t3x3.merge_cells((0, 1), (0, 2)) + + assert isinstance(result, _Cell) + assert result._tc is t3x3.cell(0, 0)._tc + + def it_is_idempotent_on_exact_re_merge(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) + # ---second call must not raise and not change shape--- + result = t3x3.merge_cells((0, 1), (0, 2)) + + assert t3x3.cell(0, 0).grid_span == 3 + assert t3x3.cell(0, 0).row_span == 2 + assert result._tc is t3x3.cell(0, 0)._tc + + def it_accepts_a_python_range_object(self, t3x3): + # ---half-open per Python convention; range(0, 2) covers rows 0 and 1--- + t3x3.merge_cells(range(0, 2), range(0, 3)) + + assert t3x3.cell(0, 0).row_span == 2 + assert t3x3.cell(0, 0).grid_span == 3 + + def it_treats_a_single_cell_range_as_a_noop(self, t3x3): + # ---single-cell merge = no merge needed; returns the cell itself--- + result = t3x3.merge_cells((0, 0), (0, 0)) + + assert result._tc is t3x3.cell(0, 0)._tc + assert t3x3.cell(0, 0).grid_span == 1 + assert t3x3.cell(0, 0).row_span == 1 + + def it_is_order_agnostic_within_a_tuple(self, t3x3): + # ---(2,0) means same as (0,2): rows 0..2 inclusive--- + t3x3.merge_cells((2, 0), (2, 0)) + + assert t3x3.cell(0, 0).row_span == 3 + assert t3x3.cell(0, 0).grid_span == 3 + + def it_raises_ValueError_on_partial_overlap_with_different_shape(self, t3x3): + # ---first merge: 1 row x 2 cols at top-left--- + t3x3.merge_cells((0, 0), (0, 1)) + + # ---second merge: 2 rows x 2 cols at top-left = different shape--- + with pytest.raises(ValueError) as excinfo: + t3x3.merge_cells((0, 1), (0, 1)) + assert "partially overlaps" in str(excinfo.value) + + def it_writes_correct_hMerge_vMerge_for_block_merge(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) # 2x3 + + # ---origin: gridSpan=3, rowSpan=2, no hMerge/vMerge + assert t3x3.cell(0, 0).h_merge is False + assert t3x3.cell(0, 0).v_merge is False + # ---top row (excl origin): hMerge=True, vMerge=False + assert t3x3.cell(0, 1).h_merge is True + assert t3x3.cell(0, 1).v_merge is False + assert t3x3.cell(0, 2).h_merge is True + # ---left col (excl origin): vMerge=True + assert t3x3.cell(1, 0).v_merge is True + assert t3x3.cell(1, 0).h_merge is False + # ---inner: both + assert t3x3.cell(1, 1).h_merge is True + assert t3x3.cell(1, 1).v_merge is True + + def it_raises_on_empty_range(self, t3x3): + with pytest.raises(ValueError): + t3x3.merge_cells(range(0, 0), range(0, 1)) + + def it_raises_on_unsupported_range_argument_type(self, t3x3): + with pytest.raises(TypeError): + t3x3.merge_cells([0, 1], [0, 1]) # ---list, not tuple or range + + +# --------------------------------------------------------------------------- +# Table.split_cells +# --------------------------------------------------------------------------- + + +class DescribeTable_split_cells(object): + """Unit-test suite for `Table.split_cells`.""" + + def it_splits_a_merged_block(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) + + t3x3.split_cells((0, 1), (0, 2)) + + assert t3x3.cell(0, 0).grid_span == 1 + assert t3x3.cell(0, 0).row_span == 1 + assert t3x3.cell(0, 0).is_merge_origin is False + for r in range(2): + for c in range(3): + assert t3x3.cell(r, c).h_merge is False + assert t3x3.cell(r, c).v_merge is False + + def it_is_idempotent_on_unmerged_range(self, t3x3): + # ---no merges in the range; split_cells should be a no-op--- + t3x3.split_cells((0, 2), (0, 2)) + + # ---all cells still default--- + for r in range(3): + for c in range(3): + assert t3x3.cell(r, c).grid_span == 1 + + def it_accepts_a_python_range_object(self, t3x3): + t3x3.merge_cells(range(0, 2), range(0, 3)) + + t3x3.split_cells(range(0, 2), range(0, 3)) + + for r in range(2): + for c in range(3): + assert t3x3.cell(r, c).grid_span == 1 + assert t3x3.cell(r, c).row_span == 1 + + def it_raises_when_merge_extends_beyond_split_range(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) # 2x3 over top-left + + # ---try to split only (0,0)..(0,1) — merge extends to col 2--- + with pytest.raises(ValueError) as excinfo: + t3x3.split_cells((0, 0), (0, 1)) + assert "outside split range" in str(excinfo.value) + + def it_raises_when_spanned_cell_origin_is_outside_range(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) # 2x3 over top-left + + # ---try to split just (1,1)..(1,2) — origin (0,0) is outside--- + with pytest.raises(ValueError) as excinfo: + t3x3.split_cells((1, 1), (1, 2)) + assert "starts outside split range" in str(excinfo.value) + + def it_can_split_multiple_merges_in_one_call(self, t4x4): + t4x4.merge_cells((0, 0), (0, 1)) # 1x2 at top-left + t4x4.merge_cells((2, 2), (2, 3)) # 1x2 at bottom-right + + t4x4.split_cells((0, 3), (0, 3)) # entire table + + for r in range(4): + for c in range(4): + assert t4x4.cell(r, c).grid_span == 1 + assert t4x4.cell(r, c).h_merge is False + + +# --------------------------------------------------------------------------- +# _normalize_range helper +# --------------------------------------------------------------------------- + + +class Describe_normalize_range(object): + """Unit-test suite for the private `_normalize_range` helper.""" + + @pytest.mark.parametrize( + ("rng", "expected"), + [ + ((0, 1), (0, 1)), + ((1, 0), (0, 1)), # ---order-agnostic + ((5, 5), (5, 5)), # ---single-cell + (range(0, 2), (0, 1)), # ---half-open range -> inclusive low/high + (range(2, 5), (2, 4)), + ], + ) + def it_normalizes_to_inclusive_low_high(self, rng, expected): + assert _normalize_range(rng) == expected + + @pytest.mark.parametrize( + "rng", + [ + [0, 1], # ---list, not tuple + (0, 1, 2), # ---3-tuple + "01", # ---string + ], + ) + def it_raises_TypeError_on_unsupported_shape(self, rng): + with pytest.raises(TypeError): + _normalize_range(rng) + + def it_raises_ValueError_on_negative_index(self): + with pytest.raises(ValueError): + _normalize_range((-1, 0)) + + def it_raises_ValueError_on_empty_range(self): + with pytest.raises(ValueError): + _normalize_range(range(2, 2)) + + def it_raises_ValueError_on_non_unit_step(self): + with pytest.raises(ValueError): + _normalize_range(range(0, 4, 2)) + + +# --------------------------------------------------------------------------- +# Round-trip +# --------------------------------------------------------------------------- + + +class DescribeMerge_RoundTrip(object): + """Save a presentation with a merge applied via merge_cells, reload — preserved.""" + + def it_preserves_a_block_merge_through_save_and_reload(self): + prs, t = _make_table(3, 3) + t.merge_cells((0, 1), (0, 2)) + + 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.cell(0, 0).row_span == 2 + assert t2.cell(0, 0).grid_span == 3 + assert t2.cell(1, 1).h_merge is True + assert t2.cell(1, 1).v_merge is True + + +# --------------------------------------------------------------------------- +# Anti / Regression +# --------------------------------------------------------------------------- + + +class DescribePhase3_Regression(object): + """Anti-criteria: existing surfaces unchanged.""" + + def it_keeps_existing_Cell_merge_working(self, t3x3): + # ---legacy 2-cell merge API stays as-is--- + t3x3.cell(0, 0).merge(t3x3.cell(0, 1)) + + assert t3x3.cell(0, 0).is_merge_origin is True + assert t3x3.cell(0, 0).grid_span == 2 + + def it_keeps_existing_Cell_split_working(self, t3x3): + t3x3.cell(0, 0).merge(t3x3.cell(0, 1)) + t3x3.cell(0, 0).split() + + assert t3x3.cell(0, 0).grid_span == 1 + assert t3x3.cell(0, 1).h_merge is False + + def it_keeps_phase2_style_api_working(self, t3x3): + # ---no regression from Phase 2 style surface--- + assert t3x3.style_id == "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" + t3x3.apply_style("No Style, No Grid") + assert t3x3.style_name == "No Style, No Grid" + + def it_keeps_existing_is_merge_origin_and_is_spanned_working(self, t3x3): + t3x3.merge_cells((0, 1), (0, 2)) + + assert t3x3.cell(0, 0).is_merge_origin is True + assert t3x3.cell(0, 1).is_spanned is True + assert t3x3.cell(0, 0).is_spanned is False