diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc651b5c..625cd65f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,20 @@ follow semantic versioning; release dates are ISO 8601. as `โค` rather than `โค๏ธ` โ€” so the text is spelled in the fully-qualified form of UTS #51, U+FE0F restored after every character whose default presentation is text. That needs no new emoji-set release: the published `graph-compose-emoji` 1.0.0 resolves to the same text. - Nothing renders differently yet; the PDF backend reads the text in a following change. + +- **A PDF carries the text of an inline icon, so a copied line keeps its emoji.** An inline + SVG icon is drawn from paths, so the page had no character where it sat: a line with + `:rocket:` in it, copied out of a PDF into a messenger, arrived without the rocket. + `PdfParagraphFragmentRenderHandler` now writes the text an icon states into the page's text + layer, through `PdfRenderEnvironment.writeTextLayer` (`@Beta`): one invisible glyph over the + icon, on the line's baseline, whose advance spans the icon. The glyph comes from a Type 3 + font of empty glyphs whose `ToUnicode` map states each text, a whole ZWJ sequence included, + shown in rendering mode 3 so nothing is painted; one such font serves a document, and a new + one starts after 255 distinct texts. `ActualText` around the paths was tried first; PDFBox, + poppler, pdf.js and MuPDF extract nothing from it, because it replaces the text of the + glyphs it covers and a drawing has none. + The rendered page is unchanged pixel for pixel. PPTX and DOCX exports do not carry the text + yet. The committed `emoji-shortcodes.pdf` preview is re-rendered: its emoji now copy out. ### Performance @@ -71,6 +84,20 @@ follow semantic versioning; release dates are ISO 8601. `EmojiLibraryTest` checks resolved emoji carry their text and a glyph named by anything but codepoints resolves without one; `SvgIconTextTest` checks `withText` copies the icon and leaves the original as it was. +- `PdfTextLayerTest` extracts `Launch ๐Ÿš€ by ๐Ÿ‘ฉโ€๐Ÿ’ป with โค๏ธ done.` from a rendered line, codepoint + for codepoint; renders the same icon with and without text and compares the pages pixel for + pixel, which also fails if the invisible rendering mode leaks onto the words after the icon; + checks an icon given `withText("โœ“")` copies as `โœ“` and one without text adds no font; fills + one font with 255 texts and starts a second (each text used twice, one code each), its + `ToUnicode` in blocks of at most 100; keeps one font across the sections of a multi-section + document; writes a 256-unit text but not a 257-unit one; gives a 2:1 icon after a word + tracked in `Tc` a glyph exactly as wide as the icon, on the baseline, so neither the + horizontal scaling nor the spacing and rise left by the run before can go astray; keeps + `deterministic(true)` output byte-identical; fails a call made inside an open text object + with nothing written, and closes the glyph's text object and saved state when writing fails + half-way. In right-to-left Hebrew and Arabic lines it places the emoji's glyph between the + words it was written between and keeps a ZWJ sequence and a U+FE0F on one glyph each; a + left-to-right sentence with a Hebrew word reads back in written order. ## v2.4.0 โ€” 2026-09-14 diff --git a/assets/readme/examples/emoji-shortcodes.pdf b/assets/readme/examples/emoji-shortcodes.pdf index 4294baf02..e7dfd1bc6 100644 Binary files a/assets/readme/examples/emoji-shortcodes.pdf and b/assets/readme/examples/emoji-shortcodes.pdf differ diff --git a/core/src/main/java/com/demcha/compose/document/layout/InlineLayoutToken.java b/core/src/main/java/com/demcha/compose/document/layout/InlineLayoutToken.java index cf2a0ec67..d5e626fbc 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/InlineLayoutToken.java +++ b/core/src/main/java/com/demcha/compose/document/layout/InlineLayoutToken.java @@ -150,7 +150,8 @@ record InlineSvgToken( double height, InlineImageAlignment alignment, double baselineOffset, - DocumentLinkTarget linkTarget + DocumentLinkTarget linkTarget, + String text ) implements InlineLayoutToken { InlineSvgToken { alignment = alignment == null ? InlineImageAlignment.CENTER : alignment; @@ -174,7 +175,8 @@ static InlineSvgToken of(InlineSvgRun run) { run.height(), run.alignment(), run.baselineOffset(), - run.linkTarget()); + run.linkTarget(), + icon.text()); } /** diff --git a/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java b/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java index 6de626a60..4c35374e1 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java +++ b/core/src/main/java/com/demcha/compose/document/layout/ParagraphWrapping.java @@ -876,7 +876,8 @@ private static ParagraphLine toInlineParagraphLine(List token svgToken.height(), svgToken.alignment(), svgToken.baselineOffset(), - svgToken.linkTarget())); + svgToken.linkTarget(), + svgToken.text())); width += svgToken.width(); tokenIndex++; } else { diff --git a/core/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSvgSpan.java b/core/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSvgSpan.java index 418f16b17..2279d7616 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSvgSpan.java +++ b/core/src/main/java/com/demcha/compose/document/layout/payloads/ParagraphSvgSpan.java @@ -17,6 +17,8 @@ * @param alignment vertical alignment relative to the surrounding text * @param baselineOffset extra vertical offset in points; positive moves up * @param linkTarget optional link metadata + * @param text the text the icon stands for when the page is read as + * text (its {@code SvgIcon.text()}), or {@code null} * @author Artem Demchyshyn * @since 1.9.0 */ @@ -26,7 +28,8 @@ public record ParagraphSvgSpan( double height, InlineImageAlignment alignment, double baselineOffset, - DocumentLinkTarget linkTarget + DocumentLinkTarget linkTarget, + String text ) implements ParagraphSpan { /** * Copies the layer stack defensively and normalizes alignment defaults. diff --git a/docs/api-stability.md b/docs/api-stability.md index f7b2bfb80..2b36f1333 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -57,7 +57,7 @@ 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. > -> Six members of the otherwise-Stable **PDF backend** also carry `@Beta`. The +> Seven 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 @@ -67,12 +67,16 @@ matrix. > `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; and +> opt-in that may still move; > `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. +> the call โ€” a nullable result, a face bound to one size โ€” may still move; and +> `PdfRenderEnvironment.writeTextLayer`, the seam a render handler uses to put +> the text a drawing stands for โ€” an inline emoji's character โ€” in the page's +> text layer as an invisible glyph. The text layer it writes is settled; the +> shape of the call may still move. ### What each tier promises diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index ab832886e..125c282a2 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -60,6 +60,7 @@ Payload records live in `core` under | Inline images (`ParagraphImageSpan`) | โœ… `PdfParagraphFragmentRenderHandler` | โœ… `PptxParagraphFragmentRenderHandler` | โŒ | | Inline vector shapes (`ParagraphShapeSpan`) | โœ… `PdfParagraphFragmentRenderHandler` | โš ๏ธ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius โ€” single-adjust preset) | โŒ | | Inline SVG (`ParagraphSvgSpan`) | โœ… `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | โš ๏ธ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback; gradient paints use their primary colour) | โŒ | +| Text an inline icon stands for โ€” copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | โœ… `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF โ€” it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | โŒ the icon is drawn and its text is not written | โŒ inline SVG is not written | | Rectangle shape โ€” fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | โœ… `PdfShapeFragmentRenderHandler` | โš ๏ธ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners โ€” single-adjust `roundRect` preset โ€” until custom geometry lands; uniform radii and side borders exact) | โŒ | | Ellipse (`EllipseFragmentPayload`) | โœ… `PdfEllipseFragmentRenderHandler` | โœ… `PptxEllipseFragmentRenderHandler` | โŒ | | Line โ€” dash pattern, line cap (`LineFragmentPayload`) | โœ… `PdfLineFragmentRenderHandler` | โš ๏ธ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | โŒ | diff --git a/docs/recipes/rich-text.md b/docs/recipes/rich-text.md index cb500297a..3524f4ccf 100644 --- a/docs/recipes/rich-text.md +++ b/docs/recipes/rich-text.md @@ -128,6 +128,15 @@ both take `alignment` / `baselineOffset` / link overloads and a clickable form via `DocumentLinkOptions`. `SvgIcon.parse(String)` reads inline SVG markup; `SvgIcon.read(Path)` loads it from a file. +An icon is a drawing, so copying the line out of the PDF skips it. When the +icon stands for a character, say which with `withText` โ€” the PDF then carries +that text where the icon sits, invisible, and a copy, a search or a text +extractor reads it: + +```java +SvgIcon check = SvgIcon.read(Path.of("icons/check.svg")).withText("โœ“"); +``` + ## Emoji / shortcodes `emoji(":code:")` resolves a GitHub-style shortcode to an inline colour @@ -148,6 +157,11 @@ in the optional, independently-versioned `graph-compose-emoji` companion artifact (Noto Emoji, SIL OFL 1.1) โ€” add it to the classpath to resolve shortcodes; the engine itself carries no emoji art. +Each resolved emoji states its own text, so a line copied out of the PDF +pastes with the emoji in it โ€” `:rocket:` as ๐Ÿš€, `:heart:` as โค๏ธ โ€” into a +messenger, a search box or a text extractor. PPTX and DOCX exports do not +carry it yet. + ## Inline shapes and checkboxes Geometric figures drawn from geometry โ€” not font glyphs โ€” so they render diff --git a/knowledge/api/backends.json b/knowledge/api/backends.json index 43fe600c8..492e2dd98 100644 --- a/knowledge/api/backends.json +++ b/knowledge/api/backends.json @@ -23,7 +23,7 @@ ], "counts": { "types": 70, - "methods": 375, + "methods": 376, "constants": 18, "generated": 192 }, @@ -1060,6 +1060,41 @@ ], "stability": "beta" }, + { + "kind": "method", + "name": "writeTextLayer", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "void", + "params": [ + { + "type": "PDPageContentStream", + "name": "stream" + }, + { + "type": "String", + "name": "text" + }, + { + "type": "double", + "name": "x" + }, + { + "type": "double", + "name": "baselineY" + }, + { + "type": "double", + "name": "width" + }, + { + "type": "double", + "name": "height" + } + ], + "stability": "beta" + }, { "kind": "method", "name": "document", diff --git a/knowledge/api/backends.md b/knowledge/api/backends.md index 4957342cc..3c3488dbb 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.1-SNAPSHOT -Types: 70 ยท methods: 375 ยท constants: 18 ยท compiler-generated members: 192 +Types: 70 ยท methods: 376 ยท constants: 18 ยท compiler-generated members: 192 ## com.demcha.compose.document.backend.fixed @@ -116,6 +116,7 @@ Types: 70 ยท methods: 375 ยท constants: 18 ยท compiler-generated members: 192 ### PdfRenderEnvironment (class) - `PdfRenderEnvironment.LetterSpacedFont letterSpacedFont(PDFont font, double fontSize, double letterSpacing, String text) [beta]` +- `void writeTextLayer(PDPageContentStream stream, String text, double x, double baselineY, double width, double height) [beta]` - `PDDocument document()` - `FontLibrary fonts()` - `PDPageContentStream pageSurface(int pageIndex)` 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 144df85ac..5ffbc5003 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 @@ -412,8 +412,8 @@ private Rendered buildDocument(LayoutGraph graph, FixedLayoutRenderContext conte List pages = createPages(document, graph); try (PdfRenderSession session = new PdfRenderSession(document, pages)) { - PdfRenderEnvironment environment = - new PdfRenderEnvironment(document, fonts, session, letterSpacedFonts); + PdfRenderEnvironment environment = new PdfRenderEnvironment( + document, fonts, session, letterSpacedFonts, new PdfTextLayer(document)); renderGraph(graph, environment); reorderedText = environment.reorderedText(); PdfBookmarkOutlineWriter.apply(document, environment.bookmarkRecords()); @@ -539,6 +539,8 @@ private Rendered buildSectionsDocument(List sections) throws Except // 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); + // Likewise one text-layer font, however many sections draw icons that state text. + PdfTextLayer textLayer = new PdfTextLayer(document); int pageOffset = 0; for (SectionUnit section : sections) { LayoutGraph graph = section.graph(); @@ -548,7 +550,7 @@ private Rendered buildSectionsDocument(List sections) throws Except // 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, letterSpacedFonts); + document, fonts, renderSession, pageOffset, letterSpacedFonts, textLayer); chrome.renderGraph(graph, environment); reorderedText |= environment.reorderedText(); bookmarks.addAll(environment.bookmarkRecords()); 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 57d238731..13112b533 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 @@ -43,11 +43,12 @@ public final class PdfRenderEnvironment { private final Map anchorDestinations = new LinkedHashMap<>(); private final List deferredInternalLinks = new ArrayList<>(); private final PdfTrackedFontResources letterSpacedFonts; + private final PdfTextLayer textLayer; private boolean reorderedText; PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session, - PdfTrackedFontResources letterSpacedFonts) { - this(document, fonts, session, 0, letterSpacedFonts); + PdfTrackedFontResources letterSpacedFonts, PdfTextLayer textLayer) { + this(document, fonts, session, 0, letterSpacedFonts, textLayer); } /** @@ -65,14 +66,16 @@ public final class PdfRenderEnvironment { * @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 + * @param textLayer the document's text-layer font, shared by all its sections */ PdfRenderEnvironment(PDDocument document, FontLibrary fonts, PdfRenderSession session, int pageIndexOffset, - PdfTrackedFontResources letterSpacedFonts) { + PdfTrackedFontResources letterSpacedFonts, PdfTextLayer textLayer) { this.document = document; this.fonts = fonts; this.session = session; this.pageIndexOffset = pageIndexOffset; this.letterSpacedFonts = letterSpacedFonts; + this.textLayer = textLayer; } /** @@ -148,6 +151,45 @@ PdfTrackedFontResources letterSpacedFonts() { return letterSpacedFonts; } + /** + * Puts {@code text} in the page's text layer over a box a handler has drawn, painting nothing. + * + *

