fix(docx): bound the layout grid DOCX tables can declare (#4519)
## Summary
`w:gridBefore`, `w:gridAfter` and `w:gridSpan` values were expanded into
one matrix entry per layout-grid position when building `text_as_html`.
A small document declaring millions of positions per row therefore used
GB of memory. python-docx's `row.cells` also repeats a spanned cell once
per position, and the HTML, nested-table and emphasis paths re-read that
cell's content every time.
- **Grid budget.** Each table's grid size is computed from the declared
values without expanding them, against a document-wide
`DOCX_TABLE_MAX_CELLS` budget (new env setting, default 5,000,000). A
table over the remaining budget gets `text_as_html=None` and a warning.
Its text is still extracted, because `_iter_table_texts` already walks
physical `tc` elements.
- **Each cell read once.** The HTML path computes each cell's text once
per `tc` instead of once per grid position.
- **Physical `tc` walk.** Nested-table text and emphasis now walk
physical `tc` elements, skipping `vMerge="continue"`, as
`_iter_table_texts` already does. Neither expands spans any more.
- **Failure containment.** A `w:gridSpan`/`w:gridBefore`/`w:gridAfter`
value that can't be parsed omits only that table's `text_as_html`, with
a warning, without expanding it or using budget; its text is still
extracted. A table paragraph whose `w:b`/`w:i` can't be read contributes
no emphasis, with a warning; each paragraph's emphasis is collected
before any is emitted, so contents and tags stay aligned.
## Measurements (2-column tables in a ~37 KB DOCX)
| Case | Before | After |
|---|---|---|
| 2 rows, `gridSpan=100000` | 11.2s, 200,000 emphasis entries | 0.4s, 2
entries |
| 20 rows, `gridSpan` or `gridBefore` = 5,000,000 | not run (≈100M grid
positions) | 0.3s, no `text_as_html`, text extracted |
## Behaviour change
`emphasized_text_contents`/`_tags` no longer repeat entries for a cell
that spans several grid positions or merges vertically. Previously a
bold cell spanning N positions was reported N times.
## Tests
- A table declaring 10M positions per row (via `gridSpan` and via
`gridBefore`) gets no HTML, but keeps its text and its de-duplicated
emphasis.
- The budget is summed across tables: the first table gets HTML and the
second doesn't.
- A spanned cell renders as one `colspan` cell and reports its emphasis
once.
- An invalid or missing `gridSpan` value keeps the table's text, omits
its HTML and uses none of the budget, while a later table still gets
HTML.
- Unreadable bold/italic values drop that paragraph's emphasis, with and
without `infer_table_structure`, and extraction continues.
- A vertically merged cell's emphasis is reported once, and a
10M-position span inside a nested table is not expanded.
ODT and DOC go through this code after conversion to DOCX. Their tests
weren't run locally, because they need pandoc and LibreOffice.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/Unstructured-IO/unstructured/pull/4519?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>