diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d68c394e..e44aa7b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,40 @@ All notable changes to GraphCompose are documented here. Versions follow semantic versioning; release dates are ISO 8601. +## v2.2.1 — Planned + +### Fixed + +- **A PDF now carries the words it draws.** Text set in a bundled TrueType family lost + letters from its text layer: `Platform` extracted as `Pla orm`, `certification` as + `cer fica on`. The page looked right, so nothing showed it — but the text layer is what + a search box, a copy-and-paste, a screen reader and an applicant tracking system all + read, so a CV rendered through one of these families quietly failed to contain the + words printed on it. + + PDFBox applies a font's `GSUB` substitutions itself whenever a face carrying them is + made current on a content stream, and most of the bundled families define ligatures + over the commonest English letter pairs — `ti`, `tf`, `ft`. Each pair was drawn as a + single glyph, and the map that says what a glyph stands for is built by reading the + font's character map backwards, where a ligature is reachable from no character at all. + The entry was therefore absent and both letters were lost. The families whose ligatures + happen to have code points of their own (`fi`, `fl`) survived, which is why the damage + looked arbitrary. + + A Latin face is now handed to PDFBox with nothing to substitute. That is also what the + engine already assumed: layout measures a string ligature-blind, so a line drawn with + ligatures was slightly narrower than the box measured for it, and the DOCX and PPTX + backends never substituted. Non-Latin faces are untouched — PDFBox shapes Devanagari, + Bengali and Gujarati through the same mechanism, and there the substitutions are how + the script renders rather than a flourish on top of it. + + Visible consequence: text set in a bundled family no longer forms ligatures, so `fi` + and `fl` are drawn as two letters. PDFBox applies `ccmp`, `liga` and `clig` together + and offers no way to keep one without the others, but in the bundled families the + Latin `ccmp` changes nothing — decomposed combining sequences and precomposed letters + are drawn exactly as before. The committed visual baselines for the layered CV and + cover-letter presets moved by the ligatures alone and were re-recorded. + ## v2.2.0 — 2026-08-15 ### Public API diff --git a/assets/readme/examples/cover-letter-blue-banner-v2.pdf b/assets/readme/examples/cover-letter-blue-banner-v2.pdf index 1afd7caae..17ee1b9cf 100644 Binary files a/assets/readme/examples/cover-letter-blue-banner-v2.pdf and b/assets/readme/examples/cover-letter-blue-banner-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-boxed-sections-v2.pdf b/assets/readme/examples/cover-letter-boxed-sections-v2.pdf index bd6bf780b..296163727 100644 Binary files a/assets/readme/examples/cover-letter-boxed-sections-v2.pdf and b/assets/readme/examples/cover-letter-boxed-sections-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-centered-headline-v2.pdf b/assets/readme/examples/cover-letter-centered-headline-v2.pdf index 9ac4f6949..baff74e0a 100644 Binary files a/assets/readme/examples/cover-letter-centered-headline-v2.pdf and b/assets/readme/examples/cover-letter-centered-headline-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-classic-serif-v2.pdf b/assets/readme/examples/cover-letter-classic-serif-v2.pdf index 92c6768f8..26f88acca 100644 Binary files a/assets/readme/examples/cover-letter-classic-serif-v2.pdf and b/assets/readme/examples/cover-letter-classic-serif-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-compact-mono-v2.pdf b/assets/readme/examples/cover-letter-compact-mono-v2.pdf index 206db3523..ace2c251d 100644 Binary files a/assets/readme/examples/cover-letter-compact-mono-v2.pdf and b/assets/readme/examples/cover-letter-compact-mono-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-engineering-resume-v2.pdf b/assets/readme/examples/cover-letter-engineering-resume-v2.pdf index 3d49fc197..16028083c 100644 Binary files a/assets/readme/examples/cover-letter-engineering-resume-v2.pdf and b/assets/readme/examples/cover-letter-engineering-resume-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-executive-v2.pdf b/assets/readme/examples/cover-letter-executive-v2.pdf index 752ab764b..fe344ef33 100644 Binary files a/assets/readme/examples/cover-letter-executive-v2.pdf and b/assets/readme/examples/cover-letter-executive-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf b/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf index 0fad9abf7..786ea4733 100644 Binary files a/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf and b/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-nordic-clean-v2.pdf b/assets/readme/examples/cover-letter-nordic-clean-v2.pdf index 0d94825b6..b20ce6be1 100644 Binary files a/assets/readme/examples/cover-letter-nordic-clean-v2.pdf and b/assets/readme/examples/cover-letter-nordic-clean-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-panel-v2.pdf b/assets/readme/examples/cover-letter-panel-v2.pdf index f60d0084c..71f726558 100644 Binary files a/assets/readme/examples/cover-letter-panel-v2.pdf and b/assets/readme/examples/cover-letter-panel-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf b/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf index 8fe4ec3c3..484889209 100644 Binary files a/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf and b/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf b/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf index b14418ab4..c863e7525 100644 Binary files a/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf and b/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf differ diff --git a/assets/readme/examples/cv-blue-banner-v2.pdf b/assets/readme/examples/cv-blue-banner-v2.pdf index a9a0d04de..010e9d3ec 100644 Binary files a/assets/readme/examples/cv-blue-banner-v2.pdf and b/assets/readme/examples/cv-blue-banner-v2.pdf differ diff --git a/assets/readme/examples/cv-boxed-sections-v2.pdf b/assets/readme/examples/cv-boxed-sections-v2.pdf index 0d83de2de..0b49abd10 100644 Binary files a/assets/readme/examples/cv-boxed-sections-v2.pdf and b/assets/readme/examples/cv-boxed-sections-v2.pdf differ diff --git a/assets/readme/examples/cv-centered-headline-v2.pdf b/assets/readme/examples/cv-centered-headline-v2.pdf index be44eb277..cc61f808f 100644 Binary files a/assets/readme/examples/cv-centered-headline-v2.pdf and b/assets/readme/examples/cv-centered-headline-v2.pdf differ diff --git a/assets/readme/examples/cv-classic-serif-v2.pdf b/assets/readme/examples/cv-classic-serif-v2.pdf index 164c16625..52462b01d 100644 Binary files a/assets/readme/examples/cv-classic-serif-v2.pdf and b/assets/readme/examples/cv-classic-serif-v2.pdf differ diff --git a/assets/readme/examples/cv-compact-mono-v2.pdf b/assets/readme/examples/cv-compact-mono-v2.pdf index 7ec84ede2..bb9451dd6 100644 Binary files a/assets/readme/examples/cv-compact-mono-v2.pdf and b/assets/readme/examples/cv-compact-mono-v2.pdf differ diff --git a/assets/readme/examples/cv-engineering-resume-v2.pdf b/assets/readme/examples/cv-engineering-resume-v2.pdf index e768f2d0e..8f6bf3ad1 100644 Binary files a/assets/readme/examples/cv-engineering-resume-v2.pdf and b/assets/readme/examples/cv-engineering-resume-v2.pdf differ diff --git a/assets/readme/examples/cv-executive-v2.pdf b/assets/readme/examples/cv-executive-v2.pdf index 5c3ceaad0..b4e7134e3 100644 Binary files a/assets/readme/examples/cv-executive-v2.pdf and b/assets/readme/examples/cv-executive-v2.pdf differ diff --git a/assets/readme/examples/cv-minimal-underlined-v2.pdf b/assets/readme/examples/cv-minimal-underlined-v2.pdf index 29f3aefbc..fcb3c7020 100644 Binary files a/assets/readme/examples/cv-minimal-underlined-v2.pdf and b/assets/readme/examples/cv-minimal-underlined-v2.pdf differ diff --git a/assets/readme/examples/cv-monogram-sidebar-v2.pdf b/assets/readme/examples/cv-monogram-sidebar-v2.pdf index 8c1fa3c10..cf2361508 100644 Binary files a/assets/readme/examples/cv-monogram-sidebar-v2.pdf and b/assets/readme/examples/cv-monogram-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cv-nordic-clean-v2.pdf b/assets/readme/examples/cv-nordic-clean-v2.pdf index 5573e450d..90a8bf4de 100644 Binary files a/assets/readme/examples/cv-nordic-clean-v2.pdf and b/assets/readme/examples/cv-nordic-clean-v2.pdf differ diff --git a/assets/readme/examples/cv-panel-v2.pdf b/assets/readme/examples/cv-panel-v2.pdf index 0c4d3a8f0..d25037d83 100644 Binary files a/assets/readme/examples/cv-panel-v2.pdf and b/assets/readme/examples/cv-panel-v2.pdf differ diff --git a/assets/readme/examples/cv-sidebar-portrait-v2.pdf b/assets/readme/examples/cv-sidebar-portrait-v2.pdf index 7e18a86c1..3be8260ce 100644 Binary files a/assets/readme/examples/cv-sidebar-portrait-v2.pdf and b/assets/readme/examples/cv-sidebar-portrait-v2.pdf differ diff --git a/assets/readme/examples/cv-timeline-minimal-v2.pdf b/assets/readme/examples/cv-timeline-minimal-v2.pdf index 0d9bfe00e..6ac4510f0 100644 Binary files a/assets/readme/examples/cv-timeline-minimal-v2.pdf and b/assets/readme/examples/cv-timeline-minimal-v2.pdf differ diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java new file mode 100644 index 000000000..a842aaff4 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/CvPresetTextLayerTest.java @@ -0,0 +1,132 @@ +package com.demcha.compose.document.templates.cv.presets; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.EntriesSection; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.data.SkillsSection; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A CV is read twice: once by a person looking at the page, and once by software reading + * the text layer underneath it. This holds every preset to the second reading. + * + *
The two came apart silently. PDFBox draws a font's ligatures for the engine, so in + * most of the bundled families {@code ti}, {@code tf} and {@code ft} each became one + * glyph that the font's glyph-to-character map had no entry for — and the letters were + * simply gone from the text layer. {@code Platform} extracted as {@code Pla orm} while the + * page looked perfect. For a CV that is not a cosmetic problem: an applicant tracking + * system parses the text layer, so the keyword an applicant was matched on was not in the + * file, and neither the author nor the renderer had any way to see it.
+ * + *Every preset gets the same document, and the probe words are ordinary English words + * carrying the pairs that broke. Asking for them back is the only check that sees this — + * a rendered page cannot show it, and a layout snapshot holds what the engine meant to + * draw rather than what the file says it drew.
+ */ +class CvPresetTextLayerTest { + + /** + * Words carrying the letter pairs the bundled families ligate, planted in the profile + * paragraph — the one block every preset renders as ordinary prose, rather than + * upper-casing or letter-spacing it into something no extractor would return whole. + */ + private static final ListPDFBox runs a font's {@code GSUB} substitutions itself: the moment a + * {@link PDType0Font} that carries them is made current on a content stream, every + * string shown through it is rewritten from characters into glyph identifiers, and + * {@code ti}, {@code tf} and {@code ft} become one glyph each in most of the + * bundled families. Nothing then records what that glyph meant. The + * {@code ToUnicode} map a subset font carries is built by reading the font's + * character map backwards, and a ligature is reachable from no character at all — + * so the entry is simply absent, and a reader extracting the page loses both + * letters: {@code Platform} comes back as {@code Pla orm}. It is invisible on + * screen and fatal everywhere the text layer is what is actually read — search, + * copy-and-paste, a screen reader, an applicant tracking system parsing a CV.
+ * + *The substitution was never the engine's decision. Layout measures a string + * with {@code getStringWidth}, which knows nothing of ligatures, so a line drawn + * with them is a little narrower than the box measured for it; the DOCX and PPTX + * backends do not substitute either. Turning it off is what makes the PDF draw the + * text this engine actually laid out, and it is the whole fix: with no + * substitution the glyphs come from the character map, and the map back to + * Unicode is complete by construction.
+ * + *What is silenced for a Latin face is the whole of its {@code GSUB}, not the + * ligature features alone: PDFBox applies {@code ccmp}, {@code liga} and + * {@code clig} together and offers no way to keep one without the others. In the + * bundled families that costs nothing — their Latin {@code ccmp} leaves both + * decomposed combining sequences and precomposed letters drawn exactly as before, + * and only the ligature pairs change.
+ * + *Only Latin is silenced. PDFBox also shapes Devanagari, Bengali and Gujarati + * through the same mechanism, and there the substitutions are how the script + * renders rather than a flourish on top of it — a face whose active script is one + * of those keeps them.
+ * + * @param ttf a freshly parsed face + * @param sourceDescription the face's identity, for logging + */ + private static void keepLatinTextSpelled(TrueTypeFont ttf, String sourceDescription) { + try { + GsubData substitutions = ttf.getGsubData(); + if (substitutions != GsubData.NO_DATA_FOUND + && DECORATIVE_SUBSTITUTION_SCRIPT.equals(substitutions.getActiveScriptName())) { + ttf.setEnableGsub(false); + } + } catch (IOException e) { + // A face whose substitution table cannot be read is still a usable face: + // PDFBox will reach the same conclusion and substitute nothing. + log.debug("Unable to read the substitution table of {}", sourceDescription, e); + } + } } diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLatinLigatureTextLayerTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLatinLigatureTextLayerTest.java new file mode 100644 index 000000000..18bbc9919 --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLatinLigatureTextLayerTest.java @@ -0,0 +1,137 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.engine.components.content.text.TextDecoration; +import com.demcha.compose.font.DefaultFonts; +import com.demcha.compose.font.FontFamilyDefinition; +import com.demcha.compose.font.FontName; + +import org.apache.fontbox.ttf.model.GsubData; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * What a bundled Latin family says its own text is, to a reader that is not this engine. + * + *PDFBox runs a font's {@code GSUB} substitutions on the engine's behalf, and most of + * the bundled families define ligatures over the commonest letter pairs in English — + * {@code ti}, {@code tf}, {@code ft}. Each pair drawn that way becomes one glyph, and the + * {@code ToUnicode} map that says what a glyph means is built by reading the font's + * character map backwards, where a ligature is reachable from no character at all. The + * entry is therefore absent and both letters are lost on extraction: {@code Platform} + * comes back as {@code Pla orm}. Nothing about the rendered page shows it.
+ * + *Which makes extraction the only place it can be caught, and the reason this asks for + * the whole sentence back rather than for the presence of a word: a test that looked for + * {@code Pla} would pass on a broken file. The probe is built from the pairs that break — + * a family that stops substituting for some other reason still has to return them.
+ */ +class PdfLatinLigatureTextLayerTest { + + /** + * A sentence of ordinary English words, chosen so that every one of them carries a + * pair the bundled families ligate. Kept to one line at the size rendered, because a + * wrap would put a line break into the extracted text and say nothing about glyphs. + */ + private static final String PROBE = + "Platform certification retired after fifteen notification drafts"; + + /** The letter pairs the bundled families draw as one glyph. */ + private static final List