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 List PROBES = + List.of("Platform", "certification", "retired", "drafts", "fifteen"); + + @ParameterizedTest(name = "{0}") + @MethodSource("presets") + void theProfileTextIsInTheFileAsItWasWritten( + String slug, double margin, Supplier> factory) + throws Exception { + + String extracted = renderText(factory.get(), margin); + + assertThat(PROBES) + .allSatisfy(probe -> assertThat(extracted) + .describedAs("\"%s\" was drawn on the page but is not in the " + + "text layer, so a search, a copy-and-paste and an " + + "applicant tracking system all miss it", probe) + .contains(probe)); + } + + private static String renderText(DocumentTemplate template, double margin) + throws Exception { + byte[] pdf; + float m = (float) margin; + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(m, m, m, m) + .create()) { + template.compose(session, probeDocument()); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + // Collapse the layout's own line breaks: a word split across two lines is a + // wrapping decision, not a text-layer defect. + return new PDFTextStripper().getText(document).replaceAll("\\s+", " "); + } + } + + /** Short enough that no preset's profile block wraps it off the page. */ + private static CvDocument probeDocument() { + return CvDocument.builder() + .identity(CvIdentity.builder() + .name("Jane", "Doe") + .jobTitle("Backend Engineer") + .contact("+44 0", "j@d.com", "London") + .build()) + .sections( + new ParagraphSection("Professional Summary", + "Platform work, one certification, retired drafts, " + + "fifteen years."), + SkillsSection.builder("Technical Skills") + .group("Languages", "Java", "Kotlin") + .build(), + EntriesSection.builder("Professional Experience") + .entry("Senior Engineer", "Acme Rendering", + "2021-2024", "Built rendering services.") + .build()) + .build(); + } + + private static Stream presets() { + return Stream.of( + preset("boxed_sections", BoxedSections.RECOMMENDED_MARGIN, BoxedSections::create), + preset("minimal_underlined", MinimalUnderlined.RECOMMENDED_MARGIN, MinimalUnderlined::create), + preset("modern_professional", ModernProfessional.RECOMMENDED_MARGIN, ModernProfessional::create), + preset("nordic_clean", NordicClean.RECOMMENDED_MARGIN, NordicClean::create), + preset("centered_headline", CenteredHeadline.RECOMMENDED_MARGIN, CenteredHeadline::create), + preset("blue_banner", BlueBanner.RECOMMENDED_MARGIN, BlueBanner::create), + preset("editorial_blue", EditorialBlue.RECOMMENDED_MARGIN, EditorialBlue::create), + preset("classic_serif", ClassicSerif.RECOMMENDED_MARGIN, ClassicSerif::create), + preset("compact_mono", CompactMono.RECOMMENDED_MARGIN, CompactMono::create), + preset("executive", Executive.RECOMMENDED_MARGIN, Executive::create), + preset("panel", Panel.RECOMMENDED_MARGIN, Panel::create), + preset("timeline_minimal", TimelineMinimal.RECOMMENDED_MARGIN, TimelineMinimal::create), + preset("engineering_resume", EngineeringResume.RECOMMENDED_MARGIN, EngineeringResume::create), + preset("monogram_sidebar", MonogramSidebar.RECOMMENDED_MARGIN, MonogramSidebar::create), + preset("sidebar_portrait", SidebarPortrait.RECOMMENDED_MARGIN, SidebarPortrait::create), + preset("mint_editorial", MintEditorial.RECOMMENDED_MARGIN, MintEditorial::create)); + } + + private static Arguments preset(String slug, double margin, + Supplier> factory) { + return Arguments.of(slug, margin, factory); + } +} diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png index 61189b5b5..08389bb66 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/blue_banner-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png index 7c5557343..0c0844a58 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/boxed_sections-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png index a9a7bfb62..45060aad9 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/centered_headline-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png index ad38a57be..244697b94 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/classic_serif-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/compact_mono-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/compact_mono-page-0.png index 6e34b9284..546bebad1 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/compact_mono-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/compact_mono-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/editorial_blue-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/editorial_blue-page-0.png index b331a7da9..13b72568e 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/editorial_blue-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/editorial_blue-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/engineering_resume-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/engineering_resume-page-0.png index 808897eed..3ff26b1c9 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/engineering_resume-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/engineering_resume-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/executive-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/executive-page-0.png index bd13449ae..f67ec1719 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/executive-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/executive-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/modern_professional-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/modern_professional-page-0.png index 333545d93..7c1516f17 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/modern_professional-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/modern_professional-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png index ff0ec8e5d..0f05c9be3 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/monogram_sidebar-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/nordic_clean-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/nordic_clean-page-0.png index 40ca8b4df..f69e4c495 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/nordic_clean-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/nordic_clean-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/panel-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/panel-page-0.png index 5ed6a4d19..da26f16a0 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/panel-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/panel-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png index 12e3502d0..538a8a6c8 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/sidebar_portrait-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png index 97c5765c5..1bc53bcba 100644 Binary files a/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png and b/qa/src/test/resources/visual-baselines/coverletter-v2-layered/timeline_minimal-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png index 13f82984a..5944e26d7 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png index be4ceda8b..183b4bdcc 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/blue_banner-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png index 4af9eb4d3..f2e1028dc 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png index d73e12322..ed1c956e9 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/boxed_sections-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png index e5c183086..dd20d39db 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png index 9b72f59c4..d440b06e6 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/centered_headline-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png index 62a5b6c20..d65937224 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png index 15e823076..321e39e00 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/classic_serif-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/compact_mono-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/compact_mono-page-0.png index 5de448da2..fef1083bd 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/compact_mono-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/compact_mono-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png index 90dfa8082..f81ed7a8e 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/editorial_blue-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/engineering_resume-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/engineering_resume-page-0.png index eecfc9661..8965d8855 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/engineering_resume-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/engineering_resume-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-0.png index 1aa5f6a90..98f3ad4d8 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-1.png index ef3a0bb79..17e18087d 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/executive-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png index 80e6f5b03..0c2c81adb 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png index e266c334f..256431aa5 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/minimal_underlined-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png index 032b03952..c210db266 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png index d48cbc50e..4913c387e 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/modern_professional-page-1.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png index ac1306204..6cc28e781 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/monogram_sidebar-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/nordic_clean-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/nordic_clean-page-0.png index 43329f691..2c0440b0b 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/nordic_clean-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/nordic_clean-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png index a2af93a3f..7aabda0a4 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/panel-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png index f6f426f92..99340aeb6 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/sidebar_portrait-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png index d010bdaaf..dd6224f16 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-0.png differ diff --git a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-1.png b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-1.png index 17e0515f8..b0a152504 100644 Binary files a/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-1.png and b/qa/src/test/resources/visual-baselines/cv-v2-layered/timeline_minimal-page-1.png differ diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLoader.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLoader.java index 4921c3a38..404d774cb 100644 --- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLoader.java +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFontLoader.java @@ -3,6 +3,7 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fontbox.ttf.TTFParser; import org.apache.fontbox.ttf.TrueTypeFont; +import org.apache.fontbox.ttf.model.GsubData; import org.apache.pdfbox.io.RandomAccessReadBuffer; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.font.PDType0Font; @@ -27,6 +28,13 @@ final class PdfFontLoader { private static final Map RAW_FONT_CACHE = new ConcurrentHashMap<>(); + /** + * The one script whose {@code GSUB} substitutions are decoration rather than + * spelling. Everything else — the Indic scripts PDFBox shapes, above all — needs + * its substitutions to render at all, and keeps them. + */ + private static final String DECORATIVE_SUBSTITUTION_SCRIPT = "latn"; + /** * Per-thread access-order LRU. ThreadLocal already confines the map to one * thread, so we do not need an external synchronization wrapper. The cap @@ -142,10 +150,65 @@ private static TrueTypeFont resolveTrueTypeFont(InputStream streamToClose, Strin return THREAD_LOCAL_TTF_CACHE.get().computeIfAbsent(sourceDescription, key -> { try { RandomAccessReadBuffer buffer = new RandomAccessReadBuffer(fontBytes); - return new TTFParser().parse(buffer); + TrueTypeFont parsed = new TTFParser().parse(buffer); + keepLatinTextSpelled(parsed, sourceDescription); + return parsed; } catch (IOException e) { throw new RuntimeException(e); } }); } + + /** + * Stops a Latin face from being drawn as ligatures, so the page says the letters + * the author wrote. + * + *

PDFBox 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 LIGATED_PAIRS = List.of("ti", "tf", "ft", "fi"); + + /** The binary families — the standard-14 are Type 1 and substitute nothing. */ + private static final List FAMILIES = DefaultFonts.googleFamilies().stream() + .map(FontFamilyDefinition::name) + .toList(); + + @Test + void theProbeCarriesEveryPairThatGoesMissing() { + // The rest of this class is only as strong as the sentence it asks about, and a + // sentence quietly edited into one without ligature pairs would leave every + // assertion below passing against a file that still loses text. + assertThat(LIGATED_PAIRS) + .allSatisfy(pair -> assertThat(PROBE) + .describedAs("the probe has to contain %s to say anything about it", pair) + .contains(pair)); + } + + @Test + void everyBundledFamilyDrawsTextThatComesBackOutAsWritten() throws Exception { + List mangled = new ArrayList<>(); + for (FontName family : FAMILIES) { + String extracted = extractedText(PROBE, family); + if (!PROBE.equals(extracted)) { + mangled.add(family + ": \"" + extracted + "\""); + } + } + + assertThat(mangled) + .describedAs("a page drawn with these families lost letters on extraction, " + + "which is what a search box, a copy-and-paste and an applicant " + + "tracking system all read") + .isEmpty(); + } + + @Test + void aLatinFaceIsHandedToPdfboxWithNothingToSubstitute() { + // The mechanism, not its symptom: PDFBox substitutes whenever the face it is given + // reports substitutions for the Latin script, so the fix is that the face reports + // none. Held separately because a family could stop losing letters for an + // unrelated reason — a ligature that happened to gain a code point of its own — + // and the page would still be drawn as shapes the engine never measured. + assertThat(substitutionsOf(FontName.LATO)) + .describedAs("a Latin face still carrying substitutions will be drawn " + + "as ligatures again the moment it is made current") + .isSameAs(GsubData.NO_DATA_FOUND); + } + + @Test + void aFaceThatNeedsItsSubstitutionsToRenderKeepsThem() { + // Poppins carries Devanagari, and PDFBox shapes that script through the same + // mechanism. There the substitutions are how the script renders rather than a + // flourish on top of it, so silencing Latin must not reach them. + assertThat(substitutionsOf(FontName.POPPINS)) + .describedAs("a face whose script is shaped by substitution cannot lose it") + .isNotSameAs(GsubData.NO_DATA_FOUND); + } + + /** What the face a family resolves to reports it would substitute. */ + private static GsubData substitutionsOf(FontName family) { + PDFont face = FontCoverageProbe.face(family, TextDecoration.DEFAULT); + assertThat(face).isInstanceOf(PDType0Font.class); + return ((PDType0Font) face).getGsubData(); + } + + /** The text a reader gets back from a page drawn in one family. */ + private static String extractedText(String text, FontName family) throws IOException { + try (PDDocument document = Loader.loadPDF(render(text, family))) { + return new PDFTextStripper().getText(document).trim(); + } + } + + private static byte[] render(String text, FontName family) { + try (DocumentSession document = GraphCompose.document() + .pageSize(600, 100) + .margin(DocumentInsets.of(20)) + .create()) { + + document.pageFlow(page -> page.addParagraph(p -> p.text(text) + .textStyle(DocumentTextStyle.builder().fontName(family).size(11).build()))); + + return document.toPdfBytes(); + } + } +}