Skip to content

Fix DOCX text_as_html duplicating merged-cell text instead of colspan/rowspan - #4469

Merged
qued merged 19 commits into
mainfrom
alan/ml-1776-unstructured-fix-docx-text_as_html-duplicates-merged-cell
Sep 8, 2026
Merged

qued merged 19 commits into
mainfrom
alan/ml-1776-unstructured-fix-docx-text_as_html-duplicates-merged-cell

Conversation

@qued

@qued qued commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

DOCX table extraction produced two different representations of a merged cell: .text included its content once, but .metadata.text_as_html repeated the content into every <td> the merge visually covered, with no colspan/rowspan attribute marking that a merge had happened at all.

Root cause: _convert_table_to_html built its HTML from python-docx's row.cells, which resolves both horizontal (gridSpan) and vertical (vMerge="continue") merges by yielding the same underlying cell content at every grid position the merge spans. That already-expanded grid was serialized straight into HTML with one <td> per matrix position and no span-collapsing step.

Fix: track the underlying tc XML element's identity (not cell text) when building the matrix — the same tc object appears at every grid position a merge covers — then collapse runs of identical identity into (colspan, rowspan) before emitting <td>s. DOCX only permits rectangular merges, so there's no irregular-region case to handle.

A new shared helper (collapse_matrix_of_keyed_cells_to_spans) does the collapsing; the existing htmlify_matrix_of_cell_texts (still used unchanged by the pptx and HTML-parser partitioners) now shares its cell-escaping logic with the new span-aware path via an extracted _format_td helper.

Test plan

  • partition_docx on example-docs/docx-tables.docx (the merged-cell fixture) now produces text_as_html with correct colspan/rowspan and no duplicated cell text — added a behavioral regression test through the public partition_docx API.
  • Existing test_docx.py fixtures pinning the old duplicated-text/no-span output updated to the new expected output.
  • Checked downstream consumers that assumed DOCX tables never carry spans:
    • unstructured/metrics/table/table_extraction.py's span-aware grid reconstruction already handles colspan/rowspan correctly (written for other span-producing sources) — traced by hand against the merged-cell fixture.
    • Chunking's table splitter handles real spanned DOCX tables without crashing at multiple max_characters values. Found one pre-existing (not introduced here) limitation: splitting between rows that share a rowspan can drop that cell's data in the later chunk — this was always latent for any spanned HTML source, just never exercised for DOCX before since DOCX never emitted spans until now. Not fixed in this PR; flagging as a possible follow-up.
  • Full relevant test suites green (partition/docx, common/html_table, chunking, metrics/table), lint clean.

Review in cubic

qued added 14 commits September 3, 2026 17:22
DOCX table HTML built one <td> per grid position that a gridSpan/vMerge
merge visually covered, repeating that cell's text into each one with no
colspan/rowspan attribute. Track the underlying tc element's identity
across grid positions and collapse matching runs into a single <td>
carrying the appropriate colspan/rowspan, so merged-cell text appears
exactly once and the merge geometry is recoverable from the HTML.
htmlify_matrix_of_spanned_cell_texts() suppressed rows with no
originating cells, but such a row can legitimately represent a real
grid-row entirely covered by a rowspan from an earlier row. Since HTML
rowspan counts actual <tr> elements rather than "rows that had
content", dropping the row shifted the column-placement of every
subsequent row.

Add a regression test through the public partition_docx() API, plus a
combined collapse-and-render test covering a full-width (every-column)
vertical merge -- the collapse and render steps were previously only
unit-tested in isolation, which is how this went unnoticed.
The table chunker split purely on row text length, with no awareness of an
active `rowspan`. A split falling inside one left the origin chunk claiming
more rows than were present, and shifted every cell in the continuation
chunk into the wrong column (a fresh standalone `<table>` has no earlier
row to carry the span forward).

