diff --git a/CHANGELOG.md b/CHANGELOG.md index 453583921..78a59ae37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -239,13 +239,36 @@ follow semantic versioning; release dates are ISO 8601. The default is `DocumentLetterSpacing.NONE`, which resolves to zero at every font size, so a document that never asks for tracking renders exactly as it did. - **PDF honours it natively.** The advance comes from the PDF `Tc` operator, not from spaces - pushed into the string, so a spaced-caps headline still reads as `JANE DOE` to search, - copy/paste, text extraction and ATS parsers — one glyph per character, the original text. - Tracked runs also state their own text via `ActualText`, because an extractor decides - where words are by how far apart glyphs sit and tracking is the act of moving them apart; - without that statement a widely tracked line comes back as `J A N E D O E` from a file - that is otherwise perfectly correct. + **PDF honours it natively.** The advance is drawn by the PDF itself — in the glyph widths + of the font, or with the `Tc` operator — not from spaces pushed into the string, so a + spaced-caps headline still reads as `JANE DOE` to search, copy/paste, text extraction and + ATS parsers — one glyph per character, the original text. + + Placing the letters is only half of it. An extractor decides where the words are from the + gaps between glyph boxes, and a box is as wide as the font says its glyph is: drawn with + `Tc` alone, every tracked letter sits in the right place and still stands apart from the + next by the whole tracking, and pdf.js (past about a tenth of the font size) and + pdfplumber (past three points) read a widely tracked line as `J A N E D O E`. So a + positively tracked run is drawn with a second font resource over the same embedded font + program — its FontFile2, ToUnicode, CIDToGIDMap and descriptor shared by reference — whose + widths include the tracking. The glyphs land where `Tc` puts them, each box reaches the + next, and no font program is duplicated or modified. The widths are whole thousandths of + an em, because PDFium truncates fractional CID widths; the rest of the tracking, at most + half a thousandth of the font size, stays in `Tc`. One resource serves each face and + tracking per em for the whole document, on every page. + + Tracked runs still state their own text via `ActualText`, for the readers that honour it. + Negative tracking, Standard 14 faces, text drawn in visual order (a right-to-left run, every + line of a right-to-left table cell, a highlight chip whose text needs bidi) and text a + face's GSUB substitutions would rewrite keep drawing with `Tc` alone; a table cell decides + once for all its lines, so a single line the face cannot serve keeps the whole cell on `Tc`. + The raised widths differ from the font program's own advances on purpose, which ISO 32000 + allows and PDF/A and PDF/UA do not; the backend has no output mode that claims either. A + custom paragraph or table handler draws tracked text the same way through + `PdfRenderEnvironment.letterSpacedFont(font, fontSize, letterSpacing, text)`, which ships + `@Beta` with its result record `LetterSpacedFont`: the glyph positions and text layer it + produces are settled, while the shape of the call — a nullable result, a face bound to one + size — may still move in a minor release. Measurement and drawing use one rule, measured off PDFBox rather than assumed: one spacing unit per Unicode **code point** of the string actually drawn, the trailing unit included. @@ -1832,6 +1855,32 @@ follow semantic versioning; release dates are ISO 8601. ### Tests +- **Letter spacing carried in widths is held to the picture `Tc` draws.** + `PdfLetterSpacedFontTest` compares every glyph origin of a tracked run with the same run + drawn by PDFBox with `Tc` from the same point — at 0.18em, at 0.02em, and at a points + tracking across three sizes, to 0.01pt, the trailing advance included — and requires the + gap a reader measures between two tracked letters to be no more than 0.005pt, in a + paragraph, a table cell and a highlight chip alike. It reads the text layer back from + each; checks that the letter-spaced resource draws with the base font's own program, + ToUnicode and CIDToGIDMap with every width raised by the tracking and no `Tc` carrying + it; opens a password-protected document and finds the resource still there; and holds + one resource per face and tracking per em across styles, sizes, pages and the sections of + one document, with a face handed back standing for its base instead of stacking a second + tracking on it. `PdfLetterSpacedFontFallbackTest` pins each run that keeps `Tc`: negative + tracking, a Standard 14 face, a right-to-left run, a highlight chip whose text needs + bidi, tracking below half a thousandth of an em, and Devanagari that Poppins' + substitutions rewrite — while the same face still carries a Latin heading's spacing in + its widths. A table cell decides once from the text it draws, so one line the face cannot + serve keeps the whole cell on `Tc`, and a line drawn in visual order goes back to `Tc` + inside a cell whose other lines keep the widths. Verified by mutation: sending every run + back to `Tc`, widening by one unit too many, skipping the substitution check, excluding + every face that keeps substitutions, handing a table cell no text, not unwrapping a face + handed back, and sending a highlight chip or a table cell back to `Tc` or leaving the + full `Tc` on top of its widths each turn the tests that describe them red. + `LetterSpacingAcrossBackendsTest` and `TrackingFixedLayoutParityTest` read the tracking a + PDF declares as the width raise over its embedded program plus the `Tc` in force, and + turn red when the widths are one unit too wide or the residual `Tc` is dropped. + - **The schedule fixtures no longer carry a real venue's staff.** The weekly-schedule test fixture and the example data factory were written from a real bar's rota and kept its people's names, its venue name, and two third parties named in the day notes. diff --git a/assets/readme/examples/cover-letter-blue-banner-v2.pdf b/assets/readme/examples/cover-letter-blue-banner-v2.pdf index 75b3c51d0..eddecc378 100644 Binary files a/assets/readme/examples/cover-letter-blue-banner-v2.pdf and b/assets/readme/examples/cover-letter-blue-banner-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-boxed-sections-v2.pdf b/assets/readme/examples/cover-letter-boxed-sections-v2.pdf index 15bb3d355..5f3cb3b52 100644 Binary files a/assets/readme/examples/cover-letter-boxed-sections-v2.pdf and b/assets/readme/examples/cover-letter-boxed-sections-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-centered-headline-v2.pdf b/assets/readme/examples/cover-letter-centered-headline-v2.pdf index b716d4bb4..65996004d 100644 Binary files a/assets/readme/examples/cover-letter-centered-headline-v2.pdf and b/assets/readme/examples/cover-letter-centered-headline-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-classic-serif-v2.pdf b/assets/readme/examples/cover-letter-classic-serif-v2.pdf index 778fe9e2f..980ad249d 100644 Binary files a/assets/readme/examples/cover-letter-classic-serif-v2.pdf and b/assets/readme/examples/cover-letter-classic-serif-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-mint-editorial-v2.pdf b/assets/readme/examples/cover-letter-mint-editorial-v2.pdf index bc2b94892..1074c09d5 100644 Binary files a/assets/readme/examples/cover-letter-mint-editorial-v2.pdf and b/assets/readme/examples/cover-letter-mint-editorial-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf b/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf index 892a13782..eabd97d35 100644 Binary files a/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf and b/assets/readme/examples/cover-letter-monogram-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf b/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf index b3374c5f1..3888718a2 100644 Binary files a/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf and b/assets/readme/examples/cover-letter-sidebar-portrait-v2.pdf differ diff --git a/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf b/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf index dfc57099f..d73c6d6b0 100644 Binary files a/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf and b/assets/readme/examples/cover-letter-timeline-minimal-v2.pdf differ diff --git a/assets/readme/examples/cv-blue-banner-v2.pdf b/assets/readme/examples/cv-blue-banner-v2.pdf index acdc018f0..1d2e99f58 100644 Binary files a/assets/readme/examples/cv-blue-banner-v2.pdf and b/assets/readme/examples/cv-blue-banner-v2.pdf differ diff --git a/assets/readme/examples/cv-boxed-sections-v2.pdf b/assets/readme/examples/cv-boxed-sections-v2.pdf index 6904d64d9..60736954d 100644 Binary files a/assets/readme/examples/cv-boxed-sections-v2.pdf and b/assets/readme/examples/cv-boxed-sections-v2.pdf differ diff --git a/assets/readme/examples/cv-centered-headline-v2.pdf b/assets/readme/examples/cv-centered-headline-v2.pdf index 0632c0ef2..f21cfc99b 100644 Binary files a/assets/readme/examples/cv-centered-headline-v2.pdf and b/assets/readme/examples/cv-centered-headline-v2.pdf differ diff --git a/assets/readme/examples/cv-charcoal-gold-v2.pdf b/assets/readme/examples/cv-charcoal-gold-v2.pdf index a0ea137d2..e718e0ce1 100644 Binary files a/assets/readme/examples/cv-charcoal-gold-v2.pdf and b/assets/readme/examples/cv-charcoal-gold-v2.pdf differ diff --git a/assets/readme/examples/cv-classic-serif-v2.pdf b/assets/readme/examples/cv-classic-serif-v2.pdf index 19dde156c..79f67767d 100644 Binary files a/assets/readme/examples/cv-classic-serif-v2.pdf and b/assets/readme/examples/cv-classic-serif-v2.pdf differ diff --git a/assets/readme/examples/cv-midnight-navy-v2.pdf b/assets/readme/examples/cv-midnight-navy-v2.pdf index 931e5bfbe..1e55b67c6 100644 Binary files a/assets/readme/examples/cv-midnight-navy-v2.pdf and b/assets/readme/examples/cv-midnight-navy-v2.pdf differ diff --git a/assets/readme/examples/cv-minimal-underlined-v2.pdf b/assets/readme/examples/cv-minimal-underlined-v2.pdf index 505478f17..0bc47c1a4 100644 Binary files a/assets/readme/examples/cv-minimal-underlined-v2.pdf and b/assets/readme/examples/cv-minimal-underlined-v2.pdf differ diff --git a/assets/readme/examples/cv-mint-editorial-v2-custom.pdf b/assets/readme/examples/cv-mint-editorial-v2-custom.pdf index 3d553946a..1c121fe14 100644 Binary files a/assets/readme/examples/cv-mint-editorial-v2-custom.pdf and b/assets/readme/examples/cv-mint-editorial-v2-custom.pdf differ diff --git a/assets/readme/examples/cv-mint-editorial-v2.pdf b/assets/readme/examples/cv-mint-editorial-v2.pdf index c74cc0965..ea80bdb92 100644 Binary files a/assets/readme/examples/cv-mint-editorial-v2.pdf and b/assets/readme/examples/cv-mint-editorial-v2.pdf differ diff --git a/assets/readme/examples/cv-monogram-sidebar-v2.pdf b/assets/readme/examples/cv-monogram-sidebar-v2.pdf index bec4f121e..7c9f6f9b5 100644 Binary files a/assets/readme/examples/cv-monogram-sidebar-v2.pdf and b/assets/readme/examples/cv-monogram-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cv-navy-sidebar-v2.pdf b/assets/readme/examples/cv-navy-sidebar-v2.pdf index 06b4ef5f2..200cd991e 100644 Binary files a/assets/readme/examples/cv-navy-sidebar-v2.pdf and b/assets/readme/examples/cv-navy-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cv-professional-sidebar-v2.pdf b/assets/readme/examples/cv-professional-sidebar-v2.pdf index c61d2a66e..54fdb672d 100644 Binary files a/assets/readme/examples/cv-professional-sidebar-v2.pdf and b/assets/readme/examples/cv-professional-sidebar-v2.pdf differ diff --git a/assets/readme/examples/cv-serif-headline-v2.pdf b/assets/readme/examples/cv-serif-headline-v2.pdf index d105a3cd7..d6d73d506 100644 Binary files a/assets/readme/examples/cv-serif-headline-v2.pdf and b/assets/readme/examples/cv-serif-headline-v2.pdf differ diff --git a/assets/readme/examples/cv-sidebar-portrait-v2.pdf b/assets/readme/examples/cv-sidebar-portrait-v2.pdf index a60cb9968..8251b580d 100644 Binary files a/assets/readme/examples/cv-sidebar-portrait-v2.pdf and b/assets/readme/examples/cv-sidebar-portrait-v2.pdf differ diff --git a/assets/readme/examples/cv-teal-pulse-v2.pdf b/assets/readme/examples/cv-teal-pulse-v2.pdf index e1f9822a1..c23841bff 100644 Binary files a/assets/readme/examples/cv-teal-pulse-v2.pdf and b/assets/readme/examples/cv-teal-pulse-v2.pdf differ diff --git a/assets/readme/examples/cv-terracotta-rail-v2.pdf b/assets/readme/examples/cv-terracotta-rail-v2.pdf index e5457a93a..34ae45cce 100644 Binary files a/assets/readme/examples/cv-terracotta-rail-v2.pdf and b/assets/readme/examples/cv-terracotta-rail-v2.pdf differ diff --git a/assets/readme/examples/cv-timeline-minimal-v2.pdf b/assets/readme/examples/cv-timeline-minimal-v2.pdf index e26d082e9..16b957360 100644 Binary files a/assets/readme/examples/cv-timeline-minimal-v2.pdf and b/assets/readme/examples/cv-timeline-minimal-v2.pdf differ diff --git a/assets/readme/examples/cv-violet-grid-v2.pdf b/assets/readme/examples/cv-violet-grid-v2.pdf index db0398ce5..4a0cf992a 100644 Binary files a/assets/readme/examples/cv-violet-grid-v2.pdf and b/assets/readme/examples/cv-violet-grid-v2.pdf differ diff --git a/assets/readme/examples/invoice-consulting-v2.pdf b/assets/readme/examples/invoice-consulting-v2.pdf index cd77c060c..60dd166ec 100644 Binary files a/assets/readme/examples/invoice-consulting-v2.pdf and b/assets/readme/examples/invoice-consulting-v2.pdf differ diff --git a/assets/readme/examples/invoice-luma-studio-v2.pdf b/assets/readme/examples/invoice-luma-studio-v2.pdf index c6dd269d1..c2f8bfa7c 100644 Binary files a/assets/readme/examples/invoice-luma-studio-v2.pdf and b/assets/readme/examples/invoice-luma-studio-v2.pdf differ diff --git a/assets/readme/examples/letter-spacing.pdf b/assets/readme/examples/letter-spacing.pdf index 56b06d7a1..06f7838e8 100644 Binary files a/assets/readme/examples/letter-spacing.pdf and b/assets/readme/examples/letter-spacing.pdf differ diff --git a/assets/readme/examples/proposal-editorial-v2.pdf b/assets/readme/examples/proposal-editorial-v2.pdf index 45f0ccd6b..1363cd572 100644 Binary files a/assets/readme/examples/proposal-editorial-v2.pdf and b/assets/readme/examples/proposal-editorial-v2.pdf differ diff --git a/assets/readme/examples/proposal-northline-v2.pdf b/assets/readme/examples/proposal-northline-v2.pdf index 4216dc4e5..a52e37120 100644 Binary files a/assets/readme/examples/proposal-northline-v2.pdf and b/assets/readme/examples/proposal-northline-v2.pdf differ diff --git a/docs/api-stability.md b/docs/api-stability.md index d187dbd9c..210afacb9 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -57,17 +57,22 @@ matrix. > Geometry identity with the PDF backend is a design invariant and will not > change; the API shape around it may still move in a minor release. > -> Four members of the otherwise-Stable **PDF backend** also carry `@Beta`. The +> Six members of the otherwise-Stable **PDF backend** also carry `@Beta`. The > package is not Experimental — these are: > `PdfFixedLayoutBackend.renderSections` / `writeSections`, the low-level seam > that concatenates several sections into one document, where > `MultiSectionDocument` via `GraphCompose.documents()` is the settled entry -> point most callers want instead; and +> point most callers want instead; > `PdfFixedLayoutBackend.Builder.deterministic` in both overloads, which pins > `CreationDate` / `ModDate` and derives the `/ID` from metadata so a document > renders byte-identically across runs. Determinism is off by default, and what > reproducible builds depend on is the *behaviour* — it is the shape of the -> opt-in that may still move. +> opt-in that may still move; and +> `PdfRenderEnvironment.letterSpacedFont` with its result record +> `PdfRenderEnvironment.LetterSpacedFont`, the seam a render handler uses to +> draw tracked text with the spacing in the glyph widths rather than in `Tc` +> gaps. The glyph positions and text layer it produces are settled; the shape of +> the call — a nullable result, a face bound to one size — may still move. ### What each tier promises diff --git a/knowledge/api/backends.json b/knowledge/api/backends.json index 171320496..d2ef4c6e4 100644 --- a/knowledge/api/backends.json +++ b/knowledge/api/backends.json @@ -22,10 +22,10 @@ "graph-compose-testing:sources" ], "counts": { - "types": 69, - "methods": 371, + "types": 70, + "methods": 375, "constants": 18, - "generated": 189 + "generated": 192 }, "packages": [ { @@ -1033,6 +1033,33 @@ ], "artifact": "graph-compose-render-pdf", "members": [ + { + "kind": "method", + "name": "letterSpacedFont", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "PdfRenderEnvironment.LetterSpacedFont", + "params": [ + { + "type": "PDFont", + "name": "font" + }, + { + "type": "double", + "name": "fontSize" + }, + { + "type": "double", + "name": "letterSpacing" + }, + { + "type": "String", + "name": "text" + } + ], + "stability": "beta" + }, { "kind": "method", "name": "document", @@ -1149,6 +1176,57 @@ ] } ] + }, + { + "name": "PdfRenderEnvironment.LetterSpacedFont", + "binaryName": "com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment$LetterSpacedFont", + "kind": "record", + "modifiers": [ + "final" + ], + "artifact": "graph-compose-render-pdf", + "stability": "beta", + "members": [ + { + "kind": "constructor", + "name": "LetterSpacedFont", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": null, + "params": [ + { + "type": "PDFont", + "name": null + }, + { + "type": "float", + "name": null + } + ], + "stability": "beta" + }, + { + "kind": "method", + "name": "font", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "PDFont", + "params": [], + "stability": "beta" + }, + { + "kind": "method", + "name": "characterSpacing", + "static": false, + "origin": "generated", + "typeParameters": null, + "returns": "float", + "params": [], + "stability": "beta" + } + ] } ] }, diff --git a/knowledge/api/backends.md b/knowledge/api/backends.md index 0713aa562..113b6dfdb 100644 --- a/knowledge/api/backends.md +++ b/knowledge/api/backends.md @@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se **GraphCompose version:** 2.4.0-SNAPSHOT -Types: 69 · methods: 371 · constants: 18 · compiler-generated members: 189 +Types: 70 · methods: 375 · constants: 18 · compiler-generated members: 192 ## com.demcha.compose.document.backend.fixed @@ -115,6 +115,7 @@ Types: 69 · methods: 371 · constants: 18 · compiler-generated members: 189 - `PdfHeaderFooterOptions toPdf(DocumentHeaderFooter entry)` ### PdfRenderEnvironment (class) +- `PdfRenderEnvironment.LetterSpacedFont letterSpacedFont(PDFont font, double fontSize, double letterSpacing, String text) [beta]` - `PDDocument document()` - `FontLibrary fonts()` - `PDPageContentStream pageSurface(int pageIndex)` @@ -125,6 +126,11 @@ Types: 69 · methods: 371 · constants: 18 · compiler-generated members: 189 - `void markReorderedText()` - `void registerAnchor(PlacedFragment fragment, String anchor)` +### PdfRenderEnvironment.LetterSpacedFont (record) [beta] +- `new LetterSpacedFont(PDFont, float) [beta]` +- `PDFont font() [beta]` +- `float characterSpacing() [beta]` + ## com.demcha.compose.document.backend.fixed.pdf.handlers ### PdfAnchorMarkerRenderHandler (class) diff --git a/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java b/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java index 19a29b390..fafe0775b 100644 --- a/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java +++ b/qa/src/test/java/com/demcha/compose/document/api/LetterSpacingAcrossBackendsTest.java @@ -28,6 +28,7 @@ import java.util.regex.Pattern; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; /** * One authored style, all three backends. @@ -40,8 +41,10 @@ * *

