fix: table rendering fidelity round 2 (SD-3308, SD-3035)#3658
Draft
tupizz wants to merge 12 commits into
Draft
Conversation
A table style's base-level <w:tcPr> (e.g. <w:shd w:fill="F2F2F2"/>) is the wholeTable conditional layer per ECMA-376 17.7.6: Word paints it on every cell of a table referencing the style. The translator stores it on the style definition's own tableCellProperties, a sibling of tableStyleProperties, but resolveConditionalProps only read tableStyleProperties[region], so style-only cell fills were dropped and such tables rendered with no background. Collect the base-level tableCellProperties into the wholeTable chain while walking the basedOn hierarchy, ordered so an explicit tableStyleProperties.wholeTable entry still wins within one definition, a leaf style's base props beat any ancestor's, and inline cell shading wins over everything. Verified against the SD-3035 mutation fixtures: tblStyle_applied, style_plus_direct_border_overrides, and style_plus_width_interactions now paint F2F2F2 on all cells, matching Word's render pixel values. Banding and conditional-region fixtures (first/last row/column, banded rows or columns) are byte-identical before and after, since bands and regions sit above wholeTable in the cascade. Layout corpus compare: 476 docs, zero changes.
CSS `double` only renders two distinct rules when the border is at least 3px wide (1px rule + 1px gap + 1px rule). The painter emitted the authored width verbatim, so a typical w:val="double" w:sz="12" border (~2px) was collapsed by the browser into a single solid-looking line, while Word always renders two parallel rules for double borders. Clamp the rendered width up to 3px when the resolved CSS style is double, keeping authored widths that are already 3px or wider. Every border paint path funnels through applyBorder, so cell borders, outer table edges, and continuation rows are all covered by the one change. The prior unit test asserted the collapsed 2px output; the expectation flip to 3px is deliberate. Verified against the SD-3308 mutation fixture (double_or_dotted_borders.docx): the double table now shows two rules on every outer and interior edge, dotted and dashed render unchanged.
This was referenced Jun 5, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Word renders w:val="double" at three times the authored w:sz: sz is the width of EACH rule and the painted band is rule + gap + rule. Measured against Word output, the dotted sz12 (2px band) and double sz12 (6px band) tables have identical content regions and their row pitch differs by exactly the band delta, so Word reserves the full band height in the table's vertical extent. SuperDoc painted double borders at the floor width and reserved no row height, leaving fat bands a single thin line and, when widened, overlapping row content. Three coordinated changes: - contracts: add getBorderBandWidthPx, the single source of truth for rendered band width (none 0, thick 2x min 3px, double 3x min 3px, else authored width), so paint and measurement can never disagree. - painter: applyBorder delegates its width math to the shared helper and now emits the full 3x double band. - measuring: replace the drifted local copy of the width logic with the shared helper, and reserve per-gridline band heights in collapsed mode. Each row reserves its top gridline, the last row also the bottom edge, with row-level tblPrEx overrides and cell tcBorders included (max across candidates approximates the 17.4.66 winner). Bands at or below 2px reserve nothing, keeping hairline-border geometry byte-stable across the corpus; wider bands reserve band minus 1px, the same slack every bordered table already absorbs. Painter cells are border-box, so the reserved height lets band and content coexist exactly like Word. Verification: the double fixture renders 6px bands on all 12 edges with zero clipped lines and rows growing exactly by the reservation; Word row pitch on sd-2343-table-border-widths band-8 rows is 43px at 100dpi, ours moved from 33px to 41px. Layout corpus: 8/476 docs changed, six of them border fixtures with pure height diffs in the fix direction (the other two carry unrelated font-substitution noise vs the npm reference); visual compare passed. Suites: painter-dom 1244, measuring-dom 351 plus one pre-existing text-width flake also failing on the clean tree.
A pure-auto table (autofit layout, auto/nil/absent tblW, and no cell anywhere carrying a concrete width preference) was pinned to its authored grid sum by the auto-grid width budget, stretching it to the grid width. Word recomputes layout for these tables on open and content-sizes them: each column takes its max-content width and the table ends at the content demand, capped by the available width. The stored w:tblGrid is only a Word layout cache for organically authored documents, which always carry tcW; a grid with no width preferences anywhere is not authoritative. Scope is deliberately narrow so every grid-trusting path keeps its proven behavior: tables claimed by preserveAutoGrid (non-uniform grids), preserveExplicitAutoGrid, explicit tblW, any concrete tcW, or grids wider than the available width (preserved overflow per SD-1239 and the overhang behavior) are untouched. The layout corpus confirms the blast radius: zero corpus documents change, since every real-world pure-auto table has a non-uniform cached grid. On the content-size path, columns also reserve their owned vertical border band (left gridline, last column also the right edge) since border-box cells subtract the band from the text box, and spanning cells top up their covered columns to their max-content demand so span text keeps Word's line breaks. Two legacy tests asserted the grid pin for pure-auto shapes and were updated to the Word behavior with their regression intent preserved (synthesized runtime columns, no upscaling beyond content). The SD-1239 wide-grid preservation test passes unchanged. Verified against fresh Word renders of the showcase fixture: all five sections now match, including the auto double-border table (Word ~165px, ours 157px) and the gridSpan/vMerge table (Word ~250px one line per cell, ours 252px one line per cell). Fixture gate auto_or_nil_widths content-sizes to 124px. Suites: measuring-dom 351 passing plus the pre-existing text-width flake, painter-dom 1244.
CSS double borders miter diagonally where two sides of the same element meet and land on fractional device pixels (table row heights are fractional), so the two rules rendered with uneven weights and notched corner joins. Word draws both rules at even weight and crosses them squarely at junctions; verified against 300dpi Word probe renders (1x1, 1x2, 2x1 double-border tables), which also confirm the macro geometry is one rule-gap-rule band per edge with shared interior edges drawn once, exactly the existing single-owner model. Cells keep their CSS double border with a TRANSPARENT color so border-box layout (content inset and band reservation) is untouched; the visible rules are absolutely positioned strip overlays per owned edge, snapped to integer pixels, painted as two solid rules with the band gap between. Strips from adjacent owned edges overlap squarely at corners, matching Word's junctions. Verified at 3x zoom against the Word render: even rule weights, square crossings, identical band structure. painter-dom suite: 1245 passing.
Word does not paint w:val="double" as a self-contained two-rule band per edge. Magnified 300dpi renders of the same file show it decomposes the double border into CLOSED RECTANGLES: one single-rule outline at the table boundary plus one complete single-rule inner rectangle per cell, the band's gap separating them, rules joined squarely at the corners. A per-edge band (CSS double or strip overlays) puts the rules in the right positions but the wrong connectivity: junctions render as crossings instead of nested boxes, which reads entirely differently when zoomed. Replace the strip overlays with the Word decomposition: each cell paints one inner rectangle with ALL FOUR sides sourced from the cell's EFFECTIVE borders (the single-owner-suppressed set only describes who paints a shared band, not which sides exist; every surrounding double edge contributes a side to the rectangle). Ownership picks the band face: rules sit inset band minus rule on sides whose band lives in this cell, and extend rule px past the box on interior sides whose band lives in the neighbor, so the two rules of a shared band land exactly on its faces with per-cell attribution. The table fragment paints the outline rule at the boundary for double outer borders, skipping broken edges on continuation fragments. Cells keep a transparent CSS double border so border-box layout, content insets, and the row band reservation are unchanged. Verified at 3x zoom against the 300dpi Word render of the same file: outer outline, four closed per-cell boxes, square joins, rules on the band faces. painter-dom suite: 1245 passing.
1d50a82 to
f1bed4b
Compare
Word renders triple and the nine thinThick* ST_Border styles as multi-rule bands composed of nested rectangles, measured from 300dpi Word probes at sz 4/12/24: - triple: [w, w, w, w, w] - thinThickSmallGap: [w, 0.75pt, 0.75pt] (thickThin mirrors) - thinThickMediumGap: [w, w/2, w/2] (mirrors) - thinThickLargeGap: [1.5pt, w, 0.75pt] (gap scales; mirrors) - thinThickThin*: thin rules around a scaled center per family contracts getBorderBandProfile encodes the measured segments and becomes the band source of truth (getBorderBandWidthPx delegates, double unchanged). The painter generalizes the double-border nested-rectangle path: outline paints the outer-face rule, the per-cell inner rectangle paints the inner-face rule, and 3-rule bands add a middle rectangle inset by outer rule + gap so corners join cleanly. dashSmallGap routes to css dashed (same approximation class as dotDash). The layout-adapter whitelists and 17.4.66 weight tables cover the new styles. (SD-3308)
Word probes (single-cell auto tables, band styles x sz 4/12/24) pin three sizing rules SuperDoc was missing: - A single-column pure-auto grid is not a Word layout cache: Word content-sizes the table on open. preserveAutoGrid no longer claims single-column grids with no tcW anywhere (hasNonUniformGrid treats length <= 1 as non-uniform, which pinned the stale grid at 200px where Word renders ~70px). - A content-sized column grows by HALF the border band per adjacent gridline, not a full band per side: resolveColumnBandAllowances halves each edge contribution. The painted band then eats the other half back from the padding: renderTableCell compresses horizontal padding by band/2 on compound sides (floor 0), matching Word's measured leftover margin = padding - band/2. - Padding compresses to zero but text never clips: the solver floors the column at text + 2x band via text-only bounds threaded from table-autofit-metrics (horizontalInsets) into accumulateBounds. Net effect: column = text + max(padding + band, 2x band), verified against Word renders of all 18 probe tables; the residual width delta is a constant font-metrics difference. Corpus layout compare vs the previous commit: 476 docs, zero changes (no corpus doc carries these shapes). (SD-3308)
Word writes w:tcW on every cell it inserts; the concrete cell width marks the stored grid as a real layout cache. SuperDoc's createTable only set the PM colwidth attr, which feeds the grid but not the cell width preference, so the SD-3309 pure-auto classifier content-sized freshly inserted tables to their text instead of keeping the requested full-width columns. Sets tableCellProperties.cellWidth (dxa twips) alongside colwidth, matching Word's emission and the existing import shape, so inserted tables keep their geometry and export real tcW values. Fixes the behavior CI failure in drag-selection-into-table-feedback (SD-2676 spec): the drag endpoint at the last cell's line midpoint landed mid-text once the inserted table content-sized, so the selection stopped at "R4C1 " instead of covering the cell. Verified failing without this change and passing with it on chromium; all 83 table behavior specs pass.
…grid Two Word behaviors measured from 300dpi probes of the triple fixtures: - An interior compound band is CENTERED on the gridline (spans gridline -band/2 .. +band/2), so both adjacent cells keep equal content widths. SuperDoc placed the whole band inside the owner cell, making the left cell a full band wider than the right. Each adjacent cell now carries HALF the band as its transparent CSS border (borderBandOverridesPx), the inner rectangles place their divider-facing rules at the straddled band's faces, and the padding compression skips straddled edges (the half-band the column was granted covers them). - The MIDDLE rule of 3-rule bands (triple, thinThickThin*) is a continuous grid: it runs unbroken through perpendicular band crossings and meets the boundary band's middle ring squarely. Per-cell middle rectangles break at every intersection, so table-level 3-rule borders now paint a fragment-level ring plus full-length center strips, and the per-cell middle rectangle is suppressed for those sides (kept for cell-level-only compound borders). Verified in-browser against the Word probes: divider rules symmetric around the gridline, equal cell boxes, continuous middle grid. Painter suite 1256 tests.
A row whose cells are ALL row-spanning (vMerge start or continuation) measured 0 high: row base heights only count rowspan=1 cells, and the span constraints were already satisfied by the neighboring rows, so nothing raised it. The row collapsed and the next row painted over it (showcase: head/only-real-cell/new rendered as two rows where Word renders three). In OOXML the vMerge continuation cells are real w:tc elements holding an empty paragraph and Word sizes the row from them, but the import merges those cells away. The measurer now grants every spanned row with no height of its own the spanning cell's first-line height (non-text spans such as logo images fall back to an even share so a spanning picture never doubles a header). Corpus layout compare vs the previous commit: 1 changed doc (sd-1797-autofit-tables, an invisible boundary redistribution inside spanned cells; the visible region matches a fresh Word render).
swapCellBordersLR returns CellBorders | undefined; the straddle code read rectBorders.left/right without narrowing, failing tsc -b under the references config (TS18048). Fall back to the unswapped specs, which the function only skips when given undefined input.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second round of table rendering fidelity fixes for the SD-3028 epic, following #3617. One PR holding all round-2 fixes as separate semantic commits; further confirmed gaps from the epic plan will land here as additional commits.
Linear: SD-3028, SD-3308, SD-3035
Commits
fix(style-engine): surface table style base tcPr as the wholeTable layer (SD-3035)
A table style's base-level w:tcPr shading (e.g. fill F2F2F2) is the wholeTable conditional layer per ECMA-376 17.7.6: Word paints it on every cell of a table referencing the style. The cascade only read tableStyleProperties[region] and never the style definition's base-level tableCellProperties, so style-only cell fills were dropped and such tables rendered with no background. The fix collects the base-level props into the wholeTable chain while walking the basedOn hierarchy, ordered so explicit wholeTable entries beat base-level within one definition, leaf styles beat ancestors, bands and regions sit above wholeTable, and inline shading wins over everything.
fix(painter): clamp double borders to CSS-visible width (SD-3308)
First step of the double-border work: clamp the rendered width so CSS draws two rules instead of collapsing to a single line. Superseded in behavior by the band-width commit below, kept as its own commit for review clarity.
fix(layout): double border band width and row reservation (SD-3308)
Word renders w:val="double" at three times the authored w:sz (sz is the per-rule width; band = rule + gap + rule, measured from Word output). Word also reserves the full band height in the table's vertical extent: dotted (2px band) and double (6px band) sz12 tables have identical content regions and pitch differing by exactly the band delta.
Three coordinated changes sharing one source of truth:
Verification