Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified assets/readme/examples/cover-letter-blue-banner-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-boxed-sections-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-centered-headline-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-classic-serif-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-compact-mono-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-engineering-resume-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-executive-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-nordic-clean-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-panel-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cover-letter-timeline-minimal-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-blue-banner-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-boxed-sections-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-centered-headline-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-classic-serif-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-compact-mono-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-engineering-resume-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-executive-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-minimal-underlined-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-monogram-sidebar-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-nordic-clean-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-panel-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-sidebar-portrait-v2.pdf
Binary file not shown.
Binary file modified assets/readme/examples/cv-timeline-minimal-v2.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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<String> PROBES =
List.of("Platform", "certification", "retired", "drafts", "fifteen");

@ParameterizedTest(name = "{0}")
@MethodSource("presets")
void theProfileTextIsInTheFileAsItWasWritten(
String slug, double margin, Supplier<DocumentTemplate<CvDocument>> 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<CvDocument> 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<Arguments> 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<DocumentTemplate<CvDocument>> factory) {
return Arguments.of(slug, margin, factory);
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +28,13 @@ final class PdfFontLoader {

private static final Map<String, byte[]> 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
Expand Down Expand Up @@ -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.
*
* <p>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 <em>meant</em>. 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.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*
* <p>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.</p>
*
* @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);
}
}
}
Loading
Loading