Skip to content

fix(export): keep text an exact-height row clips whole in the PDF text layer (#1140) - #1172

Merged
JSv4 merged 5 commits into
mainfrom
fix/issue-1140
Oct 16, 2026
Merged

JSv4 merged 5 commits into
mainfrom
fix/issue-1140

Conversation

@JSv4

@JSv4 JSv4 commented Oct 16, 2026

Copy link
Copy Markdown
Owner

Closes #1140

Trade-off to decide before merging. This makes the PDF text layer correct for text clipped by an exact-height table row. The cost is that the visible page is no longer pixel-identical to main along one pixel row at the clip's bottom edge, but only in cells that actually hide text. The worst measured difference is 145/255 on a few dozen pixels at 96 dpi in poppler, 7/255 at 300 dpi, and 0 at 600 dpi. If pixel fidelity matters more than search, copy and screen readers for this rare case, don't merge, and keep main's behaviour. The issue's investigation comment showed that no route available from CSS gives both.

Why

A table row with w:hRule="exact" clips content taller than the row, as Word does, and that clipping is visually correct. But Chromium's PDF writer keeps a glyph in the text layer only when the glyph's bounds touch the clip's bounding rectangle. The clipped line therefore survived as fragments: Overflow Birch continuation line. extracted as fl h l. LibreOffice writes the whole line.

How it works

The new module npm/src/exact-row-text.ts runs only in the standalone export (export-browser.ts). It runs after the export's clip check and baseline alignment, and before word-text painting and PageMap materialization. The paginated viewer and editor are unaffected.

  • Which cells. A cell is rewritten only if its row really hides text. Vertically, that means the clip edge passes some text line's baseline, measured from the text node's own line rects and font metrics, not element boxes or paragraph spacing. Horizontally, it means a whole character lies beyond a side edge. Of the 214 exact-row cells in the repository's test corpus, none needs it, and none is rewritten.
  • The rewrite.
    • The cell's clip box moves into an inline <svg><foreignObject>.
    • It is clipped by an SVG clipPath whose painted area is exactly the rectangle Chromium used for the overflow:hidden clip. Zero-area spikes in four directions make the clip's bounding rectangle cover the hidden content, so Skia writes every glyph to the text layer, and the clip still hides it.
    • The SVG root sits on the page's whole pixel, and the foreignObject takes back the fraction (to 1/64 px), so the content lays out and paints exactly where it did.
    • The clip id is minted so that it can't collide with an existing element id, such as a bookmark.
  • Paint order. An inline SVG keeps the cell's text in its place in the content stream, where CSS clip-path would move it to the end of the page. The root takes position:relative when any of the cell's text is in a positioned box, so the cell paints in the same phase as that text did. A cell with text in both phases gives its in-flow text an offset-free relative box, so the cell reads in tree order.

Validation

  • New tests in npm-export/tests/export.test.mjs. Each fails if its part of the fix is mutated out (dropping the fractional compensation, the edge snapping, either position rule, the six-decimal lengths, the id skip, or the hidden-text detection):
    • Text layer: the hidden line comes out whole, exactly once, and in order in both the content stream and the tag tree. This holds for a positioned page, an all-at-least page, and a mixed cell. It fails on main with fl h l.
    • Geometry regression: on Letter, a nested table, and A4 page 2 with odd margins, with bottom and middle vertical alignment, the rewritten cell's baseline matches its unrewritten neighbour within 0.01pt. Its PDF clip rect matches the neighbour's, and undoing the rewrite moves no element.
    • A bookmark named like the clip id doesn't defeat the clip.
    • Fitting rows are left alone: one line in a 13pt or 14pt exact row, and one with w:after.
  • Matrix measured through the real export. It covered Letter; A4 page 2 with top, center and bottom alignment; odd margins; nested and nested-exact tables; gridSpan; vMerge; RTL; an inline image; an anchored drawing; a hyperlink; and exact or at-least spacing.
    • The PageMap is identical to main in every case.
    • PDF clip rects and text positions are identical.
    • Link annotations are unchanged.
  • Test runs.
    • npm-export: 61 of 64 pass. The 3 failures are this machine's Chromium-sandbox environment failures, which also fail on main.
    • npm run typecheck passes.
    • The local npm/ Playwright specs could not run on this machine, so they run in CI.

Residuals (also in docs/architecture/standalone_paginated_export.md)

  • The clip-edge tint described above.
  • On screen, viewing the standalone HTML, the SVG root snaps to the window's pixel grid, not the page's. On later pages of fractional-height page sizes, a rewritten cell can be a pixel off on screen.
  • A glyph drawn wholly below its baseline, such as an underscore, doesn't trigger the rewrite.
  • A cell holding an absolutely positioned element is left as it was.
  • An exact row that hides a whole paragraph still fails export. That bug is on main too and is filed as PDF export fails when an exact-height row hides a whole paragraph #1168.

Adversarial review

One round of hostile review ran on the first three commits. It confirmed the pipeline order, that the change is export-only, the tests' mutation coverage, and the link and footnote handling. It found three defects, fixed in the last two commits:

  1. A bookmark named docx-exact-row-clip-0 captured the clip id, so the hidden text painted over the next row. Ids are now minted free.
  2. Over-triggering. On the test corpus the rewrite fired on 47 cells and recovered no text. Detection now requires hidden text, and it fires on 0 of 214.
  3. Overclaiming wording about stream order. One mixed-phase cell put its hidden line before its visible one. The wording is corrected, and mixed cells are now normalized to tree order.

Two pre-existing bugs it found are filed as #1171.

JSv4 added 5 commits October 16, 2026 01:59
…t layer (#1140)

Chromium's PDF writer drops every glyph that lies wholly outside the
bounding rectangle of the clip it is drawn under, so a line an exact row
cuts through extracted as broken word fragments. An overflowing exact-row
clip box now moves into an inline SVG whose clipPath paints the same
visible rectangle but whose bounds, stretched by zero-area spikes, cover
the hidden content: the clipped text stays whole, once and in order.
… clip id, keep mixed cells in tree order (#1140)

Layout overflow counted a one-line row a little shorter than its line,
or a paragraph's space after, as hidden text; a cell is now rewritten
only when the clip edge passes a text line's baseline or a whole
character lies beyond a side edge. The clipPath id skips ids a bookmark
already holds, which url(#id) would otherwise resolve to. A cell with
text in both paint phases puts its in-flow text in a relative box so its
own text is written in tree order.
@JSv4
JSv4 merged commit 44864b4 into main Oct 16, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Clipped exact-row text leaves broken word fragments in the PDF text layer

1 participant