The three units are genuinely different numbers for the same distance: * 2.4pt is {@code spc="240"} in DrawingML's hundredths, {@code w:val="48"} in - * Word's twentieths, and a {@code Tc} of 2.4 in the PDF's points. A test that - * asserted one number across all three would be asserting a bug.

+ * Word's twentieths, and 2.4 points of advance per glyph in the PDF, which states + * it in the widths of the font it draws with (120 thousandths of the 20pt em) and + * in {@code Tc} for any remainder. A test that asserted one number across all + * three would be asserting a bug.

*/ class LetterSpacingAcrossBackendsTest { @@ -84,12 +87,13 @@ void docxReceivesItInTwentiethsOfAPoint() throws Exception { } @Test - void pdfReceivesItAsPointsOfCharacterSpacing() throws Exception { + void pdfReceivesItAsPointsOfAdvancePerGlyph() throws Exception { byte[] pdf = render(session -> session.render(new PdfFixedLayoutBackend())); - // Tc is written in points, so the operator carries the resolved value - // itself rather than a converted one. - assertThat(contentStream(pdf)).containsPattern("2\\.4\\d*\\s+Tc"); + // Read off the file, not off the backend: what the glyph widths carry + // beyond the embedded program's own, plus Tc, is the distance every glyph + // moves, and it is the resolved value itself. + assertThat(PdfDeclaredTracking.ofFirstRun(pdf)).isCloseTo(EXPECTED_POINTS, within(1e-6)); assertThat(pdfText(pdf)).isEqualTo(NAME); } @@ -192,11 +196,4 @@ private static String pdfText(byte[] pdf) throws Exception { return new PDFTextStripper().getText(document).trim(); } } - - private static String contentStream(byte[] pdf) throws Exception { - try (PDDocument document = Loader.loadPDF(pdf)) { - return new String(document.getPage(0).getContents().readAllBytes(), - java.nio.charset.StandardCharsets.ISO_8859_1); - } - } } diff --git a/qa/src/test/java/com/demcha/compose/document/api/PdfDeclaredTracking.java b/qa/src/test/java/com/demcha/compose/document/api/PdfDeclaredTracking.java new file mode 100644 index 000000000..5701a7271 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/PdfDeclaredTracking.java @@ -0,0 +1,110 @@ +package com.demcha.compose.document.api; + +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.COSName; +import org.apache.pdfbox.cos.COSNumber; +import org.apache.pdfbox.cos.COSString; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.font.PDCIDFontType2; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +/** + * The letter spacing a PDF declares for the first text on its first page, in points per glyph. + * + *

A PDF has two places to state tracking, and the PDF backend uses both: the widths of the font + * resource a run is drawn with, raised above the widths its embedded program gives the same + * glyphs, and the {@code Tc} operator for what whole thousandths of an em cannot state. A reader's + * pen moves by their sum beyond the glyph's own width, so the sum is what the file declares. It is + * read off the file — the shown glyph's width against its embedded program, plus the {@code Tc} in + * force — so it holds whichever way the backend divides the distance between the two.

+ */ +final class PdfDeclaredTracking { + + private PdfDeclaredTracking() { + } + + static double ofFirstRun(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + PDPage page = document.getPage(0); + PDFStreamParser parser = new PDFStreamParser(page); + List operands = new ArrayList<>(); + Deque saved = new ArrayDeque<>(); + TextState state = new TextState(null, 0f, 0f); + Object token; + while ((token = parser.parseNextToken()) != null) { + if (token instanceof COSBase operand) { + operands.add(operand); + continue; + } + switch (((Operator) token).getName()) { + case "q" -> saved.push(state); + case "Q" -> state = saved.pop(); + case "Tf" -> state = new TextState((COSName) operands.get(0), + ((COSNumber) operands.get(1)).floatValue(), state.characterSpacing()); + case "Tc" -> state = new TextState(state.font(), state.size(), + ((COSNumber) operands.get(0)).floatValue()); + case "Tj", "TJ" -> { + byte[] shown = firstString(operands); + if (shown.length > 0) { + PDFont font = page.getResources().getFont(state.font()); + int code = font.readCode(new ByteArrayInputStream(shown)); + return widthRaise(font, code) * (double) state.size() / 1000.0 + state.characterSpacing(); + } + } + default -> { + } + } + operands.clear(); + } + } + throw new AssertionError("the first page shows no text"); + } + + /** + * How many thousandths of an em the font resource's width for {@code code} exceeds the width a + * PDF writer states for the same glyph of the embedded program, which is its advance rounded to + * a whole thousandth of an em. + */ + private static long widthRaise(PDFont font, int code) throws IOException { + if (!(font instanceof PDType0Font type0) || !(type0.getDescendantFont() instanceof PDCIDFontType2 cid)) { + return 0; + } + TrueTypeFont program = cid.getTrueTypeFont(); + int programWidth = Math.round(program.getAdvanceWidth(cid.codeToGID(code)) + * (1000f / program.getUnitsPerEm())); + return Math.round(font.getWidth(code)) - programWidth; + } + + private static byte[] firstString(List 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; + } +}