A drawing has no characters, so a reader that copies, searches or extracts the page + * finds nothing where one sits: a colour emoji drawn from SVG paths pastes as nothing. This + * writes the text the drawing stands for as one invisible glyph whose advance spans the box's + * width, so extracted text reads it in place and a selection runs across the drawing. The + * glyph rises one {@code height} from {@code baselineY}; write it on the surrounding line's + * baseline, which keeps it in that line for readers that group text by position. Call it + * outside a text object; the graphics state is left as it was, including when writing fails + * part-way โ€” the glyph's own text object and saved state are closed before the exception + * leaves.

+ * + *

Experimental. The text layer this produces is settled; the shape of the call may + * still change in a minor release.

+ * + * @param stream the page's content stream, outside a text object + * @param text the text the drawing stands for; nothing is written when it is + * {@code null} or empty, or longer than 256 UTF-16 code units + * @param x left edge of the box in page space + * @param baselineY baseline to write the text on + * @param width width of the box in points + * @param height height of the box in points + * @throws IOException if the content stream cannot be written + * @throws IllegalStateException if there is text to write and {@code stream} is inside a text + * object; nothing is written to the stream then, and the + * caller's text object stays open + * @since 2.5.0 + */ + @Beta + public void writeTextLayer(PDPageContentStream stream, + String text, + double x, + double baselineY, + double width, + double height) throws IOException { + textLayer.write(stream, text, (float) x, (float) baselineY, (float) width, (float) height); + } + /** * Returns the live PDFBox document for the current render pass. * diff --git a/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTextLayer.java b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTextLayer.java new file mode 100644 index 000000000..c694206b4 --- /dev/null +++ b/render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfTextLayer.java @@ -0,0 +1,253 @@ +package com.demcha.compose.document.backend.fixed.pdf; + +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSFloat; +import org.apache.pdfbox.cos.COSInteger; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSStream; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.apache.pdfbox.pdmodel.graphics.state.RenderingMode; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; + +/** + * Puts text in a page's text layer without painting anything. + * + *

An inline icon is a drawing, and a drawing has no characters: a colour emoji drawn from + * SVG paths leaves nothing where it sits for a reader that copies, searches or extracts the page, + * so a line copied into a messenger arrives without its emoji. {@code ActualText} cannot supply + * one. It replaces the text of the glyphs it covers, and a drawing covers none; PDFBox, poppler, + * pdf.js and MuPDF all extract nothing from a {@code Span} with {@code ActualText} around paths.

+ * + *

So the text goes in as a glyph of its own, shown over the drawing from a font that paints + * nothing: a Type 3 font whose glyph procedures are empty, and whose {@code ToUnicode} map states, + * code by code, the text each glyph stands for — a whole ZWJ sequence when that is what the + * icon depicts. The glyph is shown in rendering mode 3, neither filled nor stroked, so a viewer + * that cannot read the font still paints nothing. An OCR'd scan carries its text layer the same + * way, as invisible text over the picture; PDFBox, poppler, pdf.js and MuPDF all read this one.

+ * + *

One font serves a document, with one code per distinct text; a font has 255 codes, and the + * text after that starts another. The font's dictionaries grow as codes are handed out, so the + * file is complete whenever it is saved, however many pages have been drawn.

+ */ +final class PdfTextLayer { + + /** + * The longest text one glyph may stand for, in UTF-16 code units. A {@code ToUnicode} + * destination is limited to 512 bytes; longer text is not written. + */ + static final int MAX_TEXT_UNITS = 256; + + private final PDDocument document; + private final Map glyphs = new HashMap<>(); + private TextLayerFont font; + + PdfTextLayer(PDDocument document) { + this.document = document; + } + + /** + * Writes {@code text} as one invisible glyph whose advance spans {@code width}. + * + * @param stream page content stream, outside a text object + * @param text the text to write; nothing is written when {@code null}, empty or longer + * than {@link #MAX_TEXT_UNITS} + * @param x left edge of the glyph in page space + * @param baselineY baseline the glyph sits on + * @param width advance of the glyph in points + * @param height font size of the glyph in points + * @throws IOException if the content stream cannot be written + */ + void write(PDPageContentStream stream, + String text, + float x, + float baselineY, + float width, + float height) throws IOException { + if (text == null || text.isEmpty() || text.length() > MAX_TEXT_UNITS + || !(width > 0) || !(height > 0) || Float.isInfinite(width) || Float.isInfinite(height)) { + return; + } + Glyph glyph = glyphs.get(text); + if (glyph == null) { + if (font == null || font.isFull()) { + font = TextLayerFont.create(document); + } + glyph = font.add(text); + glyphs.put(text, glyph); + } + // Text state is graphics state and outlives ET, in both directions. Out: without the + // q/Q every word drawn after the glyph would be invisible too. In: spacing or rise left + // by the run before (a letter-spaced word keeps its tracking in Tc) would stretch or + // lift the glyph, so each is set here rather than inherited. + // Inside a caller's text object PDFBox refuses the q before writing it, so a misplaced + // call fails with nothing written. Past the q, the text object and the q are closed on + // every path: a failure half-way must not leave the rest of the page inside them. + stream.saveGraphicsState(); + try { + stream.beginText(); + try { + stream.setFont(glyph.font(), height); + stream.setRenderingMode(RenderingMode.NEITHER); + stream.setCharacterSpacing(0); + stream.setWordSpacing(0); + stream.setTextRise(0); + // The glyph is one em wide, and an em is the font size: scale it to the box. + stream.setHorizontalScaling(100f * width / height); + stream.newLineAtOffset(x, baselineY); + stream.showText(glyph.shown()); + } finally { + stream.endText(); + } + } finally { + stream.restoreGraphicsState(); + } + } + + /** A code of one text-layer font, and the string that shows it. */ + private record Glyph(TextLayerFont font, String shown) { + } + + /** + * A Type 3 font of empty glyphs whose codes are handed out one text at a time. + * + *

PDFBox cannot encode text for a Type 3 font, so {@link #encode(int)} is overridden: a + * glyph is shown as the private-use character {@code U+E000} plus its code, and encodes to + * that one byte.

+ * + *

Only the dictionary is kept current. What {@code PDType3Font} read from it at + * construction — the encoding, the widths, an absent {@code ToUnicode} — goes + * stale as codes are added, and nothing on PDFBox 3.0's write path reads it: {@code setFont} + * and {@code showText} reach only {@code encode} and the embedding flags. A PDFBox upgrade + * that measured or looked glyphs up while writing would see {@code .notdef} here.

+ */ + private static final class TextLayerFont extends PDType3Font { + + private static final int CARRIER = 0xE000; + private static final int LAST_CODE = 255; + private static final int GLYPH_WIDTH = 1000; + /** A CMap block holds at most 100 mappings. */ + private static final int BFCHAR_BLOCK = 100; + + private final COSDictionary charProcs; + private final COSArray differences; + private final COSArray widths; + private final COSStream emptyGlyph; + private final COSStream toUnicode; + private final List texts = new ArrayList<>(); + + private TextLayerFont(COSDictionary dictionary, COSStream emptyGlyph, COSStream toUnicode) + throws IOException { + super(dictionary); + this.charProcs = dictionary.getCOSDictionary(COSName.CHAR_PROCS); + this.differences = dictionary.getCOSDictionary(COSName.ENCODING).getCOSArray(COSName.DIFFERENCES); + this.widths = dictionary.getCOSArray(COSName.WIDTHS); + this.emptyGlyph = emptyGlyph; + this.toUnicode = toUnicode; + } + + static TextLayerFont create(PDDocument document) throws IOException { + COSStream emptyGlyph = document.getDocument().createCOSStream(); + try (OutputStream out = emptyGlyph.createOutputStream()) { + // Advance one em, no marks, no bounding box: d0 with nothing after it. + out.write((GLYPH_WIDTH + " 0 d0\n").getBytes(StandardCharsets.US_ASCII)); + } + COSStream toUnicode = document.getDocument().createCOSStream(); + + COSDictionary dictionary = new COSDictionary(); + dictionary.setItem(COSName.TYPE, COSName.FONT); + dictionary.setItem(COSName.SUBTYPE, COSName.TYPE3); + dictionary.setItem(COSName.FONT_BBOX, numbers(0, 0, GLYPH_WIDTH, GLYPH_WIDTH)); + dictionary.setItem(COSName.FONT_MATRIX, numbers(0.001f, 0, 0, 0.001f, 0, 0)); + dictionary.setItem(COSName.CHAR_PROCS, new COSDictionary()); + COSDictionary encoding = new COSDictionary(); + encoding.setItem(COSName.TYPE, COSName.ENCODING); + COSArray differences = new COSArray(); + differences.add(COSInteger.ONE); + encoding.setItem(COSName.DIFFERENCES, differences); + dictionary.setItem(COSName.ENCODING, encoding); + dictionary.setInt(COSName.FIRST_CHAR, 1); + dictionary.setInt(COSName.LAST_CHAR, 1); + dictionary.setItem(COSName.WIDTHS, new COSArray()); + dictionary.setItem(COSName.RESOURCES, new COSDictionary()); + TextLayerFont font = new TextLayerFont(dictionary, emptyGlyph, toUnicode); + // Attached only now: PDFont's constructor reads ToUnicode, and this stream is still + // empty until the first code is added. + dictionary.setItem(COSName.TO_UNICODE, toUnicode); + return font; + } + + boolean isFull() { + return texts.size() == LAST_CODE; + } + + /** Gives {@code text} the next code, and states it in every dictionary that lists codes. */ + Glyph add(String text) throws IOException { + texts.add(text); + int code = texts.size(); + COSName glyphName = COSName.getPDFName("t" + code); + charProcs.setItem(glyphName, emptyGlyph); + differences.add(glyphName); + widths.add(COSInteger.get(GLYPH_WIDTH)); + getCOSObject().setInt(COSName.LAST_CHAR, code); + writeToUnicode(); + return new Glyph(this, new String(Character.toChars(CARRIER + code))); + } + + @Override + protected byte[] encode(int unicode) { + int code = unicode - CARRIER; + if (code < 1 || code > texts.size()) { + throw new IllegalArgumentException("No text-layer glyph for U+" + Integer.toHexString(unicode)); + } + return new byte[]{(byte) code}; + } + + private void writeToUnicode() throws IOException { + StringBuilder cmap = new StringBuilder(128 + texts.size() * 24); + cmap.append("/CIDInit /ProcSet findresource begin\n") + .append("12 dict begin\n") + .append("begincmap\n") + .append("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n") + .append("/CMapName /Adobe-Identity-UCS def\n") + .append("/CMapType 2 def\n") + .append("1 begincodespacerange\n<00> \nendcodespacerange\n"); + HexFormat hex = HexFormat.of().withUpperCase(); + for (int start = 0; start < texts.size(); start += BFCHAR_BLOCK) { + int end = Math.min(texts.size(), start + BFCHAR_BLOCK); + cmap.append(end - start).append(" beginbfchar\n"); + for (int i = start; i < end; i++) { + cmap.append('<').append(hex.toHexDigits((byte) (i + 1))).append("> <") + .append(hex.formatHex(texts.get(i).getBytes(StandardCharsets.UTF_16BE))) + .append(">\n"); + } + cmap.append("endbfchar\n"); + } + cmap.append("endcmap\n") + .append("CMapName currentdict /CMap defineresource pop\n") + .append("end\n") + .append("end\n"); + try (OutputStream out = toUnicode.createOutputStream(COSName.FLATE_DECODE)) { + out.write(cmap.toString().getBytes(StandardCharsets.US_ASCII)); + } + } + + private static COSArray numbers(float... values) { + COSArray array = new COSArray(); + for (float value : values) { + array.add(value == (int) value ? COSInteger.get((int) value) : new COSFloat(value)); + } + return array; + } + } +} 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 c0ca9501d..3ae1096d0 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 @@ -270,7 +270,9 @@ private static void renderShape(PDPageContentStream stream, * Draws an inline SVG-icon span: each resolved vector layer is painted on * the baseline-seated box through the shared {@link PdfPathPainter}, so the * inline glyph matches the block path fragment for fragment (flat colours, - * gradients, dashes alike). + * gradients, dashes alike). An icon that states text โ€” a colour emoji, a + * symbol given {@code SvgIcon.withText} โ€” also leaves that text in the + * page's text layer, so copying the line carries it. */ private static void renderSvg(PDPageContentStream stream, ParagraphSvgSpan span, @@ -312,6 +314,9 @@ private static void renderSvg(PDPageContentStream stream, } finally { stream.restoreGraphicsState(); } + // On the line's baseline rather than the icon's bottom: readers assemble lines + // from glyph positions, so the text sits where the text around it sits. + environment.writeTextLayer(stream, span.text(), cursorX, baselineY, width, height); } @Override diff --git a/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfTextLayerTest.java b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfTextLayerTest.java new file mode 100644 index 000000000..2f35b1261 --- /dev/null +++ b/render-pdf/src/test/java/com/demcha/compose/document/backend/fixed/pdf/PdfTextLayerTest.java @@ -0,0 +1,428 @@ +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.dsl.ParagraphBuilder; +import com.demcha.compose.document.emoji.EmojiLibrary; +import com.demcha.compose.document.node.TextDirection; +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.svg.SvgIcon; +import com.demcha.compose.font.FontName; +import com.demcha.testing.VisualTestOutputs; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.cos.COSStream; +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.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType3Font; +import org.apache.pdfbox.pdmodel.graphics.state.PDTextState; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.apache.pdfbox.util.Matrix; +import org.apache.pdfbox.util.Vector; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; + +/** + * An inline icon that states text leaves that text in the page's text layer: copying the line + * carries the emoji, and nothing extra is painted. + */ +class PdfTextLayerTest { + + private static final EmojiLibrary EMOJI = EmojiLibrary.getDefault(); + + private static final String ROCKET = "๐Ÿš€"; + private static final String WOMAN_TECHNOLOGIST = "๐Ÿ‘ฉโ€๐Ÿ’ป"; + private static final String RED_HEART = "โค๏ธ"; + + @Test + void copiedLineCarriesEachEmojiWholeAndInPlace() throws Exception { + byte[] pdf = render(p -> p + .inlineText("Launch ").inlineEmoji(":rocket:", 14) + .inlineText(" by ").inlineEmoji(":woman_technologist:", 14) + .inlineText(" with ").inlineEmoji(":heart:", 14) + .inlineText(" done.")); + Path output = VisualTestOutputs.preparePdf("emoji-copy-text", "text-layer"); + Files.write(output, pdf); + + assertThat(text(pdf)).contains( + "Launch " + ROCKET + " by " + WOMAN_TECHNOLOGIST + " with " + RED_HEART + " done."); + } + + @Test + void textLayerPaintsNothing() throws Exception { + // Same drawing twice, once stating its text and once not. The text layer must add no + // pixel, and must hand the graphics state back: rendering mode 3 outlives ET, so a + // missing q/Q would leave " done." invisible and the pages would differ. + SvgIcon rocket = EMOJI.require(":rocket:"); + byte[] stating = render(p -> p.inlineText("Launch ").inlineSvgIcon(rocket, 14).inlineText(" done.")); + byte[] silent = render(p -> p.inlineText("Launch ").inlineSvgIcon(rocket.withText(null), 14) + .inlineText(" done.")); + + assertThat(text(stating)).contains("Launch " + ROCKET + " done."); + assertThat(text(silent)).doesNotContain(ROCKET); + assertThat(pixels(stating)).isEqualTo(pixels(silent)); + } + + @Test + void iconStatingItsOwnTextCopiesThatText() throws Exception { + SvgIcon check = SvgIcon.parse(""); + + byte[] stating = render(p -> p.inlineText("Tested ").inlineSvgIcon(check.withText("โœ“"), 10)); + byte[] silent = render(p -> p.inlineText("Tested ").inlineSvgIcon(check, 10)); + + assertThat(text(stating)).contains("Tested โœ“"); + assertThat(text(silent).strip()).isEqualTo("Tested"); + assertThat(type3Fonts(silent)).as("an icon without text adds no font").isEmpty(); + } + + @Test + void textsShareOneFontPerDocumentAndStartAnotherAfter255() throws Exception { + SvgIcon dot = SvgIcon.parse(""); + List texts = IntStream.range(0, 300).mapToObj(i -> "<" + i + ">").toList(); + + // Every text twice, on a flow long enough to run over several pages. + byte[] pdf = render(p -> { + for (int round = 0; round < 2; round++) { + for (String text : texts) { + p.inlineSvgIcon(dot.withText(text), 8).inlineText(" "); + } + } + }); + + String extracted = text(pdf); + assertThat(texts).allSatisfy(text -> assertThat(extracted).contains(text)); + try (PDDocument document = Loader.loadPDF(pdf)) { + assertThat(document.getNumberOfPages()).isGreaterThan(1); + } + // Repeated text reuses its code: 300 distinct texts fill one font and start a second. + assertThat(lastCodes(pdf)).containsExactlyInAnyOrder(255, 45); + // A CMap block holds at most 100 mappings, so the full font states its 255 in three. + try (PDDocument document = Loader.loadPDF(pdf)) { + List blocks = new ArrayList<>(); + for (PDType3Font font : type3Fonts(document)) { + if (font.getCOSObject().getInt(COSName.LAST_CHAR) == 255) { + COSStream map = font.getCOSObject().getCOSStream(COSName.TO_UNICODE); + try (InputStream in = map.createInputStream()) { + Matcher block = Pattern.compile("(\\d+) beginbfchar") + .matcher(new String(in.readAllBytes(), StandardCharsets.US_ASCII)); + while (block.find()) { + blocks.add(block.group(1)); + } + } + } + } + assertThat(blocks).containsExactly("100", "100", "55"); + } + } + + @Test + void glyphSpansTheIconWhateverTextStateTheRunBeforeLeft() throws Exception { + // A 2:1 icon after a word tracked in Tc (a Standard 14 face keeps all its tracking + // there): the glyph's advance must be the icon's width, not its height plus the + // tracking the word before it left behind, and it must sit on the baseline. + SvgIcon wide = SvgIcon.parse("") + .withText("[wide]"); + DocumentTextStyle tracked = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .size(10) + .letterSpacing(DocumentLetterSpacing.points(4)) + .build(); + + byte[] pdf = render(p -> p.inlineText("SPACED", tracked).inlineSvgIcon(wide, 10).inlineText(" end")); + + try (PDDocument document = Loader.loadPDF(pdf)) { + TextLayerProbe probe = new TextLayerProbe(); + probe.getText(document); + assertThat(probe.advances).singleElement() + .satisfies(advance -> assertThat(advance).isCloseTo(20.0, within(0.01))); + assertThat(probe.rises).containsOnly(0.0); + } + } + + @Test + void deterministicOutputStaysByteIdenticalWithATextLayer() throws Exception { + assertThat(renderDeterministic()).isEqualTo(renderDeterministic()); + } + + @Test + void sectionsOfOneDocumentShareOneFont() throws Exception { + byte[] pdf; + try (MultiSectionDocument document = GraphCompose.documents() + .section(emojiSection("Cover ")) + .section(emojiSection("Body ")) + .create()) { + pdf = document.toPdfBytes(); + } + + assertThat(text(pdf)).contains("Cover " + ROCKET).contains("Body " + ROCKET); + assertThat(lastCodes(pdf)).containsExactly(1); + } + + @Test + void textLongerThanACMapCanStateIsNotWritten() throws Exception { + SvgIcon dot = SvgIcon.parse(""); + String longest = "a".repeat(PdfTextLayer.MAX_TEXT_UNITS); + String tooLong = "b".repeat(PdfTextLayer.MAX_TEXT_UNITS + 1); + + byte[] pdf = render(p -> p.inlineSvgIcon(dot.withText(longest), 8) + .inlineText(" ").inlineSvgIcon(dot.withText(tooLong), 8)); + + assertThat(text(pdf)).contains(longest).doesNotContain("b"); + } + + @Test + void calledInsideATextObjectFailsWithNothingWritten() throws Exception { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(); + document.addPage(page); + PdfTextLayer layer = new PdfTextLayer(document); + try (PDPageContentStream stream = new PDPageContentStream(document, page)) { + stream.beginText(); + assertThatThrownBy(() -> layer.write(stream, ROCKET, 10, 10, 12, 12)) + .isInstanceOf(IllegalStateException.class); + // The caller's text object is still open, and closes as if nothing happened. + stream.endText(); + } + assertThat(operators(page)).containsExactly("BT", "ET"); + } + } + + @Test + void aFailureHalfWayStillClosesTheGlyphsTextObjectAndSavedState() throws Exception { + try (PDDocument document = new PDDocument()) { + PDPage page = new PDPage(); + document.addPage(page); + PdfTextLayer layer = new PdfTextLayer(document); + PDPageContentStream stream = spy(new PDPageContentStream(document, page)); + doThrow(new IOException("stream refused the glyph")).when(stream).showText(anyString()); + + assertThatThrownBy(() -> layer.write(stream, ROCKET, 10, 10, 12, 12)) + .isInstanceOf(IOException.class) + .hasMessage("stream refused the glyph"); + stream.close(); + + List operators = operators(page); + assertThat(operators).startsWith("q", "BT").endsWith("ET", "Q"); + assertThat(Collections.frequency(operators, "q")).isEqualTo(Collections.frequency(operators, "Q")); + assertThat(Collections.frequency(operators, "BT")).isEqualTo(Collections.frequency(operators, "ET")); + } + } + + /** + * Right-to-left lines. The emoji's glyph must sit between the words it was written + * between and state its whole text; both are what the file controls. + * + *

Reading the line back is the reader's job, and readers disagree: PDFBox's + * {@code PDFTextStripper.handleDirection} reverses a right-to-left run one UTF-16 unit at + * a time, so any emoji inside one comes back with its surrogates swapped, and poppler + * reverses the code points of a multi-code-point glyph (a ZWJ sequence, a U+FE0F); + * pdf.js and MuPDF keep the glyph's text whole. So these hold the glyph, not a line.

+ */ + @Test + void anEmojiInARightToLeftLineSitsBetweenItsWordsAndStatesItselfWhole() throws Exception { + DocumentTextStyle hebrew = DocumentTextStyle.builder().fontName(FontName.DAVID_LIBRE).size(18).build(); + DocumentTextStyle arabic = DocumentTextStyle.builder().fontName(FontName.AMIRI).size(18).build(); + // Words with no letter in common, so every glyph belongs to exactly one of them. + assertEmojiBetween("ืฉืœื•ื", "ื—ื‘ืจ", hebrew); + assertEmojiBetween("ุณู„ุงู…", "ูƒุชุจ", arabic); + } + + @Test + void zwjSequenceAndSelectorInARightToLeftLineStayOnOneGlyphEach() throws Exception { + DocumentTextStyle hebrew = DocumentTextStyle.builder().fontName(FontName.DAVID_LIBRE).size(18).build(); + byte[] pdf = render(p -> p.inlineEmoji(":woman_technologist:", 18).inlineText(" ืฉืœื•ื ", hebrew) + .inlineEmoji(":heart:", 18).direction(TextDirection.RTL)); + + assertThat(glyphs(pdf).stream().filter(Glyph::textLayer).map(Glyph::unicode)) + .containsExactlyInAnyOrder(WOMAN_TECHNOLOGIST, RED_HEART); + } + + @Test + void aMixedLineWithARightToLeftWordReadsBackInWrittenOrder() throws Exception { + // A left-to-right sentence with a Hebrew word: the emoji resolves left-to-right, and + // PDFBox reads the whole line back exactly as written. + DocumentTextStyle hebrew = DocumentTextStyle.builder().fontName(FontName.DAVID_LIBRE).size(18).build(); + byte[] pdf = render(p -> p.inlineText("Deploy ").inlineText("ืฉืœื•ื", hebrew).inlineText(" ") + .inlineEmoji(":rocket:", 18).inlineText(" done")); + + assertThat(text(pdf)).contains("Deploy ืฉืœื•ื " + ROCKET + " done"); + } + + private static void assertEmojiBetween(String first, String second, DocumentTextStyle style) + throws Exception { + byte[] pdf = render(p -> p.inlineText(first + " ", style).inlineEmoji(":rocket:", 18) + .inlineText(" " + second, style).direction(TextDirection.RTL)); + + List glyphs = glyphs(pdf); + List textLayer = glyphs.stream().filter(Glyph::textLayer).toList(); + assertThat(textLayer).hasSize(1); + Glyph emoji = textLayer.get(0); + double firstLeft = glyphs.stream().filter(g -> first.contains(g.unicode())) + .mapToDouble(Glyph::x).min().orElseThrow(); + double secondRight = glyphs.stream().filter(g -> second.contains(g.unicode())) + .mapToDouble(g -> g.x() + g.width()).max().orElseThrow(); + + assertThat(emoji.unicode()).isEqualTo(ROCKET); + // Drawn right to left: the second word is leftmost, the first rightmost. + assertThat(emoji.x()).as("%s: emoji starts right of the second word", first) + .isGreaterThanOrEqualTo(secondRight - 0.5); + assertThat(emoji.x() + emoji.width()).as("%s: emoji ends left of the first word", first) + .isLessThanOrEqualTo(firstLeft + 0.5); + } + + private static DocumentSession emojiSection(String label) { + DocumentSession section = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(16)) + .create(); + section.pageFlow(page -> page.addParagraph(p -> p.inlineText(label).inlineEmoji(":rocket:", 12))); + return section; + } + + private static byte[] render(Consumer body) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 200) + .margin(DocumentInsets.of(16)) + .create()) { + session.pageFlow(page -> page.addParagraph(body::accept)); + return session.toPdfBytes(); + } + } + + private static byte[] renderDeterministic() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(320, 200) + .margin(DocumentInsets.of(16)) + .create()) { + session.pageFlow(page -> page.addParagraph(p -> p + .inlineText("Launch ").inlineEmoji(":rocket:", 14) + .inlineText(" with ").inlineEmoji(":heart:", 14))); + return session.render(PdfFixedLayoutBackend.builder().deterministic(true).build()); + } + } + + /** One glyph as PDFBox positions it, before any reordering of the line. */ + private record Glyph(String unicode, double x, double width, boolean textLayer) { + } + + private static List glyphs(byte[] pdf) throws IOException { + List glyphs = new ArrayList<>(); + try (PDDocument document = Loader.loadPDF(pdf)) { + new PDFTextStripper() { + @Override + protected void processTextPosition(TextPosition text) { + glyphs.add(new Glyph(text.getUnicode(), text.getXDirAdj(), text.getWidthDirAdj(), + text.getFont() instanceof PDType3Font)); + } + }.getText(document); + } + return glyphs; + } + + private static List operators(PDPage page) throws IOException { + List names = new ArrayList<>(); + for (Object token : new PDFStreamParser(page).parse()) { + if (token instanceof Operator operator) { + names.add(operator.getName()); + } + } + return names; + } + + /** Records, for every text-layer glyph shown, its advance and rise under the text state it met. */ + private static final class TextLayerProbe extends PDFTextStripper { + private final List advances = new ArrayList<>(); + private final List rises = new ArrayList<>(); + + @Override + protected void showGlyph(Matrix textRenderingMatrix, PDFont font, int code, Vector displacement) + throws IOException { + if (font instanceof PDType3Font) { + PDTextState state = getGraphicsState().getTextState(); + double wordSpacing = code == 32 ? state.getWordSpacing() : 0; + advances.add((displacement.getX() * state.getFontSize() + state.getCharacterSpacing() + + wordSpacing) * state.getHorizontalScaling() / 100); + rises.add((double) state.getRise()); + } + super.showGlyph(textRenderingMatrix, font, code, displacement); + } + } + + private static String text(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + return new PDFTextStripper().getText(document).replaceAll("\\s+", " "); + } + } + + private static int[] pixels(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + BufferedImage image = new PDFRenderer(document).renderImageWithDPI(0, 144); + return image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()); + } + } + + private static List type3Fonts(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + return type3Fonts(document); + } + } + + private static List type3Fonts(PDDocument document) throws IOException { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + List fonts = new ArrayList<>(); + for (PDPage page : document.getPages()) { + PDResources resources = page.getResources(); + for (COSName name : resources.getFontNames()) { + PDFont font = resources.getFont(name); + if (font instanceof PDType3Font type3 && seen.add(type3.getCOSObject())) { + fonts.add(type3); + } + } + } + return fonts; + } + + /** The last code of every text-layer font in the document: how many texts each holds. */ + private static List lastCodes(byte[] pdf) throws IOException { + try (PDDocument document = Loader.loadPDF(pdf)) { + List codes = new ArrayList<>(); + for (PDType3Font font : type3Fonts(document)) { + codes.add(font.getCOSObject().getInt(COSName.LAST_CHAR)); + } + return codes; + } + } +} diff --git a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineSvgRasterizerTest.java b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineSvgRasterizerTest.java index 1a7bcc639..29dd026d6 100644 --- a/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineSvgRasterizerTest.java +++ b/render-pptx/src/test/java/com/demcha/compose/document/backend/fixed/pptx/handlers/PptxInlineSvgRasterizerTest.java @@ -81,7 +81,7 @@ null, null, new Stroke(Color.BLACK, 1), null, private static ParagraphSvgSpan span(ResolvedSvgLayer layer) { return new ParagraphSvgSpan(List.of(layer), 20, 20, - InlineImageAlignment.CENTER, 0, null); + InlineImageAlignment.CENTER, 0, null, null); } private static ResolvedSvgLayer layer(List segments,