Skip to content

Measure the text runs of page.content() on the fallback faces (#658) - #664

Merged
yhay81 merged 4 commits into
mainfrom
claude/658-content-on-faces
Nov 4, 2026
Merged

yhay81 merged 4 commits into
mainfrom
claude/658-content-on-faces

Conversation

@yhay81

@yhay81 yhay81 commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Closes #658

The text runs of page.content() now describe a CJK font that a document does not embed as the fallback face that draws it, as frozen pylopdf does after a first rendering. Before this, page.pixmap() drew glyphs that page.content() gave no box for.

How the fields split

Two interpretations of the page supply a run's fields. The second runs through the faces, and only when Fallback::watching reports during the first that a face would draw a font of the page.

Field Interpretation Why
Glyph IDs, glyph boxes, run box Through the faces They describe what a rendering draws. The ink keeps a face's stretch, because the rendering draws it.
The run's font record Through the faces Frozen pylopdf reads the face's weight (400) here and None without faces. This is not in the issue's table, but the acceptance needs it. The name stays the PDF's in both.
Matrices, advances Without faces hayro 0.9 keeps a face's stretch in the glyph's transform, where nothing tells it apart (scale_non_uniform(font.glyph_stretch(..))). Under /W [0 65535 500], its 0.5 would halve the advances and matrix.a.
Text, paints, render mode, context Without faces No face changes them, so they keep what the page gives, as #624's records do.

A run with no ink at all falls back to the box of its origins and advances, also from the read without faces.

Three things follow from running a second interpretation:

  • Clip: clip keeps items by their box, so it applies after the faces have moved the boxes. A clip that meets the ink above the baseline now keeps the run.
  • Budget: the second interpretation records no items, so it counts no content_element_count, and max_elements still bounds one interpretation. Its per-glyph list is charged to memory_size and released when the read ends.
  • Faces that do not load: if the second interpretation shows a different number of glyphs, as when a face does not load, the runs keep what the page gives.

Acceptance

  • On tests/assets/text/nonembedded-cjk.pdf, the text runs of page.content() match frozen pylopdf with its CJK extras, read after a first rendering.
    • Every field of every run on all 7 pages is identical: glyph ID, text, matrix, advance, glyph box, run box, font record, render mode, paints, context, blend mode, and soft mask. I compared dumps from the oracle at target/parity/pylopdf-env, rounded to 6 decimals, and this holds for pyropdf read fresh and after a rendering.
    • FROZEN_RUNS in tests/test_fallback_fonts.py pins the IDs, boxes, and weight, so CI holds this without the oracle.
  • Editing doc.fallback_fonts changes the runs that the next call returns, and a call with the same faces reuses the memoized result (test_editing_the_fallback_fonts_changes_the_text_runs_of_the_next_call). text_runs=False keeps its memo key (test_content_without_text_runs_keeps_its_memo_key_when_the_fallback_fonts_change).
  • Without an installed face the runs are as they are now, and a document whose fonts are all embedded reads no fallback face.
    • With doc.fallback_fonts = {}, every field also matches frozen pylopdf without faces (FROZEN_RUNS_WITHOUT_FACES pins the IDs, boxes, and weight).
    • only_text_runs_of_a_font_that_is_not_embedded_read_a_face proves no face is read for an embedded font, Helvetica, and text_runs=False, by removing the face's file afterwards.

The page in /W [0 65535 500], where a face's stretch applies, also matches frozen pylopdf in every field.

Checks

  • python tools/check.py --locked: all passed on this head (sync, pytest, ruff, ruff-format, ty, fmt, clippy, cargo-test).
  • cargo check --manifest-path fuzz/Cargo.toml --bins --locked: passes. page_content and page_content_into take the faces, so the fuzz target changed; it passes no faces, so the second interpretation is not fuzzed.

For review

  • Outside the issue's paths: python/pyropdf/_document.py, docs/en/rendering.md, and docs/ja/rendering.md said only exact boxes use the faces, which this change makes untrue. docs/en/api/documents.md and pages.md are generated from the docstrings.
  • Overlap with Keep the PDF's advances when hayro stretches a substitute glyph #656: its branch is not pushed yet and will touch the advances in record_text_item. This pull request leaves that computation as it is, so Keep the PDF's advances when hayro stretches a substitute glyph #656 still applies to the read without faces. A non-embedded Latin font's advances stay as they are on main until then.
  • Not done: evidence/phase1-parity still does not compare page.content(). The issue's paths leave that out, so here only this fixture is compared with the oracle.

For 0.2.2's changelog

  • page.content() describes a CJK font that a document does not embed as the face of doc.fallback_fonts that draws it: glyph IDs, glyph and run boxes, and the font's weight. Its glyphs used to have no box, and a run's box lay on its baseline. The result now depends on doc.fallback_fonts, unless text_runs=False.

Merge

#658 is not linked as a blocker of #636, so I have not treated it as covered by the merge authorization there. This waits for the owner.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yhay81
yhay81 merged commit 07173ff into main Nov 4, 2026
15 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.

Measure the text runs of page.content() on the fallback faces

1 participant