From b38ab93601294581e6c5e9ba727b8d1df998495b Mon Sep 17 00:00:00 2001 From: Karlei Kongsiri Date: Tue, 22 Sep 2026 18:56:20 -0400 Subject: [PATCH 1/2] 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); From c31b54fe35518f6bb1b364eb1d54951a991a0cdb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:25:04 +0000 Subject: [PATCH 2/2] test(pagination): pin the colspan split exactly and add the changelog entry The colspan test asserted only that some [colspan] survived, which would also pass if the header row were duplicated across fragments or rendered with the wrong span. It now asserts exactly one spanning cell, spanning every column. Adds the missing CHANGELOG entry for issue #807. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EuVfv22yFAxchW5H2F7Sk9 --- CHANGELOG.md | 9 +++++++++ npm/tests/docxodus.spec.ts | 10 ++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a5788aa..6e631e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed + +- Paginated view: a table taller than a page whose only merged cells are horizontal + (`colspan`, e.g. full-width section-header rows from `w:gridSpan`) is now split at row + boundaries instead of clipped, where every row past the first page-height was rendered + but hidden (issue #807). A colspan never crosses a row boundary, so the row-boundary + splitter can carry it whole. Tables with vertical merges (`rowspan`), nested tables, or + footnotes keep the previous whole-block fallback. + ## [12.6.2] - 2026-09-16 ### Fixed diff --git a/npm/tests/docxodus.spec.ts b/npm/tests/docxodus.spec.ts index 164c0ea1..7f859a45 100644 --- a/npm/tests/docxodus.spec.ts +++ b/npm/tests/docxodus.spec.ts @@ -990,7 +990,13 @@ test.describe('Docxodus WASM Tests', () => { tableFragments: renderedTables.length, sourceRows, renderedRows, - colspanCells: pageContainer.querySelectorAll('.page-content table [colspan]').length + columnCount, + // Every rendered spanning cell, so a duplicated or truncated header row is caught. + renderedSpans: Array.from( + pageContainer.querySelectorAll('.page-content table td, .page-content table th') + ) + .map(cell => (cell as HTMLTableCellElement).colSpan) + .filter(span => span > 1) }; document.body.removeChild(container); return outcome; @@ -1007,7 +1013,7 @@ test.describe('Docxodus WASM Tests', () => { expect(paginationResult.totalPages).toBeGreaterThan(1); expect(paginationResult.tableFragments).toBeGreaterThan(1); expect(paginationResult.renderedRows).toEqual(paginationResult.sourceRows); - expect(paginationResult.colspanCells).toBeGreaterThan(0); + expect(paginationResult.renderedSpans).toEqual([paginationResult.columnCount]); }); test('does not split an oversized table with vertically merged cells', async ({ page }) => {