From b38ab93601294581e6c5e9ba727b8d1df998495b Mon Sep 17 00:00:00 2001 From: Karlei Kongsiri Date: Tue, 22 Sep 2026 18:56:20 -0400 Subject: [PATCH] fix(pagination): split oversized tables whose only merges are horizontal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trySplitSimpleOversizedTable rejected any table containing [colspan], sending it down the whole-block overflow fallback: every row lands in one page box whose content clips, so everything past the first page-height is rendered but invisible. A colspan is horizontal merging, contained entirely within a single row — and this splitter only ever cuts between rows, so a row-boundary split can never break one. createSimpleTableFragment already clones the colgroup and whole rows, so colspans travel intact with their cells. The shape this hurts most is a legal/business staple: a multi-page table whose section-header rows span all columns (w:gridSpan). On the reporting document (landscape, 25 rows, 7 colspan header rows, zero rowspan), 17 rows were silently hidden; Word paginates the same file to 6 pages. With this change the engine produces 6 pages with every row visible, verified against that document. [rowspan] stays rejected — a vertical merge genuinely can cross a row-boundary split. The existing merged-cells regression test keeps its fixture and assertions (its table carries both rowspan and colspan, and the rowspan alone keeps the conservative fallback); it is retitled to say what it now pins. New test: a colspan-only oversized table splits at row boundaries with all rows preserved. Fixes #807 --- npm/src/pagination.ts | 6 ++- npm/tests/docxodus.spec.ts | 78 +++++++++++++++++++++++++++++++++++++- 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/npm/src/pagination.ts b/npm/src/pagination.ts index 2b162291..c91e4e3a 100644 --- a/npm/src/pagination.ts +++ b/npm/src/pagination.ts @@ -1608,7 +1608,9 @@ export class PaginationEngine { /** * Builds a clone of a simple table wrapper containing a contiguous run of rows. * Complex table features are deliberately rejected by the caller: a split across - * merged cells, nested tables, or footnotes cannot be made correct by cloning rows. + * vertically merged cells (rowspan), nested tables, or footnotes cannot be made + * correct by cloning rows. Horizontal merges (colspan) are safe: a colspan lives + * entirely inside one row, and rows are only ever cloned whole. */ private createSimpleTableFragment( wrapper: HTMLElement, @@ -1678,7 +1680,7 @@ export class PaginationEngine { table.tFoot || body.rows.length < 2 || Array.from(table.children).some(child => child !== body && child.tagName !== "COLGROUP") || - table.querySelector("table, [rowspan], [colspan], [data-footnote-id]") || + table.querySelector("table, [rowspan], [data-footnote-id]") || wrapper.querySelector("[data-footnote-id]") ) { return null; diff --git a/npm/tests/docxodus.spec.ts b/npm/tests/docxodus.spec.ts index 37e422f8..164c0ea1 100644 --- a/npm/tests/docxodus.spec.ts +++ b/npm/tests/docxodus.spec.ts @@ -934,7 +934,83 @@ test.describe('Docxodus WASM Tests', () => { expect(paginationResult.retainedAnchors).toBe(1); }); - test('does not split an oversized table with merged cells', async ({ page }) => { + test('splits an oversized table whose only merges are horizontal (colspan)', async ({ page }) => { + const bytes = readTestFile('HW002-Table14.docx'); + const result = await convertToHtmlWithPagination(page, bytes, 1, 1.0); + + expect(result.error).toBeUndefined(); + expect(result.html).toBeDefined(); + + await page.addScriptTag({ path: 'dist/pagination.bundle.js' }); + + const paginationResult = await page.evaluate((html) => { + const container = document.createElement('div'); + container.id = 'test-pagination-colspan-oversized-table'; + container.innerHTML = html; + document.body.appendChild(container); + + const staging = container.querySelector('#pagination-staging') as HTMLElement; + const pageContainer = container.querySelector('#pagination-container') as HTMLElement; + const sourceTable = staging?.querySelector('table') as HTMLTableElement | null; + if (!staging || !pageContainer || !sourceTable) { + document.body.removeChild(container); + return { error: 'Pagination elements or source table not found' }; + } + + // Turn a middle row into a full-width section-header row — the shape of a + // legal issues list, whose header rows span every column. A colspan lives + // entirely inside one row, so a row-boundary split can never break it. + const headerRow = sourceTable.rows[1]; + const columnCount = sourceTable.rows[0].cells.length; + while (headerRow.cells.length > 1) { + headerRow.deleteCell(1); + } + headerRow.cells[0].colSpan = columnCount; + + const sourceRows = Array.from(sourceTable.rows).map(row => + (row.textContent || '').replace(/\s+/g, ' ').trim() + ); + + const { PaginationEngine } = (window as any).DocxodusPagination; + try { + const engine = new PaginationEngine(staging, pageContainer, { + scale: 1, + showPageNumbers: true + }); + const pagination = engine.paginate(); + + const renderedTables = Array.from( + pageContainer.querySelectorAll('.page-content table') + ) as HTMLTableElement[]; + const renderedRows = renderedTables.flatMap(table => + Array.from(table.rows).map(row => (row.textContent || '').replace(/\s+/g, ' ').trim()) + ); + const outcome = { + totalPages: pagination.totalPages, + tableFragments: renderedTables.length, + sourceRows, + renderedRows, + colspanCells: pageContainer.querySelectorAll('.page-content table [colspan]').length + }; + document.body.removeChild(container); + return outcome; + } catch (e) { + document.body.removeChild(container); + return { error: (e as Error).message }; + } + }, result.html!); + + if ('error' in paginationResult) { + throw new Error(paginationResult.error as string); + } + + expect(paginationResult.totalPages).toBeGreaterThan(1); + expect(paginationResult.tableFragments).toBeGreaterThan(1); + expect(paginationResult.renderedRows).toEqual(paginationResult.sourceRows); + expect(paginationResult.colspanCells).toBeGreaterThan(0); + }); + + test('does not split an oversized table with vertically merged cells', async ({ page }) => { const bytes = readTestFile('HW002-Table17.docx'); const result = await convertToHtmlWithPagination(page, bytes, 1, 1.0);