Skip to content

feat(tables): merge robustness — Table.merge_cells/split_cells + Cell inspection accessors (Tables 2.0 Phase 3)#41

Merged
MHoroszowski merged 1 commit into
masterfrom
feature/tables-phase3
May 8, 2026
Merged

feat(tables): merge robustness — Table.merge_cells/split_cells + Cell inspection accessors (Tables 2.0 Phase 3)#41
MHoroszowski merged 1 commit into
masterfrom
feature/tables-phase3

Conversation

@MHoroszowski
Copy link
Copy Markdown
Owner

Phase 3 of issue #12 (Tables 2.0): merge robustness API

Closes the third sub-feature on the Tables 2.0 epic. Phase 1 (PR #37) shipped row/column add/remove. Phase 2 (PR #40) shipped the table style API. This PR adds the range-style block-merge and inspection surface, leaving sizing/ergonomics for a follow-up Phase 4.

Before this change the only merge surface was Cell.merge(other_cell) — two-cell, raises ValueError on any range overlap, no idempotency. Power users hit this every time they programmatically built tables with block merges; they ended up wrapping every merge in try/except or pre-checking with is_merge_origin/is_spanned. Inspection of the underlying gridSpan / rowSpan / hMerge / vMerge was only reachable via the private _tc element.

Public surface added (additive — existing Cell.merge and Cell.split keep current behavior)

# Read-only inspection on _Cell (snake_case per module convention)
cell.grid_span            # 1 by default; >1 only on horizontal merge origin
cell.row_span             # 1 by default; >1 only on vertical merge origin
cell.h_merge              # True only on cells horizontally spanned
cell.v_merge              # True only on cells vertically spanned

# Range-style block merge
table.merge_cells((0, 1), (0, 2))           # tuples = inclusive (rows 0..1, cols 0..2)
table.merge_cells(range(0, 2), range(0, 3)) # range objects = half-open
table.merge_cells((0, 1), (0, 2))           # idempotent — second call is a no-op

# Range-style split (inverse)
table.split_cells((0, 1), (0, 2))           # un-merge a contained block
table.split_cells((0, 0), (0, 1))           # raises ValueError if merge extends beyond range

What it adds

_Cell read-only inspection accessors

  • Cell.grid_span / row_span / h_merge / v_merge — read-throughs to tc.gridSpan / tc.rowSpan / tc.hMerge / tc.vMerge. Snake_case per the existing _Cell convention (is_merge_origin, is_spanned, span_height, span_width, margin_top, etc.). The issue body used the OOXML attribute names literally (camelCase) but those are XML serialization details, not the Python public surface. Forge audit (pre-commit) flagged the original camelCase as a permanent API contract violation; renamed before merge.

Table range-style block merge

  • Table.merge_cells(row_range, col_range)row_range and col_range accept either a 2-tuple (start, end) (inclusive — (0, 1) covers rows 0 and 1) or a Python range object (half-open per Python convention). Order within either form is irrelevant. Idempotent on exact re-merge: a second call with the same range on an already-merged-exactly-this-way region is a no-op and returns the existing merge-origin _Cell. Single-cell range is also a no-op. Returns the merge-origin _Cell. Raises ValueError on partial overlap with a different-shape existing merge.
  • Table.split_cells(row_range, col_range) — inverse operation. Idempotent on un-merged ranges. Splits any merges fully contained in the range. Raises ValueError if a merge in the range extends beyond the boundary (would orphan the rest).

Helpers (private)

  • _normalize_range(rng) — single normalization point: tuple → inclusive (low, high), range → inclusive low/high (high = stop-1). Raises TypeError on other shapes; raises ValueError on negatives, empty range, or non-unit step.
  • _find_merge_origin(tbl, r, c) — two-pass walk (left through hMerge, then up through vMerge) used by split-boundary validation. Verified by Forge to land correctly on the origin for h-only, v-only, and block-inner spanned cells.

Out of scope (deferred)

  • Replacing or deprecating Cell.merge(other_cell) / Cell.split() — they stay as-is.
  • Cross-table merges (both ranges must be in the same Table instance).
  • Diagonal or non-rectangular merges (PowerPoint's data model is rectangular only).
  • Per-merge formatting carryover beyond what Cell.merge already does (move_content_to_origin continues to handle text-content collation; we don't add new merging strategies).
  • Phase 4: per-row height / per-column width round-trip + Table.row_count / column_count ergonomics.

Tests

  • 46 pytest cases in tests/test_tables_phase3.py covering each new accessor, every public path through merge_cells / split_cells (including partial-overlap, boundary-cross, single-cell, range-object, order-agnostic, multiple-merges-in-one-call), the _normalize_range helper (parametrized), round-trip preservation through save/reload, and Phase-1/Phase-2 regression checks. Full suite: 3404 passed.
  • 8 behave scenarios in features/tbl-merge.feature exercising block merge, idempotent re-merge, range-object form, partial overlap ValueError, single-cell no-op, block split, idempotent split, and boundary-cross split ValueError. Full behave: 1029 scenarios passed, 0 failed (baseline 1021 + 8 new).
  • Ruff clean: ruff check src tests → All checks passed; ruff format --check → no diff.

Reporting contract (CLAUDE.md §7)

$ python3 -m pytest tests/ -q | tail -3
........................................................................ [ 99%]
....................                                                     [100%]
3404 passed in 4.66s

$ python3 -m ruff check src tests | tail -3
All checks passed!

$ python3 -m behave features/ --no-color 2>&1 | tail -3
1029 scenarios passed, 0 failed, 0 skipped
3100 steps passed, 0 failed, 0 skipped
Took 0min 1.621s

UAT

  • uat_tables_phase3.py (untracked per CLAUDE.md §6) at repo root.
  • Generates uat_tables_phase3_out.pptx with three tables: a 2×3 block merge, an idempotent merge+split round-trip, and a mixed-shape merge with header bar / vertical merge / bottom-right block.
  • All round-trip assertions pass programmatically.
  • Visual UAT in PowerPoint signed off by maintainer.

Refs #12

… inspection accessors (Tables 2.0 Phase 3)

Issue: #12 (Phase 3)

Phase 1 (PR #37) shipped row/column add/remove. Phase 2 (PR #40) shipped the
table style API. Phase 3 closes the third sub-feature on issue #12: range-
style block merge with idempotent semantics, the inverse split operation,
and read-only inspection of the underlying merge attributes.

Before this change the only merge surface was `Cell.merge(other_cell)` —
two-cell, raises ValueError on any range overlap, no idempotency. Power
users hit this every time they programmatically built tables with block
merges; they ended up wrapping every merge in try/except or pre-checking
with `is_merge_origin`/`is_spanned`. Inspection of the underlying
`gridSpan` / `rowSpan` / `hMerge` / `vMerge` was only reachable via the
private `_tc` element.

Public surface added (additive — existing `Cell.merge` and `Cell.split`
keep current behavior):

`_Cell` (read-only)
- `Cell.grid_span` — mirrors `tc.gridSpan` (1 by default; >1 only on
  horizontal merge origin).
- `Cell.row_span` — mirrors `tc.rowSpan` (1 by default; >1 only on
  vertical merge origin).
- `Cell.h_merge` — True only on cells horizontally spanned by an origin
  to the left.
- `Cell.v_merge` — True only on cells vertically spanned by an origin
  above. Snake_case per the module's existing `_Cell` convention
  (`is_merge_origin`, `is_spanned`, `span_height`, `span_width`,
  `margin_top`, etc.); the issue body used the OOXML attribute names
  literally (camelCase) but those are XML-serialization details, not
  the Python public surface.

`Table` (range-style block merge)
- `Table.merge_cells(row_range, col_range)` — range = 2-tuple (inclusive)
  or Python `range` (half-open per Python convention). Order-agnostic
  within each form. Idempotent on exact re-merge; single-cell range is a
  no-op. Returns the merge-origin `_Cell`. Raises `ValueError` on partial
  overlap with a different-shape existing merge.
- `Table.split_cells(row_range, col_range)` — inverse operation.
  Idempotent on un-merged ranges; splits any merges fully contained in
  the range. Raises `ValueError` if a merge in the range extends beyond
  the boundary (would orphan the rest).

Helpers
- Private `_normalize_range(rng)` — single normalization point for both
  methods; tuple → inclusive `(low, high)`, `range` → inclusive
  half-open conversion. Raises TypeError on other shapes; raises
  ValueError on negatives, empty range, or non-unit step.
- Private `_find_merge_origin(tbl, r, c)` — two-pass walk (left through
  hMerge, then up through vMerge) used by split-boundary validation.

Tests
- 46 new pytest cases in `tests/test_tables_phase3.py` covering each new
  accessor, every public path through merge_cells / split_cells (including
  partial-overlap, boundary-cross, single-cell, range-object,
  order-agnostic, multiple-merges-in-one-call), the `_normalize_range`
  helper (parametrized), round-trip preservation through save/reload,
  and Phase-1/2 regression checks. Full suite: `3404 passed`.
- 8 new behave scenarios in `features/tbl-merge.feature` exercising
  block merge, idempotent re-merge, range-object form, partial overlap
  ValueError, single-cell no-op, block split, idempotent split, and
  boundary-cross split ValueError. Full behave: `1029 scenarios passed,
  0 failed` (baseline 1021 + 8 new).
- Ruff: `ruff check src tests` → All checks passed; `ruff format --check`
  → no diff.

Out of scope (deferred to later phases or separate work)
- Replacing or deprecating `Cell.merge` / `Cell.split` — they stay.
- Cross-table merges (both ranges must be in the same Table instance).
- Diagonal or non-rectangular merges (PowerPoint's data model is
  rectangular only).
- Per-merge formatting carryover beyond what `Cell.merge` already does.

UAT
- `uat_tables_phase3.py` (untracked per CLAUDE.md §6) at repo root
  builds three tables: a 2×3 block merge, an idempotent merge+split
  round-trip, and a mixed-shape merge with header bar / vertical merge /
  bottom-right block. Five round-trip assertions pass programmatically;
  visual UAT in PowerPoint or Keynote pending maintainer signoff.

Refs #12
@MHoroszowski MHoroszowski merged commit 1aea816 into master May 8, 2026
12 checks passed
@MHoroszowski MHoroszowski deleted the feature/tables-phase3 branch May 8, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant