Skip to content

Keep the PDF's advances when hayro stretches a substitute glyph #656

Closed

Description

@yhay81

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 /Widths differ from its substitute's, words(), layout(chars=True), and search() 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-parity measures such a page, so that this cannot come back unseen.

Notes

  • The stretch is not reachable from hayro_interpret's public API: OutlineGlyph and PositionedGlyph do not expose it. hayro_font::Font::glyph_stretch and FontCache are public in hayro-font, but hayro_interpret::font re-exports them as pub(crate), so the options are a direct dependency on hayro-font that 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 /FontDescriptor and no FontFile*, and /Widths far from Helvetica's.

Activity

  1. added this to the Phase 1: Reading milestone on Nov 4, 2026
  2. added
    area:enginecrates/pyropdf-engine: interpretation, rendering, writing, understanding
    on Nov 4, 2026
  3. added
    in-progressClaimed; the latest comment names the branch
    on Nov 4, 2026
  4. yhay81 commented on Nov 4, 2026

    @yhay81
    OwnerAuthor

    Claiming on branch claude/656-stretched-advances.

  5. yhay81 commented on Nov 4, 2026

    @yhay81
    OwnerAuthor

    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.rs gets the stretch from hayro-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-reload measures 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 under Tz, and estimates that such a document would pay about that much.

    Evidence. python tools/check.py --locked passes on Windows, and cargo audit --deny warnings is clean. evidence/phase1-parity now measures tests/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 Tz cancels exactly goes unseen: 200 Tz with every width exactly half of the substitute's reads H, e, llo, where frozen pylopdf reads Hello. 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.

  6. removed
    in-progressClaimed; the latest comment names the branch
    on Nov 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:enginecrates/pyropdf-engine: interpretation, rendering, writing, understanding

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions