diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b69abcd7..49153822d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,36 @@ follow semantic versioning; release dates are ISO 8601.
### 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.
+
- **A composite node inside a composed table cell renders its children.**
`DocumentTableCell.node(...)` holding a `SectionNode`, `ContainerNode`,
`RowNode` or `LayerStackNode` measured the child, reserved its full height,
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();
+ }
+ }
+}