Repository navigation
Description
Context
DESIGN.md section 14: pyropdf is compared with frozen pylopdf. #609 moved pyropdf to hayro 0.9, after 0.2.1, which is on hayro 0.8. hayro 0.8 stretched the outline of a substitute glyph to the width that the PDF expects (stretch_glyph in hayro-interpret/src/font/). hayro 0.9 puts that stretch in the glyph's transform instead (Affine::scale_non_uniform(font.glyph_stretch(glyph_id, code), 1.0) in hayro-interpret/src/interpret/text.rs).
Text extraction takes a glyph's advance through that transform (combined * Point::new(adv, 0.0) in collect_glyph in crates/pyropdf-engine/src/extract/text.rs, and likewise in record_text_item in model.rs and widen_holder). So a font that a document does not embed, drawn in a Standard 14 substitute whose widths differ from the PDF's /Widths, gets each advance scaled by that glyph's stretch. The corpus of evidence/phase1-parity has no such page, so the parity report does not show it.
Found while working on #624. A page in /BaseFont /Verdana, not embedded, with /Widths of 700 throughout, at 20 points, gives these page.words() boxes:
| Word | Frozen pylopdf (x1) | pyropdf on main (x1) |
|---|---|---|
Hello |
90.0 | 106.14 |
big |
146.0 | 162.14 |
world |
230.0 | 246.14 |
words(exact_bboxes=True) matches, since ink goes through the same transform on purpose, but chars without ink in layout(chars=True, exact_bboxes=True) do not: a space ends at 125.25 and not at 104.0. Word gaps also read these advances, so a stretch below 1 can split a word and one above 1 can join two.
0.2.2 is meant to move no text (#622, #636), so this is a regression of that release if it ships as it is. The owner decides whether it goes into 0.2.2.
Goal
A glyph's advance in text extraction is the PDF's width, whatever stretch hayro applies to draw it, as on hayro 0.8 and in frozen pylopdf.
Scope
In: the advances of page.text, words, layout, chars, search, selection, and tables, and of the text runs of page.content(), for fonts that a document does not embed.
Out: the ink boxes, which should keep the stretch, because a rendering draws it. Exact boxes of CJK fonts on fallback faces, which #624 handles by taking only ink from the faces.
Paths
crates/pyropdf-engine/src/extract/, the tests of text boxes, and a new page in evidence/phase1-parity/ if the fixture joins the corpus. crates/pyropdf-engine/Cargo.toml and tests/test_crate_dependencies.py if the fix depends on hayro-font directly.
Acceptance
- On a page in a non-embedded font whose
/Widthsdiffer from its substitute's,words(),layout(chars=True), andsearch()match frozen pylopdf. - The advances of
page.content()'s text runs on that page are the PDF's widths. - Ink boxes on that page do not change.
-
evidence/phase1-paritymeasures such a page, so that this cannot come back unseen.
Notes
- The stretch is not reachable from
hayro_interpret's public API:OutlineGlyphandPositionedGlyphdo not expose it.hayro_font::Font::glyph_stretchandFontCacheare public inhayro-font, buthayro_interpret::fontre-exports them aspub(crate), so the options are a direct dependency onhayro-fontthat loads a font again for runs in a font without an embedded program, or an upstream accessor on the glyph, which would then wait on hayro or on the fork that Build against released hayro again: remove the [patch.crates-io] of LaurenzV/hayro#1734 #603 removes. GlyphRun::font_dict()tells which font a run uses, which is enough to skip embedded fonts: hayro stretches only a substitute.- The reproduction is a Type1 or TrueType font dictionary with
/FontDescriptorand noFontFile*, and/Widthsfar from Helvetica's.
Activity
yhay81 commentedon Nov 4, 2026
Claiming on branch
claude/656-stretched-advances.yhay81 commentedon Nov 4, 2026
Handoff: PR #667, branch
claude/656-stretched-advances.What changed. Text and the advances of
page.content()'s text runs read the PDF's widths on a substitute font. Ink boxes and glyph matrices keep the stretch.extract/stretch.rsgets the stretch fromhayro-font, a new direct dependency.A departure from this issue's Notes, for the owner to review. Skipping every font that names a program left a gap, because hayro also stretches a substitute when the named program does not load. So a font loads again when a glyph's x basis differs in length from its y basis.
evidence/stretch-reloadmeasures this: within noise over the corpus, where loading every font would cost up to +47.3%. It does not measure a Type 1 or TrueType program underTz, and estimates that such a document would pay about that much.Evidence.
python tools/check.py --lockedpasses on Windows, andcargo audit --deny warningsis clean.evidence/phase1-paritynow measurestests/assets/text/nonembedded-latin.pdf, 13 pages that match frozen pylopdf in every output. Of 176 rasters, 175 match byte for byte and one is close, and nothing is unexplained.Known limit. A stretch that
Tzcancels exactly goes unseen:200 Tzwith every width exactly half of the substitute's readsH,e,llo, where frozen pylopdf readsHello. I left this rather than load every font that names no program.Next action. Check CI on the PR's current head, then merge. #656 is now a blocker of #636, so the authorization in #636 (comment) applies once its conditions hold. I have not merged it. The branch does not include #661 or #662, which merge cleanly.
Keep the PDF's advances when hayro stretches a substitute glyph (#656) (
Measure the text runs of page.content() on the fallback faces (#658) (#…