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); + ListThis 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 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. 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. Experimental, like {@link #letterSpacedFont}: its shape may still change in a minor
+ * release.
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 MapA 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(); + ListWhile 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; ListGeometry 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(ConsumerEach 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); + ListNative 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