Rows spanned by an earlier row's `rowspan` are now grouped and chunked as
one atomic unit, computed via the standard overlapping-interval merge over
each row's declared span reach. A group that doesn't fit even alone is
emitted as one (necessarily oversized) chunk, the same tolerance already
granted a single oversized row or cell.
The rowspan-aware chunking boundary could silently lose an entire
table's trailing rows when a declared rowspan named more rows than
the table had (the group-closing index was never reached, so the
final group was never yielded), and coerced rowspan="0" (HTML's
"span every remaining row") to 1, letting a chunk boundary fall
through an active maximal span. Both now resolve to "the rest of
the table."
…ot row-group

A rowspan="0" cell (or any positive rowspan) was resolved as reaching to the end
of the whole table rather than the end of its own row-group, so a short header
section could swallow an entire, otherwise-boundable body section into one
unbounded chunk. Rows are now grouped by the identity of their actual containing
<thead>/<tbody>/<tfoot> element (or the table itself, for a row with no section
wrapper), and a span can no longer bind rows across a real section boundary.
…0" cell

The chunk accumulator packed row-groups together purely by character budget,
so a rowspan="0" header bound to its own one-row <thead> could still end up
in the same chunk as following <tbody> rows once section wrappers are
stripped from the emitted HTML -- reintroducing the column-shift corruption
this feature exists to prevent. A row-group change is now a hard boundary
specifically when a rowspan="0" row is involved; ordinary rows, and rows
with a positive rowspan meant to carry across a boundary (e.g. a repeated
header), are unaffected -- an earlier version of this fix flushed on every
row-group change unconditionally and broke that intentional behavior.
A positive rowspan declaring more rows than its own thead/tbody/tfoot
row-group actually has is clipped when grouping rows for chunking, but
its emitted HTML still carries the original, uncorrected value. If that
group got packed into the same chunk as a following row-group's rows,
the value would legitimately reach into rows it was never meant to
bind once section wrappers are stripped. A row-group change is now
also a hard chunk boundary when the preceding group was clipped this
way. A thead row's positive span is exempt, since it's already handled
separately as a repeated/carried-forward header.
…alues

