From 37d8ee37ec426f460e64d929b68896deef877898 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 03:30:25 +0100 Subject: [PATCH 1/5] fix(doc-codec): snap table column boundaries within a point when unioning rows into one grid [MS-DOC] 2.6.4 states a table's column layout per row, so reconstructing the table's shared grid means unioning N independently-stated rgdxaCenter arrays. That union compared boundaries by exact integer equality, so two rows meaning the identical grid but differing by a single twip -- which nothing in the format forbids, and which LibreOffice's own WW8TabDesc::CalcDefaults produces by widening imported cells up to MINLAY per row -- split into a phantom hairline column, with a spurious colSpan on the cells of every row either side of it. Two rows one twip apart across a 2338-twip boundary read back as columnWidthsPt [116.9, 0.05, 144.95, 220] instead of [116.9, 145, 220]. The union now clusters boundaries within one point of each other, taking each cluster's smallest member as its representative and sorting first so the result depends on the boundary values alone rather than on row order. One point is TWIPS_PER_POINT itself: columnWidthsPt states the reconstructed grid in points, so a segment narrower than that is below the smallest unit the grid can distinguish. It also matches what LibreOffice applies to this same computation (COLFUZZY, 20 twips, wrtswtbl.hxx), whose changeover was confirmed at exactly 20/21 by sweeping one patched int16 through its own .doc importer, and it stays below that implementation's own 23-twip minimum cell width, so it cannot swallow a legitimately narrow column. Looking a row's own boundary up on the grid now snaps with the same tolerance rather than calling indexOf, since a raw boundary need not appear in the clustered array at all, and a boundary matching nothing throws instead of yielding a negative index. A physical cell whose two boundaries land on one canonical entry -- the zero-width cell rgdxaCenter's "non-decreasing" rule permits -- is carried as an ordinary un-spanned cell rather than one reporting a span of zero. --- packages/doc-codec/src/table/read.test.ts | 262 +++++++++++++++++++++- packages/doc-codec/src/table/read.ts | 52 ++++- 2 files changed, 303 insertions(+), 11 deletions(-) diff --git a/packages/doc-codec/src/table/read.test.ts b/packages/doc-codec/src/table/read.test.ts index f4e2d1b1..b19307cd 100644 --- a/packages/doc-codec/src/table/read.test.ts +++ b/packages/doc-codec/src/table/read.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { DocFormatError, DocUnsupportedError } from "../errors"; import { readDocContent } from "../read"; -import { buildDoc } from "../test-support/doc"; +import { buildDoc, type DocParagraphSpec } from "../test-support/doc"; import { CELL_MARK } from "../text/special"; // Every writeDocContent table test in write.test.ts reads back bytes this package's own writer produced -- a round trip proves the reader and writer agree with each other, not that either agrees with [MS-DOC] itself. These tests hand-assemble the sgc-5 (table) grpprl bytes straight from the specification's own field tables, independently of tap-write.ts's construction logic, so they exercise table/read.ts and table/tap.ts against bytes this package never wrote. @@ -384,3 +384,263 @@ describe("readDocContent tables, from hand-assembled bytes", () => { expect(rowTwoCells.map((cell) => cellText(cell))).toEqual(["a", "b", "c"]); }); }); + +// The tolerance the reconstruction snaps boundaries within is one point, and ContentTable.columnWidthsPt is stated in points, so every expectation below is written in points and every drift is written as a fraction of one -- restated here from the point's own definition rather than imported from table/read.ts, so the two agree only if both are right. +const TWIPS_PER_POINT = 20; + +// The exact rgdxaCenter a real LibreOffice 26.2.5.2-authored three-column table states, taken from a 2.5cm/3.1cm/4.7cm .fodt converted with `soffice --headless --convert-to doc` -- widths deliberately chosen not to land on whole twips, and still byte-identical in every one of that table's rows. That is why no LibreOffice-derived fixture in this package ever exercises per-row drift: LibreOffice rounds a table's columns to twips once for the whole table, not once per row (ExaDev/documents.js#898). +const LIBREOFFICE_ROW_BOUNDARIES = [0, 2338, 5238, 9638]; +/** The same table's columns in points, the shape a reconstruction that recognises its rows as sharing one grid produces: 2338/20, 2900/20, 4400/20. */ +const LIBREOFFICE_COLUMN_WIDTHS_PT = [116.9, 145, 220]; +/** That middle column's own width, 145pt: the distance the zero-width-cell case below pulls its right boundary back by so the two coincide. */ +const MIDDLE_COLUMN_WIDTH_TWIPS = 2900; +/** The boundary between that table's first and second columns: the single int16 the tolerance sweep patched inside a real LibreOffice-authored file's second row, and the one a row merging those two columns omits from its own array entirely. */ +const INTERIOR_BOUNDARY_INDEX = 1; +/** sprmTCellPaddingDefault's own documented default wWidth ([MS-DOC] 2.6.4), which is why Word writes -108 rather than 0 as an unindented table's first rgdxaCenter entry -- and so also the size of the real-world one-row leading indent the mode-2 case below uses. */ +const WORD_DEFAULT_CELL_MARGIN_TWIPS = 108; + +function withBoundaryShifted( + boundariesTwips: readonly number[], + index: number, + deltaTwips: number, +): number[] { + return boundariesTwips.map((boundary, at) => + at === index ? boundary + deltaTwips : boundary, + ); +} + +// Builds a whole table's paragraph sequence from nothing but each row's own rgdxaCenter array and its cells' text. No cell carries a TC80.tcgrf merge flag and no sprmTMerge or sprmTVertMerge is written, so any colSpan that comes back was reconstructed purely by comparing these boundary arrays against each other -- which is exactly what the column-grid union does, and the only thing these tests are about. +function tableParagraphs( + rows: readonly { + boundariesTwips: readonly number[]; + cells: readonly string[]; + }[], +): DocParagraphSpec[] { + const unmerged = { horzMerge: 0, vertMerge: 0 }; + return rows.flatMap((row): DocParagraphSpec[] => [ + ...row.cells.map((text): DocParagraphSpec => ({ + runs: [{ text }], + grpprl: SPRM_P_F_IN_TABLE, + mark: CELL_MARK, + })), + { + runs: [], + grpprl: [ + ...SPRM_P_F_IN_TABLE, + ...SPRM_P_F_TTP, + ...sprmTDefTable( + row.boundariesTwips, + row.cells.map(() => unmerged), + ), + ], + mark: CELL_MARK, + }, + ]); +} + +function readTableFromRowBoundaries( + rows: readonly { + boundariesTwips: readonly number[]; + cells: readonly string[]; + }[], +) { + return tableBlock( + readDocContent(buildDoc({ paragraphs: tableParagraphs(rows) })), + ); +} + +function colSpansPerRow( + block: ReturnType, +): (number | undefined)[][] { + return block.rows.map((row) => row.cells.map((cell) => cell.colSpan)); +} + +// [MS-DOC] 2.6.4 states a table's column layout per row, and 2.9.321's rgdxaCenter is a plain array of twip offsets from the page margin with no coarser quantum defined anywhere -- so two rows meaning the identical grid may legally disagree by a twip or two, and reconstructing the shared grid from them needs a tolerance rather than exact integer equality (ExaDev/documents.js#898). The threshold is one point, matching what a real, independent [MS-DOC] implementation applies to the identical per-row-boundaries-to-shared-grid problem: LibreOffice's `#define COLFUZZY 20` twips (sw/source/filter/inc/wrtswtbl.hxx), whose changeover was confirmed empirically at exactly 20/21 by sweeping a single patched int16 through LibreOffice 26.2.5.2's own .doc importer. +describe("readDocContent table column grids, from hand-assembled rgdxaCenter arrays", () => { + it("reads rows stating the identical LibreOffice-authored boundary array as one shared three-column grid", () => { + const block = readTableFromRowBoundaries([ + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a1", "b1", "c1"], + }, + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a2", "b2", "c2"], + }, + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a3", "b3", "c3"], + }, + ]); + expect(block.columnWidthsPt).toEqual(LIBREOFFICE_COLUMN_WIDTHS_PT); + expect(colSpansPerRow(block)).toEqual([ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]); + }); + + it("reads a row whose interior boundary drifts a single twip as part of the same column grid, not a phantom hairline column", () => { + const block = readTableFromRowBoundaries([ + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a1", "b1", "c1"], + }, + { + boundariesTwips: withBoundaryShifted( + LIBREOFFICE_ROW_BOUNDARIES, + INTERIOR_BOUNDARY_INDEX, + 1, + ), + cells: ["a2", "b2", "c2"], + }, + ]); + expect(block.columnWidthsPt).toEqual(LIBREOFFICE_COLUMN_WIDTHS_PT); + expect(colSpansPerRow(block)).toEqual([ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]); + expect( + block.rows.map((row) => row.cells.map((cell) => cellText(cell))), + ).toEqual([ + ["a1", "b1", "c1"], + ["a2", "b2", "c2"], + ]); + }); + + it("still collapses a boundary drifting a full point, the widest gap the tolerance absorbs", () => { + const block = readTableFromRowBoundaries([ + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a1", "b1", "c1"], + }, + { + boundariesTwips: withBoundaryShifted( + LIBREOFFICE_ROW_BOUNDARIES, + INTERIOR_BOUNDARY_INDEX, + TWIPS_PER_POINT, + ), + cells: ["a2", "b2", "c2"], + }, + ]); + expect(block.columnWidthsPt).toEqual(LIBREOFFICE_COLUMN_WIDTHS_PT); + expect(colSpansPerRow(block)).toEqual([ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]); + }); + + // One twip past the tolerance the rows genuinely do describe different grids, and the reconstruction says so rather than absorbing the difference: the sliver between the two boundaries becomes its own column, with each row's first cell spanning whichever pair of segments its own boundaries cover. This is the same shape LibreOffice's own importer produces from the identical bytes at the identical threshold -- the tolerance moves where the split happens, it does not remove the split. + it("keeps a boundary drifting one point and one twip as its own column, matching where LibreOffice's own importer splits", () => { + const block = readTableFromRowBoundaries([ + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a1", "b1", "c1"], + }, + { + boundariesTwips: withBoundaryShifted( + LIBREOFFICE_ROW_BOUNDARIES, + INTERIOR_BOUNDARY_INDEX, + TWIPS_PER_POINT + 1, + ), + cells: ["a2", "b2", "c2"], + }, + ]); + expect(block.columnWidthsPt).toEqual([116.9, 1.05, 143.95, 220]); + expect(colSpansPerRow(block)).toEqual([ + [undefined, 2, undefined], + [2, undefined, undefined], + ]); + }); + + // Word writes -108 rather than 0 as an unindented table's first rgdxaCenter entry (LibreOffice's own WW8 importer carries the fact as a named comment in ww8par2.cxx's CalcDefaults), compensating for [MS-DOC]'s own 108-twip default cell margin. Every row states it, so the rows still describe one grid -- and the indent itself has nowhere to land, since ContentTable carries only rows and columnWidthsPt (see the README's own note). + it("reads rows sharing Word's own -108 leading offset as one grid, carrying the column widths and dropping the offset", () => { + const wordUnindented = LIBREOFFICE_ROW_BOUNDARIES.map( + (boundary) => boundary - WORD_DEFAULT_CELL_MARGIN_TWIPS, + ); + const block = readTableFromRowBoundaries([ + { boundariesTwips: wordUnindented, cells: ["a1", "b1", "c1"] }, + { boundariesTwips: wordUnindented, cells: ["a2", "b2", "c2"] }, + ]); + expect(block.columnWidthsPt).toEqual(LIBREOFFICE_COLUMN_WIDTHS_PT); + expect(colSpansPerRow(block)).toEqual([ + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]); + }); + + // A leading indent that only ONE row carries is not drift and is not absorbed: sprmTWidthBefore ([MS-DOC] 2.6.4) makes a per-row leading indent a first-class construct, and rgdxaCenter's own first entry is "the horizontal position of the logical left edge of the table, as indented from the logical left page margin" (2.9.321) -- so rows disagreeing about it genuinely occupy different horizontal extents. The reconstructed grid honestly carries the extra boundary, with the rows that begin further left spanning both segments. LibreOffice 26.2.5.2 reads the identical bytes into the identical shape: four columns, a table:number-columns-spanned="2" anchor and a real table:covered-table-cell on those rows. + it("keeps a leading indent only one row states as a real boundary, spanning it on the rows that begin further left", () => { + const block = readTableFromRowBoundaries([ + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a1", "b1", "c1"], + }, + { + boundariesTwips: withBoundaryShifted( + LIBREOFFICE_ROW_BOUNDARIES, + 0, + WORD_DEFAULT_CELL_MARGIN_TWIPS, + ), + cells: ["a2", "b2", "c2"], + }, + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a3", "b3", "c3"], + }, + ]); + expect(block.columnWidthsPt).toEqual([5.4, 111.5, 145, 220]); + expect(colSpansPerRow(block)).toEqual([ + [2, undefined, undefined], + [undefined, undefined, undefined], + [2, undefined, undefined], + ]); + }); + + // The tolerance must not swallow a genuine horizontal merge, whose own boundary gap is a whole column wide rather than a twip. These are the real arrays a LibreOffice-authored table with a merged first row states (ExaDev/documents.js#895): the merged row's own rgdxaCenter is an exact subset of the unmerged rows'. + it("still reconstructs a horizontal merge from a real LibreOffice-authored merged row's own narrower boundary array", () => { + const mergedRow = LIBREOFFICE_ROW_BOUNDARIES.filter( + (_, index) => index !== INTERIOR_BOUNDARY_INDEX, + ); + const block = readTableFromRowBoundaries([ + { boundariesTwips: mergedRow, cells: ["merged", "c1"] }, + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a2", "b2", "c2"], + }, + { + boundariesTwips: LIBREOFFICE_ROW_BOUNDARIES, + cells: ["a3", "b3", "c3"], + }, + ]); + expect(block.columnWidthsPt).toEqual(LIBREOFFICE_COLUMN_WIDTHS_PT); + expect(colSpansPerRow(block)).toEqual([ + [2, undefined], + [undefined, undefined, undefined], + [undefined, undefined, undefined], + ]); + expect(cellText(block.rows[0]?.cells[0])).toBe("merged"); + }); + + // rgdxaCenter's entries "MUST be in non-decreasing order" ([MS-DOC] 2.9.321) -- equal adjacent entries, and so a genuine zero-width physical cell, are explicitly legal. Such a cell covers no segment of the reconstructed grid, and ContentTableCell has no way to say "zero columns wide", so it comes back carrying its own content as an ordinary un-spanned cell rather than as a cell claiming a span of zero. + it("carries a legal zero-width physical cell as an ordinary un-spanned cell rather than one spanning no columns", () => { + // The same table's array with its third boundary pulled back onto its second, collapsing the middle column to nothing: 0, 2338, 2338, 9638. + const block = readTableFromRowBoundaries([ + { + boundariesTwips: withBoundaryShifted( + LIBREOFFICE_ROW_BOUNDARIES, + INTERIOR_BOUNDARY_INDEX + 1, + -MIDDLE_COLUMN_WIDTH_TWIPS, + ), + cells: ["a", "b", "c"], + }, + ]); + expect(block.columnWidthsPt).toEqual([116.9, 365]); + expect(colSpansPerRow(block)).toEqual([[undefined, undefined, undefined]]); + expect(block.rows[0]?.cells.map((cell) => cellText(cell))).toEqual([ + "a", + "b", + "c", + ]); + }); +}); diff --git a/packages/doc-codec/src/table/read.ts b/packages/doc-codec/src/table/read.ts index 62c16d1b..33689cc6 100644 --- a/packages/doc-codec/src/table/read.ts +++ b/packages/doc-codec/src/table/read.ts @@ -17,10 +17,17 @@ import { CELL_MARK } from "../text/special"; // Groups the flat paragraph-entry sequence read.ts produces into the final ContentBlock list, folding every contiguous run of table-depth-1 paragraphs into a real ContentTable with row/cell/merge structure -- [MS-DOC] 2.4.3's own Overview of Tables model: a table is a run of paragraphs each marked sprmPFInTable, cells delimited by cell-mark (0x07) characters (a cell holding more than one paragraph ends every paragraph but its last with an ordinary 0x0D mark), and each row closed by a row-ending mark of its own (sprmPFTtp, itself a 0x07 mark) that carries the row's TAP -- its column layout and every physical cell's own horizontal/vertical merge state, resolved by tap.ts. A non-table entry passes through untouched. A run whose TAP this reader cannot resolve degrades to its own paragraphs rather than failing the whole document -- see tryAssembleTable's own note. // -// Column layout is derived per row, never assumed shared: [MS-DOC] 2.6.4 permits each row of a table to declare its own independent rgdxaCenter, and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge -- a merged row's own TDefTableOperand simply has fewer, wider physical cells, with no TCGRF.horzMerge or sprmTMerge signal at all (ExaDev/documents.js#895; see table/write.ts's own top-of-file note for the full ground-truth finding). buildRows below reconstructs the table's shared grid as the union of every row's own column boundaries, then expresses each physical cell's own colSpan as however many of that shared grid's segments its own boundaries cover -- folding in this writer's own legacy TCGRF.horzMerge-flagged continuation cells (a spec-conformant encoding this reader still honours, in case a genuine third-party producer uses it) exactly as before. A column boundary that no row in the table ever states on its own -- every row happens to merge across it identically -- cannot be recovered from the physical bytes at all; this is a real limitation of [MS-DOC]'s own physical model, not an approximation this reader is choosing to make (see the README's own note on this). +// Column layout is derived per row, never assumed shared: [MS-DOC] 2.6.4 permits each row of a table to declare its own independent rgdxaCenter, and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge -- a merged row's own TDefTableOperand simply has fewer, wider physical cells, with no TCGRF.horzMerge or sprmTMerge signal at all (ExaDev/documents.js#895; see table/write.ts's own top-of-file note for the full ground-truth finding). buildRows below reconstructs the table's shared grid as the union of every row's own column boundaries -- taken within one point rather than by exact integer equality, since rows stating the identical grid independently may legally disagree by a twip or two (see isSameColumnBoundary's own note) -- then expresses each physical cell's own colSpan as however many of that shared grid's segments its own boundaries cover -- folding in this writer's own legacy TCGRF.horzMerge-flagged continuation cells (a spec-conformant encoding this reader still honours, in case a genuine third-party producer uses it) exactly as before. A column boundary that no row in the table ever states on its own -- every row happens to merge across it identically -- cannot be recovered from the physical bytes at all; this is a real limitation of [MS-DOC]'s own physical model, not an approximation this reader is choosing to make (see the README's own note on this). const TWIPS_PER_POINT = 20; +// Whether two column boundaries, stated independently by two of a table's own rows, name the same boundary of its shared grid rather than two distinct columns. The tolerance is a whole point -- TWIPS_PER_POINT itself, not a picked number: ContentTable.columnWidthsPt states the reconstructed grid in points, so a segment narrower than one point is below the smallest unit that grid can meaningfully distinguish at all. +// +// A real, independent [MS-DOC] implementation applies the identical fuzz to the identical computation. LibreOffice's own table model is per-row too (SwTableLine -> SwTableBox, each box carrying its own width), so it faces the same reconstruct-one-shared-grid-from-N-per-row-arrays problem this function exists for, and sw/source/filter/inc/wrtswtbl.hxx answers it with `#define COLFUZZY 20` twips: SwWriteTableCol::operator== compares two column positions as equal when they differ by at most that, and SwXMLTableColumn_Impl's own column set is an o3tl::sorted_vector ordered by that fuzzy comparator. Sweeping a single patched int16 through LibreOffice 26.2.5.2's own .doc importer confirms the threshold empirically and exactly: a second row's boundary drifting 1 to 20 twips from the first's reads back as one shared 3-column grid, 21 and beyond as 4 columns with a real table:covered-table-cell. The fuzz provably cannot swallow a legitimately narrow column either, since LibreOffice's own minimum cell width (MINLAY, 23 twips in sw/inc/swtypes.hxx, which WW8TabDesc::CalcDefaults actively widens an imported cell up to) is wider than COLFUZZY -- and CalcDefaults mutating boundaries per row during import is itself one real mechanism by which a .doc in the wild comes to carry per-row drift at all. See ExaDev/documents.js#898. +function isSameColumnBoundary(left: number, right: number): boolean { + return Math.abs(left - right) <= TWIPS_PER_POINT; +} + interface RawCell { readonly horzMerge: number; readonly vertMerge: number; @@ -139,17 +146,40 @@ function tryAssembleTable( }; } -// The table's own shared column grid, reconstructed as the union of every row's own rgdxaCenter boundary values rather than assumed from any single row -- see this module's own top-of-file note on why a merged row's own boundaries are a genuine subset of the table's full grid, not the whole thing. +// The table's own shared column grid, reconstructed as the union of every row's own rgdxaCenter boundary values rather than assumed from any single row -- see this module's own top-of-file note on why a merged row's own boundaries are a genuine subset of the table's full grid, not the whole thing. The union is taken within isSameColumnBoundary's own tolerance rather than by exact integer equality: [MS-DOC] states each row's boundaries independently, so two rows meaning the identical grid can differ by a twip or two without either being wrong, and an exact union would turn that drift into a phantom hairline column plus a spurious colSpan on every row (ExaDev/documents.js#898). Sorting before clustering makes the result depend only on the boundary values themselves, never on which row happened to be read first -- unlike LibreOffice's own insertion-ordered fuzzy set -- and taking each cluster's smallest member as its representative keeps the canonical array non-decreasing and anchored on the leftmost row's own left edge. function canonicalColumnBoundariesTwips( definitions: readonly TableRowDefinition[], ): number[] { - const boundaries = new Set(); - for (const definition of definitions) { - for (const boundary of definition.columnBoundariesTwips) { - boundaries.add(boundary); + const sorted = definitions + .flatMap((definition) => definition.columnBoundariesTwips) + .sort((left, right) => left - right); + const canonical: number[] = []; + for (const boundary of sorted) { + const representative = canonical[canonical.length - 1]; + if ( + representative === undefined || + !isSameColumnBoundary(representative, boundary) + ) { + canonical.push(boundary); } } - return Array.from(boundaries).sort((left, right) => left - right); + return canonical; +} + +// The index of the canonical grid boundary one row's own raw boundary belongs to. A raw boundary need not appear in the canonical array at all once boundaries are clustered, so this snaps rather than looks up. The first match is always its own cluster's: canonicalColumnBoundariesTwips opens a new canonical entry only beyond the tolerance, so consecutive canonical entries are further apart than it, and every canonical entry below the one this boundary was absorbed into is therefore further than the tolerance from it. Finding no match at all cannot happen for a boundary that went into building the grid -- which is every boundary this is ever asked about -- so it is an internal invariant, not a malformed-input case. +function gridIndexFor( + canonicalBoundariesTwips: readonly number[], + boundary: number, +): number { + const index = canonicalBoundariesTwips.findIndex((candidate) => + isSameColumnBoundary(candidate, boundary), + ); + if (index === -1) { + throw new DocFormatError( + `internal defect: a table row's column boundary ${String(boundary)} matches no boundary on the table's own reconstructed grid, which was built from that boundary among others`, + ); + } + return index; } function columnWidthsFromBoundaries(boundaries: readonly number[]): number[] { @@ -199,11 +229,13 @@ function logicalCellsForRow( "a table row's own column-boundary array has fewer entries than its physical cell count requires", ); } - const startGridIndex = canonicalBoundariesTwips.indexOf(left); - const endGridIndex = canonicalBoundariesTwips.indexOf(right); + const startGridIndex = gridIndexFor(canonicalBoundariesTwips, left); + const endGridIndex = gridIndexFor(canonicalBoundariesTwips, right); logical.push({ startGridIndex, - colSpan: endGridIndex - startGridIndex, + // A physical cell whose own boundaries snap to one canonical entry covers no segment of the shared grid at all. That is a legal cell, not corruption: [MS-DOC] 2.9.321 requires rgdxaCenter only to be "in non-decreasing order", so two adjacent entries may be equal (a genuine zero-width cell) or -- now that the union snaps -- within the tolerance of each other. ContentTableCell has no way to say "zero columns wide", so such a cell is carried with its content as an ordinary un-spanned cell, and the cell following it keeps its own start index rather than being displaced by a span this one never occupied. + colSpan: + endGridIndex > startGridIndex ? endGridIndex - startGridIndex : 1, vertMerge: cell.vertMerge, blocks: cell.blocks, }); From 5b7a678db0d4b4246799bd3beb3d7a9f5e318482 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 03:31:56 +0100 Subject: [PATCH 2/5] docs(doc-codec): state the column-grid tolerance and the missing table indent in the Tables section The Reading paragraph claimed an exact union of every row's rgdxaCenter, which is no longer what the reader does: it records the one-point snap instead, why the point is the tolerance rather than a picked number, and the LibreOffice COLFUZZY/MINLAY evidence and 20/21 sweep that ground it. "What is not resolved" gains the table's own horizontal position, which document-schema.js has no field for on either a table or a row. A table indent is therefore dropped on read even in the simple case where every row agrees on it, and rows that legally disagree about it -- Word writes -108 for an unindented table, so a table with one genuinely indented row has rows at -108 and 0 -- keep the extra boundary between them, with the wider rows' first cell spanning both segments. That is the shape LibreOffice reads the same bytes into, so it is the correct reading rather than a defect the tolerance should absorb. The zero-width cell rgdxaCenter's "non-decreasing" rule permits is recorded alongside it, since it shares a grid position with the cell after it and ContentTableCell cannot distinguish the two. --- packages/doc-codec/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index 4060fa27..291b408d 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -85,7 +85,7 @@ Fields are handled structurally: everything between a field-begin (`U+0013`) and A table in [MS-DOC] is not a separate container: it is a run of ordinary paragraphs marked `sprmPFInTable`, with cell boundaries at literal `0x07` cell-mark characters in the text stream and each row closed by its own row-ending mark — a cell mark additionally carrying `sprmPFTtp` — per [MS-DOC] 2.4.3's own Overview of Tables. `src/table/` implements exactly this model, at table depth 1 only; a table nested inside a table cell is refused rather than mis-read (see the "Nested tables" row in the scope table above). -**Reading** (`table/read.ts`'s `assembleBlocks`, called from `read.ts`). It walks the flat paragraph sequence `read.ts` already produces, grouping every contiguous run of `inTable` paragraphs into a `ContentTable`: consecutive paragraphs up to and including the one terminated by an ordinary cell mark become one cell's own `blocks` (a cell may hold more than one paragraph — only its last ends in a cell mark, per 2.4.3's own "the last paragraph in a table cell is terminated by a cell mark"), and the row's own trailing mark resolves the row's whole TAP through `table/tap.ts`'s `applyTableSprms`: column boundaries and every physical cell's own merge state, read directly from `sprmTDefTable`'s `TDefTableOperand` — its `rgdxaCenter` array and its `rgTc80` array of per-column `TC80` records ([MS-DOC] 2.9.339-341) — folded with a `sprmTMerge` range or `sprmTVertMerge` per-cell flag on top where a real producer states a merge incrementally instead, genuinely regardless of which order the two appear in within the grpprl (`table/tap.ts`'s own note). Column layout is never assumed shared across a table's own rows: [MS-DOC] 2.6.4 permits each row to declare its own independent `rgdxaCenter` ("There is no requirement that each row of a table have the same number of cells"), and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge — its own merged row simply has fewer, wider physical cells, with no `TCGRF.horzMerge`/`sprmTMerge` signal at all (see the third-party verification paragraph below). `table/read.ts` reconstructs the table's shared column grid as the union of every row's own `rgdxaCenter` boundary values, then expresses each physical cell's own `colSpan` as however many of that shared grid's segments its own boundaries cover: [MS-DOC]'s own physical-cell model keeps every horizontally- and vertically-merged-away cell present in the text stream with its own cell mark and its own `TC80` entry — never omitted the way OOXML's `w:gridSpan` model omits a horizontally-merged-away `` outright — so a horizontal-continuation cell stated the legacy way (`TCGRF.horzMerge` = 1, still honoured for a genuine third-party producer that uses it) is folded into the preceding real cell's own `colSpan` exactly as before, while a genuinely narrower, wider physical cell (no flag, LibreOffice's own encoding) resolves to a `colSpan` greater than 1 directly from its own boundaries — both mechanisms produce the identical shape downstream. A vertical-continuation cell (`TCGRF.vertMerge` = `fvmMerge`) is kept as its own `{blocks: []}` entry — carrying its own `colSpan` too when it is also part of a horizontal-merge group in that row — with `rowSpan` computed on the anchor by scanning subsequent rows for a cell starting at the same position on the table's own shared grid, never a raw physical-array index, since two rows may genuinely have different physical cell counts and still need their vertical merges to line up correctly. Both conventions mirror `ooxml.js`'s own docx table reader exactly, since `colSpan`/`rowSpan`/`{blocks: []}` are precisely the shape `document-schema.js`'s `ContentTableCell` was designed to hold for either format's own cousin of the same merge model. A column boundary that no row in the table ever states on its own — every row happens to merge across it identically — cannot be recovered from the physical bytes at all; this is a genuine limitation of [MS-DOC]'s own physical model, not an approximation this reader chooses to make (see the third-party verification paragraph below for the confirmed reproduction, and `write.test.ts`'s own "narrows columnWidthsPt" test for the honest degraded shape this produces). +**Reading** (`table/read.ts`'s `assembleBlocks`, called from `read.ts`). It walks the flat paragraph sequence `read.ts` already produces, grouping every contiguous run of `inTable` paragraphs into a `ContentTable`: consecutive paragraphs up to and including the one terminated by an ordinary cell mark become one cell's own `blocks` (a cell may hold more than one paragraph — only its last ends in a cell mark, per 2.4.3's own "the last paragraph in a table cell is terminated by a cell mark"), and the row's own trailing mark resolves the row's whole TAP through `table/tap.ts`'s `applyTableSprms`: column boundaries and every physical cell's own merge state, read directly from `sprmTDefTable`'s `TDefTableOperand` — its `rgdxaCenter` array and its `rgTc80` array of per-column `TC80` records ([MS-DOC] 2.9.339-341) — folded with a `sprmTMerge` range or `sprmTVertMerge` per-cell flag on top where a real producer states a merge incrementally instead, genuinely regardless of which order the two appear in within the grpprl (`table/tap.ts`'s own note). Column layout is never assumed shared across a table's own rows: [MS-DOC] 2.6.4 permits each row to declare its own independent `rgdxaCenter` ("There is no requirement that each row of a table have the same number of cells"), and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge — its own merged row simply has fewer, wider physical cells, with no `TCGRF.horzMerge`/`sprmTMerge` signal at all (see the third-party verification paragraph below). `table/read.ts` reconstructs the table's shared column grid as the union of every row's own `rgdxaCenter` boundary values, then expresses each physical cell's own `colSpan` as however many of that shared grid's segments its own boundaries cover. That union is taken within one point rather than by exact integer equality, because [MS-DOC] states those boundaries per row and defines no quantum coarser than the twip itself for them, so two rows meaning the identical grid may legally disagree by a twip or two — and an exact union turns that drift into a phantom hairline column plus a spurious `colSpan` on the cells of every row either side of it (two rows one twip apart across a 2338-twip boundary read back as `columnWidthsPt` `[116.9, 0.05, 144.95, 220]` instead of `[116.9, 145, 220]`; [ExaDev/documents.js#898](https://github.com/ExaDev/documents.js/issues/898)). The tolerance is `TWIPS_PER_POINT` itself, not a picked number: `columnWidthsPt` states the reconstructed grid in points, so a segment narrower than one point sits below the smallest unit that grid can distinguish at all. It is also the fuzz a real, independent implementation applies to this identical reconstruct-one-shared-grid-from-N-per-row-arrays problem — LibreOffice's table model is per-row too (`SwTableLine` → `SwTableBox`, each box carrying its own width), and `sw/source/filter/inc/wrtswtbl.hxx` answers it with `#define COLFUZZY 20` twips, `SwWriteTableCol::operator==` treating two column positions as equal when they differ by at most that. Its changeover was confirmed empirically and exactly, not assumed: patching a single `int16` inside a real LibreOffice-authored table's second row and sweeping the drift back through that implementation's own `.doc` importer gives three columns and no covered cell for a drift of 1 through 20 twips, and four columns with a real `table:covered-table-cell` from 21 — and because `COLFUZZY` sits below the same implementation's own minimum cell width (`MINLAY`, 23 twips in `sw/inc/swtypes.hxx`, which `WW8TabDesc::CalcDefaults` actively widens a narrower imported cell up to), the snap provably cannot swallow a legitimately narrow column. Per-row drift is not hypothetical: `CalcDefaults` mutating boundaries per row on import is one real mechanism by which a `.doc` in the wild comes to carry it. Beyond that, [MS-DOC]'s own physical-cell model keeps every horizontally- and vertically-merged-away cell present in the text stream with its own cell mark and its own `TC80` entry — never omitted the way OOXML's `w:gridSpan` model omits a horizontally-merged-away `` outright — so a horizontal-continuation cell stated the legacy way (`TCGRF.horzMerge` = 1, still honoured for a genuine third-party producer that uses it) is folded into the preceding real cell's own `colSpan` exactly as before, while a genuinely narrower, wider physical cell (no flag, LibreOffice's own encoding) resolves to a `colSpan` greater than 1 directly from its own boundaries — both mechanisms produce the identical shape downstream. A vertical-continuation cell (`TCGRF.vertMerge` = `fvmMerge`) is kept as its own `{blocks: []}` entry — carrying its own `colSpan` too when it is also part of a horizontal-merge group in that row — with `rowSpan` computed on the anchor by scanning subsequent rows for a cell starting at the same position on the table's own shared grid, never a raw physical-array index, since two rows may genuinely have different physical cell counts and still need their vertical merges to line up correctly. Both conventions mirror `ooxml.js`'s own docx table reader exactly, since `colSpan`/`rowSpan`/`{blocks: []}` are precisely the shape `document-schema.js`'s `ContentTableCell` was designed to hold for either format's own cousin of the same merge model. A column boundary that no row in the table ever states on its own — every row happens to merge across it identically — cannot be recovered from the physical bytes at all; this is a genuine limitation of [MS-DOC]'s own physical model, not an approximation this reader chooses to make (see the third-party verification paragraph below for the confirmed reproduction, and `write.test.ts`'s own "narrows columnWidthsPt" test for the honest degraded shape this produces). A row whose own TAP cannot be resolved this way — no direct `sprmTDefTable` anywhere in its grpprl (a producer may legally state it indirectly instead, through `sprmPTableProps`; see the `sprmPHugePapx`/`sprmPTableProps` scope row above), or a cell-mark count that disagrees with what its `TDefTableOperand` declares — degrades the _whole_ contiguous run of table-depth paragraphs back to flat paragraphs, rather than refusing the whole document: this is a legal, real-world construct this reader does not yet implement, not corruption, and paragraphs that would have become a table simply stay paragraphs instead, the identical class of degrade the `sprmPHugePapx`/`sprmPTableProps` row above already documents for ordinary paragraph formatting. A row ending mid-cell with no terminating mark at all is different in kind — the stream itself is truncated, not merely using an unsupported mechanism — and still throws `DocFormatError`. @@ -99,6 +99,10 @@ The horizontal-merge gap #892 left open ([ExaDev/documents.js#895](https://githu **What is not resolved.** Cell shading and borders (`ContentTableCell.background`/`.borders`) are neither read from nor written to `TC80`'s own `brcTop`/`brcLeft`/`brcBottom`/`brcRight`/shading fields — every border this writer emits is `Brc80MayBeNil`'s "no border" sentinel (all bits set). `sprmTMerge` and `sprmTVertMerge` are both still read (folded onto `sprmTDefTable`'s own layout, for a genuine third-party producer that states a merge that way) but neither is written any more — this writer states a horizontal merge purely through a merged row's own narrower, wider physical cells (see [Writing](#writing) above), and a vertical merge only through `TC80.tcgrf`. Every table-level TAP sprm beyond `sprmTDefTable`/`sprmTDyaRowHeight`/`sprmTMerge`/`sprmTVertMerge` — absolute position, table style, cell padding, and the rest of [MS-DOC] 2.6.4's roughly seventy table sprms — is unread and unwritten, exactly as the read-side scope note already states for ordinary paragraph sprms this package does not convert. A genuine, if narrow, information-loss case remains inherent to the physical model itself rather than a gap in this package: when literally every row of a table merges across the identical column boundary (a single-row table with one merged cell is the simplest case), no row's own `rgdxaCenter` ever states that boundary, so a round trip cannot recover it — `columnWidthsPt` narrows to however many columns the physical bytes actually distinguish, and the merged cell's own `colSpan` comes back `undefined` rather than the value it was written with (`write.test.ts`'s own "narrows columnWidthsPt" test states this precisely). A table with at least one row that does not merge across the same span — the common case, since a merge is usually a header row sitting above ordinary data rows — round-trips `colSpan` and `columnWidthsPt` exactly. +A table's own horizontal position is not read or written either, and this one is a schema boundary rather than a gap in this package. `rgdxaCenter`'s first entry is "the horizontal position of the logical left edge of the table, as indented from the logical left page margin" ([MS-DOC] 2.9.321), and `sprmTDxaLeft`/`sprmTDxaGapHalf`/`sprmTWidthBefore` state the same fact incrementally — but `document-schema.js`'s `ContentTable` carries only `rows` and `columnWidthsPt`, with no field on the table or on a row that could hold a horizontal offset, so a table indent is dropped on read and every row this writer emits starts at 0. No codec in this family models a table indent, so nothing downstream would have anywhere to put one. This is already live in the simple case: a LibreOffice table with `fo:margin-left="1.27cm"` writes `rgdxaCenter = [720, 2884, 5567, 9638]` in **every** row, and reads back as `columnWidthsPt` `[108.2, 134.15, 203.55]` with the 720-twip indent gone. Because [MS-DOC] states the boundary array per row, two rows of one table may also legally begin at different positions — Word's own default for an unindented table is `-108` rather than 0, compensating for the format's own 108-twip default cell margin (`sprmTCellPaddingDefault`), so a table one of whose rows carries a real leading indent has rows at `-108` and `0`. Those rows genuinely occupy different horizontal extents, and the reconstructed grid honestly carries the extra boundary between them, with the wider rows' first cell spanning both segments. That is not the twip-drift case above and is deliberately not absorbed by its tolerance: verified against LibreOffice 26.2.5.2, which reads the identical bytes into the identical grid — four columns, a `table:number-columns-spanned="2"` anchor and a real `table:covered-table-cell` on the rows that start further left. The one cosmetic difference is that LibreOffice pads the short row with an empty filler cell so every row covers the full grid, which this reader does not: `ContentTableRow.cells` carries no grid-position field, so a reader-invented empty cell would be indistinguishable from real empty content on the write side, and `table/write.ts` reconstructs each row's own narrower `rgdxaCenter` from spans without needing one. + +One narrow accuracy limit follows from the same missing field. `rgdxaCenter`'s entries need only be "in non-decreasing order", so two adjacent entries may be equal — a legal zero-width physical cell. Such a cell covers no segment of the reconstructed grid, and `ContentTableCell` cannot say "zero columns wide", so it comes back carrying its own content as an ordinary un-spanned cell sharing a grid position with the cell after it. Nothing is lost, but the two are indistinguishable by position, so a vertical merge anchored at that position in a later row matches whichever of them comes first. + ## Metadata A `.doc`'s title, author, and dates do not live in any [MS-DOC] structure at all — they live in a `"\x05SummaryInformation"` stream, a genuinely different format ([MS-OLEPS] Property Set Streams) that happens to sit beside `WordDocument`/`1Table` in the same [MS-CFB] compound file. `readDocContent` reads that stream when present (`archive-codec`'s `readSummaryInformation`, since the property-set format itself is zero document-format knowledge, exactly as the [MS-CFB] container it sits inside is) and maps it onto `document-schema.js`'s `LayoutMetadata` (`archive-codec`'s own `summaryInformationToLayoutMetadata` — the mapping is format-agnostic, so it lives there rather than being copied in this package, alongside `xls-codec`'s and `ppt-codec`'s identical need for it); `writeDocContent` does the inverse (`src/metadata.ts`'s `layoutMetadataToSummaryInformation`, which validates `createdIso`/`modifiedIso` as real dates and throws a `DocFormatError` naming the offending field before delegating to `archive-codec`'s own mapping — see [Writing](#writing)), including a `"\x05SummaryInformation"` stream in its `writeCompoundFile` call only when the input's metadata actually carries something that stream can hold — an input whose metadata is `{}`, or carries only fields the mapping below has no destination for, produces no stream at all, matching what an absent-metadata read already returns. From 6d6983a7cf0baa289bf9ba2a45ba7007239fa0bd Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 04:16:00 +0100 Subject: [PATCH 3/5] fix(doc-codec): clamp the column-boundary tolerance below a real narrow column The one-point drift tolerance assumed a MINLAY-style minimum cell width that only LibreOffice's own writer guarantees -- this package's own writer widens nothing, so a real producer's rgdxaCenter can legally state a column narrower than a point, and folding its own two boundaries together as drift would silently delete it rather than fix a phantom one. effectiveColumnBoundaryTolerance clamps the tolerance, per table, to one twip below the narrowest strictly-positive gap any single row states between two of its own adjacent boundaries: two boundaries one row itself distinguishes are never folded together, however close. --- packages/doc-codec/src/table/read.ts | 72 ++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/doc-codec/src/table/read.ts b/packages/doc-codec/src/table/read.ts index 33689cc6..85cdca9b 100644 --- a/packages/doc-codec/src/table/read.ts +++ b/packages/doc-codec/src/table/read.ts @@ -21,11 +21,40 @@ import { CELL_MARK } from "../text/special"; const TWIPS_PER_POINT = 20; -// Whether two column boundaries, stated independently by two of a table's own rows, name the same boundary of its shared grid rather than two distinct columns. The tolerance is a whole point -- TWIPS_PER_POINT itself, not a picked number: ContentTable.columnWidthsPt states the reconstructed grid in points, so a segment narrower than one point is below the smallest unit that grid can meaningfully distinguish at all. +// Whether two column boundaries, stated independently by two of a table's own rows, name the same boundary of its shared grid rather than two distinct columns, within toleranceTwips -- effectiveColumnBoundaryTolerance's own result, never the bare TWIPS_PER_POINT constant, since a table that itself states a narrower real column needs a narrower fuzz (see that function's own note). // -// A real, independent [MS-DOC] implementation applies the identical fuzz to the identical computation. LibreOffice's own table model is per-row too (SwTableLine -> SwTableBox, each box carrying its own width), so it faces the same reconstruct-one-shared-grid-from-N-per-row-arrays problem this function exists for, and sw/source/filter/inc/wrtswtbl.hxx answers it with `#define COLFUZZY 20` twips: SwWriteTableCol::operator== compares two column positions as equal when they differ by at most that, and SwXMLTableColumn_Impl's own column set is an o3tl::sorted_vector ordered by that fuzzy comparator. Sweeping a single patched int16 through LibreOffice 26.2.5.2's own .doc importer confirms the threshold empirically and exactly: a second row's boundary drifting 1 to 20 twips from the first's reads back as one shared 3-column grid, 21 and beyond as 4 columns with a real table:covered-table-cell. The fuzz provably cannot swallow a legitimately narrow column either, since LibreOffice's own minimum cell width (MINLAY, 23 twips in sw/inc/swtypes.hxx, which WW8TabDesc::CalcDefaults actively widens an imported cell up to) is wider than COLFUZZY -- and CalcDefaults mutating boundaries per row during import is itself one real mechanism by which a .doc in the wild comes to carry per-row drift at all. See ExaDev/documents.js#898. -function isSameColumnBoundary(left: number, right: number): boolean { - return Math.abs(left - right) <= TWIPS_PER_POINT; +// A real, independent [MS-DOC] implementation applies an analogous fuzz to an analogous computation: LibreOffice's own table model is per-row too (SwTableLine -> SwTableBox, each box carrying its own width), so its own ODF export -- the point at which it projects that per-row model onto one shared grid, sw/source/filter/xml/xmltble.cxx's SwXMLTableColumn_Impl -- faces the same reconstruct-one-shared-grid-from-N-per-row-arrays problem this function exists for, and sw/source/filter/inc/wrtswtbl.hxx answers it with `#define COLFUZZY 20` twips: SwWriteTableCol::operator== compares two column positions as equal when they differ by at most that. Round-tripping a single patched int16 through LibreOffice 26.2.5.2 (.doc import, then its own ODF export) confirms the threshold empirically and exactly: a second row's boundary drifting 1 to 20 twips from the first's reads back as one shared 3-column grid, 21 and beyond as 4 columns with a real table:covered-table-cell. See ExaDev/documents.js#898. +function isSameColumnBoundary( + left: number, + right: number, + toleranceTwips: number, +): boolean { + return Math.abs(left - right) <= toleranceTwips; +} + +// The tolerance isSameColumnBoundary actually uses for one table, never wider than TWIPS_PER_POINT and never wide enough to fold two boundaries the SAME row states as genuinely distinct into one: this reader's own writer has no equivalent of LibreOffice's MINLAY minimum-cell-width widening, so nothing stops a real producer's own table from stating a column narrower than a point, and treating that column's own two boundaries as "the same" would silently delete it -- a real narrow column, not phantom drift, since a single row's own rgdxaCenter entries are never ambiguous about how many columns that row states. Clamping to one twip below the narrowest strictly-positive gap any row states between two of its own adjacent boundaries makes that impossible: two boundaries closer together than the tightest real column this table declares are never merged, whichever rows they came from. A zero-width gap is a legal adjacent-duplicate boundary (a genuine zero-width cell, see logicalCellsForRow's own note) rather than a column at all, and is excluded so one zero-width cell in a table does not collapse every other boundary to exact matching. +function effectiveColumnBoundaryTolerance( + definitions: readonly TableRowDefinition[], +): number { + let narrowestRealGapTwips: number | undefined; + for (const definition of definitions) { + const boundaries = definition.columnBoundariesTwips; + for (let index = 1; index < boundaries.length; index += 1) { + const left = boundaries[index - 1]; + const right = boundaries[index]; + if (left === undefined || right === undefined) continue; + const gap = right - left; + if ( + gap > 0 && + (narrowestRealGapTwips === undefined || gap < narrowestRealGapTwips) + ) { + narrowestRealGapTwips = gap; + } + } + } + return narrowestRealGapTwips === undefined + ? TWIPS_PER_POINT + : Math.min(TWIPS_PER_POINT, narrowestRealGapTwips - 1); } interface RawCell { @@ -138,10 +167,20 @@ function tryAssembleTable( ); } - const columnBoundariesTwips = canonicalColumnBoundariesTwips(rowDefinitions); + const toleranceTwips = effectiveColumnBoundaryTolerance(rowDefinitions); + const columnBoundariesTwips = canonicalColumnBoundariesTwips( + rowDefinitions, + toleranceTwips, + ); return { kind: "table", - rows: buildRows(rawRows, rowDefinitions, columnBoundariesTwips, rowHeights), + rows: buildRows( + rawRows, + rowDefinitions, + columnBoundariesTwips, + rowHeights, + toleranceTwips, + ), columnWidthsPt: columnWidthsFromBoundaries(columnBoundariesTwips), }; } @@ -149,6 +188,7 @@ function tryAssembleTable( // The table's own shared column grid, reconstructed as the union of every row's own rgdxaCenter boundary values rather than assumed from any single row -- see this module's own top-of-file note on why a merged row's own boundaries are a genuine subset of the table's full grid, not the whole thing. The union is taken within isSameColumnBoundary's own tolerance rather than by exact integer equality: [MS-DOC] states each row's boundaries independently, so two rows meaning the identical grid can differ by a twip or two without either being wrong, and an exact union would turn that drift into a phantom hairline column plus a spurious colSpan on every row (ExaDev/documents.js#898). Sorting before clustering makes the result depend only on the boundary values themselves, never on which row happened to be read first -- unlike LibreOffice's own insertion-ordered fuzzy set -- and taking each cluster's smallest member as its representative keeps the canonical array non-decreasing and anchored on the leftmost row's own left edge. function canonicalColumnBoundariesTwips( definitions: readonly TableRowDefinition[], + toleranceTwips: number, ): number[] { const sorted = definitions .flatMap((definition) => definition.columnBoundariesTwips) @@ -158,7 +198,7 @@ function canonicalColumnBoundariesTwips( const representative = canonical[canonical.length - 1]; if ( representative === undefined || - !isSameColumnBoundary(representative, boundary) + !isSameColumnBoundary(representative, boundary, toleranceTwips) ) { canonical.push(boundary); } @@ -170,9 +210,10 @@ function canonicalColumnBoundariesTwips( function gridIndexFor( canonicalBoundariesTwips: readonly number[], boundary: number, + toleranceTwips: number, ): number { const index = canonicalBoundariesTwips.findIndex((candidate) => - isSameColumnBoundary(candidate, boundary), + isSameColumnBoundary(candidate, boundary, toleranceTwips), ); if (index === -1) { throw new DocFormatError( @@ -206,6 +247,7 @@ function logicalCellsForRow( cells: readonly RawCell[], rowBoundariesTwips: readonly number[], canonicalBoundariesTwips: readonly number[], + toleranceTwips: number, ): LogicalCell[] { const logical: LogicalCell[] = []; let physicalIndex = 0; @@ -229,8 +271,16 @@ function logicalCellsForRow( "a table row's own column-boundary array has fewer entries than its physical cell count requires", ); } - const startGridIndex = gridIndexFor(canonicalBoundariesTwips, left); - const endGridIndex = gridIndexFor(canonicalBoundariesTwips, right); + const startGridIndex = gridIndexFor( + canonicalBoundariesTwips, + left, + toleranceTwips, + ); + const endGridIndex = gridIndexFor( + canonicalBoundariesTwips, + right, + toleranceTwips, + ); logical.push({ startGridIndex, // A physical cell whose own boundaries snap to one canonical entry covers no segment of the shared grid at all. That is a legal cell, not corruption: [MS-DOC] 2.9.321 requires rgdxaCenter only to be "in non-decreasing order", so two adjacent entries may be equal (a genuine zero-width cell) or -- now that the union snaps -- within the tolerance of each other. ContentTableCell has no way to say "zero columns wide", so such a cell is carried with its content as an ordinary un-spanned cell, and the cell following it keeps its own start index rather than being displaced by a span this one never occupied. @@ -250,6 +300,7 @@ function buildRows( rowDefinitions: readonly TableRowDefinition[], canonicalBoundariesTwips: readonly number[], rowHeights: readonly (number | undefined)[], + toleranceTwips: number, ): ContentTableRow[] { const logicalRows = rawRows.map((row, rowIndex): LogicalCell[] => { const definition = rowDefinitions[rowIndex]; @@ -262,6 +313,7 @@ function buildRows( row, definition.columnBoundariesTwips, canonicalBoundariesTwips, + toleranceTwips, ); }); From 6eead02a886df6c4eeaa68ad224c3d1774b576b3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 04:16:12 +0100 Subject: [PATCH 4/5] test(doc-codec): pin the narrow-column clamp against the drift tolerance Three cases: a genuinely narrow column surviving intact in a single row with no cross-row drift involved at all, the ordinary one-point tolerance still applying when nothing in the table states a narrower real column, and the tolerance narrowing below a real column any row states even when a different row's own cross-row drift would otherwise fall within the un-clamped default. Confirmed against a mutation reverting the clamp to the flat TWIPS_PER_POINT tolerance: two of the three new cases fail, restoring the clamp passes them again. Also corrects two comments that attributed the empirically-confirmed 20/21-twip changeover to LibreOffice's own .doc importer -- the import side preserves per-row drift untouched; the fuzz is applied on its ODF export, the actual point where a per-row table model is projected onto one shared grid -- and hedges the causal claim linking Word's own -108 default to sprmTCellPaddingDefault as a plausible inference rather than a sourced fact. --- packages/doc-codec/src/table/read.test.ts | 37 +++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/doc-codec/src/table/read.test.ts b/packages/doc-codec/src/table/read.test.ts index b19307cd..e21ec085 100644 --- a/packages/doc-codec/src/table/read.test.ts +++ b/packages/doc-codec/src/table/read.test.ts @@ -396,7 +396,7 @@ const LIBREOFFICE_COLUMN_WIDTHS_PT = [116.9, 145, 220]; const MIDDLE_COLUMN_WIDTH_TWIPS = 2900; /** The boundary between that table's first and second columns: the single int16 the tolerance sweep patched inside a real LibreOffice-authored file's second row, and the one a row merging those two columns omits from its own array entirely. */ const INTERIOR_BOUNDARY_INDEX = 1; -/** sprmTCellPaddingDefault's own documented default wWidth ([MS-DOC] 2.6.4), which is why Word writes -108 rather than 0 as an unindented table's first rgdxaCenter entry -- and so also the size of the real-world one-row leading indent the mode-2 case below uses. */ +/** Word's own default for an unindented table's first rgdxaCenter entry, confirmed against LibreOffice's WW8 importer source (a named -108 constant, "Word sets the first nCenter value to -108 when no indent is used") -- plausibly the format's own 108-twip default cell margin, sprmTCellPaddingDefault ([MS-DOC] 2.6.4), compensated for, though neither source states that link outright (see the README's own identical hedge). Also the size of the real-world one-row leading indent the mode-2 case below uses. */ const WORD_DEFAULT_CELL_MARGIN_TWIPS = 108; function withBoundaryShifted( @@ -455,7 +455,7 @@ function colSpansPerRow( return block.rows.map((row) => row.cells.map((cell) => cell.colSpan)); } -// [MS-DOC] 2.6.4 states a table's column layout per row, and 2.9.321's rgdxaCenter is a plain array of twip offsets from the page margin with no coarser quantum defined anywhere -- so two rows meaning the identical grid may legally disagree by a twip or two, and reconstructing the shared grid from them needs a tolerance rather than exact integer equality (ExaDev/documents.js#898). The threshold is one point, matching what a real, independent [MS-DOC] implementation applies to the identical per-row-boundaries-to-shared-grid problem: LibreOffice's `#define COLFUZZY 20` twips (sw/source/filter/inc/wrtswtbl.hxx), whose changeover was confirmed empirically at exactly 20/21 by sweeping a single patched int16 through LibreOffice 26.2.5.2's own .doc importer. +// [MS-DOC] 2.6.4 states a table's column layout per row, and 2.9.321's rgdxaCenter is a plain array of twip offsets from the page margin with no coarser quantum defined anywhere -- so two rows meaning the identical grid may legally disagree by a twip or two, and reconstructing the shared grid from them needs a tolerance rather than exact integer equality (ExaDev/documents.js#898). The threshold is one point, matching what a real, independent [MS-DOC] implementation applies to the identical per-row-boundaries-to-shared-grid problem: LibreOffice's `#define COLFUZZY 20` twips (sw/source/filter/inc/wrtswtbl.hxx), applied by its own ODF export -- the point at which its per-row table model is projected onto one shared grid, not its .doc importer, which preserves per-row drift untouched -- whose changeover was confirmed empirically at exactly 20/21 by round-tripping a single patched int16 through LibreOffice 26.2.5.2's own .doc import followed by that ODF export. describe("readDocContent table column grids, from hand-assembled rgdxaCenter arrays", () => { it("reads rows stating the identical LibreOffice-authored boundary array as one shared three-column grid", () => { const block = readTableFromRowBoundaries([ @@ -643,4 +643,37 @@ describe("readDocContent table column grids, from hand-assembled rgdxaCenter arr "c", ]); }); + + // The clamp effectiveColumnBoundaryTolerance exists for: this writer has no equivalent of LibreOffice's own MINLAY minimum-cell-width widening, so nothing stops a real producer's rgdxaCenter from stating a column genuinely narrower than the tolerance's own one-point default -- and a single row's own adjacent boundaries are never ambiguous about how many columns that row states, whatever the gap between them. A single-row table with no cross-row drift at all isolates this: if the tolerance folded a real narrow column into its neighbour here, that would be exactly the same defect the drift tolerance exists to fix, applied to the wrong pair of boundaries. + it("keeps a genuinely narrow column intact rather than folding it into its neighbour", () => { + const block = readTableFromRowBoundaries([ + { + boundariesTwips: [0, 1000, 1010, 3000], + cells: ["a", "b", "c"], + }, + ]); + expect(block.columnWidthsPt).toEqual([50, 0.5, 99.5]); + expect(colSpansPerRow(block)).toEqual([[undefined, undefined, undefined]]); + }); + + it("still applies the ordinary one-point tolerance to cross-row drift when no row states a narrower real column", () => { + const block = readTableFromRowBoundaries([ + { boundariesTwips: [0, 2000, 3000], cells: ["a", "b"] }, + { boundariesTwips: [0, 2001, 3000], cells: ["a", "b"] }, + ]); + expect(block.columnWidthsPt).toEqual([100, 50]); + expect(colSpansPerRow(block)).toEqual([ + [undefined, undefined], + [undefined, undefined], + ]); + }); + + it("narrows the drift tolerance to below a real column any row in the same table states, rather than the fixed one-point default", () => { + // Row 1 states a genuine 10-twip column (0.5pt) between 1000 and 1010, so the table-wide tolerance clamps to 9 twips -- one less than that gap. Row 2's own boundary at 1025 is 15 twips from row 1's 1010, further than the clamped 9-twip tolerance but within the un-clamped one-point (20-twip) default: without the clamp this boundary would fold into 1010 and silently widen the real narrow column into whatever gap it shares with 1025. With it, 1025 stays its own boundary. + const block = readTableFromRowBoundaries([ + { boundariesTwips: [0, 1000, 1010, 3000], cells: ["a", "b", "c"] }, + { boundariesTwips: [0, 1025, 3000], cells: ["a", "b"] }, + ]); + expect(block.columnWidthsPt).toEqual([50, 0.5, 0.75, 98.75]); + }); }); From 87d1db36d977c197e988c22bffc73849a4fe07b4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 4 Sep 2026 04:16:21 +0100 Subject: [PATCH 5/5] docs(doc-codec): correct the drift-tolerance changeover's own attribution The README attributed the confirmed 20/21-twip changeover to LibreOffice's own .doc importer; the import side preserves a drifted boundary untouched, and the fuzz is applied by its ODF export instead, the point where its per-row table model is genuinely projected onto one shared grid. Also corrects a claim that the snap "provably cannot swallow a legitimately narrow column" -- that guarantee is LibreOffice's own writer-side MINLAY widening, which this package's writer has no equivalent of, so a real narrow column is possible and the tolerance now clamps below it instead. Hedges the -108/sprmTCellPaddingDefault causal link as a plausible inference rather than a sourced fact. --- packages/doc-codec/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/doc-codec/README.md b/packages/doc-codec/README.md index 291b408d..fad37fa7 100644 --- a/packages/doc-codec/README.md +++ b/packages/doc-codec/README.md @@ -85,7 +85,9 @@ Fields are handled structurally: everything between a field-begin (`U+0013`) and A table in [MS-DOC] is not a separate container: it is a run of ordinary paragraphs marked `sprmPFInTable`, with cell boundaries at literal `0x07` cell-mark characters in the text stream and each row closed by its own row-ending mark — a cell mark additionally carrying `sprmPFTtp` — per [MS-DOC] 2.4.3's own Overview of Tables. `src/table/` implements exactly this model, at table depth 1 only; a table nested inside a table cell is refused rather than mis-read (see the "Nested tables" row in the scope table above). -**Reading** (`table/read.ts`'s `assembleBlocks`, called from `read.ts`). It walks the flat paragraph sequence `read.ts` already produces, grouping every contiguous run of `inTable` paragraphs into a `ContentTable`: consecutive paragraphs up to and including the one terminated by an ordinary cell mark become one cell's own `blocks` (a cell may hold more than one paragraph — only its last ends in a cell mark, per 2.4.3's own "the last paragraph in a table cell is terminated by a cell mark"), and the row's own trailing mark resolves the row's whole TAP through `table/tap.ts`'s `applyTableSprms`: column boundaries and every physical cell's own merge state, read directly from `sprmTDefTable`'s `TDefTableOperand` — its `rgdxaCenter` array and its `rgTc80` array of per-column `TC80` records ([MS-DOC] 2.9.339-341) — folded with a `sprmTMerge` range or `sprmTVertMerge` per-cell flag on top where a real producer states a merge incrementally instead, genuinely regardless of which order the two appear in within the grpprl (`table/tap.ts`'s own note). Column layout is never assumed shared across a table's own rows: [MS-DOC] 2.6.4 permits each row to declare its own independent `rgdxaCenter` ("There is no requirement that each row of a table have the same number of cells"), and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge — its own merged row simply has fewer, wider physical cells, with no `TCGRF.horzMerge`/`sprmTMerge` signal at all (see the third-party verification paragraph below). `table/read.ts` reconstructs the table's shared column grid as the union of every row's own `rgdxaCenter` boundary values, then expresses each physical cell's own `colSpan` as however many of that shared grid's segments its own boundaries cover. That union is taken within one point rather than by exact integer equality, because [MS-DOC] states those boundaries per row and defines no quantum coarser than the twip itself for them, so two rows meaning the identical grid may legally disagree by a twip or two — and an exact union turns that drift into a phantom hairline column plus a spurious `colSpan` on the cells of every row either side of it (two rows one twip apart across a 2338-twip boundary read back as `columnWidthsPt` `[116.9, 0.05, 144.95, 220]` instead of `[116.9, 145, 220]`; [ExaDev/documents.js#898](https://github.com/ExaDev/documents.js/issues/898)). The tolerance is `TWIPS_PER_POINT` itself, not a picked number: `columnWidthsPt` states the reconstructed grid in points, so a segment narrower than one point sits below the smallest unit that grid can distinguish at all. It is also the fuzz a real, independent implementation applies to this identical reconstruct-one-shared-grid-from-N-per-row-arrays problem — LibreOffice's table model is per-row too (`SwTableLine` → `SwTableBox`, each box carrying its own width), and `sw/source/filter/inc/wrtswtbl.hxx` answers it with `#define COLFUZZY 20` twips, `SwWriteTableCol::operator==` treating two column positions as equal when they differ by at most that. Its changeover was confirmed empirically and exactly, not assumed: patching a single `int16` inside a real LibreOffice-authored table's second row and sweeping the drift back through that implementation's own `.doc` importer gives three columns and no covered cell for a drift of 1 through 20 twips, and four columns with a real `table:covered-table-cell` from 21 — and because `COLFUZZY` sits below the same implementation's own minimum cell width (`MINLAY`, 23 twips in `sw/inc/swtypes.hxx`, which `WW8TabDesc::CalcDefaults` actively widens a narrower imported cell up to), the snap provably cannot swallow a legitimately narrow column. Per-row drift is not hypothetical: `CalcDefaults` mutating boundaries per row on import is one real mechanism by which a `.doc` in the wild comes to carry it. Beyond that, [MS-DOC]'s own physical-cell model keeps every horizontally- and vertically-merged-away cell present in the text stream with its own cell mark and its own `TC80` entry — never omitted the way OOXML's `w:gridSpan` model omits a horizontally-merged-away `` outright — so a horizontal-continuation cell stated the legacy way (`TCGRF.horzMerge` = 1, still honoured for a genuine third-party producer that uses it) is folded into the preceding real cell's own `colSpan` exactly as before, while a genuinely narrower, wider physical cell (no flag, LibreOffice's own encoding) resolves to a `colSpan` greater than 1 directly from its own boundaries — both mechanisms produce the identical shape downstream. A vertical-continuation cell (`TCGRF.vertMerge` = `fvmMerge`) is kept as its own `{blocks: []}` entry — carrying its own `colSpan` too when it is also part of a horizontal-merge group in that row — with `rowSpan` computed on the anchor by scanning subsequent rows for a cell starting at the same position on the table's own shared grid, never a raw physical-array index, since two rows may genuinely have different physical cell counts and still need their vertical merges to line up correctly. Both conventions mirror `ooxml.js`'s own docx table reader exactly, since `colSpan`/`rowSpan`/`{blocks: []}` are precisely the shape `document-schema.js`'s `ContentTableCell` was designed to hold for either format's own cousin of the same merge model. A column boundary that no row in the table ever states on its own — every row happens to merge across it identically — cannot be recovered from the physical bytes at all; this is a genuine limitation of [MS-DOC]'s own physical model, not an approximation this reader chooses to make (see the third-party verification paragraph below for the confirmed reproduction, and `write.test.ts`'s own "narrows columnWidthsPt" test for the honest degraded shape this produces). +**Reading** (`table/read.ts`'s `assembleBlocks`, called from `read.ts`). It walks the flat paragraph sequence `read.ts` already produces, grouping every contiguous run of `inTable` paragraphs into a `ContentTable`: consecutive paragraphs up to and including the one terminated by an ordinary cell mark become one cell's own `blocks` (a cell may hold more than one paragraph — only its last ends in a cell mark, per 2.4.3's own "the last paragraph in a table cell is terminated by a cell mark"), and the row's own trailing mark resolves the row's whole TAP through `table/tap.ts`'s `applyTableSprms`: column boundaries and every physical cell's own merge state, read directly from `sprmTDefTable`'s `TDefTableOperand` — its `rgdxaCenter` array and its `rgTc80` array of per-column `TC80` records ([MS-DOC] 2.9.339-341) — folded with a `sprmTMerge` range or `sprmTVertMerge` per-cell flag on top where a real producer states a merge incrementally instead, genuinely regardless of which order the two appear in within the grpprl (`table/tap.ts`'s own note). Column layout is never assumed shared across a table's own rows: [MS-DOC] 2.6.4 permits each row to declare its own independent `rgdxaCenter` ("There is no requirement that each row of a table have the same number of cells"), and a real, independent [MS-DOC] implementation (LibreOffice 26.2.5.2) was confirmed to rely on exactly this for a horizontal merge — its own merged row simply has fewer, wider physical cells, with no `TCGRF.horzMerge`/`sprmTMerge` signal at all (see the third-party verification paragraph below). `table/read.ts` reconstructs the table's shared column grid as the union of every row's own `rgdxaCenter` boundary values, then expresses each physical cell's own `colSpan` as however many of that shared grid's segments its own boundaries cover. That union is taken within one point rather than by exact integer equality, because [MS-DOC] states those boundaries per row and defines no quantum coarser than the twip itself for them, so two rows meaning the identical grid may legally disagree by a twip or two — and an exact union turns that drift into a phantom hairline column plus a spurious `colSpan` on the cells of every row either side of it (two rows one twip apart across a 2338-twip boundary read back as `columnWidthsPt` `[116.9, 0.05, 144.95, 220]` instead of `[116.9, 145, 220]`; [ExaDev/documents.js#898](https://github.com/ExaDev/documents.js/issues/898)). The default tolerance is `TWIPS_PER_POINT` itself, not a picked number: `columnWidthsPt` states the reconstructed grid in points, so a segment narrower than one point sits below the smallest unit that grid can distinguish at all. It is also the fuzz a real, independent implementation applies to an analogous reconstruct-one-shared-grid-from-N-per-row-arrays problem — LibreOffice's table model is per-row too (`SwTableLine` → `SwTableBox`, each box carrying its own width), and `sw/source/filter/inc/wrtswtbl.hxx` answers it, on its own ODF export (the point at which it projects that per-row model onto one shared grid, `sw/source/filter/xml/xmltble.cxx`'s `SwXMLTableColumn_Impl`), with `#define COLFUZZY 20` twips, `SwWriteTableCol::operator==` treating two column positions as equal when they differ by at most that. Its changeover was confirmed empirically and exactly, not assumed: patching a single `int16` inside a real LibreOffice-authored table's second row and round-tripping it through that implementation's own `.doc` import followed by its ODF export gives three columns and no covered cell for a drift of 1 through 20 twips, and four columns with a real `table:covered-table-cell` from 21 (the `.doc` import side alone preserves the drifted boundary byte-for-byte — the fuzz is applied on export, not import). Per-row drift is not hypothetical even without Word or LibreOffice's own export step in the picture: `WW8TabDesc::CalcDefaults` widens any imported cell narrower than that same implementation's own minimum cell width (`MINLAY`, 23 twips in `sw/inc/swtypes.hxx`) by mutating boundaries per row during `.doc` import itself, so a document that has been through that import is one real mechanism by which per-row drift reaches a `.doc` at all. + +The one-point default is not applied unconditionally, because `MINLAY`'s own guarantee is LibreOffice's alone: this package's own writer widens nothing, so nothing stops a real producer's `rgdxaCenter` from stating a column genuinely narrower than a point, and folding that column's own two boundaries together as "drift" would silently delete it rather than fix a phantom one. The tolerance is therefore clamped, per table, to one twip below the narrowest strictly-positive gap any single row states between two of its _own_ adjacent boundaries (a zero-width gap is a legal adjacent-duplicate boundary, not a column, and is excluded) — two boundaries a row itself distinguishes are never folded together, however close, and the clamp can only ever be as generous as the tightest real column that table actually declares. Beyond that, [MS-DOC]'s own physical-cell model keeps every horizontally- and vertically-merged-away cell present in the text stream with its own cell mark and its own `TC80` entry — never omitted the way OOXML's `w:gridSpan` model omits a horizontally-merged-away `` outright — so a horizontal-continuation cell stated the legacy way (`TCGRF.horzMerge` = 1, still honoured for a genuine third-party producer that uses it) is folded into the preceding real cell's own `colSpan` exactly as before, while a genuinely narrower, wider physical cell (no flag, LibreOffice's own encoding) resolves to a `colSpan` greater than 1 directly from its own boundaries — both mechanisms produce the identical shape downstream. A vertical-continuation cell (`TCGRF.vertMerge` = `fvmMerge`) is kept as its own `{blocks: []}` entry — carrying its own `colSpan` too when it is also part of a horizontal-merge group in that row — with `rowSpan` computed on the anchor by scanning subsequent rows for a cell starting at the same position on the table's own shared grid, never a raw physical-array index, since two rows may genuinely have different physical cell counts and still need their vertical merges to line up correctly. Both conventions mirror `ooxml.js`'s own docx table reader exactly, since `colSpan`/`rowSpan`/`{blocks: []}` are precisely the shape `document-schema.js`'s `ContentTableCell` was designed to hold for either format's own cousin of the same merge model. A column boundary that no row in the table ever states on its own — every row happens to merge across it identically — cannot be recovered from the physical bytes at all; this is a genuine limitation of [MS-DOC]'s own physical model, not an approximation this reader chooses to make (see the third-party verification paragraph below for the confirmed reproduction, and `write.test.ts`'s own "narrows columnWidthsPt" test for the honest degraded shape this produces). A row whose own TAP cannot be resolved this way — no direct `sprmTDefTable` anywhere in its grpprl (a producer may legally state it indirectly instead, through `sprmPTableProps`; see the `sprmPHugePapx`/`sprmPTableProps` scope row above), or a cell-mark count that disagrees with what its `TDefTableOperand` declares — degrades the _whole_ contiguous run of table-depth paragraphs back to flat paragraphs, rather than refusing the whole document: this is a legal, real-world construct this reader does not yet implement, not corruption, and paragraphs that would have become a table simply stay paragraphs instead, the identical class of degrade the `sprmPHugePapx`/`sprmPTableProps` row above already documents for ordinary paragraph formatting. A row ending mid-cell with no terminating mark at all is different in kind — the stream itself is truncated, not merely using an unsupported mechanism — and still throws `DocFormatError`. @@ -99,7 +101,7 @@ The horizontal-merge gap #892 left open ([ExaDev/documents.js#895](https://githu **What is not resolved.** Cell shading and borders (`ContentTableCell.background`/`.borders`) are neither read from nor written to `TC80`'s own `brcTop`/`brcLeft`/`brcBottom`/`brcRight`/shading fields — every border this writer emits is `Brc80MayBeNil`'s "no border" sentinel (all bits set). `sprmTMerge` and `sprmTVertMerge` are both still read (folded onto `sprmTDefTable`'s own layout, for a genuine third-party producer that states a merge that way) but neither is written any more — this writer states a horizontal merge purely through a merged row's own narrower, wider physical cells (see [Writing](#writing) above), and a vertical merge only through `TC80.tcgrf`. Every table-level TAP sprm beyond `sprmTDefTable`/`sprmTDyaRowHeight`/`sprmTMerge`/`sprmTVertMerge` — absolute position, table style, cell padding, and the rest of [MS-DOC] 2.6.4's roughly seventy table sprms — is unread and unwritten, exactly as the read-side scope note already states for ordinary paragraph sprms this package does not convert. A genuine, if narrow, information-loss case remains inherent to the physical model itself rather than a gap in this package: when literally every row of a table merges across the identical column boundary (a single-row table with one merged cell is the simplest case), no row's own `rgdxaCenter` ever states that boundary, so a round trip cannot recover it — `columnWidthsPt` narrows to however many columns the physical bytes actually distinguish, and the merged cell's own `colSpan` comes back `undefined` rather than the value it was written with (`write.test.ts`'s own "narrows columnWidthsPt" test states this precisely). A table with at least one row that does not merge across the same span — the common case, since a merge is usually a header row sitting above ordinary data rows — round-trips `colSpan` and `columnWidthsPt` exactly. -A table's own horizontal position is not read or written either, and this one is a schema boundary rather than a gap in this package. `rgdxaCenter`'s first entry is "the horizontal position of the logical left edge of the table, as indented from the logical left page margin" ([MS-DOC] 2.9.321), and `sprmTDxaLeft`/`sprmTDxaGapHalf`/`sprmTWidthBefore` state the same fact incrementally — but `document-schema.js`'s `ContentTable` carries only `rows` and `columnWidthsPt`, with no field on the table or on a row that could hold a horizontal offset, so a table indent is dropped on read and every row this writer emits starts at 0. No codec in this family models a table indent, so nothing downstream would have anywhere to put one. This is already live in the simple case: a LibreOffice table with `fo:margin-left="1.27cm"` writes `rgdxaCenter = [720, 2884, 5567, 9638]` in **every** row, and reads back as `columnWidthsPt` `[108.2, 134.15, 203.55]` with the 720-twip indent gone. Because [MS-DOC] states the boundary array per row, two rows of one table may also legally begin at different positions — Word's own default for an unindented table is `-108` rather than 0, compensating for the format's own 108-twip default cell margin (`sprmTCellPaddingDefault`), so a table one of whose rows carries a real leading indent has rows at `-108` and `0`. Those rows genuinely occupy different horizontal extents, and the reconstructed grid honestly carries the extra boundary between them, with the wider rows' first cell spanning both segments. That is not the twip-drift case above and is deliberately not absorbed by its tolerance: verified against LibreOffice 26.2.5.2, which reads the identical bytes into the identical grid — four columns, a `table:number-columns-spanned="2"` anchor and a real `table:covered-table-cell` on the rows that start further left. The one cosmetic difference is that LibreOffice pads the short row with an empty filler cell so every row covers the full grid, which this reader does not: `ContentTableRow.cells` carries no grid-position field, so a reader-invented empty cell would be indistinguishable from real empty content on the write side, and `table/write.ts` reconstructs each row's own narrower `rgdxaCenter` from spans without needing one. +A table's own horizontal position is not read or written either, and this one is a schema boundary rather than a gap in this package. `rgdxaCenter`'s first entry is "the horizontal position of the logical left edge of the table, as indented from the logical left page margin" ([MS-DOC] 2.9.321), and `sprmTDxaLeft`/`sprmTDxaGapHalf`/`sprmTWidthBefore` state the same fact incrementally — but `document-schema.js`'s `ContentTable` carries only `rows` and `columnWidthsPt`, with no field on the table or on a row that could hold a horizontal offset, so a table indent is dropped on read and every row this writer emits starts at 0. No codec in this family models a table indent, so nothing downstream would have anywhere to put one. This is already live in the simple case: a LibreOffice table with `fo:margin-left="1.27cm"` writes `rgdxaCenter = [720, 2884, 5567, 9638]` in **every** row, and reads back as `columnWidthsPt` `[108.2, 134.15, 203.55]` with the 720-twip indent gone. Because [MS-DOC] states the boundary array per row, two rows of one table may also legally begin at different positions — Word's own default for an unindented table is `-108` rather than 0 (confirmed against LibreOffice's own WW8 importer source, which carries `-108` as a named constant with the comment "Word sets the first nCenter value to -108 when no indent is used"; it is plausibly the format's own 108-twip default cell margin, `sprmTCellPaddingDefault`, compensated for, but neither [MS-DOC] nor that source states the two facts are linked, so take the value as confirmed and the reason as a reasonable guess) — so a table one of whose rows carries a real leading indent has rows at `-108` and `0`. Those rows genuinely occupy different horizontal extents, and the reconstructed grid honestly carries the extra boundary between them, with the wider rows' first cell spanning both segments. That is not the twip-drift case above and is deliberately not absorbed by its tolerance: verified against LibreOffice 26.2.5.2, which reads the identical bytes into the identical grid — four columns, a `table:number-columns-spanned="2"` anchor and a real `table:covered-table-cell` on the rows that start further left. The one cosmetic difference is that LibreOffice pads the short row with an empty filler cell so every row covers the full grid, which this reader does not: `ContentTableRow.cells` carries no grid-position field, so a reader-invented empty cell would be indistinguishable from real empty content on the write side, and `table/write.ts` reconstructs each row's own narrower `rgdxaCenter` from spans without needing one. One narrow accuracy limit follows from the same missing field. `rgdxaCenter`'s entries need only be "in non-decreasing order", so two adjacent entries may be equal — a legal zero-width physical cell. Such a cell covers no segment of the reconstructed grid, and `ContentTableCell` cannot say "zero columns wide", so it comes back carrying its own content as an ordinary un-spanned cell sharing a grid position with the cell after it. Nothing is lost, but the two are indistinguishable by position, so a vertical merge anchored at that position in a later row matches whichever of them comes first.