Skip to content

fix(pdf): place a run by its own font, and land a search hit on its word - #694

Merged
andiwand merged 2 commits into
mainfrom
fix/pdf-text-placement-and-search-geometry
Aug 16, 2026
Merged

fix(pdf): place a run by its own font, and land a search hit on its word#694
andiwand merged 2 commits into
mainfrom
fix/pdf-text-placement-and-search-geometry

Conversation

@andiwand

@andiwand andiwand commented Aug 16, 2026

Copy link
Copy Markdown
Member

🤖 Generated with Claude Code

The ios app reported a search hit covering the wrong word, on
OpenDocumentReader/Samples/sample-paper-en.pdf. Four faults in the html pdf
renderer, all in how a run is placed relative to the one before it. Everything
here is dual-layer unless noted; the tint path in the 6.7.0 notes was engaging
all along — the highlight was simply sitting over the wrong glyphs.

A recovered word break was counted twice

The extractor prepends a space to a run when it infers a word break; that space
"backs no code and no advance". The glyph and collapse paths skip it, but both
fallback paths rendered it literally while the run's margin-left already
spanned the whole gap. On a page whose fonts are not embedded the text drifted a
space right per word — the repro's second line ran 630px against the selection
layer's 562px, so <mark> on "quarter" painted over "cond q".

Both paths now take the run's core text. Single-layer gives the break its own
span, which is what its collapse path already did.

A bold substitute rendered at regular widths

The generated face was src:local("Helvetica") with no weight descriptor.
local() matches a face's full or PostScript name, never a family plus the
weight the @font-face declares, so it resolved the regular cut and the browser
faked the bold — synthetic bold does not widen advances. The .sr box came from
the pdf's bold metrics, so a hit on "Invoice" measured 101.1px over 92.9px of
glyphs.

The cut is now asked for by name (local("Helvetica-Bold"), local("Helvetica Bold"), then the plain name so a system without it still resolves), and the
face declares the weight and style so nothing is synthesised on top.

A line block anchored every run on its first run

A visual line block carries its first run's placement and the rest flow inline
off it. A run in another font or size was therefore anchored against metrics
that were not its own. On the Infineon datasheet a bullet at 8pt put the 10pt
text beside it 7px off its own baseline, a full line away from the highlight
that selects it — and every subscript on the page was flattened onto the
baseline for the same reason.

The block now breaks on a flow key of font, size, ascent and substitute — the
same thing the single-layer path already did.

"Optimized for UPS/Inverter Applications" before after
visual block top 264.107px 257.08px
selection block top 257.08px 257.08px
visual text x 485.0 487.5
selection text x 487.5 487.5

A gap spacer painted a sliver

.sg is an inline-block of the pdf's gap width, but its lone space renders at
roughly half that under size-adjust:50%, and a highlight paints the glyph, not
the box — so a selection or a multi-word hit left white at every word break.
.sr escapes this because justify stretches the word to fill.

Its advance is now padded and clipped back by the box, but only for a gap that
still reads as a word space (<= 1em). Above that it is a column of white, not
a space: the datasheet's footer puts www.irf.com and the page number 1 in
one line separated by a 481px spacer, and padding that painted a bar across the
whole page.

Verification

  • Hits land on their words in both text modes, checked against the pdf's own
    Td positions — "quarter," at 265.8pt renders at 713.7px, exact.
  • Subscripts on the datasheet now read V_DSS, R_DS(on), I_D @ T_C = 25°C
    instead of flattened, and HEXFET® superscripts.
  • Full suite: 881 passed, 8 skipped, 0 failed.
  • Corpus A/B: no change to embedded-font documents beyond the new css rule;
    phd-agreement.pdf identical but for one form placeholder moving to its own
    baseline. Html grows 0.43% (odr-public) / 0.16% (odr-private).

A quoted family was quoted again

family_stack already quotes a name that needs it — 'Times New Roman',
'Courier New', 'Zapf Dingbats' — and local_font_sources quoted it a second
time, so a serif or monospace substitute asked for a face named with literal
apostrophes. Nothing matched, and the text rendered through the plain family
stack without the ascent/descent overrides its placement assumes — the very
misplacement the rest of this PR fixes. Found by Codex on the first commit;
fixed in 3bd49b4b. Probed on macOS/Chrome: local("Times New Roman Bold")
now resolves to the bold cut (311.1px against 299.9px regular).

Notes

  • Reference output is regenerated and pinned in test/data.cmake:
    OpenDocument.test.output@ea67115 (17 files) and
    OpenDocument.test-private.output@97f3bb9 (581 files), both committed on
    main of their repos. Only pdf output changed — no other format, and no file
    added or removed.
  • Horizontal accumulation within one run's font is untouched: the visual layer
    still chains margin-left, so a long substituted run can drift. Fixing that
    needs per-run widths in the visual layer.
  • Inside a single .sr, text-justify:inter-character spreads the fallback
    across the pdf width, so a partial-word hit stays approximate. Whole-word
    hits are exact.

The ios app reported a search hit covering the wrong word. Four faults in
the html pdf renderer, all in how a run is placed relative to the one
before it.

A recovered word break was written into the run text *and* spanned by the
`margin-left` placing that run, so a page whose fonts are not embedded
drifted a space to the right per word — far enough by the end of a line
that the selection layer's highlight sat over a neighbouring word. Both
fallback paths now take the run's core text; single-layer gives the break
its own span, as its collapse path already did.

A substitute `@font-face` named only the family, so `local(Helvetica)`
resolved the regular cut and the browser faked the bold at the regular's
advance widths. It is now asked for by the name of the cut it wants, and
declares the weight and style so nothing is synthesised on top.

A visual line block carries its first run's placement and the rest flow
inline off it, so a run in another font or size was anchored against
metrics that were not its own — subscripts flattened onto the baseline,
and a bullet floated the text beside it a line away from the highlight
that selects it. The block now breaks on the same flow key the
single-layer path already used.

A gap spacer's lone space cannot be justified to its box the way a word
is, so a selection or a search hit left a sliver of white at every word
break. Its advance is padded and clipped back, for a gap that still reads
as a word space — a wider one is a column of white, not a space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QELPoDuuBiFJ6B3jVFDok5

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cd60b068dc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/odr/internal/html/pdf_file.cpp
`family_stack` quotes a name that needs it ('Times New Roman', 'Courier
New', 'Zapf Dingbats'), and `local_font_sources` quotes again — so a serif
or monospace substitute asked for a face named with literal apostrophes,
matched nothing, and rendered through the plain stack without the
ascent/descent overrides its placement assumes.

Advances the reference-output pins for the whole branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QELPoDuuBiFJ6B3jVFDok5
@andiwand
andiwand enabled auto-merge (squash) August 16, 2026 19:59
@andiwand
andiwand disabled auto-merge August 16, 2026 20:03
@andiwand
andiwand merged commit c6cfdfe into main Aug 16, 2026
36 checks passed
@andiwand
andiwand deleted the fix/pdf-text-placement-and-search-geometry branch August 16, 2026 20:03
andiwand added a commit that referenced this pull request Aug 16, 2026
They landed under the `v6.7.0` heading: the branch was cut before the
release run stamped it, so the merge put them where `## Unreleased` had
been. v6.7.0 shipped without that fix — it is in the next release.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant