@@ -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+
249413class _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