Replace chunk-boundary bookkeeping ("is this group unsafe to extend
across a row-group boundary") with a structural safety net: at the
point each chunk's rows are finalized, any cell whose declared
rowspan (including rowspan="0") would claim more rows than are
actually present in that same chunk gets its rowspan rewritten to
match reality. An emitted span can no longer overreach regardless of
what else gets packed into the same chunk, closing this class of bug
by construction rather than by enumerating unsafe cases one at a time.

The existing row-group-boundary flush logic is kept (it still avoids
visually merging a clipped group's own row-group with unrelated
content), but two real gaps in it are fixed along the way:

- An overdeclared <thead> rowspan was unconditionally exempted from
  clipping, even when header repetition isn't actually configured/
  active for it -- nothing else protects such a row, so it's now only
  exempted when it's a genuine, active carried-forward header row.
- The accumulator compared a candidate group's row-group identity
  against the FIRST row it had ever accumulated rather than the most
  recently accumulated one, which could miss a real transition when
  an earlier and later row happened to share the same row-group
  identity (e.g. two direct rows around an explicit <tbody>).
…nd the thead row's original occurrence correctly

html_clipped_to_rows() previously reconstructed each cell from its plain text when correcting an
overreaching rowspan, discarding any nested table, hyperlink, image, or other markup/attributes
the source cell carried. It now deep-copies the real <tr> and mutates only the rowspan attribute
on cells that need correction, leaving everything else byte-for-byte unchanged.

Separately, the carried-header exemption in _iter_rowspan_bound_row_groups was keyed on whether
header repetition was configured at all, which incorrectly also exempted the thead row's own
ORIGINAL, wrapper-less occurrence -- not just an actual repeated/carried copy (built separately by
_as_header_row_html, wrapped in its own real <thead>, and never routed through this bound at all).
Every row is now bounded to its own row-group uniformly; only a genuinely repeated copy escapes
correction, because it was never subject to it in the first place.
A rowspan-bound row too large to fit any chunk even alone was handed to
the cell splitter with its original, uncorrected rowspan attribute --
the self-correcting rewrite only applied to rows going through the
normal row accumulator. reconstruct_table_from_chunks() can reassemble
separately-emitted chunks, at which point the stale span reaches into
rows from a later chunk. The row's own bound is now applied via
row_clipped_to_rows() before it's split cell-by-cell.
Each review-fix round bumped __version__ and added its own CHANGELOG entry as
part of that round's commit, leaving 9 separate release-note entries and a
9-patch version jump for what is one user-facing fix. Consolidated into a
single 0.27.6 entry and a one-increment version bump.
…ract

Comments that compared a test's current assertion to what it used to be
(e.g. "4 chunks, not 3", "now correctly recognized", "the pre-fix behavior")
are rewritten to describe only the current contract, or removed where the
assertion/code already made the point without a comment.
@qued
qued marked this pull request as ready for review September 8, 2026 13:26

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="CHANGELOG.md">

<violation number="1" location="CHANGELOG.md:5">
P2: The 0.27.6 release note overstates the chunking fix: boundaries between rows sharing a rowspan can still misattribute spanned rows, according to the stated scope of this PR. Remove the chunking claim or qualify it to describe only the cases actually fixed.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread CHANGELOG.md

### Fixes

- **Stop duplicating merged-cell text in DOCX `text_as_html`.** A merged cell (`gridSpan`/`vMerge`) was repeated into every `<td>` its merge visually covered, with no `colspan`/`rowspan` attribute marking the merge; merged cells are now emitted once, with `colspan`/`rowspan` reflecting the true geometry. Since DOCX tables can now carry real spans, table chunking was also made rowspan-aware, so a chunk boundary can no longer split a table in a way that misattributes a spanned cell's rows to the wrong columns.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The 0.27.6 release note overstates the chunking fix: boundaries between rows sharing a rowspan can still misattribute spanned rows, according to the stated scope of this PR. Remove the chunking claim or qualify it to describe only the cases actually fixed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 5:

<comment>The 0.27.6 release note overstates the chunking fix: boundaries between rows sharing a rowspan can still misattribute spanned rows, according to the stated scope of this PR. Remove the chunking claim or qualify it to describe only the cases actually fixed.</comment>

<file context>
@@ -1,3 +1,9 @@
+
+### Fixes
+
+- **Stop duplicating merged-cell text in DOCX `text_as_html`.** A merged cell (`gridSpan`/`vMerge`) was repeated into every `<td>` its merge visually covered, with no `colspan`/`rowspan` attribute marking the merge; merged cells are now emitted once, with `colspan`/`rowspan` reflecting the true geometry. Since DOCX tables can now carry real spans, table chunking was also made rowspan-aware, so a chunk boundary can no longer split a table in a way that misattributes a spanned cell's rows to the wrong columns.
+
 ## 0.27.5
</file context>
Suggested change
- **Stop duplicating merged-cell text in DOCX `text_as_html`.** A merged cell (`gridSpan`/`vMerge`) was repeated into every `<td>` its merge visually covered, with no `colspan`/`rowspan` attribute marking the merge; merged cells are now emitted once, with `colspan`/`rowspan` reflecting the true geometry. Since DOCX tables can now carry real spans, table chunking was also made rowspan-aware, so a chunk boundary can no longer split a table in a way that misattributes a spanned cell's rows to the wrong columns.
- **Stop duplicating merged-cell text in DOCX `text_as_html`.** A merged cell (`gridSpan`/`vMerge`) was repeated into every `<td>` its merge visually covered, with no `colspan`/`rowspan` attribute marking the merge; merged cells are now emitted once, with `colspan`/`rowspan` reflecting the true geometry. Table chunking now preserves rowspan relationships where supported; splitting between rows that share a rowspan remains a known limitation.

Comment thread test_unstructured/chunking/test_base.py Outdated
Comment thread unstructured/partition/docx.py Outdated
Comment thread test_unstructured/partition/test_docx.py Outdated
Comment thread test_unstructured/chunking/test_base.py Outdated
Comment thread unstructured/chunking/base.py Outdated
…ble branch, and harden tests

Nested tables inside a DOCX cell are flattened to text; that flattening
duplicated a merged cell's text at every grid position it covered, the
same class of bug this PR already fixed for top-level tables. Also
removes a table-chunking branch in _iter_rowspan_bound_row_groups that
can never execute (the last row's own row-group always closes on the
loop's final iteration), makes the chunking test module collectible
without the optional pandas extra, drops a verbatim-duplicate test, and
strengthens a chunking regression test to check exact per-chunk HTML
instead of only word presence.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 4 files (changes from recent commits).

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@aadland6

aadland6 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

# Severity Issue Primary location
1 High Rowspan crossing a <thead>/<tbody>/<tfoot> boundary is clipped at the section edge, shifting columns unstructured/chunking/base.py (_iter_rowspan_bound_row_groups, _group_last_idx)
2 High Repeated headers re-emit the pre-clip rowspan on every continuation chunk, shifting columns (and reconstruction bakes it in) unstructured/chunking/base.py (_as_header_row_html); unstructured/chunking/dispatch.py (_first_carried_header_rows)
3 Medium-High A rowspan-bound row group that can't fit is emitted whole, exceeding the hard max_characters/max_tokens limit unstructured/chunking/base.py (_iter_subtables, final else branch)
4 Medium Nested DOCX tables still duplicate merged-cell text in text_as_html unstructured/partition/docx.py (_convert_table_to_html → iter_cell_block_items)
5 Medium Splitting an oversized non-empty merged cell drops its colspan unstructured/chunking/base.py (_iter_cell_splits)
6 Medium HtmlCell.html drops colspan/rowspan on empty cells, so cell-split chunks lose merge geometry unstructured/common/html_table.py (HtmlCell.html); consumed by _CellAccumulator.flush

1. (High) Rowspan crossing a section boundary is clipped, shifting every following column

Where: unstructured/chunking/base.py, _HtmlTableSplitter._iter_rowspan_bound_row_groups and _group_last_idx; the shift is then made permanent by reconstruct_table_from_chunks() in unstructured/chunking/dispatch.py.

What's wrong: A positive rowspan is valid HTML when it extends from <thead> into <tbody> (or <tbody> into <tfoot>). The continuation rows in the next section deliberately omit the covered column. _group_last_idx() groups rows strictly by their row-group parent, and _iter_rowspan_bound_row_groups() treats any span that reaches past its own row-group as clipped, rewriting it down to the section's own row count and making the section boundary a hard chunk split. The covering cell is then gone from the continuation rows, so every remaining cell in those rows slides one column to the left. This fires whenever such a table is split — including at the default max_characters=500 — not just at tiny windows.

Reproduced: For

<table>
  <thead><tr><th rowspan="2">Name</th><th>Field</th></tr></thead>
  <tbody>
    <tr><td>Age</td></tr>
    <tr><td>person00</td><td>val00 extra padding text</td></tr>
    <tr><td>person01</td><td>val01 extra padding text</td></tr>
  </tbody>
</table>

chunk_by_title(..., max_characters=80, repeat_table_headers=False) produces chunk 0 <table><tr><td>Name</td><td>Field</td></tr></table> (the rowspan is stripped) and chunk 1 beginning <table><tr><td>Age</td></tr>.... Reconstruction yields <table><tr><td>Name</td><td>Field</td></tr><tr><td>Age</td></tr>.... Age is now in column 0; it belongs in column 1 under Field (the Name cell originally covered column 0 of that row).

Proposed fix: Stop clamping a positive rowspan to its row-group's last row. Bind rows by the span's true reach (clamped only to the table's last row, n-1) so a section-crossing span keeps every row it covers in the same atomic group, and never clips the covering cell out of a continuation row. Retain the row-group clamp only for rowspan="0" (HtmlRow.max_rowspan is None), which the HTML spec scopes to the containing row group. Concretely, in _iter_rowspan_bound_row_groups, for the positive-span branch set reach[idx] = min(idx + row.max_rowspan - 1, n - 1) and clipped[idx] = declared_reach > n - 1, and set each row's bound from the resulting merged group extent rather than from own_group_last.

Note on the ambiguous case: An overdeclared header span whose body rows are already full-width (a cell in every column) is genuinely different from a span that legitimately covers a short row, but the two cannot be distinguished from row-group membership alone. Binding by true reach is the safe choice: it never shifts columns. The trade-off is that an overdeclared span may keep a slightly larger group together than strictly necessary, which is acceptable since correctness outranks split granularity. The existing in-tree assertions only cover the overdeclared-with-full-body case, so they will need to be updated to reflect the corrected behavior.


2. (High) Repeated headers re-emit the pre-clip rowspan on continuation chunks

Where: unstructured/chunking/base.py, _HtmlTableSplitter._as_header_row_html (via _header_rows_html / _prepend_repeated_headers); reconstruction in unstructured/chunking/dispatch.py, _first_carried_header_rows / _merge_table_chunks.

What's wrong: Header repetition is on by default (repeat_table_headers=True). Continuation <thead> markup is rebuilt from HtmlRow.source_html, which is captured before compactification and before rowspan clipping, so it still carries the original, possibly over-reaching rowspan. The first-chunk header is correctly clipped, but every continuation chunk re-injects the unclipped span. When the span is larger than the number of header rows actually prepended, it reaches down into the first body row of the continuation and eats that row's first column. Reconstruction then makes it permanent: _first_carried_header_rows() prefers the carried (unclipped) header rows as the canonical <thead> and discards the clipped first-chunk header, so the reconstructed table also carries the over-reaching span. First-chunk and continuation-chunk HTML additionally disagree about the same table's header geometry.

Reproduced: For

<table>
  <thead><tr><th rowspan="2">Region</th><th>Quarter</th></tr></thead>
  <tbody>
    <tr><td>Northwest Territory</td><td>Q1 FY2026</td></tr>
    <tr><td>Southwest Territory</td><td>Q2 FY2026</td></tr>
    <tr><td>Midwest Territory</td><td>Q3 FY2026</td></tr>
  </tbody>
</table>

chunk_by_title(..., max_characters=55) (default header repetition) emits chunk 0 <table><tr><td>Region</td><td>Quarter</td></tr></table> (clipped), but each continuation chunk is <table><thead><tr><th rowspan="2">Region</th><th>Quarter</th></tr></thead><tr><td>Northwest Territory</td><td>Q1 FY2026</td></tr></table>. The one-row <thead> declares rowspan="2", so once the <thead> is dropped, Region covers into the body row and Northwest Territory / Q1 FY2026 shift right. Reconstruction preserves the rowspan="2".

Proposed fix: Build the repeated header from the clipped header row, not from source_html. In _as_header_row_html (or in _header_rows_html), clip each header cell's rowspan down to the number of header rows actually prepended — the count in _header_rows / carried_over_header_row_count — using the existing HtmlRow.row_clipped_to_rows() / html_clipped_to_rows() before converting <td>→<th>. With a single prepended header row the rowspan is clipped to 1 (removed), so the continuation <thead> matches the clipped first-chunk header and reconstruction stays correct.


3. (Medium-High) A rowspan-bound group that can't fit is emitted whole, exceeding the hard limit

Where: unstructured/chunking/base.py, _HtmlTableSplitter._iter_subtables, final else branch (the "emit whole" fallback for a group that doesn't fit an empty window).

What's wrong: When a rowspan-bound group does not fit even in an empty chunking window, the splitter adds it and flushes it unchanged, producing a chunk larger than max_characters/max_tokens. This contradicts _TableChunker's "maxlen or smaller" contract and can yield arbitrarily large chunks for tables with long vertical merges, which downstream embedding/model calls (that enforce the size limit) can reject. The in-code comment claims this matches "the same tolerance the codebase already grants a single oversized row/cell," but that is inaccurate: a single oversized row is cell-split and a single oversized cell is text-split — this group path is the only one that emits over-limit content.

Reproduced: A three-row table with a rowspan="3" covering cell and ~55 characters of total text, chunked with chunk_by_title(..., max_characters=50), returns a single TableChunk of text length 55 (HTML length 142) instead of chunks at or below the hard maximum.

Proposed fix: Replace the "emit whole" fallback with a size-respecting split, reusing the oversized-row path (_iter_row_splits / _iter_cell_splits). Split the group row-by-row (then cell-by-cell / text-split as needed), and for each emitted fragment rewrite the covering cell's rowspan to the number of rows actually present in that fragment (via row_clipped_to_rows), re-injecting the covering cell as rowspan="1" into any continuation row that would otherwise be missing its covered column so columns stay aligned. Splitting a vertical merge below its own size unavoidably repeats the covering cell's text across fragments rather than preserving it as a single merge; that is the correct trade-off for honoring the hard limit, and it matches how the other oversized paths behave.


4. (Medium) Nested DOCX tables still duplicate merged-cell text in text_as_html

Where: unstructured/partition/docx.py, _convert_table_to_html → iter_cell_block_items (the DocxTable branch, lines ~521-523).

What's wrong: The merge-collapse fix is applied only to the outermost table. A nested table is flattened into its parent <td> by walking row.cells directly (yield from (text for text, _ in iter_row_cells(row))), and python-docx repeats a merged cell's origin tc at every grid position it covers (for both gridSpan and vMerge="continue"). So a merged cell's text is concatenated once per covered position. .text (which walks tr.tc_lst and skips vMerge="continue") does not duplicate, so .text and metadata.text_as_html disagree.

Reproduced: python-docx row.cells for a 2×2 nested table whose top row is horizontally merged to MERGED yields ['MERGED', 'MERGED', 'c', 'dd']; the parent <td> therefore receives parent MERGED MERGED c dd (MERGED twice), while .text contains a single MERGED. A fully-merged 2×2 nested table repeats its text four times.

Proposed fix: Apply the same merge-collapse to the nested table before flattening its text. Replace the nested-table branch with a keyed-collapse pass and emit each origin cell's text once:

elif isinstance(table := block_item, DocxTable):
    matrix = [list(iter_row_merge_keyed_texts(row)) for row in table.rows]
    for spanned_row in collapse_matrix_of_keyed_cells_to_spans(matrix):
        yield from (text for text, _colspan, _rowspan in spanned_row)

This dedups merged cells exactly as the outer table already does and recurses correctly for tables nested more than one level deep (via iter_row_merge_keyed_texts → iter_row_cells → cell_text → iter_cell_block_items), bringing text_as_html back into agreement with .text.


5. (Medium) Splitting an oversized non-empty merged cell drops its colspan

Where: unstructured/chunking/base.py, _HtmlTableSplitter._iter_cell_splits (lines ~1411 and ~1416).

What's wrong: When a single cell's text is too large for the window, _iter_cell_splits() text-splits it and hardcodes each fragment as a bare <table><tr><td>{text}</td></tr></table>, discarding the cell's colspan/rowspan. reconstruct_table_from_chunks() then rebuilds the fragments as plain single-column rows, losing the merge geometry this PR exists to preserve.

Reproduced: A one-row table containing <td colspan="2"> with thirty word tokens, chunked with max_characters=50, produces ten fragments, each <table><tr><td>word word word</td></tr></table> with no colspan. Reconstruction yields ten single-column rows and zero colspan attributes instead of a two-column-spanning cell.

Proposed fix: Carry the cell's span onto each emitted fragment. Emit the <td> using the shared _format_td(text, cell.colspan, rowspan=1) helper (from unstructured/common/html_table.py) instead of a hardcoded <td> string. Preserve colspan on every fragment (each fragment is its own one-row table, so the columns line up on reconstruction); clip rowspan to 1 on fragments, since a vertical span across the newly created fragment-rows would over-reach.


6. (Medium) HtmlCell.html drops colspan/rowspan on empty cells

Where: unstructured/common/html_table.py, HtmlCell.html; consumed by unstructured/chunking/base.py, _CellAccumulator.flush (the cell-split path used when a rowspan-bound group is a single row that doesn't fit the window).

What's wrong: HtmlCell.html serializes any cell with no text as a bare "<td/>", discarding span attributes:

return etree.tostring(self._td, encoding=str) if self.text else "<td/>"

Row-level emission (row.html / html_clipped_to_rows) keeps the attributes, but _CellAccumulator.flush() uses HtmlCell.html, so when an oversized row is split on cell boundaries an empty merged cell loses its span and the remaining cells in that sub-row shift left. This now matters because DOCX empty horizontal merges emit <td colspan="2"/>.

Reproduced: HtmlCell on <td colspan="2" rowspan="3"></td> reports colspan=2, rowspan=3 but .html == "<td/>". For <table><tr><td colspan="2"></td><td>aaaaaaaaaaaaaaaaaaaa</td><td>bbbbbbbbbbbbbbbbbbbb</td></tr></table> split at max_characters=30, the first sub-chunk is <table><tr><td/><td>aaaaaaaaaaaaaaaaaaaa</td></tr></table> — the colspan="2" is gone, so aaaa… lands in column 1 instead of column 2.

Proposed fix: Preserve span attributes even when the cell is empty. Emit the empty cell via the shared _format_td(self.text, self.colspan, self.rowspan) helper (which already renders <td colspan="2"/> for an empty spanned cell), or, minimally, only shortcut to "<td/>" when the cell has no span attributes:

return _format_td(self.text, self.colspan, self.rowspan)

This keeps compact <td/> output for genuinely attribute-free empty cells while retaining colspan/rowspan on empty merged cells.

(Findings 5 and 6 share a theme — the cell-split path loses spans — but they are distinct bugs in different functions with different fixes: #5 is the hardcoded <td> string in _iter_cell_splits for oversized non-empty cells, #6 is the empty-cell shortcut in HtmlCell.html. Both should be fixed.)


…n groups

A rowspan may legitimately reach from a <thead> into a <tbody> (or <tbody>
into <tfoot>); table chunking previously clipped any positive rowspan to its
own row-group, silently truncating a valid span and shifting later rows into
the wrong column. It's now bound only by the table's actual last row (rowspan
"0" still clips to its own row-group, since that's what the HTML spec scopes
it to).

Repeating a table header on continuation chunks could re-inject a header
cell's rowspan uncorrected, letting it reach past the repeated header block
into the continuation's own body content. The repeated copy is now clipped to
the number of header rows actually carried over.

Finally, a rowspan-bound group too large to fit in one chunk was previously
emitted whole, violating the chunk size limit -- increasingly likely once a
positive rowspan's larger legitimate reach (above) is accounted for. It's now
split on a row boundary like an ordinary oversized row, with the covering
cell's rowspan rewritten per fragment and re-materialized in any fragment
that doesn't include the row that originally declared it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="unstructured/chunking/base.py">

<violation number="1" location="unstructured/chunking/base.py:1417">
P2: When an oversized rowspan-bound group is split, this text-only materialization drops nested markup and image-only content from `text_as_html`. Preserve the source cell HTML alongside the span metadata when emitting continuation cells.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

if next_span is not None and next_span.col == col:
if materialize:
remaining = next_span.reach_idx - idx + 1
cells.append(_format_td(next_span.text, next_span.colspan, remaining))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an oversized rowspan-bound group is split, this text-only materialization drops nested markup and image-only content from text_as_html. Preserve the source cell HTML alongside the span metadata when emitting continuation cells.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured/chunking/base.py, line 1417:

<comment>When an oversized rowspan-bound group is split, this text-only materialization drops nested markup and image-only content from `text_as_html`. Preserve the source cell HTML alongside the span metadata when emitting continuation cells.</comment>

<file context>
@@ -1367,6 +1381,114 @@ def _group_last_idx(rows: Sequence[HtmlRow]) -> list[int]:
+                if next_span is not None and next_span.col == col:
+                    if materialize:
+                        remaining = next_span.reach_idx - idx + 1
+                        cells.append(_format_td(next_span.text, next_span.colspan, remaining))
+                        if next_span.text:
+                            texts.append(next_span.text)
</file context>

…ng empty spanned cells

Oversized-cell text splitting now keeps the cell's colspan on each emitted
fragment, and HtmlCell.html now preserves colspan/rowspan for empty cells
instead of collapsing them to a bare <td/>.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread unstructured/chunking/base.py Outdated
…cells

The fixed-overhead assumption for an oversized cell's split fragments didn't
account for a colspan attribute's own characters, or for the fact that
escaping cell text (&, <, >) can make the formatted fragment longer than the
raw text budgeted for it. Overhead is now derived from the cell's actual
colspan, and each split verifies the formatted fragment against the limit,
shrinking and re-splitting (falling back to a raw truncation in pathological
cases) until it fits.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="test_unstructured/chunking/test_base.py">

<violation number="1" location="test_unstructured/chunking/test_base.py:3056">
P2: This escaping test does not verify preservation of the escaped character. Compare the joined chunk text with the original `text` and assert at least one emitted fragment contains `&amp;`, otherwise regressions can pass while corrupting cell content or escaping.</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

for _, html in chunks:
assert len(html) <= 50
# -- no text lost or duplicated across the split --
assert " ".join(text for text, _ in chunks).replace(" & ", " ").split() == ["x"] * 20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This escaping test does not verify preservation of the escaped character. Compare the joined chunk text with the original text and assert at least one emitted fragment contains &amp;, otherwise regressions can pass while corrupting cell content or escaping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test_unstructured/chunking/test_base.py, line 3056:

<comment>This escaping test does not verify preservation of the escaped character. Compare the joined chunk text with the original `text` and assert at least one emitted fragment contains `&amp;`, otherwise regressions can pass while corrupting cell content or escaping.</comment>

<file context>
@@ -3021,6 +3021,55 @@ def and_it_preserves_colspan_when_splitting_an_oversized_cell(self):
+        for _, html in chunks:
+            assert len(html) <= 50
+        # -- no text lost or duplicated across the split --
+        assert " ".join(text for text, _ in chunks).replace(" & ", " ").split() == ["x"] * 20
+
+    def and_it_accounts_for_colspan_and_escaping_together_when_splitting_an_oversized_cell(self):
</file context>
Suggested change
assert " ".join(text for text, _ in chunks).replace(" & ", " ").split() == ["x"] * 20
assert " ".join(chunk_text for chunk_text, _ in chunks) == text
assert any("&amp;" in html for _, html in chunks)

The test compared only the parallel plain-text tuple element across
fragments, never inspecting the emitted HTML itself, so a regression
that broke escaping in the HTML specifically (while leaving the plain
text correct) would have passed unnoticed.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Shadow auto-approve: would not auto-approve. Auto-approval blocked by 3 unresolved issues from previous reviews.

Re-trigger cubic

@aadland6
aadland6 self-requested a review September 8, 2026 17:03
@qued
qued added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 69d50a7 Sep 8, 2026
55 checks passed
@qued
qued deleted the alan/ml-1776-unstructured-fix-docx-text_as_html-duplicates-merged-cell branch September 8, 2026 17:43
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.

2 participants