Skip to content

fix(pdf): draw tracked text with the spacing in the glyph widths - #688

Merged
DemchaAV merged 3 commits into
developfrom
fix/pdf-letter-spacing-parser-compat
Sep 14, 2026
Merged

DemchaAV merged 3 commits into
developfrom
fix/pdf-letter-spacing-parser-compat

Conversation

@DemchaAV

Copy link
Copy Markdown
Owner

Why tracked text read as single letters

Letter spacing (#676) reaches the PDF as Tc on a run wrapped in ActualText marked content. Tc moves the pen after each glyph, but each glyph's width in the font's /W array stays the program's own width. So the glyph box an extractor derives from /W stops short of the next glyph by the whole tracking.

  • Readers that honour ActualText (PDFBox, Acrobat) get the right text, which is why no PDFBox-based test ever showed the problem.
  • Readers that ignore it build words from glyph boxes:
    • pdf.js starts a new word once a gap passes about a tenth of the font size;
    • pdfplumber starts a new word once a gap passes its 3 pt tolerance.
    • A tracked JANE DOE comes out as J A N E D O E.

Tracked caps set the name and section headings of most CV presets. One CV was rendered through the 24 CV presets that can lay it out, and the PDFs were read by three resume parsers:

  • OpenResume (pdf.js 3.7.107)
  • ATS Reader (pdfplumber 0.11.10)
  • resume-parser-ats 1.2.3 (pdf-parse)

On develop the three together found 100 words split into letters, across 12 presets. Six of those presets had their name found by only 1 of the 3 parsers.

What changed

A positively tracked run now draws with a second font resource whose glyph widths carry the tracking, over the same embedded font program.

  • PdfTrackedFontView is that resource.

    • Shares the font program. It shares the base font's FontFile2, ToUnicode, CIDToGIDMap and FontDescriptor by reference, and writes /W and /DW with every width raised by the tracking. Each glyph lands where Tc put it, and its box reaches the next glyph.
    • Nothing in the font program is copied or modified. A modified copy would duplicate the program for every tracking (about +20% on a CV) for no visual gain.
    • Widths are whole thousandths of an em, because PDFium truncates fractional CID widths and glyphs would move in Chrome. The remainder, at most half a thousandth of the font size, stays in Tc.
  • PdfTrackedFontResources hands out one resource per face and tracking per em, reused across the document's pages and sections. A run qualifies when:

    • its face is a subset-embedded horizontal Type 0 font;
    • its tracking is at least half a thousandth of an em;
    • the face's GSUB substitutions leave its glyphs alone.

    PDFBox applies a face's substitutions when it draws, but a letter-spaced resource encodes through the character map. So each run is checked with PDFBox's own GSUB worker, split on whitespace the way PDFBox splits it. Poppins keeps its Devanagari substitutions: a Latin heading qualifies, a Devanagari conjunct does not.

  • Fallbacks keep develop's Tc + ActualText unchanged:

    • negative tracking;
    • Standard 14 faces;
    • tracking below half a thousandth of an em;
    • text drawn in visual order: right-to-left runs, every line of a right-to-left table cell, and a highlight chip whose text needs bidi;
    • text the face's substitutions would rewrite.
  • PdfSubsetAwareSave: documents with a letter-spaced resource save twice.

    • A font's subset only exists once the document saves, and the resource's widths must name the subset's glyphs. So the document first saves into a null sink, the resources are completed, and then it saves for real.
    • Protection is applied on the real save only. A protected first save would leave glyph maps the completion cannot read.
    • The save orchestration moves here from PdfShapedGlyphUnicode, whose reordered-text correction runs at the same point.
    • A document without letter-spaced resources or reordered text keeps its single save.
  • Handlers. PdfParagraphFragmentRenderHandler (lines and highlight chips) and PdfTableRowFragmentRenderHandler draw through the new resources.

    • Table cells decide once for all their lines. That keeps develop's Tf/rg/Tc order and leaves untracked cells byte-identical.
    • Mixed cells: a line drawn in visual order switches back to the base face and Tc, and a single line the face cannot serve keeps the whole cell on Tc.
  • Unchanged: tracked runs still carry ActualText, layout measurement is untouched (only the PDF representation of the same advance changes), and DOCX and PPTX are unchanged.

  • PDF/A and PDF/UA. The raised widths differ from the program's own advances on purpose. ISO 32000 allows that; PDF/A and PDF/UA do not. The backend has no output mode that claims either, and one added later must switch these resources off. The CHANGELOG says so.

@Beta on the new seam

PdfRenderEnvironment.letterSpacedFont(font, fontSize, letterSpacing, text) and its result record PdfRenderEnvironment.LetterSpacedFont let a custom paragraph or table handler draw tracked text the same way.

  • Why not Stable. They are new public members of the otherwise-Stable PDF backend, and render-pdf is not japicmp-gated. A Stable shape would be frozen with nothing to catch a break.
  • What is settled, and what may move. What they draw — glyph positions and text layer — is settled. The shape of the call may still change: a nullable result, and a face bound to one document, size and spacing.
  • Precedent. Both carry @Beta, as PdfFixedLayoutBackend.renderSections / writeSections and Builder.deterministic already do.
  • Documented. docs/api-stability.md names them, and the knowledge pack is regenerated (only backends.json and backends.md change).

Before → after

Develop 88eb90ed against this branch, on the same CV and the same 24 presets:

  • Parsers: 100 split words across 12 presets → 0.
    • Name found by all three parsers: 1/3 → 3/3 in blue-banner, boxed-sections, centered-headline, classic-serif and minimal-underlined; 1/3 → 2/3 in mint-editorial.
    • Two other presets report 6 and 3 split words, identically on develop and on the branch. These are two-word headings and a name that the check's own PDFBox reading joins (ProfessionalSummary, ARTEMDEMCHYSHYN), not letters.
  • Glyphs: pages, glyph counts and extracted text are identical in all 24 presets.
    • Glyph origins match develop within 1e-4 pt in pdfplumber on 21 presets.
    • On mint-editorial, navy-sidebar and teal-pulse, pdfminer places develop's Tc runs its own way; there PDFium's glyph boxes agree within 4.6e-5 pt.
  • Pixels (PDFium, 100 dpi, every page): 23 presets are identical. teal-pulse differs in 38 pixels by one level in one channel: a one-pixel antialiasing column over its tracked name.
  • Size: no font program is ever duplicated; files grow by 0.0% to 1.9%.
  • Render time (local harness, directional: 5 alternating rounds, 60 warm-up + 200 measured renders per side):
    • BoxedSections has letter-spaced headings and saves twice: median 14.01 → 15.24 ms (+1.2 ms, +9%), slower in all five rounds.
    • EditorialBlue has no letter-spaced resource: 4.66 → 4.62 ms, byte-identical output. Untracked documents are unchanged.

Committed previews

31 previews under assets/readme/examples draw positively tracked text in an embedded face, and they are re-rendered here: 8 cover letters, 18 CVs, 2 invoices, 2 proposals and letter-spacing.

  • What stays the same. Against the files they replace, every page has 0 pixels different in PDFium at 100 dpi. Page counts, glyph counts and extracted text are unchanged, and no font program is added.
  • What changes. The files differ only structurally: letter-spaced resources are added over the existing programs, for +0.1% to +2.9% bytes.
  • Not affected. receipt-modern does not move, because its tracked text is Standard 14 Helvetica, which keeps Tc.

Known limits and follow-up

  • No CV template is certified here. This PR changes how the PDF backend represents tracked text; it does not make any CV template ATS-certified. On the branch, the parsers still miss some fields on the same CV, and those misses come from:

    • template layout: columns read out of order, names drawn as monograms or split across lines;
    • section-heading wording and CV content, such as "Education & Development";
    • the parsers' own heuristics.

    None of them comes from the renderer. Certifying and tuning the CV templates is follow-up work.

  • Trailing tracking unit. The last tracking unit of a word now sits inside its last glyph box, so the gap from that box to whatever follows is one tracking unit narrower than under Tc. The space glyph is drawn either way.

    • OpenResume's glued-word counts match develop on 23 of the 24 presets.
    • On mint-editorial, four tracked titles move from "split" to "glued". Develop read them as letters with no word gap (B A C K E N D E N G I N E E R I N G); the branch reads the words, still without that gap (BACKENDENGINEERING).
  • Right-to-left tracked text still draws with Tc, so pdf.js still splits it.

  • PDFBox upgrades. The substitution check follows PDFBox 3.0.8's GSUB word split, so a PDFBox upgrade should re-run PdfLetterSpacedFontFallbackTest.

Verification

  • ./mvnw -B -ntp clean verify -pl :graph-compose-core,:graph-compose-render-pdf,:graph-compose-render-docx,:graph-compose-render-pptx,:graph-compose-templates,:graph-compose-testing,:graph-compose,:graph-compose-bundle,:graph-compose-qa,:graph-compose-coverage -am (CI's build-and-test command) → BUILD SUCCESS.
    • 3,159 tests, 0 failures, 2 skipped: core 762, render-pdf 274 (22 new), templates 127, qa 1759, plus the DOCX, PPTX and testing modules.
    • All coverage checks met.
  • ./mvnw -B -ntp install javadoc:javadoc -pl :graph-compose-render-pdfBUILD SUCCESS. Javadoc's 5 warnings are all in PdfFont.java, which this branch does not touch.
  • ./mvnw -B -ntp -f examples/pom.xml testBUILD SUCCESS, 77 tests, including CommittedAssetDriftTest and ExampleContentGuardTest on the re-rendered previews. Before the re-render, the drift test named exactly these 31 files.
  • ./mvnw -B -ntp -f benchmarks/pom.xml testBUILD SUCCESS, 44 deterministic benchmark gates, including the exact render-operator counts.
  • Knowledge pack, as CI runs it:
    • extract-api.mjs --from-reactor --check reports the surfaces current;
    • check-stability-doc.mjs confirms every @Beta is named (7 members);
    • check-claims.mjs --check (46 claims, 10 proofs), check-routes.mjs and the five fixture suites pass.

New tests

  • PdfLetterSpacedFontTest (12):
    • Paragraph geometry. Every glyph origin of a tracked run is within 0.01 pt of the same run drawn by PDFBox with Tc. Cases cover 0.18 em, 0.02 em and point tracking at 10, 14 and 24 pt, trailing advance included.
    • Reader gap. The gap a reader measures between tracked letters is at most 0.005 pt; the Tc reference leaves the full 1.73 pt.
    • Other paths. The same geometry holds for a table cell and a highlight chip, and the text layer reads back correctly from a paragraph, a cell and a chip.
    • Structure. The resource shares its base's program, ToUnicode and CIDToGIDMap, with every width raised.
    • Protection. A password-protected document keeps the resource.
    • Reuse. There is one resource per face and tracking per em across styles, sizes, pages and sections, and a face handed back stands for its base.
  • PdfLetterSpacedFontFallbackTest (10):
    • Every fallback keeps Tc and draws no letter-spaced resource: negative tracking, a Standard 14 face (which keeps ActualText), a right-to-left run, a bidi chip, tracking below 0.0005 em, and a Devanagari run that Poppins' substitutions rewrite.
    • Poppins still carries a Latin heading's spacing in its widths.
    • Table cells. A table cell is decided by the text it draws. A cell with one line the face cannot serve keeps every line on Tc, and a reordered line switches back inside a letter-spaced cell.
  • LetterSpacedPdf recognises a letter-spaced resource by what it states: widths above those its own embedded program gives the same glyphs. It does not look for any key the backend writes, so a fallback assertion cannot pass by accident.
  • qa: LetterSpacingAcrossBackendsTest and TrackingFixedLayoutParityTest read the tracking a PDF declares as the width raise over its program plus the Tc in force (PdfDeclaredTracking), instead of reading Tc alone.

Sabotage. Each change was made to the real sources, the tests were run, and the sources were restored:

sabotage tests that went red
every run sent back to Tc 14 of the 22
widths one unit too wide 4 (paragraph, cell and chip geometry; structure)
substitution check skipped the 3 Devanagari fallbacks
every face that keeps substitutions excluded 2 (Poppins Latin, cell decision)
a table cell passes no text 3 (cell geometry, both cell-decision tests)
a face handed back not unwrapped 1 (the handed-back face test)
highlight chip back to Tc 1 (chip geometry)
chip keeps full Tc over widened widths 1 (chip geometry)
table cell back to Tc 3 (cell geometry and cell fallbacks)
cell keeps full Tc over widened widths 2 (cell geometry, reordered-line test)
qa: widths one unit too wide 5
qa: residual Tc dropped 4

With the sources restored, everything is green again.

Lane: shared-engine — PDF backend (render-pdf) only; DOCX, PPTX and layout measurement unchanged.


Pre-merge checklist
  • Targets develop; branch is fix/pdf-letter-spacing-parser-compat.
  • CI's ./mvnw -B -ntp clean verify module list passes locally — the Verification proof above.
  • Java 17 compatible — no Java 21 APIs in the diff.
  • Public API changedCHANGELOG.md entries under ## v2.4.0 — Planned (Public API and Tests); docs/api-stability.md and the knowledge pack updated.
  • Examples touched → 31 committed previews re-rendered; the full examples suite (77) is green.

A tracked run drawn with Tc places every glyph correctly, but the added
space sits between the glyph boxes a reader derives from the font's
widths. Readers that ignore ActualText build words from those boxes, so
pdf.js (past about a tenth of the font size) and pdfplumber (past three
points) read a tracked heading as single letters. Across the CV presets
that track their names and headings, three resume parsers built on those
readers found 100 split words in 12 presets; drawn this way they find none.

A positively tracked run now draws with a second Type 0 resource over the
same embedded font program. It shares its base's FontFile2, ToUnicode,
CIDToGIDMap and descriptor by reference and raises every width by the
tracking, in whole thousandths of an em because PDFium truncates
fractional CID widths; the remainder, at most half a thousandth of the
font size, stays in Tc. Glyphs land where Tc put them, each box reaches
the next, and no font program is duplicated or modified.

- PdfTrackedFontResources hands out one resource per face and tracking
  per em for the whole document, across pages and sections, and decides
  what qualifies: a subset-embedded horizontal Type 0 face, tracking of
  at least half a thousandth of an em, and text the face's GSUB
  substitutions leave alone. Negative tracking, Standard 14 faces, text
  drawn in visual order and substituted text keep Tc and ActualText.
- PdfTrackedFontView is that resource. Its widths are completed after a
  first save has built the subsets, so a document carrying one saves
  twice (PdfSubsetAwareSave), with protection applied on the real save.
- Paragraph runs, highlight chips and table cells draw through it. A
  table cell decides once for all its lines, and a line drawn in visual
  order goes back to Tc.
- PdfRenderEnvironment.letterSpacedFont and its LetterSpacedFont record
  are the seam a custom handler uses, marked @beta: what they draw is
  settled, the shape of the call may still move. api-stability.md names
  them and the knowledge pack is regenerated.

PdfLetterSpacedFontTest holds glyph origins to the Tc picture to 0.01pt
and reader gaps to 0.005pt in paragraphs, table cells and chips, and pins
resource sharing, protection and sections; PdfLetterSpacedFontFallbackTest
pins every run that keeps Tc. Ten sabotages of the real sources each turn
the tests that describe them red. The qa tracking tests read a PDF's
tracking as the width raise plus Tc. render-pdf (274), templates (127)
and qa (1759) pass.
The committed previews that draw positively tracked text in an embedded
face now carry letter-spaced font resources, and these 31 no longer
matched what their examples render.

Against the files they replace, every page matches in PDFium at 100 dpi
with no pixel changed, page and glyph counts and the extracted text are
unchanged, and no font program is added: the new resources reuse the
programs already embedded. The files grow by 0.1% to 2.9%.
CommittedAssetDriftTest and ExampleContentGuardTest pass.
* {@code c [w1 ... wn]} and the {@code cFirst cLast w} forms. The widths stay integers:
* PDFium reads CID widths as integers, so a fractional width would move glyphs there only.
*/
private COSArray widened(COSArray widths) {
@DemchaAV
DemchaAV merged commit e9a0110 into develop Sep 14, 2026
12 checks passed
@DemchaAV
DemchaAV deleted the fix/pdf-letter-spacing-parser-compat branch September 14, 2026 12:42
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.

2 participants