feat(tables): merge robustness — Table.merge_cells/split_cells + Cell inspection accessors (Tables 2.0 Phase 3)#41
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, raisesValueErroron 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 withis_merge_origin/is_spanned. Inspection of the underlyinggridSpan/rowSpan/hMerge/vMergewas only reachable via the private_tcelement.Public surface added (additive — existing
Cell.mergeandCell.splitkeep current behavior)What it adds
_Cellread-only inspection accessorsCell.grid_span/row_span/h_merge/v_merge— read-throughs totc.gridSpan/tc.rowSpan/tc.hMerge/tc.vMerge. Snake_case per the existing_Cellconvention (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.Tablerange-style block mergeTable.merge_cells(row_range, col_range)—row_rangeandcol_rangeaccept either a 2-tuple(start, end)(inclusive —(0, 1)covers rows 0 and 1) or a Pythonrangeobject (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. RaisesValueErroron 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. RaisesValueErrorif 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). RaisesTypeErroron other shapes; raisesValueErroron negatives, empty range, or non-unit step._find_merge_origin(tbl, r, c)— two-pass walk (left throughhMerge, then up throughvMerge) 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)
Cell.merge(other_cell)/Cell.split()— they stay as-is.Tableinstance).Cell.mergealready does (move_content_to_origincontinues to handle text-content collation; we don't add new merging strategies).Table.row_count/column_countergonomics.Tests
tests/test_tables_phase3.pycovering each new accessor, every public path throughmerge_cells/split_cells(including partial-overlap, boundary-cross, single-cell, range-object, order-agnostic, multiple-merges-in-one-call), the_normalize_rangehelper (parametrized), round-trip preservation through save/reload, and Phase-1/Phase-2 regression checks. Full suite:3404 passed.features/tbl-merge.featureexercising block merge, idempotent re-merge, range-object form, partial overlapValueError, single-cell no-op, block split, idempotent split, and boundary-cross splitValueError. Full behave:1029 scenarios passed, 0 failed(baseline 1021 + 8 new).ruff check src tests→ All checks passed;ruff format --check→ no diff.Reporting contract (CLAUDE.md §7)
UAT
uat_tables_phase3.py(untracked per CLAUDE.md §6) at repo root.uat_tables_phase3_out.pptxwith 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.Refs #12