operands) {
+ for (COSBase operand : operands) {
+ if (operand instanceof COSString string) {
+ return string.getBytes();
+ }
+ if (operand instanceof COSArray array) {
+ for (COSBase element : array) {
+ if (element instanceof COSString string) {
+ return string.getBytes();
+ }
+ }
+ }
+ }
+ return new byte[0];
+ }
+
+ private record TextState(COSName font, float size, float characterSpacing) {
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
index 04430ef32..284915071 100644
--- a/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/api/TrackingFixedLayoutParityTest.java
@@ -40,8 +40,8 @@
* hundredths of a point, so if the engine measured a finer value than that, the
* width it reserved is a width the deck will never draw. The engine therefore
* measures on the grid the file can express, and this holds the three numbers
- * together: what the engine measured, what the PDF's {@code Tc} says, and what
- * the deck's {@code spc} says.
+ * together: what the engine measured, what the PDF declares — its glyph widths
+ * and its {@code Tc} together — and what the deck's {@code spc} says.
*
* This is not a claim that a PDF and a deck rasterise identically.
* Measured by exporting both through PowerPoint, an untracked
@@ -55,7 +55,6 @@ class TrackingFixedLayoutParityTest {
/** Long enough that a per-code-point residue would be unmistakable. */
private static final String LONG = "ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMN";
private static final Pattern SPC = Pattern.compile("spc=\"(-?[0-9]+)\"");
- private static final Pattern TC = Pattern.compile("(-?[0-9]*\\.?[0-9]+)\\s+Tc");
private static final Pattern W_SPACING = Pattern.compile("spacing[^/>]*val=\"(-?[0-9]+)\"");
private static DocumentTextStyle style(DocumentLetterSpacing spacing) {
@@ -71,11 +70,13 @@ void whatThePdfDeclaresIsWhatTheDeckDeclares(double points) throws Exception {
DocumentTextStyle style = style(DocumentLetterSpacing.points(points));
int spc = spcOf(render(style, s -> s.render(new PptxFixedLayoutBackend())));
- double tc = tcOf(render(style, s -> s.render(new PdfFixedLayoutBackend())));
+ double pdf = PdfDeclaredTracking.ofFirstRun(render(style, s -> s.render(new PdfFixedLayoutBackend())));
// Both files state the same distance. Unquantised, the PDF said
- // 0.3333333333333333 where the deck said 0.33.
- assertThat(spc / 100.0).as("PPTX spc=%d against PDF Tc=%s", spc, tc).isEqualTo(tc);
+ // 0.3333333333333333 where the deck said 0.33. The PDF's figure is the sum
+ // of what its widths and its Tc carry, so it holds ordinary float residue;
+ // the gap being ruled out is thousands of times larger.
+ assertThat(pdf).as("PPTX spc=%d against PDF %s pt", spc, pdf).isCloseTo(spc / 100.0, within(1e-6));
}
@ParameterizedTest(name = "[{index}] {0} pt")
@@ -333,14 +334,6 @@ private static int wSpacingOf(byte[] docx) throws Exception {
throw new AssertionError("no w:spacing written");
}
- private static double tcOf(byte[] pdf) throws Exception {
- Matcher matcher = TC.matcher(contentStream(pdf));
- if (!matcher.find()) {
- throw new AssertionError("no Tc written");
- }
- return Double.parseDouble(matcher.group(1));
- }
-
private static List pptxRunXml(byte[] pptx) throws Exception {
List xml = new ArrayList<>();
try (XMLSlideShow show = new XMLSlideShow(new ByteArrayInputStream(pptx))) {
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
index 84f787150..144df85ac 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java
@@ -321,7 +321,7 @@ public void write(LayoutGraph graph, FixedLayoutRenderContext context) throws Ex
private int renderToOutput(LayoutGraph graph, FixedLayoutRenderContext context, OutputStream output) throws Exception {
Rendered rendered = buildDocument(graph, context);
try (PDDocument document = rendered.document()) {
- PdfShapedGlyphUnicode.save(document, rendered.reorderedText(),
+ PdfSubsetAwareSave.save(document, rendered.reorderedText(), rendered.letterSpacedFonts(),
rendered.deferredProtection(), output);
return document.getNumberOfPages();
}
@@ -358,7 +358,7 @@ public List renderToImages(LayoutGraph graph,
Rendered rendered = buildDocument(graph, context);
try (PDDocument document = rendered.document();
ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
- PdfShapedGlyphUnicode.save(document, rendered.reorderedText(),
+ PdfSubsetAwareSave.save(document, rendered.reorderedText(), rendered.letterSpacedFonts(),
rendered.deferredProtection(), buffer);
documentBytes = buffer.toByteArray();
}
@@ -385,12 +385,14 @@ public List renderToImages(LayoutGraph graph,
* A built document, and whether drawing it put any run in an order other than the
* one it was written in.
*
- * The second half is what {@link PdfShapedGlyphUnicode#save} needs and cannot
- * work out for itself: it is a fact about the render, and by the time the document
- * is saved the render is over.
+ * The rest is what {@link PdfSubsetAwareSave#save} needs and cannot work out for
+ * itself: facts about the render, and by the time the document is saved the render is
+ * over. {@code letterSpacedFonts} holds the letter-spaced font resources the render drew
+ * with, which the save completes once the fonts are subset.
*/
private record Rendered(PDDocument document,
boolean reorderedText,
+ PdfTrackedFontResources letterSpacedFonts,
PdfProtectionOptions deferredProtection) {
}
@@ -404,12 +406,14 @@ private record Rendered(PDDocument document,
private Rendered buildDocument(LayoutGraph graph, FixedLayoutRenderContext context) throws Exception {
PDDocument document = new PDDocument();
boolean reorderedText = false;
+ PdfTrackedFontResources letterSpacedFonts = new PdfTrackedFontResources(document);
try {
FontLibrary fonts = PdfFontLibraryFactory.library(document, context.customFontFamilies());
List pages = createPages(document, graph);
try (PdfRenderSession session = new PdfRenderSession(document, pages)) {
- PdfRenderEnvironment environment = new PdfRenderEnvironment(document, fonts, session);
+ PdfRenderEnvironment environment =
+ new PdfRenderEnvironment(document, fonts, session, letterSpacedFonts);
renderGraph(graph, environment);
reorderedText = environment.reorderedText();
PdfBookmarkOutlineWriter.apply(document, environment.bookmarkRecords());
@@ -421,12 +425,13 @@ private Rendered buildDocument(LayoutGraph graph, FixedLayoutRenderContext conte
environment.deferredInternalLinks());
}
- // Protection is deferred for a reordered document: encrypting happens while
- // saving and writes ciphertext back into the streams it encrypted, which
- // would leave the glyph maps unreadable to the correction that runs between
- // the saves. PdfShapedGlyphUnicode.save applies it after correcting.
+ // Protection is deferred when the save has to finish dictionaries after the font
+ // subsets exist: encrypting happens while saving and writes ciphertext back into the
+ // streams it encrypted, so a protected first save would be encrypted again by the
+ // second. PdfSubsetAwareSave applies it once, before the final save.
+ boolean savesTwice = reorderedText || letterSpacedFonts.hasResources();
PdfProtectionOptions protectNow =
- reorderedText && protectionOptions != null ? null : protectionOptions;
+ savesTwice && protectionOptions != null ? null : protectionOptions;
PdfDocumentPostProcessor.apply(
document,
context.canvas(),
@@ -440,8 +445,8 @@ private Rendered buildDocument(LayoutGraph graph, FixedLayoutRenderContext conte
PdfDeterminismWriter.apply(document, deterministicTimestamp);
}
- return new Rendered(document, reorderedText,
- reorderedText ? protectionOptions : null);
+ return new Rendered(document, reorderedText, letterSpacedFonts,
+ savesTwice ? protectionOptions : null);
} catch (Exception ex) {
document.close();
throw ex;
@@ -514,7 +519,7 @@ public void writeSections(List sections, OutputStream output) throw
Objects.requireNonNull(output, "output");
Rendered rendered = buildSectionsDocument(sections);
try (PDDocument document = rendered.document()) {
- PdfShapedGlyphUnicode.save(document, rendered.reorderedText(),
+ PdfSubsetAwareSave.save(document, rendered.reorderedText(), rendered.letterSpacedFonts(),
rendered.deferredProtection(), output);
}
}
@@ -531,6 +536,9 @@ private Rendered buildSectionsDocument(List sections) throws Except
List links = new ArrayList<>();
List bookmarks = new ArrayList<>();
boolean reorderedText = false;
+ // One registry for the combined document: a letter-spaced face drawn in two sections
+ // is one font resource, like the base font it shares its program with.
+ PdfTrackedFontResources letterSpacedFonts = new PdfTrackedFontResources(document);
int pageOffset = 0;
for (SectionUnit section : sections) {
LayoutGraph graph = section.graph();
@@ -539,8 +547,8 @@ private Rendered buildSectionsDocument(List sections) throws Except
try (PdfRenderSession renderSession = new PdfRenderSession(document, pages)) {
// Each section renders with its OWN backend's handlers/debug, but
// records navigation against the combined document via the page offset.
- PdfRenderEnvironment environment =
- new PdfRenderEnvironment(document, fonts, renderSession, pageOffset);
+ PdfRenderEnvironment environment = new PdfRenderEnvironment(
+ document, fonts, renderSession, pageOffset, letterSpacedFonts);
chrome.renderGraph(graph, environment);
reorderedText |= environment.reorderedText();
bookmarks.addAll(environment.bookmarkRecords());
@@ -566,12 +574,12 @@ private Rendered buildSectionsDocument(List sections) throws Except
// combined outline resolve in a single pass over the merged maps.
PdfBookmarkOutlineWriter.apply(document, List.copyOf(bookmarks));
PdfInternalLinkWriter.apply(document, Map.copyOf(anchors), List.copyOf(links));
- PdfProtectionOptions deferred =
- applyDocumentMetadataAndProtection(document, sections, reorderedText);
+ PdfProtectionOptions deferred = applyDocumentMetadataAndProtection(
+ document, sections, reorderedText || letterSpacedFonts.hasResources());
if (deterministicTimestamp != null) {
PdfDeterminismWriter.apply(document, deterministicTimestamp);
}
- return new Rendered(document, reorderedText, deferred);
+ return new Rendered(document, reorderedText, letterSpacedFonts, deferred);
} catch (Exception ex) {
document.close();
throw ex;
@@ -580,14 +588,14 @@ private Rendered buildSectionsDocument(List sections) throws Except
/**
* Applies document-global metadata and viewer preferences, and either applies or
- * defers protection: a reordered document must not be encrypted before its glyph
- * maps are corrected, so its protection is returned to the caller to apply between
- * the two saves instead.
+ * defers protection: a document whose save finishes dictionaries after the font subsets
+ * exist (reordered text, letter-spaced fonts) is saved twice and must not be encrypted
+ * before that, so its protection is returned to the caller to apply between the saves.
*
- * @return the protection to apply after glyph-map correction, or {@code null}
+ * @return the protection to apply before the final save, or {@code null}
*/
private static PdfProtectionOptions applyDocumentMetadataAndProtection(
- PDDocument document, List sections, boolean reorderedText)
+ PDDocument document, List sections, boolean savesTwice)
throws IOException {
// Metadata, protection, and viewer preferences are document-global in PDF;
// the first section that declares each wins for the combined document.
@@ -606,7 +614,7 @@ private static PdfProtectionOptions applyDocumentMetadataAndProtection(
viewerPreferences = chrome.viewerPreferencesOptions;
}
}
- PdfProtectionOptions deferred = reorderedText ? protection : null;
+ PdfProtectionOptions deferred = savesTwice ? protection : null;
PdfDocumentPostProcessor.applyDocumentMetadataAndProtection(
document, metadata, deferred == null ? protection : null);
PdfDocumentPostProcessor.applyViewerPreferences(document, viewerPreferences);
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java
index 1895bea2e..57d238731 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfRenderEnvironment.java
@@ -1,5 +1,6 @@
package com.demcha.compose.document.backend.fixed.pdf;
+import com.demcha.compose.document.api.Beta;
import com.demcha.compose.document.layout.PlacedFragment;
import com.demcha.compose.document.node.DocumentBookmarkOptions;
import com.demcha.compose.engine.components.content.ImageData;
@@ -41,10 +42,12 @@ public final class PdfRenderEnvironment {
private final List bookmarkRecords = new ArrayList<>();
private final Map anchorDestinations = new LinkedHashMap<>();
private final List deferredInternalLinks = new ArrayList<>();
+ private final PdfTrackedFontResources letterSpacedFonts;
private boolean reorderedText;
- PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session) {
- this(document, fonts, session, 0);
+ PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session,
+ PdfTrackedFontResources letterSpacedFonts) {
+ this(document, fonts, session, 0, letterSpacedFonts);
}
/**
@@ -61,12 +64,88 @@ public final class PdfRenderEnvironment {
* @param fonts shared font library
* @param session page-scoped drawing surface for this section
* @param pageIndexOffset number of pages already placed before this section
+ * @param letterSpacedFonts the document's letter-spaced font resources, shared by all its sections
*/
- PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session, int pageIndexOffset) {
+ PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session, int pageIndexOffset,
+ PdfTrackedFontResources letterSpacedFonts) {
this.document = document;
this.fonts = fonts;
this.session = session;
this.pageIndexOffset = pageIndexOffset;
+ this.letterSpacedFonts = letterSpacedFonts;
+ }
+
+ /**
+ * Returns the face a letter-spaced run should be drawn with so that its spacing lives in the
+ * glyph widths instead of between the glyph boxes.
+ *
+ * A run drawn with {@code Tc} is placed correctly, but readers that ignore
+ * {@code ActualText} find word breaks by the gaps between glyph boxes, and tracking is exactly
+ * such a gap: pdf.js and pdfminer read a heading tracked at 0.18em as single letters. The
+ * returned face shares the embedded program of {@code font} and states every width raised by
+ * the tracking, so the glyphs land where {@code Tc} would put them and no gap is left. Draw the
+ * run with {@link LetterSpacedFont#font()}, and with {@link LetterSpacedFont#characterSpacing()}
+ * as its {@code Tc}.
+ *
+ * Only positive tracking of at least half a thousandth of an em and at most a hundred ems,
+ * drawn with an embedded, subset, horizontal Type 0 font, qualifies, and only for non-empty
+ * text the font's GSUB substitutions, if it keeps any, leave unchanged. For anything else
+ * — and when the resource cannot be registered — this returns {@code null}, and the
+ * run keeps drawing with {@code font} and a {@code Tc} equal to its letter spacing, exactly as
+ * before.
+ *
+ * The returned face belongs to the document being rendered and to the size and spacing
+ * asked for; ask again for another size. {@code font} must belong to the same document.
+ * Asking is not free: once any face has been returned, the document is saved twice, because
+ * the resource can only be completed after its base font has been subset. Text a handler
+ * draws in visual order, such as reordered right-to-left text, should not be passed; the
+ * built-in handlers keep {@code Tc} for it. A face this method returned may be passed back,
+ * and stands for its base font.
+ *
+ * Experimental. The glyph positions and text layer this produces are settled; the
+ * shape of the call — a nullable result, a face bound to one size — may still change
+ * in a minor release.
+ *
+ * @param font the face the run would otherwise be drawn with
+ * @param fontSize font size in points
+ * @param letterSpacing the run's resolved letter spacing in points
+ * @param text the text the returned face would draw, after sanitizing: one run, or
+ * several lines joined on line breaks when one decision covers them
+ * @return the face and character spacing to draw the run with, or {@code null} to keep
+ * drawing with {@code font} and {@code Tc}
+ * @since 2.4.0
+ */
+ @Beta
+ public LetterSpacedFont letterSpacedFont(org.apache.pdfbox.pdmodel.font.PDFont font,
+ double fontSize,
+ double letterSpacing,
+ String text) {
+ return letterSpacedFonts.resolve(font, fontSize, letterSpacing, text);
+ }
+
+ /**
+ * A face whose glyph widths carry a run's letter spacing, and the character spacing still owed.
+ *
+ * Experimental, like {@link #letterSpacedFont}: its shape may still change in a minor
+ * release.
+ *
+ * @param font the font resource to select for the run
+ * @param characterSpacing the {@code Tc} to draw the run with: the part of the letter spacing a
+ * whole thousandth of an em cannot state, at most half a thousandth of
+ * the font size, or zero
+ * @since 2.4.0
+ */
+ @Beta
+ public record LetterSpacedFont(org.apache.pdfbox.pdmodel.font.PDFont font, float characterSpacing) {
+ }
+
+ /**
+ * The document's letter-spaced font resources, which the backend completes when it saves.
+ *
+ * @return the shared registry
+ */
+ PdfTrackedFontResources letterSpacedFonts() {
+ return letterSpacedFonts;
}
/**
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfShapedGlyphUnicode.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfShapedGlyphUnicode.java
index 5c52533ec..f6bcab9cb 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfShapedGlyphUnicode.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfShapedGlyphUnicode.java
@@ -1,6 +1,5 @@
package com.demcha.compose.document.backend.fixed.pdf;
-import com.demcha.compose.document.backend.fixed.pdf.options.PdfProtectionOptions;
import com.demcha.compose.engine.text.bidi.ArabicShaper;
import org.apache.pdfbox.cos.COSBase;
@@ -44,9 +43,8 @@
* are shapes standing in for something else.
*
* The rewrite has to happen after the map exists, and the map is built during the save.
- * Hence the shape of {@link #save}: the first save is what builds the subsets, and a second
- * one is spent only when there is something to correct. A document that never reordered a
- * line skips all of it and is saved exactly as before.
+ * {@link PdfSubsetAwareSave} therefore saves once to build the subsets and then saves for real;
+ * this correction runs between the two only when a line was reordered.
*/
final class PdfShapedGlyphUnicode {
@@ -67,60 +65,6 @@ final class PdfShapedGlyphUnicode {
private PdfShapedGlyphUnicode() {
}
- /**
- * Saves {@code document}, correcting what its glyphs claim to mean.
- *
- * {@code mayCarryShapedText} is the caller's answer to whether the render reordered
- * anything, which is the only way text reaches the page in a shaped form. When it did
- * not, this is {@link PDDocument#save(OutputStream)} and nothing else — no second
- * pass, no behaviour to regress.
- *
- * When it did, the map this needs to read is built during a save, so the
- * document is saved twice: once into a null sink, which builds the font subsets and
- * their glyph maps and clears the subsetting queue, and once for real after the maps
- * are corrected. Both saves stream; nothing is buffered.
- *
- * {@code deferredProtection} is how the correction survives encryption. Encrypting
- * is part of saving, and it writes the ciphertext back into the streams it encrypted —
- * so a map built by a protected first save would be unreadable, and the correction
- * would silently find nothing. The caller therefore builds a protected, reordered
- * document without its protection and hands it here; the policy is applied
- * between the two saves, so the first save writes readable maps and the second
- * encrypts the corrected document exactly once.
- *
- * @param document the rendered document
- * @param mayCarryShapedText whether the render drew any reordered text
- * @param deferredProtection protection to apply between the saves, or {@code null}
- * when the document is unprotected or was already protected
- * by the build (which the caller does whenever no text was
- * reordered)
- * @param output where to write
- * @throws IOException if saving fails
- */
- static void save(PDDocument document,
- boolean mayCarryShapedText,
- PdfProtectionOptions deferredProtection,
- OutputStream output) throws IOException {
-
- if (!mayCarryShapedText) {
- document.save(output);
- return;
- }
-
- // The first save is what builds the font subsets and, with them, the glyph maps
- // this needs to read. It also clears the document's subsetting queue, so the
- // second save writes the corrected maps rather than rebuilding them. Its bytes
- // are not kept: the second save produces the same document, corrected, and
- // streaming it directly to the caller is what keeps memory flat for a document
- // of any size.
- document.save(OutputStream.nullOutputStream());
- restoreBaseLetters(document);
- if (deferredProtection != null) {
- PdfDocumentPostProcessor.applyProtection(document, deferredProtection);
- }
- document.save(output);
- }
-
/**
* Rewrites every glyph map in the document that names a shaped form.
*
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfSubsetAwareSave.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfSubsetAwareSave.java
new file mode 100644
index 000000000..47aa3aa8a
--- /dev/null
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfSubsetAwareSave.java
@@ -0,0 +1,77 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import com.demcha.compose.document.backend.fixed.pdf.options.PdfProtectionOptions;
+import org.apache.pdfbox.pdmodel.PDDocument;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+/**
+ * Saves a document whose dictionaries can only be finished once PDFBox has built its font subsets.
+ *
+ * PDFBox subsets embedded fonts inside {@code save()}, and offers no hook between subsetting
+ * and writing. Two things this backend writes depend on what the subsetter produces: the glyph
+ * maps of Arabic text drawn in shaped forms, which {@link PdfShapedGlyphUnicode} corrects, and
+ * the letter-spaced font resources of {@link PdfTrackedFontResources}, which share a base font's
+ * subset and cannot name it before it exists.
+ *
+ * A document that needs neither is saved exactly once, as it always was. One that needs
+ * either is saved twice: once into a null sink, which builds the subsets and clears PDFBox's
+ * subsetting queue, and once for real after the dictionaries are finished. Both saves stream;
+ * nothing is buffered, so memory stays flat for a document of any size.
+ *
+ * {@code deferredProtection} is how both survive encryption. Encrypting is part of saving and
+ * writes ciphertext back into the streams it encrypts, so a protected first save would leave glyph
+ * maps the correction cannot read, and would encrypt the document a second time on the real save.
+ * The caller therefore builds a document that saves twice without its protection and hands the
+ * policy here, to be applied once, before the final save. A deferred policy is applied on the
+ * single-save path as well, so a caller that defers protection cannot lose it.
+ */
+final class PdfSubsetAwareSave {
+
+ private PdfSubsetAwareSave() {
+ }
+
+ /**
+ * Saves {@code document}, finishing what depends on its font subsets.
+ *
+ * @param document the rendered document
+ * @param mayCarryShapedText whether the render drew any reordered text
+ * @param letterSpacedFonts the letter-spaced font resources the render created
+ * @param deferredProtection protection to apply before the final save, or {@code null} when
+ * the document is unprotected or was already protected by the build
+ * @param output where to write
+ * @throws IOException if saving fails
+ */
+ static void save(PDDocument document,
+ boolean mayCarryShapedText,
+ PdfTrackedFontResources letterSpacedFonts,
+ PdfProtectionOptions deferredProtection,
+ OutputStream output) throws IOException {
+ boolean finishesFonts = letterSpacedFonts.hasResources();
+ if (!mayCarryShapedText && !finishesFonts) {
+ if (deferredProtection != null) {
+ PdfDocumentPostProcessor.applyProtection(document, deferredProtection);
+ }
+ document.save(output);
+ return;
+ }
+
+ // The first save builds the font subsets and, with them, the glyph maps and names the
+ // corrections below need, and clears the subsetting queue so the second save writes the
+ // finished dictionaries instead of rebuilding them. Its bytes are not kept.
+ document.save(OutputStream.nullOutputStream());
+ if (mayCarryShapedText) {
+ PdfShapedGlyphUnicode.restoreBaseLetters(document);
+ }
+ // After the glyph maps: a letter-spaced resource shares its base font's ToUnicode by
+ // reference, so it has to pick up the corrected stream rather than the one it replaced.
+ if (finishesFonts) {
+ letterSpacedFonts.completeAfterSubsetting();
+ }
+ if (deferredProtection != null) {
+ PdfDocumentPostProcessor.applyProtection(document, deferredProtection);
+ }
+ document.save(output);
+ }
+}
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTrackedFontResources.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTrackedFontResources.java
new file mode 100644
index 000000000..a70fdc3e1
--- /dev/null
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTrackedFontResources.java
@@ -0,0 +1,238 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import org.apache.fontbox.ttf.CmapLookup;
+import org.apache.fontbox.ttf.gsub.GsubWorker;
+import org.apache.fontbox.ttf.gsub.GsubWorkerFactory;
+import org.apache.fontbox.ttf.model.GsubData;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.PDResources;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Font resources that carry letter spacing in their glyph widths instead of in {@code Tc}.
+ *
+ * A letter-spaced run drawn with {@code Tc} puts every glyph exactly where it belongs, but
+ * the space it adds sits between the glyph boxes a reader derives from the font's
+ * widths. Readers that ignore {@code ActualText} find word breaks by those gaps: pdf.js inserts
+ * a space once a gap passes about a tenth of the font size, and pdfplumber once it passes its
+ * default tolerance of three points. At 0.18em pdf.js therefore read every tracked heading as
+ * {@code P R O F E S S I O N A L}, and pdfplumber read the runs large enough to cross three
+ * points, such as a 21.5pt name, as {@code A R T E M}; a CV parser built on either could not find
+ * its sections or its candidate's name.
+ *
+ * So an eligible run is drawn with a second font resource over the same embedded
+ * font program: the base font's FontFile2, ToUnicode, CIDToGIDMap and FontDescriptor, shared by
+ * reference, with every {@code /W} entry and {@code /DW} raised by the tracking in thousandths of
+ * an em. Glyph origins do not move, but each glyph's box now reaches the next glyph, so there is
+ * no gap left to read as a word break. No font program is duplicated or modified.
+ *
+ * The widths are whole numbers. PDFium, the renderer in Chrome, reads CID widths as integers:
+ * a fractional width moved glyphs there and nowhere else. The part of the tracking a whole
+ * thousandth of an em cannot state stays in {@code Tc} — at most half a thousandth of the
+ * font size, far below any gap a reader would split on.
+ *
+ * The raised widths disagree with the advances in the font program on purpose. ISO 32000 only
+ * recommends that the two agree, so the file is valid; PDF/A and PDF/UA require it, so these
+ * resources must not be used for output that claims either. The backend offers no such output
+ * today.
+ *
+ * Eligible: positive tracking of at least half a thousandth of an em, drawn with a horizontal
+ * {@link PDType0Font} that is embedded as a subset, showing text its GSUB substitutions (if the
+ * face keeps any) leave unchanged. Everything else keeps {@code Tc}: Standard 14 faces (no
+ * embedded program to share), vertical text, negative tracking (tightening never opens a gap),
+ * and a run whose glyphs the substitutions would rewrite — the content stream shapes such a
+ * run through PDFBox's GSUB worker for the base font, which a second resource would bypass.
+ *
+ * One resource per base font and whole per-mille delta, per document, whatever size or page it
+ * is drawn at. Its dictionaries can only be completed after PDFBox has built the base font's
+ * subset, which happens inside {@code save()}; {@link PdfSubsetAwareSave} does that.
+ */
+final class PdfTrackedFontResources {
+
+ private static final Logger LOG = LoggerFactory.getLogger("com.demcha.compose.engine.render");
+
+ /** Beyond a hundred ems of tracking a run keeps {@code Tc}; no layout asks for it. */
+ private static final long MAX_EXTRA_PER_MILLE = 100_000;
+
+ /** Remainders below a millionth of a point are written as no {@code Tc} at all. */
+ private static final double NO_REMAINDER = 1.0e-6;
+
+ private final PDDocument document;
+ private final Map eligibility = new IdentityHashMap<>();
+ private final Map gsubWorkers = new IdentityHashMap<>();
+ private final Map> views = new IdentityHashMap<>();
+
+ PdfTrackedFontResources(PDDocument document) {
+ this.document = document;
+ }
+
+ /**
+ * Resolves the face and character spacing for letter-spaced text.
+ *
+ * @param font the face the text would otherwise be drawn with; a face this registry
+ * returned stands for its base font
+ * @param fontSize font size in points
+ * @param letterSpacing the letter spacing in points
+ * @param text the text the returned face would draw
+ * @return the face to draw with and the remaining character spacing, or {@code null} when
+ * the text keeps drawing with {@code font} and {@code Tc}
+ */
+ PdfRenderEnvironment.LetterSpacedFont resolve(PDFont font, double fontSize, double letterSpacing, String text) {
+ if (!(letterSpacing > 0.0) || !(fontSize > 0.0) || text == null || text.isEmpty()) {
+ return null;
+ }
+ // Asking again with a face handed out earlier must not stack its widths on top of the
+ // spacing it already carries.
+ PDFont face = font instanceof PdfTrackedFontView view ? view.base() : font;
+ long extraPerMille = Math.round(letterSpacing / fontSize * 1000.0);
+ if (extraPerMille < 1 || extraPerMille > MAX_EXTRA_PER_MILLE || !eligible(face)) {
+ return null;
+ }
+ PDType0Font base = (PDType0Font) face;
+ if (!substitutionsLeaveGlyphsAlone(base, text)) {
+ return null;
+ }
+ PdfTrackedFontView view = view(base, (int) extraPerMille);
+ if (view == null) {
+ return null;
+ }
+ double remainder = letterSpacing - extraPerMille * fontSize / 1000.0;
+ return new PdfRenderEnvironment.LetterSpacedFont(view,
+ Math.abs(remainder) < NO_REMAINDER ? 0f : (float) remainder);
+ }
+
+ /**
+ * Whether any run was drawn with a letter-spaced resource, which the save then has to complete.
+ *
+ * @return {@code true} once a resource exists
+ */
+ boolean hasResources() {
+ return !views.isEmpty();
+ }
+
+ /**
+ * Fills in every resource from its base font. Must run after the base fonts are subset and
+ * after any correction that replaces a base font's ToUnicode stream, because the resources
+ * share those objects by reference.
+ */
+ void completeAfterSubsetting() {
+ for (Map byDelta : views.values()) {
+ for (PdfTrackedFontView view : byDelta.values()) {
+ view.complete();
+ }
+ }
+ }
+
+ private boolean eligible(PDFont font) {
+ Boolean known = eligibility.get(font);
+ if (known != null) {
+ return known;
+ }
+ boolean eligible = font instanceof PDType0Font type0
+ && type0.willBeSubset()
+ && !type0.isVertical();
+ eligibility.put(font, eligible);
+ return eligible;
+ }
+
+ /**
+ * Whether the base font's GSUB substitutions leave the glyphs of {@code text} as its character
+ * map gives them. A letter-spaced resource encodes through the character map alone, while
+ * PDFBox shapes every word through the base font's GSUB worker when the base font is shown
+ * directly; only text the substitutions leave alone draws the same glyphs both ways.
+ *
+ * A face without GSUB data always qualifies, and so does every Latin face, whose GSUB
+ * {@code PdfFontLoader} switches off. What this decides is the face FontBox keeps
+ * substitutions for on behalf of another script: Poppins, whose GSUB serves Devanagari, draws
+ * a Latin heading unchanged and qualifies for it, and draws a Devanagari conjunct differently
+ * and does not.
+ */
+ private boolean substitutionsLeaveGlyphsAlone(PDType0Font font, String text) {
+ GsubData substitutions = font.getGsubData();
+ if (substitutions == GsubData.NO_DATA_FOUND) {
+ return true;
+ }
+ GsubWorker worker = gsubWorkers.computeIfAbsent(font,
+ face -> new GsubWorkerFactory().getGsubWorker(face.getCmapLookup(), substitutions));
+ int wordStart = 0;
+ for (int index = 0; index <= text.length(); index++) {
+ if (index == text.length() || separatesWords(text.charAt(index))) {
+ if (!leftAlone(font, worker, text.substring(wordStart, index))) {
+ return false;
+ }
+ wordStart = index + 1;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Mirrors how PDFBox shows one word with a face that keeps substitutions: a lone whitespace
+ * character is encoded directly, anything else is mapped to glyphs and shaped.
+ */
+ private static boolean leftAlone(PDType0Font font, GsubWorker worker, String word) {
+ if (word.isEmpty() || (word.length() == 1 && Character.isWhitespace(word.charAt(0)))) {
+ return true;
+ }
+ CmapLookup characterMap = font.getCmapLookup();
+ List glyphs = new ArrayList<>(word.length());
+ for (int codePoint : word.codePoints().toArray()) {
+ int glyph = characterMap.getGlyphId(codePoint);
+ if (glyph <= 0) {
+ return false;
+ }
+ glyphs.add(glyph);
+ }
+ return worker.applyTransforms(new ArrayList<>(glyphs)).equals(glyphs);
+ }
+
+ /** The characters PDFBox splits shown text on before shaping each word: what {@code \s} matches. */
+ private static boolean separatesWords(char character) {
+ return character == ' ' || character == '\t' || character == '\n'
+ || character == 0x0B || character == '\f' || character == '\r';
+ }
+
+ private PdfTrackedFontView view(PDType0Font base, int extraPerMille) {
+ Map byDelta = views.get(base);
+ if (byDelta == null) {
+ if (!registerForSubsetting(base)) {
+ eligibility.put(base, false);
+ return null;
+ }
+ byDelta = new HashMap<>();
+ views.put(base, byDelta);
+ }
+ return byDelta.computeIfAbsent(extraPerMille, delta -> new PdfTrackedFontView(base, delta));
+ }
+
+ /**
+ * PDFBox subsets only the fonts that were set on a content stream of the document, and a base
+ * drawn exclusively through its letter-spaced resources never is. Setting it once on a
+ * throwaway stream queues it for subsetting without writing anything to a page.
+ */
+ private boolean registerForSubsetting(PDType0Font base) {
+ PDAppearanceStream scratch = new PDAppearanceStream(document);
+ scratch.setResources(new PDResources());
+ try (PDPageContentStream stream =
+ new PDPageContentStream(document, scratch, OutputStream.nullOutputStream())) {
+ stream.setFont(base, 1f);
+ return true;
+ } catch (IOException e) {
+ LOG.warn("render.pdf.letterSpacing.fallback font={} reason=subsetRegistration", base.getName(), e);
+ return false;
+ }
+ }
+}
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTrackedFontView.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTrackedFontView.java
new file mode 100644
index 000000000..8379334cd
--- /dev/null
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTrackedFontView.java
@@ -0,0 +1,213 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import org.apache.fontbox.util.BoundingBox;
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSInteger;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSNumber;
+import org.apache.pdfbox.pdmodel.font.PDFont;
+import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.util.Matrix;
+import org.apache.pdfbox.util.Vector;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * A Type 0 font resource that draws with a base font's embedded program and states widths raised
+ * by a whole number of thousandths of an em.
+ *
+ * While a page is written it stands in for its base on the content stream: text is encoded by
+ * the base, so the glyph codes are the base's, and every code point shown is registered with the
+ * base, so the base's subset contains every glyph this resource draws. It owns no font program.
+ * Its dictionaries are filled in by {@link #complete()} once the base has been subset, because the
+ * subset's name tag, glyph map and CID-to-GID map only exist from then on.
+ *
+ * Only {@link PdfTrackedFontResources} creates and completes these.
+ */
+final class PdfTrackedFontView extends PDFont {
+
+ private final PDType0Font base;
+ private final int extraPerMille;
+ private final COSDictionary descendant = new COSDictionary();
+
+ PdfTrackedFontView(PDType0Font base, int extraPerMille) {
+ super(new COSDictionary());
+ this.base = base;
+ this.extraPerMille = extraPerMille;
+ COSArray descendants = new COSArray();
+ descendants.add(descendant);
+ COSDictionary type0 = getCOSObject();
+ type0.setItem(COSName.TYPE, COSName.FONT);
+ type0.setItem(COSName.SUBTYPE, COSName.TYPE0);
+ type0.setItem(COSName.DESCENDANT_FONTS, descendants);
+ }
+
+ /**
+ * The font whose program, glyph codes and subset this resource draws with.
+ *
+ * @return the base font
+ */
+ PDType0Font base() {
+ return base;
+ }
+
+ /**
+ * Shares the subset base's dictionaries by reference and writes the raised widths. Runs once,
+ * after PDFBox has subset the base font.
+ */
+ void complete() {
+ COSDictionary baseType0 = base.getCOSObject();
+ COSDictionary baseCid = (COSDictionary) baseType0.getCOSArray(COSName.DESCENDANT_FONTS).getObject(0);
+ COSDictionary type0 = getCOSObject();
+ type0.setItem(COSName.BASE_FONT, baseType0.getItem(COSName.BASE_FONT));
+ type0.setItem(COSName.ENCODING, baseType0.getItem(COSName.ENCODING));
+ type0.setItem(COSName.TO_UNICODE, baseType0.getItem(COSName.TO_UNICODE));
+ descendant.setItem(COSName.TYPE, COSName.FONT);
+ descendant.setItem(COSName.SUBTYPE, baseCid.getItem(COSName.SUBTYPE));
+ descendant.setItem(COSName.BASE_FONT, baseCid.getItem(COSName.BASE_FONT));
+ descendant.setItem(COSName.CIDSYSTEMINFO, baseCid.getItem(COSName.CIDSYSTEMINFO));
+ descendant.setItem(COSName.FONT_DESC, baseCid.getItem(COSName.FONT_DESC));
+ descendant.setItem(COSName.CID_TO_GID_MAP, baseCid.getItem(COSName.CID_TO_GID_MAP));
+ descendant.setInt(COSName.DW, baseCid.getInt(COSName.DW, 1000) + extraPerMille);
+ descendant.setItem(COSName.W, widened(baseCid.getCOSArray(COSName.W)));
+ }
+
+ /**
+ * The base {@code /W} array with every width raised by the delta, keeping both the
+ * {@code c [w1 ... wn]} and the {@code cFirst cLast w} forms. The widths stay integers:
+ * PDFium reads CID widths as integers, so a fractional width would move glyphs there only.
+ */
+ private COSArray widened(COSArray widths) {
+ COSArray result = new COSArray();
+ if (widths == null) {
+ return result;
+ }
+ int index = 0;
+ while (index + 1 < widths.size()) {
+ COSBase first = widths.getObject(index);
+ COSBase second = widths.getObject(index + 1);
+ if (second instanceof COSArray run) {
+ COSArray widenedRun = new COSArray();
+ for (int i = 0; i < run.size(); i++) {
+ widenedRun.add(COSInteger.get(widened(run.getObject(i))));
+ }
+ result.add(first);
+ result.add(widenedRun);
+ index += 2;
+ } else if (index + 2 < widths.size()) {
+ result.add(first);
+ result.add(second);
+ result.add(COSInteger.get(widened(widths.getObject(index + 2))));
+ index += 3;
+ } else {
+ break;
+ }
+ }
+ return result;
+ }
+
+ private long widened(COSBase width) {
+ return Math.round(((COSNumber) width).floatValue()) + (long) extraPerMille;
+ }
+
+ @Override
+ protected float getStandard14Width(int code) {
+ return 0f;
+ }
+
+ @Override
+ protected byte[] encode(int unicode) throws IOException {
+ return base.encode(new String(Character.toChars(unicode)));
+ }
+
+ @Override
+ public int readCode(InputStream in) throws IOException {
+ return base.readCode(in);
+ }
+
+ @Override
+ public boolean isVertical() {
+ return base.isVertical();
+ }
+
+ @Override
+ public void addToSubset(int codePoint) {
+ base.addToSubset(codePoint);
+ }
+
+ @Override
+ public void subset() {
+ // The base owns the font program and is subset on its own.
+ }
+
+ @Override
+ public boolean willBeSubset() {
+ return base.willBeSubset();
+ }
+
+ @Override
+ public String getName() {
+ // PDFont's constructor asks for the name before this class has assigned its base.
+ return base == null ? null : base.getName();
+ }
+
+ @Override
+ public PDFontDescriptor getFontDescriptor() {
+ return base.getFontDescriptor();
+ }
+
+ @Override
+ public Matrix getFontMatrix() {
+ return base.getFontMatrix();
+ }
+
+ @Override
+ public BoundingBox getBoundingBox() throws IOException {
+ return base.getBoundingBox();
+ }
+
+ @Override
+ public Vector getPositionVector(int code) {
+ return base.getPositionVector(code);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation")
+ public float getHeight(int code) throws IOException {
+ return base.getHeight(code);
+ }
+
+ @Override
+ public float getWidth(int code) throws IOException {
+ return base.getWidth(code) + extraPerMille;
+ }
+
+ @Override
+ public boolean hasExplicitWidth(int code) {
+ return true;
+ }
+
+ @Override
+ public float getWidthFromFont(int code) throws IOException {
+ return base.getWidthFromFont(code);
+ }
+
+ @Override
+ public boolean isEmbedded() {
+ return true;
+ }
+
+ @Override
+ public boolean isDamaged() {
+ return false;
+ }
+
+ @Override
+ public float getAverageFontWidth() {
+ return base.getAverageFontWidth() + extraPerMille;
+ }
+}
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
index d11f7f1af..c0ca9501d 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfParagraphFragmentRenderHandler.java
@@ -156,7 +156,8 @@ private static boolean renderChip(PDPageContentStream stream,
// skipping the resolution handed that Hebrew to the content stream logically —
// drawn left to right, the word came out backwards. The flag is the base the
// resolution runs against, not the question of whether to run it.
- if (span.rightToLeft() || BidiParagraphResolver.requiresBidi(sanitizedLogical)) {
+ boolean reordered = span.rightToLeft() || BidiParagraphResolver.requiresBidi(sanitizedLogical);
+ if (reordered) {
text = BidiVisualOrder.visualize(sanitizedLogical, span.rightToLeft());
written = PdfActualText.writtenTextOf(span);
environment.markReorderedText();
@@ -184,9 +185,20 @@ private static boolean renderChip(PDPageContentStream stream,
stream.beginText();
stream.newLineAtOffset((float) (cursorX + pad.left()), (float) baselineY);
textState.invalidate();
- textState.applyFont(stream, font.fontType(span.textStyle().decoration()), (float) span.textStyle().size());
+ PDFont face = font.fontType(span.textStyle().decoration());
+ float characterSpacing = (float) span.textStyle().letterSpacing();
+ if (!reordered && characterSpacing != 0f) {
+ // As in renderLine: the tracking goes into the widths of a letter-spaced face.
+ PdfRenderEnvironment.LetterSpacedFont spaced = environment.letterSpacedFont(
+ face, span.textStyle().size(), span.textStyle().letterSpacing(), text);
+ if (spaced != null) {
+ face = spaced.font();
+ characterSpacing = spaced.characterSpacing();
+ }
+ }
+ textState.applyFont(stream, face, (float) span.textStyle().size());
textState.applyColor(stream, span.textStyle().color());
- textState.applyCharacterSpacing(stream, (float) span.textStyle().letterSpacing());
+ textState.applyCharacterSpacing(stream, characterSpacing);
if (written != null) {
stream.beginMarkedContent(PdfActualText.tag(), PdfActualText.properties(written));
}
@@ -458,11 +470,22 @@ private void renderLine(PDPageContentStream stream,
stream.newLineAtOffset((float) cursorX, (float) baselineY);
inTextBlock = true;
}
- textState.applyFont(stream,
- font.fontType(textSpan.textStyle().decoration()),
- (float) textSpan.textStyle().size());
+ PDFont face = font.fontType(textSpan.textStyle().decoration());
+ float characterSpacing = (float) textSpan.textStyle().letterSpacing();
+ if (!textSpan.rightToLeft() && characterSpacing != 0f) {
+ // The tracking goes into the widths of a letter-spaced face rather than
+ // between the glyph boxes, where readers that ignore ActualText split on
+ // it. The glyphs land where Tc would put them; ActualText stays.
+ PdfRenderEnvironment.LetterSpacedFont spaced = environment.letterSpacedFont(
+ face, textSpan.textStyle().size(), textSpan.textStyle().letterSpacing(), text);
+ if (spaced != null) {
+ face = spaced.font();
+ characterSpacing = spaced.characterSpacing();
+ }
+ }
+ textState.applyFont(stream, face, (float) textSpan.textStyle().size());
textState.applyColor(stream, textSpan.textStyle().color());
- textState.applyCharacterSpacing(stream, (float) textSpan.textStyle().letterSpacing());
+ textState.applyCharacterSpacing(stream, characterSpacing);
if (written != null) {
stream.beginMarkedContent(PdfActualText.tag(),
PdfActualText.properties(written));
diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java
index 049f705a5..e2cf6bff8 100644
--- a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java
+++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfTableRowFragmentRenderHandler.java
@@ -15,6 +15,7 @@
import com.demcha.compose.engine.text.bidi.BidiVisualOrder;
import com.demcha.compose.font.FontLibrary;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.font.PDFont;
import java.io.IOException;
import java.util.ArrayList;
@@ -194,26 +195,66 @@ private void renderCellText(PDPageContentStream stream,
stream.saveGraphicsState();
try {
PdfAlphaSupport.applyFillAlpha(environment, stream, cell.style().textStyle().color());
- stream.setFont(font.fontType(cell.style().textStyle().decoration()), (float) cell.style().textStyle().size());
- stream.setNonStrokingColor(cell.style().textStyle().color());
+ PDFont face = font.fontType(cell.style().textStyle().decoration());
+ float size = (float) cell.style().textStyle().size();
double letterSpacing = cell.style().textStyle().letterSpacing();
- if (letterSpacing != 0.0) {
+ // A tracked cell draws with a letter-spaced face whose widths carry the spacing (see
+ // PdfRenderEnvironment#letterSpacedFont), so readers that ignore ActualText do not
+ // split its words into letters. One style, one decision: the face has to serve every
+ // line it would draw, handed over joined on line breaks. A reordered line — every line
+ // of a right-to-left cell, and any line that needs bidi — keeps the plain face and Tc.
+ // Only a tracked cell sanitises its lines ahead of drawing them, because only it needs
+ // their text for that decision.
+ String[] trackedTexts = letterSpacing == 0.0 ? null : new String[lines.size()];
+ PdfRenderEnvironment.LetterSpacedFont spaced = null;
+ if (trackedTexts != null) {
+ StringBuilder drawn = new StringBuilder();
+ for (int index = 0; index < lines.size(); index++) {
+ ResolvedTextLine line = lines.get(index);
+ trackedTexts[index] = font.sanitizeForRender(cell.style().textStyle(), line.text());
+ if (!line.reordered() && !trackedTexts[index].isEmpty()) {
+ drawn.append(trackedTexts[index]).append('\n');
+ }
+ }
+ spaced = environment.letterSpacedFont(face, size, letterSpacing, drawn.toString());
+ }
+ PDFont cellFace = spaced == null ? face : spaced.font();
+ float cellSpacing = spaced == null ? (float) letterSpacing : spaced.characterSpacing();
+ stream.setFont(cellFace, size);
+ stream.setNonStrokingColor(cell.style().textStyle().color());
+ if (cellSpacing != 0f) {
// One style for the whole cell, so Tc is set once here. Emitted
- // only when there is tracking to apply: this q..Q block starts at
+ // only when there is spacing to apply: this q..Q block starts at
// the page default of zero, so writing "0 Tc" would add a byte to
// every table ever rendered and change nothing about any of them.
// The enclosing restoreGraphicsState puts Tc back, so a tracked
// cell cannot spread the next one.
- stream.setCharacterSpacing((float) letterSpacing);
+ stream.setCharacterSpacing(cellSpacing);
}
+ PDFont currentFace = cellFace;
+ float currentSpacing = cellSpacing;
List decorations = null;
- for (ResolvedTextLine line : lines) {
+ for (int index = 0; index < lines.size(); index++) {
+ ResolvedTextLine line = lines.get(index);
if (line.text().isEmpty()) {
continue;
}
// Sanitise per-line so a single unsupported glyph in a
// cell does not crash the whole table render.
- String safeText = font.sanitizeForRender(cell.style().textStyle(), line.text());
+ String safeText = trackedTexts != null
+ ? trackedTexts[index]
+ : font.sanitizeForRender(cell.style().textStyle(), line.text());
+ boolean plainLine = spaced != null && line.reordered();
+ PDFont lineFace = plainLine ? face : cellFace;
+ float lineSpacing = plainLine ? (float) letterSpacing : cellSpacing;
+ if (lineFace != currentFace) {
+ stream.setFont(lineFace, size);
+ currentFace = lineFace;
+ }
+ if (lineSpacing != currentSpacing) {
+ stream.setCharacterSpacing(lineSpacing);
+ currentSpacing = lineSpacing;
+ }
// A reordered line goes out wrapped in what it says, so a reader copying
// the cell gets the letters that were typed rather than the order they
// happen to be painted in.
diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/LetterSpacedPdf.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/LetterSpacedPdf.java
new file mode 100644
index 000000000..09a030faf
--- /dev/null
+++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/LetterSpacedPdf.java
@@ -0,0 +1,266 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.PageFlowBuilder;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextDecoration;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.fontbox.ttf.TTFParser;
+import org.apache.fontbox.ttf.TrueTypeFont;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.contentstream.operator.Operator;
+import org.apache.pdfbox.cos.COSArray;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.cos.COSNumber;
+import org.apache.pdfbox.io.RandomAccessReadBuffer;
+import org.apache.pdfbox.pdfparser.PDFStreamParser;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.PDResources;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.font.PDCIDFontType2;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.text.PDFTextStripper;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Renders and reads documents for the letter-spaced font tests.
+ *
+ * Geometry is read with {@link DrawnPen}, not the text stripper: PDFBox honours
+ * {@code ActualText}, which is exactly why it never showed the defect these tests are about. A
+ * letter-spaced resource is recognised by what it states — widths above the ones its own
+ * embedded program gives the same glyphs — not by any key the backend happens to write.
+ */
+final class LetterSpacedPdf {
+
+ /** PT Serif: bundled, TrueType, and a face FontBox keeps no substitutions for. */
+ static final FontName FACE = FontName.PT_SERIF;
+ static final String REGULAR_RESOURCE = "/fonts/google/ptserif/PT_Serif-Web-Regular.ttf";
+ private static final String BOLD_RESOURCE = "/fonts/google/ptserif/PT_Serif-Web-Bold.ttf";
+ private static final COSName ACTUAL_TEXT = COSName.getPDFName("ActualText");
+
+ private LetterSpacedPdf() {
+ }
+
+ /** One text-showing operator: the font resource it names and the {@code Tc} in force. */
+ record Shown(String font, float characterSpacing) {
+ }
+
+ static DocumentTextStyle style(boolean bold, double size, DocumentLetterSpacing spacing) {
+ DocumentTextStyle.Builder builder = DocumentTextStyle.builder()
+ .fontName(FACE)
+ .size(size)
+ .letterSpacing(spacing);
+ if (bold) {
+ builder.decoration(DocumentTextDecoration.BOLD);
+ }
+ return builder.build();
+ }
+
+ static DocumentTextStyle style(FontName face, double size, DocumentLetterSpacing spacing) {
+ return DocumentTextStyle.builder().fontName(face).size(size).letterSpacing(spacing).build();
+ }
+
+ static byte[] render(Consumer body) {
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(40))
+ .create()) {
+ document.pageFlow(body);
+ return document.toPdfBytes();
+ }
+ }
+
+ static String text(byte[] pdf) throws IOException {
+ try (PDDocument document = Loader.loadPDF(pdf)) {
+ return new PDFTextStripper().getText(document).trim();
+ }
+ }
+
+ /** The gap a reader measures before glyph {@code index}: its origin minus the previous glyph's box end. */
+ static double gapBefore(List glyphs, int index) {
+ DrawnPen.Placement previous = glyphs.get(index - 1);
+ return glyphs.get(index).x() - (previous.x() + previous.advance());
+ }
+
+ /**
+ * {@code text}, the only text on the first page of {@code rendered}, is drawn glyph for glyph
+ * where the same string drawn with PT Serif and {@code Tc} from the same origin would be, and
+ * leaves no gap a reader could split on between two of its glyph boxes.
+ */
+ static void assertDrawnLikeCharacterSpacing(byte[] rendered, String text, boolean bold, double size,
+ double points) throws IOException {
+ List drawn = DrawnPen.placements(rendered);
+ assertThat(drawn).as("glyphs drawn for %s", text).hasSize(text.length());
+ List expected = DrawnPen.placements(
+ characterSpacingReference(text, bold, size, points, drawn.get(0).x(), drawn.get(0).y()));
+ for (int i = 0; i != drawn.size(); i++) {
+ assertThat(drawn.get(i).x()).as("%s: glyph %d x", text, i).isCloseTo(expected.get(i).x(), within(0.01));
+ assertThat(drawn.get(i).y()).as("%s: glyph %d y", text, i).isCloseTo(expected.get(i).y(), within(0.01));
+ }
+ for (int i = 1; i != drawn.size(); i++) {
+ assertThat(gapBefore(drawn, i)).as("%s: gap before glyph %d", text, i).isCloseTo(0.0, within(0.005));
+ }
+ }
+
+ /** The run drawn the way this backend used to: base font, {@code Tc}, then a marker glyph. */
+ static byte[] characterSpacingReference(String text, boolean bold, double size, double points,
+ double x, double y) throws IOException {
+ try (PDDocument document = new PDDocument();
+ InputStream program = LetterSpacedPdf.class.getResourceAsStream(bold ? BOLD_RESOURCE : REGULAR_RESOURCE)) {
+ assertThat(program).as("bundled PT Serif on the test classpath").isNotNull();
+ TrueTypeFont ttf = new TTFParser().parse(new RandomAccessReadBuffer(program.readAllBytes()));
+ ttf.setEnableGsub(false);
+ PDType0Font font = PDType0Font.load(document, ttf, true);
+ PDPage page = new PDPage(new PDRectangle(595, 842));
+ document.addPage(page);
+ try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
+ stream.beginText();
+ stream.newLineAtOffset((float) x, (float) y);
+ stream.setFont(font, (float) size);
+ stream.setCharacterSpacing((float) points);
+ stream.showText(text);
+ stream.setCharacterSpacing(0f);
+ stream.showText("X");
+ stream.endText();
+ }
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ document.save(out);
+ return out.toByteArray();
+ }
+ }
+
+ static COSDictionary descendant(COSDictionary fonts, COSName name) {
+ COSArray descendants = fonts.getCOSDictionary(name).getCOSArray(COSName.DESCENDANT_FONTS);
+ return (COSDictionary) descendants.getObject(0);
+ }
+
+ /** The descendant fonts of the page's letter-spaced resources, each once. */
+ static List letterSpacedResources(PDPage page) throws IOException {
+ List found = new ArrayList<>();
+ Map seen = new IdentityHashMap<>();
+ PDResources resources = page.getResources();
+ for (COSName name : resources.getFontNames()) {
+ if (resources.getFont(name) instanceof PDType0Font type0 && widthRaise(type0) > 0) {
+ COSDictionary cid = type0.getDescendantFont().getCOSObject();
+ if (seen.put(cid, Boolean.TRUE) == null) {
+ found.add(cid);
+ }
+ }
+ }
+ return found;
+ }
+
+ /**
+ * How many thousandths of an em a Type 0 resource's width for its first listed glyph exceeds
+ * the width a PDF writer states for that glyph of the embedded program: its advance rounded
+ * to a whole thousandth of an em.
+ */
+ private static long widthRaise(PDType0Font font) throws IOException {
+ if (!(font.getDescendantFont() instanceof PDCIDFontType2 cid)) {
+ return 0;
+ }
+ COSArray w = cid.getCOSObject().getCOSArray(COSName.W);
+ if (w == null || w.size() == 0) {
+ // Every glyph at the default width, which a writer states as 1000 when it states it at all.
+ return cid.getCOSObject().getInt(COSName.DW, 1000) - 1000L;
+ }
+ int code = ((COSNumber) w.getObject(0)).intValue();
+ TrueTypeFont program = cid.getTrueTypeFont();
+ int programWidth = Math.round(program.getAdvanceWidth(cid.codeToGID(code)) * (1000f / program.getUnitsPerEm()));
+ return Math.round(font.getWidth(code)) - programWidth;
+ }
+
+ static Map widths(COSDictionary cidFont) {
+ Map widths = new TreeMap<>();
+ COSArray w = cidFont.getCOSArray(COSName.W);
+ int i = 0;
+ while (w != null && i + 1 < w.size()) {
+ int first = ((COSNumber) w.getObject(i)).intValue();
+ COSBase next = w.getObject(i + 1);
+ if (next instanceof COSArray run) {
+ for (int j = 0; j != run.size(); j++) {
+ widths.put(first + j, ((COSNumber) run.getObject(j)).floatValue());
+ }
+ i += 2;
+ } else {
+ int last = ((COSNumber) next).intValue();
+ float value = ((COSNumber) w.getObject(i + 2)).floatValue();
+ for (int cid = first; cid <= last; cid++) {
+ widths.put(cid, value);
+ }
+ i += 3;
+ }
+ }
+ return widths;
+ }
+
+ /** Every text-showing operator with the font resource and {@code Tc} in force, q/Q respected. */
+ static List shownRuns(PDPage page) throws IOException {
+ PDFStreamParser parser = new PDFStreamParser(page);
+ List operands = new ArrayList<>();
+ List shown = new ArrayList<>();
+ Deque saved = new ArrayDeque<>();
+ String font = null;
+ float characterSpacing = 0f;
+ Object token;
+ while ((token = parser.parseNextToken()) != null) {
+ if (token instanceof COSBase operand) {
+ operands.add(operand);
+ continue;
+ }
+ if (token instanceof Operator operator) {
+ switch (operator.getName()) {
+ case "q" -> saved.push(new Shown(font, characterSpacing));
+ case "Q" -> {
+ Shown state = saved.pop();
+ font = state.font();
+ characterSpacing = state.characterSpacing();
+ }
+ case "Tf" -> font = ((COSName) operands.get(0)).getName();
+ case "Tc" -> characterSpacing = ((COSNumber) operands.get(0)).floatValue();
+ case "Tj", "TJ" -> shown.add(new Shown(font, characterSpacing));
+ default -> {
+ }
+ }
+ operands.clear();
+ }
+ }
+ return shown;
+ }
+
+ static List actualTexts(PDPage page) {
+ List texts = new ArrayList<>();
+ COSDictionary properties = page.getResources().getCOSObject().getCOSDictionary(COSName.PROPERTIES);
+ if (properties == null) {
+ return texts;
+ }
+ for (COSName name : properties.keySet()) {
+ COSDictionary property = properties.getCOSDictionary(name);
+ if (property != null && property.getString(ACTUAL_TEXT) != null) {
+ texts.add(property.getString(ACTUAL_TEXT));
+ }
+ }
+ return texts;
+ }
+}
diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacedFontFallbackTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacedFontFallbackTest.java
new file mode 100644
index 000000000..29bf0386c
--- /dev/null
+++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacedFontFallbackTest.java
@@ -0,0 +1,199 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import com.demcha.compose.document.node.TextDirection;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.document.table.DocumentTableStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.Shown;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.actualTexts;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.letterSpacedResources;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.render;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.shownRuns;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.style;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.text;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * The runs letter-spaced resources cannot serve keep the {@code Tc} path they always had.
+ *
+ * Each case is one the representation deliberately leaves alone — tightening, a face with
+ * no embedded program, text drawn in visual order, a tracking too small for a whole thousandth of
+ * an em, and text a face's GSUB substitutions would rewrite — plus the decision a table cell
+ * makes once for all its lines. A case that wrongly took the widths would show up as a
+ * letter-spaced resource on the page or as a missing {@code Tc}, which is what these assert.
+ */
+class PdfLetterSpacedFontFallbackTest {
+
+ private static final DocumentColor CHIP = DocumentColor.rgb(230, 230, 240);
+
+ @Test
+ void negativeTrackingKeepsCharacterSpacing() throws Exception {
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .text("TIGHT HEADING").textStyle(style(false, 20, DocumentLetterSpacing.points(-0.5)))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(shownRuns(page)).anySatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(-0.5f, within(1.0e-4f)));
+ assertThat(letterSpacedResources(page)).isEmpty();
+ }
+ assertThat(text(rendered)).isEqualTo("TIGHT HEADING");
+ }
+
+ @Test
+ void aStandardFourteenFaceKeepsCharacterSpacingAndActualText() throws Exception {
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .text("HELVETICA HEADING").textStyle(style(FontName.HELVETICA, 20, DocumentLetterSpacing.points(3)))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(shownRuns(page)).anySatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(3f, within(1.0e-4f)));
+ assertThat(actualTexts(page)).contains("HELVETICA HEADING");
+ assertThat(letterSpacedResources(page)).isEmpty();
+ }
+ }
+
+ @Test
+ void aRightToLeftRunKeepsCharacterSpacing() throws Exception {
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .text("שלום עולם")
+ .direction(TextDirection.RTL)
+ .textStyle(style(FontName.DAVID_LIBRE, 20, DocumentLetterSpacing.points(3)))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(shownRuns(page)).anySatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(3f, within(1.0e-4f)));
+ assertThat(letterSpacedResources(page)).isEmpty();
+ }
+ }
+
+ @Test
+ void aHighlightChipWhoseTextNeedsBidiKeepsCharacterSpacing() throws Exception {
+ // A chip drawn in visual order keeps Tc like any reordered run; David Libre could serve it.
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .inlineHighlight("שלום", style(FontName.DAVID_LIBRE, 20, DocumentLetterSpacing.points(3)),
+ CHIP, 4, DocumentInsets.of(2))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(shownRuns(page)).anySatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(3f, within(1.0e-4f)));
+ assertThat(letterSpacedResources(page)).isEmpty();
+ }
+ }
+
+ @Test
+ void trackingBelowHalfAThousandthOfAnEmKeepsCharacterSpacing() throws Exception {
+ // 0.01pt, the finest tracking fixed layout states, is 0.42 thousandths of a 24pt em: there
+ // is no whole thousandth for the widths to carry.
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .text("QUIET TRACKING").textStyle(style(false, 24, DocumentLetterSpacing.points(0.01)))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(shownRuns(page)).anySatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(0.01f, within(1.0e-4f)));
+ assertThat(page.getResources().getFontNames())
+ .as("only the base font, not a resource with nothing added to its widths")
+ .hasSize(1);
+ }
+ }
+
+ @Test
+ void aFaceWhoseSubstitutionsServeAnotherScriptStillCarriesLatinSpacingInItsWidths() throws Exception {
+ // FontBox keeps Poppins' GSUB for Devanagari. Its worker leaves a Latin heading's glyphs
+ // unchanged, so the letter-spaced face draws exactly what the base face would.
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .text("ARTEM DEMCHYSHYN").textStyle(style(FontName.POPPINS, 24, DocumentLetterSpacing.points(4.32)))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ assertThat(letterSpacedResources(document.getPage(0))).hasSize(1);
+ }
+ assertThat(text(rendered)).isEqualTo("ARTEM DEMCHYSHYN");
+ }
+
+ @Test
+ void aRunTheSubstitutionsWouldRewriteKeepsCharacterSpacing() throws Exception {
+ // Devanagari conjuncts are what that worker rewrites. A resource that encodes through the
+ // character map alone would draw other glyphs, so this run keeps the base face and Tc.
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .text("क्षत्रिय").textStyle(style(FontName.POPPINS, 24, DocumentLetterSpacing.points(2)))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(shownRuns(page)).anySatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(2f, within(1.0e-4f)));
+ assertThat(letterSpacedResources(page)).isEmpty();
+ }
+ }
+
+ @Test
+ void aTableCellIsDecidedByTheTextItDraws() throws Exception {
+ // A cell decides once, from the text of the lines it draws: the Latin cell takes the
+ // letter-spaced face (2.16pt at 12pt is 180 thousandths), the Devanagari cell keeps Tc.
+ DocumentTextStyle latin = style(FontName.POPPINS, 12, DocumentLetterSpacing.points(2.16));
+ DocumentTextStyle devanagari = style(FontName.POPPINS, 12, DocumentLetterSpacing.points(1));
+ byte[] rendered = render(page -> {
+ page.addTable(table -> table
+ .columns(DocumentTableColumn.fixed(300))
+ .defaultCellStyle(DocumentTableStyle.builder().textStyle(latin).build())
+ .row("TECHNICAL SKILLS"));
+ page.addTable(table -> table
+ .columns(DocumentTableColumn.fixed(300))
+ .defaultCellStyle(DocumentTableStyle.builder().textStyle(devanagari).build())
+ .row("क्षत्रिय"));
+ });
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(letterSpacedResources(page)).as("the Latin cell's resource, and only that").hasSize(1);
+ assertThat(shownRuns(page)).anySatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(1f, within(1.0e-4f)));
+ }
+ }
+
+ @Test
+ void aTableCellWithOneLineTheFaceCannotServeKeepsCharacterSpacingOnEveryLine() throws Exception {
+ // The first line alone would qualify; the second would draw other glyphs. One style, one
+ // decision: the whole cell keeps Tc.
+ byte[] rendered = render(page -> page.addTable(table -> table
+ .columns(DocumentTableColumn.fixed(300))
+ .defaultCellStyle(DocumentTableStyle.builder()
+ .textStyle(style(FontName.POPPINS, 12, DocumentLetterSpacing.points(1))).build())
+ .rowCells(DocumentTableCell.lines("TECHNICAL SKILLS", "क्षत्रिय"))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(letterSpacedResources(page)).isEmpty();
+ assertThat(shownRuns(page)).hasSize(2).allSatisfy(run ->
+ assertThat(run.characterSpacing()).isCloseTo(1f, within(1.0e-4f)));
+ }
+ }
+
+ @Test
+ void aReorderedLineInATrackedCellSwitchesBackToCharacterSpacing() throws Exception {
+ // Latin first keeps the cell left to right, so only the Hebrew line is drawn in visual
+ // order. 3.6pt at 20pt is 180 thousandths of an em, so the Latin line carries no Tc.
+ byte[] rendered = render(page -> page.addTable(table -> table
+ .columns(DocumentTableColumn.fixed(300))
+ .defaultCellStyle(DocumentTableStyle.builder()
+ .textStyle(style(FontName.DAVID_LIBRE, 20, DocumentLetterSpacing.points(3.6))).build())
+ .rowCells(DocumentTableCell.lines("TOTAL", "שלום"))));
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ assertThat(letterSpacedResources(page)).hasSize(1);
+ List shown = shownRuns(page);
+ assertThat(shown).hasSize(2);
+ assertThat(shown.get(0).characterSpacing()).as("the Latin line: its widths carry the spacing").isZero();
+ assertThat(shown.get(1).characterSpacing()).as("the Hebrew line: Tc carries it")
+ .isCloseTo(3.6f, within(1.0e-4f));
+ assertThat(shown.get(1).font()).isNotEqualTo(shown.get(0).font());
+ }
+ }
+}
diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacedFontTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacedFontTest.java
new file mode 100644
index 000000000..5ad3b04f6
--- /dev/null
+++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfLetterSpacedFontTest.java
@@ -0,0 +1,360 @@
+package com.demcha.compose.document.backend.fixed.pdf;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.api.MultiSectionDocument;
+import com.demcha.compose.document.output.DocumentProtection;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentLetterSpacing;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.demcha.compose.document.table.DocumentTableStyle;
+import org.apache.fontbox.ttf.TTFParser;
+import org.apache.fontbox.ttf.TrueTypeFont;
+import org.apache.pdfbox.Loader;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.io.RandomAccessReadBuffer;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.font.PDType0Font;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.Map;
+
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.REGULAR_RESOURCE;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.Shown;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.actualTexts;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.assertDrawnLikeCharacterSpacing;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.characterSpacingReference;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.descendant;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.gapBefore;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.letterSpacedResources;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.render;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.shownRuns;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.style;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.text;
+import static com.demcha.compose.document.backend.fixed.pdf.LetterSpacedPdf.widths;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Letter spacing carried in glyph widths: same picture as {@code Tc}, no gaps for a reader.
+ *
+ * Native letter spacing draws a run with {@code Tc}. That places every glyph correctly, but
+ * the added space sits between the glyph boxes a reader derives from the font's widths, and
+ * readers that ignore {@code ActualText} (pdf.js, pdfminer) read a tracked heading as single
+ * letters. A positive tracked run is therefore drawn with a font resource over the same embedded
+ * program whose widths include the tracking. These tests hold that representation to its
+ * promises for every text path that draws it — paragraph runs, table cells and highlight
+ * chips: every glyph lands where {@code Tc} would put it, the text layer says exactly what was
+ * written, the page carries the widths and not the gap, protection and sections keep it, and one
+ * resource serves a face and tracking for the whole document. What falls back to {@code Tc} is
+ * held by {@link PdfLetterSpacedFontFallbackTest}.
+ */
+class PdfLetterSpacedFontTest {
+
+ private static final String PASSWORD = "keep-out";
+
+ // --- A. geometry ----------------------------------------------------------------------
+
+ @Test
+ void everyGlyphLandsWhereCharacterSpacingWouldPutIt() throws Exception {
+ record Case(String text, boolean bold, double size, double points) {
+ }
+ List cases = List.of(
+ new Case("PROFESSIONAL SUMMARY", true, 9.6, 1.73), // 0.18em as a CV banner resolves it
+ new Case("ARTEM DEMCHYSHYN", false, 21.5, 3.87), // 0.18em at a headline size
+ new Case("SMALL POSITIVE TRACKING", false, 12, 0.24), // 0.02em
+ new Case("POINT TRACKING", false, 10, 1.2),
+ new Case("POINT TRACKING", false, 14, 1.2),
+ new Case("POINT TRACKING", false, 24, 1.2));
+
+ for (Case c : cases) {
+ DocumentTextStyle tracked = style(c.bold(), c.size(), DocumentLetterSpacing.points(c.points()));
+ DocumentTextStyle marker = style(c.bold(), c.size(), DocumentLetterSpacing.NONE);
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .inlineText(c.text(), tracked)
+ .inlineText("X", marker)));
+ List drawn = DrawnPen.placements(rendered);
+ assertThat(drawn).hasSize(c.text().length() + 1);
+
+ // The same string, drawn from the same origin with the base font and Tc: the
+ // representation this replaces. The trailing marker is where the pen stood after
+ // the run, so it also pins the run's full advance, trailing unit included.
+ List expected = DrawnPen.placements(characterSpacingReference(
+ c.text(), c.bold(), c.size(), c.points(), drawn.get(0).x(), drawn.get(0).y()));
+ assertThat(expected).hasSameSizeAs(drawn);
+ for (int i = 0; i != drawn.size(); i++) {
+ assertThat(drawn.get(i).x())
+ .as("%s at %s pt tracked %s pt: glyph %d x", c.text(), c.size(), c.points(), i)
+ .isCloseTo(expected.get(i).x(), within(0.01));
+ assertThat(drawn.get(i).y())
+ .as("%s at %s pt: glyph %d y", c.text(), c.size(), i)
+ .isCloseTo(expected.get(i).y(), within(0.01));
+ }
+ }
+ }
+
+ @Test
+ void aReaderFindsNoGapBetweenTrackedLetters() throws Exception {
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .text("PROFESSIONAL SUMMARY").textStyle(style(true, 9.6, DocumentLetterSpacing.points(1.73)))));
+ List drawn = DrawnPen.placements(rendered);
+ // The gap a reader measures: next origin minus this origin plus this glyph's width.
+ // With widths that carry the tracking only the sub-thousandth remainder is left.
+ for (int i = 1; i != drawn.size(); i++) {
+ assertThat(gapBefore(drawn, i)).as("gap before glyph %d", i).isCloseTo(0.0, within(0.005));
+ }
+
+ // The same heading drawn with Tc leaves the whole tracking between the boxes: the
+ // gap this test exists to catch, so it cannot pass on a page that still has it.
+ List reference = DrawnPen.placements(characterSpacingReference(
+ "PROFESSIONAL SUMMARY", true, 9.6, 1.73, drawn.get(0).x(), drawn.get(0).y()));
+ assertThat(gapBefore(reference, 1)).isCloseTo(1.73, within(0.01));
+ }
+
+ @Test
+ void aTrackedTableCellIsDrawnLikeCharacterSpacingWithoutTheGaps() throws Exception {
+ byte[] rendered = render(page -> page.addTable(table -> table
+ .columns(DocumentTableColumn.fixed(300))
+ .defaultCellStyle(DocumentTableStyle.builder()
+ .textStyle(style(true, 9.6, DocumentLetterSpacing.points(1.73))).build())
+ .row("TECHNICAL SKILLS")));
+
+ assertDrawnLikeCharacterSpacing(rendered, "TECHNICAL SKILLS", true, 9.6, 1.73);
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ assertThat(letterSpacedResources(document.getPage(0))).hasSize(1);
+ }
+ }
+
+ @Test
+ void aTrackedHighlightChipIsDrawnLikeCharacterSpacingWithoutTheGaps() throws Exception {
+ byte[] rendered = render(page -> page.addParagraph(p -> p
+ .inlineHighlight("EDUCATION", style(true, 9.6, DocumentLetterSpacing.points(1.73)),
+ DocumentColor.rgb(230, 230, 240), 4, DocumentInsets.of(2))));
+
+ assertDrawnLikeCharacterSpacing(rendered, "EDUCATION", true, 9.6, 1.73);
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ assertThat(letterSpacedResources(document.getPage(0))).hasSize(1);
+ }
+ }
+
+ // --- B. text layer --------------------------------------------------------------------
+
+ @Test
+ void theTextLayerSaysExactlyWhatWasWritten() throws Exception {
+ DocumentTextStyle banner = style(true, 9.6, DocumentLetterSpacing.points(1.73));
+ DocumentTextStyle regular = style(false, 9.6, DocumentLetterSpacing.points(1.73));
+
+ assertThat(text(render(page -> page.addParagraph(p -> p.text("PROFESSIONAL SUMMARY").textStyle(banner)))))
+ .isEqualTo("PROFESSIONAL SUMMARY");
+ assertThat(text(render(page -> page.addParagraph(p -> p
+ .inlineText("PROFESSIONAL ", banner)
+ .inlineText("EXPERIENCE", regular)))))
+ .isEqualTo("PROFESSIONAL EXPERIENCE");
+ assertThat(text(render(page -> page.addTable(table -> table
+ .columns(DocumentTableColumn.fixed(300))
+ .defaultCellStyle(DocumentTableStyle.builder().textStyle(banner).build())
+ .row("TECHNICAL SKILLS")))))
+ .isEqualTo("TECHNICAL SKILLS");
+ assertThat(text(render(page -> page.addParagraph(p -> p
+ .inlineHighlight("EDUCATION", banner, DocumentColor.rgb(230, 230, 240), 4, DocumentInsets.of(2))))))
+ .isEqualTo("EDUCATION");
+ }
+
+ // --- C. structure ---------------------------------------------------------------------
+
+ @Test
+ void theTrackedRunUsesWidenedWidthsOverTheSameFontProgram() throws Exception {
+ // 1.8pt at 10pt is exactly 180 thousandths of an em, so no remainder is left for Tc.
+ byte[] rendered = render(page -> {
+ page.addParagraph(p -> p.text("PROFESSIONAL SUMMARY").textStyle(style(true, 10, DocumentLetterSpacing.points(1.8))));
+ page.addParagraph(p -> p.text("Professional summary").textStyle(style(true, 10, DocumentLetterSpacing.NONE)));
+ });
+
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ PDPage page = document.getPage(0);
+ COSDictionary fonts = page.getResources().getCOSObject().getCOSDictionary(COSName.FONT);
+ List spaced = letterSpacedResources(page);
+ assertThat(spaced).as("one letter-spaced font resource").hasSize(1);
+ String viewName = null;
+ String baseName = null;
+ for (COSName name : fonts.keySet()) {
+ if (descendant(fonts, name) == spaced.get(0)) {
+ viewName = name.getName();
+ } else {
+ baseName = name.getName();
+ }
+ }
+ assertThat(viewName).as("a letter-spaced font resource").isNotNull();
+ assertThat(baseName).as("the base font resource").isNotNull();
+
+ COSDictionary view = descendant(fonts, COSName.getPDFName(viewName));
+ COSDictionary base = descendant(fonts, COSName.getPDFName(baseName));
+ assertThat(view.getCOSDictionary(COSName.FONT_DESC).getDictionaryObject(COSName.FONT_FILE2))
+ .as("the resource draws with the base font program, not a copy")
+ .isSameAs(base.getCOSDictionary(COSName.FONT_DESC).getDictionaryObject(COSName.FONT_FILE2));
+ assertThat(view.getDictionaryObject(COSName.CID_TO_GID_MAP))
+ .isSameAs(base.getDictionaryObject(COSName.CID_TO_GID_MAP));
+ assertThat(fonts.getCOSDictionary(COSName.getPDFName(viewName)).getDictionaryObject(COSName.TO_UNICODE))
+ .isSameAs(fonts.getCOSDictionary(COSName.getPDFName(baseName)).getDictionaryObject(COSName.TO_UNICODE));
+
+ assertThat(view.getInt(COSName.DW)).isEqualTo(base.getInt(COSName.DW, 1000) + 180);
+ Map viewWidths = widths(view);
+ Map baseWidths = widths(base);
+ assertThat(viewWidths.keySet()).isEqualTo(baseWidths.keySet());
+ for (Map.Entry entry : baseWidths.entrySet()) {
+ assertThat(viewWidths.get(entry.getKey()))
+ .as("width of CID %d", entry.getKey())
+ .isEqualTo(entry.getValue() + 180f);
+ }
+
+ List shown = shownRuns(page);
+ String finalViewName = viewName;
+ String finalBaseName = baseName;
+ assertThat(shown).anySatisfy(run -> {
+ assertThat(run.font()).isEqualTo(finalViewName);
+ assertThat(run.characterSpacing()).as("no Tc carries the tracking").isZero();
+ });
+ assertThat(shown).anySatisfy(run -> assertThat(run.font()).isEqualTo(finalBaseName));
+ assertThat(actualTexts(page))
+ .as("ActualText is kept for the readers that honour it")
+ .contains("PROFESSIONAL SUMMARY");
+ }
+ }
+
+ // --- D. saving ------------------------------------------------------------------------
+
+ @Test
+ void aProtectedDocumentKeepsItsLetterSpacedResourcesUnderPassword() throws Exception {
+ // The resources are completed between two saves, so protection has to wait for the
+ // second: applied to the first, the real save would encrypt the document twice.
+ byte[] pdf;
+ try (DocumentSession document = GraphCompose.document()
+ .pageSize(595, 842)
+ .margin(DocumentInsets.of(40))
+ .create()) {
+ document.protect(DocumentProtection.builder().userPassword(PASSWORD).build());
+ document.pageFlow(page -> page.addParagraph(p -> p
+ .text("PROFESSIONAL SUMMARY").textStyle(style(true, 10, DocumentLetterSpacing.points(1.8)))));
+ pdf = document.toPdfBytes();
+ }
+
+ try (PDDocument opened = Loader.loadPDF(pdf, PASSWORD)) {
+ assertThat(opened.isEncrypted()).as("the protection reached the file").isTrue();
+ assertThat(letterSpacedResources(opened.getPage(0))).hasSize(1);
+ assertThat(new PDFTextStripper().getText(opened).trim()).isEqualTo("PROFESSIONAL SUMMARY");
+ }
+ }
+
+ // --- E. reuse -------------------------------------------------------------------------
+
+ @Test
+ void identicalTrackedStylesShareOneResource() throws Exception {
+ DocumentTextStyle banner = style(true, 9.6, DocumentLetterSpacing.points(1.73));
+ byte[] rendered = render(page -> {
+ page.addParagraph(p -> p.text("PROFESSIONAL SUMMARY").textStyle(banner));
+ page.addParagraph(p -> p.text("TECHNICAL SKILLS").textStyle(banner));
+ page.addParagraph(p -> p.text("PROJECTS").textStyle(banner));
+ });
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ assertThat(letterSpacedResources(document.getPage(0))).hasSize(1);
+ }
+ }
+
+ @Test
+ void emTrackingSharesAResourceAcrossSizesWhilePointTrackingDoesNot() throws Exception {
+ // 0.18em resolves to 1.8pt at 10pt and 3.6pt at 20pt: 180 thousandths of an em both times.
+ byte[] em = render(page -> {
+ page.addParagraph(p -> p.text("TEN POINTS").textStyle(style(false, 10, DocumentLetterSpacing.ofFontSize(0.18))));
+ page.addParagraph(p -> p.text("TWENTY POINTS").textStyle(style(false, 20, DocumentLetterSpacing.ofFontSize(0.18))));
+ });
+ // 1.2pt is 120 thousandths of an em at 10pt and 60 at 20pt: two different widths.
+ byte[] points = render(page -> {
+ page.addParagraph(p -> p.text("TEN POINTS").textStyle(style(false, 10, DocumentLetterSpacing.points(1.2))));
+ page.addParagraph(p -> p.text("TWENTY POINTS").textStyle(style(false, 20, DocumentLetterSpacing.points(1.2))));
+ });
+ try (PDDocument emDocument = Loader.loadPDF(em); PDDocument pointsDocument = Loader.loadPDF(points)) {
+ assertThat(letterSpacedResources(emDocument.getPage(0))).hasSize(1);
+ assertThat(letterSpacedResources(pointsDocument.getPage(0))).hasSize(2);
+ }
+ }
+
+ @Test
+ void pagesShareTheResourceOfTheirDocument() throws Exception {
+ DocumentTextStyle banner = style(true, 18, DocumentLetterSpacing.points(3));
+ byte[] rendered;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(300, 120)
+ .margin(DocumentInsets.of(10))
+ .create()) {
+ session.pageFlow(page -> {
+ for (int i = 0; i != 8; i++) {
+ page.addParagraph(p -> p.text("SECTION HEADING").textStyle(banner));
+ }
+ });
+ rendered = session.toPdfBytes();
+ }
+ try (PDDocument document = Loader.loadPDF(rendered)) {
+ assertThat(document.getNumberOfPages()).isGreaterThan(1);
+ List first = letterSpacedResources(document.getPage(0));
+ List last = letterSpacedResources(document.getPage(document.getNumberOfPages() - 1));
+ assertThat(first).hasSize(1);
+ assertThat(last).hasSize(1);
+ assertThat(last.get(0)).isSameAs(first.get(0));
+ }
+ }
+
+ @Test
+ void sectionsShareTheResourceOfTheirDocument() throws Exception {
+ DocumentTextStyle banner = style(true, 18, DocumentLetterSpacing.points(3));
+ byte[] rendered;
+ try (MultiSectionDocument document = GraphCompose.documents()
+ .section(trackedSection(banner))
+ .section(trackedSection(banner))
+ .create()) {
+ rendered = document.toPdfBytes();
+ }
+ try (PDDocument opened = Loader.loadPDF(rendered)) {
+ assertThat(opened.getNumberOfPages()).isEqualTo(2);
+ List first = letterSpacedResources(opened.getPage(0));
+ List second = letterSpacedResources(opened.getPage(1));
+ assertThat(first).hasSize(1);
+ assertThat(second).hasSize(1);
+ assertThat(second.get(0)).as("one registry for the combined document").isSameAs(first.get(0));
+ }
+ }
+
+ @Test
+ void aFaceHandedOutEarlierStandsForItsBase() throws Exception {
+ // Asking again with a letter-spaced face must not stack its widths on the spacing it carries.
+ try (PDDocument document = new PDDocument();
+ InputStream program = PdfLetterSpacedFontTest.class.getResourceAsStream(REGULAR_RESOURCE)) {
+ TrueTypeFont ttf = new TTFParser().parse(new RandomAccessReadBuffer(program.readAllBytes()));
+ ttf.setEnableGsub(false);
+ PDType0Font base = PDType0Font.load(document, ttf, true);
+ PdfTrackedFontResources resources = new PdfTrackedFontResources(document);
+
+ PdfRenderEnvironment.LetterSpacedFont first = resources.resolve(base, 10, 1.8, "AB");
+ assertThat(first).isNotNull();
+ PdfRenderEnvironment.LetterSpacedFont again = resources.resolve(first.font(), 10, 1.8, "AB");
+ assertThat(again).isNotNull();
+ assertThat(again.font()).isSameAs(first.font());
+ assertThat(again.characterSpacing()).isZero();
+ }
+ }
+
+ private static DocumentSession trackedSection(DocumentTextStyle style) {
+ DocumentSession section = GraphCompose.document()
+ .pageSize(300, 200)
+ .margin(DocumentInsets.of(20))
+ .create();
+ section.pageFlow(page -> page.addParagraph(p -> p.text("SECTION HEADING").textStyle(style)));
+ return section;
+ }
+}
From ada2bba176c3fb700211002cb658de3ed77106e4 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 14 Sep 2026 04:07:14 +0100
Subject: [PATCH 2/2] fix(examples): re-render the 31 previews letter-spaced
resources move
The committed previews that draw positively tracked text in an embedded
face now carry letter-spaced font resources, and these 31 no longer
matched what their examples render.
Against the files they replace, every page matches in PDFium at 100 dpi
with no pixel changed, page and glyph counts and the extracted text are
unchanged, and no font program is added: the new resources reuse the
programs already embedded. The files grow by 0.1% to 2.9%.
CommittedAssetDriftTest and ExampleContentGuardTest pass.
---
.../examples/cover-letter-blue-banner-v2.pdf | Bin 26459 -> 26489 bytes
.../cover-letter-boxed-sections-v2.pdf | Bin 27638 -> 27824 bytes
.../cover-letter-centered-headline-v2.pdf | Bin 24342 -> 24363 bytes
.../cover-letter-classic-serif-v2.pdf | Bin 27739 -> 27927 bytes
.../cover-letter-mint-editorial-v2.pdf | Bin 10213 -> 10501 bytes
.../cover-letter-monogram-sidebar-v2.pdf | Bin 31443 -> 31633 bytes
.../cover-letter-sidebar-portrait-v2.pdf | Bin 28229 -> 28471 bytes
.../cover-letter-timeline-minimal-v2.pdf | Bin 34394 -> 34421 bytes
assets/readme/examples/cv-blue-banner-v2.pdf | Bin 39380 -> 39679 bytes
.../readme/examples/cv-boxed-sections-v2.pdf | Bin 48146 -> 48583 bytes
.../examples/cv-centered-headline-v2.pdf | Bin 38190 -> 38459 bytes
.../readme/examples/cv-charcoal-gold-v2.pdf | Bin 35843 -> 36110 bytes
.../readme/examples/cv-classic-serif-v2.pdf | Bin 48375 -> 48788 bytes
.../readme/examples/cv-midnight-navy-v2.pdf | Bin 37075 -> 37359 bytes
.../examples/cv-minimal-underlined-v2.pdf | Bin 48103 -> 48537 bytes
.../examples/cv-mint-editorial-v2-custom.pdf | Bin 24169 -> 24534 bytes
.../readme/examples/cv-mint-editorial-v2.pdf | Bin 24158 -> 24506 bytes
.../examples/cv-monogram-sidebar-v2.pdf | Bin 45711 -> 45998 bytes
assets/readme/examples/cv-navy-sidebar-v2.pdf | Bin 34553 -> 34696 bytes
.../examples/cv-professional-sidebar-v2.pdf | Bin 53438 -> 54043 bytes
.../readme/examples/cv-serif-headline-v2.pdf | Bin 83345 -> 83827 bytes
.../examples/cv-sidebar-portrait-v2.pdf | Bin 43000 -> 43473 bytes
assets/readme/examples/cv-teal-pulse-v2.pdf | Bin 39420 -> 39941 bytes
.../readme/examples/cv-terracotta-rail-v2.pdf | Bin 41017 -> 41597 bytes
.../examples/cv-timeline-minimal-v2.pdf | Bin 45358 -> 45382 bytes
assets/readme/examples/cv-violet-grid-v2.pdf | Bin 45228 -> 45524 bytes
.../readme/examples/invoice-consulting-v2.pdf | Bin 92152 -> 92545 bytes
.../examples/invoice-luma-studio-v2.pdf | Bin 46408 -> 47773 bytes
assets/readme/examples/letter-spacing.pdf | Bin 17432 -> 17847 bytes
.../readme/examples/proposal-editorial-v2.pdf | Bin 41593 -> 41845 bytes
.../readme/examples/proposal-northline-v2.pdf | Bin 74671 -> 74899 bytes
31 files changed, 0 insertions(+), 0 deletions(-)
diff --git a/assets/readme/examples/cover-letter-blue-banner-v2.pdf b/assets/readme/examples/cover-letter-blue-banner-v2.pdf
index 75b3c51d0e279b2cf2e3a377801793b36ffda872..eddecc3783924c51bca447e3232024e7c0e58c27 100644
GIT binary patch
delta 3009
zcmb7_YdF)5G0e<{043Zsdz>fnklC+
z{D{Y?k#YzXr7d$N=jYe+{`cnp_P(#{bKTed;=ZSbKs!UAydXXU1i*rv$)7
z^V@C64-dAMhRLH7{Xcoe`8C{x0){$Z$M|y`CbbCK-+k!1_zRMz=r}TH+Q+$*5Zi2>
zW<3L_*en!8{6!#)zp2Ni*anBkZhFwP8r@~QT{h4dSNXjxmre(j#+RY&@CouWpB%kG
zObfbjn!mdfVl}LKs6lRzEgSQWNFG8Vv(`JWXh&?h-5gig?ywt
zx?}VtY0v6qoZW&>`k1;g#4RKg8zkD8UK~|P)mD#x9;?mw?RuK|u`9RdF<-+}Z@
zAS{0K^ey5iWgg4<8DlJuIN`_RsmRSB(O
z&Jq~j31EhBC$a4aRf`spdX`J=L*!%c*}8nJ-n2QFf7`AT?}>cA%!%wLd-sjtM?F!t
zQ4i1Y=TJNz!-i=8p=xFmG9TsJtHg?zk!}gUOJkK
zFB=P0Gkr2jamGEiPv*aAcFpGX6N$EK+n%Q&`dV`-^nfUKRUOxM>^}DRKJB!XN8FL@
zcP*{Mqm3GSm(5-*PMS)}lQZOT$A&^RdcJU|KkOmG@?*|)t2t^&Kr1++lw|`OiFjG(
zQx~!9dKiITPhRgD^d*1NW8%7f2FeNnx&Dh`|RdA&Fnz2{7jb_6lA8jN8>Sq}o%2tvhkRMLY_2eEbUOR~z-YC-q?8<(b7G<6l(XWW;7h0c
zaINREhpvv-S|u!3%Dx3yYAtt%uZ9QrBptbNm<#26pNkuP>2su7L;N-3m1U^6JaUlzDOVl1Oo;|XF&SE3&>MGI1+Fa
zO8}}MGRy}GQcw^aF!wV8YMoAkbeVkUIBCG)H_Jpp^lO&b8Bq}t0#XHr(M3*L
z!uh`Z8YWlmjj5U*cq!ag$D3eW_dos`ZWPU=5ey7p
zSoTsRdvq%X!RcLbS*b4j5584E>zOeK{&;fsVF$Z{lAIm&&6`%Pn^^4?2ZROOt}mN1
zEjLEnG{_o>uA~wg!14|G_0?W$Lh-gSj!BRePlTjle=dEeGQ5X4$~MAVJ|aFi^hKqyz*kkegZuFZ9?4OT8A9REh*`|dptYsa=4a@-PCD!GDBr|
zlfzk0B7Us-wsfM&Z}{3MZ)D?@2>vizq^k?rN9jt`OKVPRuxj6xKR6zpQ*da0(Sgez
zJ-3poHIB|da8B}a(5_lOX}o>VwbDrbo0{%en{f~-O10IECN5UervwEV`*TO>+CM;~>3QoE9qZ-0
z^v7G)fwe!Ee)1O7Hl|K_qz?D6KNqRhpCx}+y#CLA)Ql@K{kyI0Ak-DJ_*8h18}@Oi
z5?MZpMzvj