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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Binary file modified assets/readme/examples/emoji-shortcodes.pdf
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -174,7 +175,8 @@ static InlineSvgToken of(InlineSvgRun run) {
run.height(),
run.alignment(),
run.baselineOffset(),
run.linkTarget());
run.linkTarget(),
icon.text());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,8 @@ private static ParagraphLine toInlineParagraphLine(List<InlineLayoutToken> token
svgToken.height(),
svgToken.alignment(),
svgToken.baselineOffset(),
svgToken.linkTarget()));
svgToken.linkTarget(),
svgToken.text()));
width += svgToken.width();
tokenIndex++;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/architecture/backend-capability-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | ❌ |
Expand Down
14 changes: 14 additions & 0 deletions docs/recipes/rich-text.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
37 changes: 36 additions & 1 deletion knowledge/api/backends.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
],
"counts": {
"types": 70,
"methods": 375,
"methods": 376,
"constants": 18,
"generated": 192
},
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion knowledge/api/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,8 @@ private Rendered buildDocument(LayoutGraph graph, FixedLayoutRenderContext conte
List<PDPage> 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());
Expand Down Expand Up @@ -539,6 +539,8 @@ private Rendered buildSectionsDocument(List<SectionUnit> 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();
Expand All @@ -548,7 +550,7 @@ private Rendered buildSectionsDocument(List<SectionUnit> 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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@ public final class PdfRenderEnvironment {
private final Map<String, AnchorDestination> anchorDestinations = new LinkedHashMap<>();
private final List<DeferredInternalLink> 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);
}

/**
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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.
*
* <p>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.</p>
*
* <p><b>Experimental.</b> The text layer this produces is settled; the shape of the call may
* still change in a minor release.</p>
*
* @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.
*
Expand Down
Loading
Loading