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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ follow semantic versioning; release dates are ISO 8601.

## v2.4.1 — Planned

### Public API

- **An inline SVG icon can state the text it stands for.** `SvgIcon.withText(String)` returns
a copy of the icon carrying that text, read back with `SvgIcon.text()`: what a reader that
copies, searches or extracts the page should find where the icon is drawn in a line of text,
since a drawing has no characters of its own — `"✓"` for a check-mark icon. The drawing is
untouched; `null` or blank clears the text. A block icon (`addSvgIcon`, `SvgIcon.node`)
does not carry it.
<br><br>
Every icon `EmojiLibrary` resolves now carries the emoji it depicts, so `:rocket:` states
`🚀` and `:woman_technologist:` its whole ZWJ sequence. The emoji set's file names drop
U+FE0F, and a text-default character without it can paste as a plain black symbol — `:heart:`
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.

### Performance

- **A barcode is drawn as vector shapes, not as an image, in PDF and PPTX.**
Expand Down Expand Up @@ -47,6 +64,13 @@ follow semantic versioning; release dates are ISO 8601.
the QR code off it, with an opaque and with a transparent foreground, checks a translucent
foreground composites with the slide, and checks the background shape lands on the
fragment box.
- `EmojiSequencesTest` pins the fully-qualified spelling — a text-default character gains
U+FE0F, an emoji-presentation one does not, a skin-tone modifier or a selector already in
the key suppresses it, a keycap base is qualified before U+20E3 — and compares the table of
text-default emoji with the running JDK's Unicode data on JDK 21 and later.
`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.

## v2.4.0 — 2026-09-14

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
* to literal text the way GitHub does. {@link #require(String)} is the strict
* variant. Parsed icons are cached per codepoint; the instance is thread-safe.</p>
*
* <p>Every resolved icon states the emoji it depicts as its
* {@link SvgIcon#text() text}, spelled from the glyph's codepoints in the fully
* qualified form — with the U+FE0F the set's file names leave out put back — so
* a page that draws the glyph can still hand a reader the emoji itself.</p>
*
* @author Artem Demchyshyn
* @since 1.9.0
*/
Expand Down Expand Up @@ -144,7 +149,7 @@ private SvgIcon iconForCodepoint(String codepoint) {
throw new UncheckedIOException("Failed to read emoji glyph for codepoint " + cp, e);
}
try {
return SvgIcon.parse(xml);
return SvgIcon.parse(xml).withText(EmojiSequences.fullyQualified(cp));
} catch (RuntimeException e) {
// A real-world glyph may use an SVG feature the parser rejects;
// treat it as unresolved rather than failing the whole render.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package com.demcha.compose.document.emoji;

import java.util.Arrays;

/**
* Spells a glyph key of an emoji set as the emoji the glyph depicts.
*
* <p>A glyph key is the emoji's codepoints in hex, joined by {@code '-'} —
* {@code 1f469-200d-1f4bb} — with U+FE0F dropped, because Noto names its files
* without it and the key is a file name first. As text that loses something.
* U+FE0F asks for the colour presentation of a character whose default is plain
* text: {@code U+2764} alone is a heart in the text font, and a messenger can
* paste it as a black symbol; {@code U+2764 U+FE0F} is the red heart.</p>
*
* <p>The form to restore is the one Unicode calls fully qualified (UTS #51):
* every emoji character whose default presentation is text is followed by
* U+FE0F, unless an emoji modifier (a skin tone) follows it, which qualifies it
* already, or the key already carries a presentation selector. That needs one
* fact per character — whether its default presentation is text — held in
* {@link #TEXT_DEFAULT}. The table is Unicode data copied into source, so it is
* checked against the JDK's own copy: a Unicode update that changes the set
* fails {@code EmojiSequencesTest} when it runs on a JDK that ships it.</p>
*/
final class EmojiSequences {

/** VARIATION SELECTOR-16: emoji presentation. */
private static final int EMOJI_PRESENTATION = 0xFE0F;

/** VARIATION SELECTOR-15: text presentation. */
private static final int TEXT_PRESENTATION = 0xFE0E;

/**
* Every codepoint with {@code Emoji=Yes} and {@code Emoji_Presentation=No}
* in the Unicode emoji data, ascending. Generated from
* {@code Character.isEmoji} / {@code Character.isEmojiPresentation} (JDK 21+;
* identical on JDK 23 and 24); {@code EmojiSequencesTest} compares it with
* the running JDK whenever the JDK has those methods.
*/
static final int[] TEXT_DEFAULT = {
0x0023, 0x002A, 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036,
0x0037, 0x0038, 0x0039, 0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139,
0x2194, 0x2195, 0x2196, 0x2197, 0x2198, 0x2199, 0x21A9, 0x21AA, 0x2328,
0x23CF, 0x23ED, 0x23EE, 0x23EF, 0x23F1, 0x23F2, 0x23F8, 0x23F9, 0x23FA,
0x24C2, 0x25AA, 0x25AB, 0x25B6, 0x25C0, 0x25FB, 0x25FC, 0x2600, 0x2601,
0x2602, 0x2603, 0x2604, 0x260E, 0x2611, 0x2618, 0x261D, 0x2620, 0x2622,
0x2623, 0x2626, 0x262A, 0x262E, 0x262F, 0x2638, 0x2639, 0x263A, 0x2640,
0x2642, 0x265F, 0x2660, 0x2663, 0x2665, 0x2666, 0x2668, 0x267B, 0x267E,
0x2692, 0x2694, 0x2695, 0x2696, 0x2697, 0x2699, 0x269B, 0x269C, 0x26A0,
0x26A7, 0x26B0, 0x26B1, 0x26C8, 0x26CF, 0x26D1, 0x26D3, 0x26E9, 0x26F0,
0x26F1, 0x26F4, 0x26F7, 0x26F8, 0x26F9, 0x2702, 0x2708, 0x2709, 0x270C,
0x270D, 0x270F, 0x2712, 0x2714, 0x2716, 0x271D, 0x2721, 0x2733, 0x2734,
0x2744, 0x2747, 0x2763, 0x2764, 0x27A1, 0x2934, 0x2935, 0x2B05, 0x2B06,
0x2B07, 0x3030, 0x303D, 0x3297, 0x3299, 0x1F170, 0x1F171, 0x1F17E, 0x1F17F,
0x1F202, 0x1F237, 0x1F321, 0x1F324, 0x1F325, 0x1F326, 0x1F327, 0x1F328, 0x1F329,
0x1F32A, 0x1F32B, 0x1F32C, 0x1F336, 0x1F37D, 0x1F396, 0x1F397, 0x1F399, 0x1F39A,
0x1F39B, 0x1F39E, 0x1F39F, 0x1F3CB, 0x1F3CC, 0x1F3CD, 0x1F3CE, 0x1F3D4, 0x1F3D5,
0x1F3D6, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DB, 0x1F3DC, 0x1F3DD, 0x1F3DE,
0x1F3DF, 0x1F3F3, 0x1F3F5, 0x1F3F7, 0x1F43F, 0x1F441, 0x1F4FD, 0x1F549, 0x1F54A,
0x1F56F, 0x1F570, 0x1F573, 0x1F574, 0x1F575, 0x1F576, 0x1F577, 0x1F578, 0x1F579,
0x1F587, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x1F590, 0x1F5A5, 0x1F5A8, 0x1F5B1,
0x1F5B2, 0x1F5BC, 0x1F5C2, 0x1F5C3, 0x1F5C4, 0x1F5D1, 0x1F5D2, 0x1F5D3, 0x1F5DC,
0x1F5DD, 0x1F5DE, 0x1F5E1, 0x1F5E3, 0x1F5E8, 0x1F5EF, 0x1F5F3, 0x1F5FA, 0x1F6CB,
0x1F6CD, 0x1F6CE, 0x1F6CF, 0x1F6E0, 0x1F6E1, 0x1F6E2, 0x1F6E3, 0x1F6E4, 0x1F6E5,
0x1F6E9, 0x1F6F0, 0x1F6F3
};

private EmojiSequences() {
}

/**
* Spells a glyph key as fully-qualified emoji text.
*
* @param key the glyph key, e.g. {@code 2764} or {@code 1f469-200d-1f4bb}
* @return the emoji as text, or {@code null} when the key is not a
* sequence of hex codepoints
*/
static String fullyQualified(String key) {
if (key == null || key.isBlank()) {
return null;
}
String[] parts = key.trim().split("-");
int[] codepoints = new int[parts.length];
for (int i = 0; i < parts.length; i++) {
try {
codepoints[i] = Integer.parseInt(parts[i], 16);
} catch (NumberFormatException e) {
return null;
}
if (!Character.isValidCodePoint(codepoints[i])) {
return null;
}
}
StringBuilder text = new StringBuilder(codepoints.length * 3);
for (int i = 0; i < codepoints.length; i++) {
int codepoint = codepoints[i];
text.appendCodePoint(codepoint);
int next = i + 1 < codepoints.length ? codepoints[i + 1] : -1;
if (isTextDefault(codepoint)
&& next != EMOJI_PRESENTATION
&& next != TEXT_PRESENTATION
&& !isModifier(next)) {
text.appendCodePoint(EMOJI_PRESENTATION);
}
}
return text.toString();
}

static boolean isTextDefault(int codepoint) {
return Arrays.binarySearch(TEXT_DEFAULT, codepoint) >= 0;
}

/** The five skin-tone modifiers, U+1F3FB..U+1F3FF. */
private static boolean isModifier(int codepoint) {
return codepoint >= 0x1F3FB && codepoint <= 0x1F3FF;
}
}
50 changes: 50 additions & 0 deletions core/src/main/java/com/demcha/compose/document/svg/SvgIcon.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@
* The XML reader refuses DOCTYPEs, so external-entity tricks cannot reach the
* file system.</p>
*
* <p>An icon drawn inline in text can also state the text it stands for —
* {@link #withText(String)} — so a reader that extracts the page's text gets
* a character where the icon sits rather than a gap: a colour emoji resolved
* by {@code EmojiLibrary} carries its emoji this way, and a check-mark icon
* can carry {@code "✓"}.</p>
*
* <pre>{@code
* SvgIcon logo = SvgIcon.read(Path.of("assets/logo.svg"));
* flow.addSvgIcon(logo, 48); // flow sugar
Expand All @@ -76,11 +82,17 @@ public final class SvgIcon {
private final List<Layer> layers;
private final double sourceWidth;
private final double sourceHeight;
private final String text;

SvgIcon(List<Layer> layers, double sourceWidth, double sourceHeight) {
this(layers, sourceWidth, sourceHeight, null);
}

private SvgIcon(List<Layer> layers, double sourceWidth, double sourceHeight, String text) {
this.layers = List.copyOf(layers);
this.sourceWidth = sourceWidth;
this.sourceHeight = sourceHeight;
this.text = text;
}

/**
Expand Down Expand Up @@ -147,6 +159,44 @@ public double aspectRatio() {
return sourceWidth / sourceHeight;
}

/**
* Returns the text this icon stands for when the page is read as text.
*
* <p>A drawing has no characters, so a reader that copies, searches or
* extracts a page finds nothing where an icon sits. This is what it should
* find instead: the emoji a colour-emoji glyph depicts, or the character a
* symbol icon replaces. It says nothing about how the icon looks.</p>
*
* <p>The text belongs to an icon drawn inline in text
* ({@code RichText.svgIcon}, {@code ParagraphBuilder.inlineSvgIcon}, emoji).
* A block icon — {@code addSvgIcon} or {@link #node(double)} — does not
* carry it.</p>
*
* @return the text, or {@code null} when the icon states none
* @since 2.5.0
*/
public String text() {
return text;
}

/**
* Returns a copy of this icon that stands for {@code text} when the page is
* read as text — see {@link #text()}. The layers and frame are shared; this
* icon is unchanged.
*
* <pre>{@code
* SvgIcon check = SvgIcon.read(Path.of("icons/check.svg")).withText("✓");
* }</pre>
*
* @param text the text the icon stands for; {@code null} or blank clears it
* @return an icon with the same drawing and the given text
* @since 2.5.0
*/
public SvgIcon withText(String text) {
String normalized = text == null || text.isBlank() ? null : text;
return new SvgIcon(layers, sourceWidth, sourceHeight, normalized);
}

/**
* Packages the icon as one ready-to-place node: a layer stack of path
* nodes at the given width, height following the icon's aspect ratio.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ void resolvedGlyphIsAParsedSvgIcon() {
assertThat(icon.aspectRatio()).isCloseTo(1.0, within(0.01));
}

@Test
void resolvedGlyphStatesTheEmojiItDepictsAsText() {
assertThat(emoji.require(":rocket:").text()).isEqualTo("🚀");
// the index key is 2764 (the set's file names drop U+FE0F); the text puts it back
assertThat(emoji.require(":heart:").text()).isEqualTo("❤️");
assertThat(emoji.require(":woman_technologist:").text())
.isEqualTo("👩‍💻");
}

@Test
void glyphWhoseKeyIsNotCodepointsResolvesWithoutText(@TempDir Path classpathRoot) throws Exception {
// A custom set may name its glyphs anything; such a glyph still draws,
// it just states no text.
Path svgDir = Files.createDirectories(classpathRoot.resolve("emoji/svg"));
Files.writeString(classpathRoot.resolve("emoji/emoji-index.properties"), "logo=company-logo\n");
Files.writeString(svgDir.resolve("company-logo.svg"),
"<svg viewBox='0 0 10 10'><rect width='10' height='10'/></svg>");
try (URLClassLoader loader = new URLClassLoader(new URL[]{classpathRoot.toUri().toURL()}, null)) {
SvgIcon logo = new EmojiLibrary(loader).require(":logo:");

assertThat(logo.layers()).isNotEmpty();
assertThat(logo.text()).isNull();
}
}

@Test
void unknownShortcodeResolvesEmpty() {
assertThat(emoji.find(":definitely_not_an_emoji:")).isEmpty();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.demcha.compose.document.emoji;

import org.junit.jupiter.api.Test;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

/**
* {@link EmojiSequences} turns a glyph key back into the emoji as text, in the
* fully-qualified form Unicode defines: U+FE0F after every character whose
* default presentation is text, and nowhere else.
*/
class EmojiSequencesTest {

@Test
void emojiPresentationCharacterNeedsNoSelector() {
assertThat(EmojiSequences.fullyQualified("1f680")).isEqualTo("🚀"); // rocket
}

@Test
void textDefaultCharacterGetsTheEmojiSelectorBack() {
assertThat(EmojiSequences.fullyQualified("2764")).isEqualTo("❤️"); // red heart
assertThat(EmojiSequences.fullyQualified("a9")).isEqualTo("©️"); // copyright
}

@Test
void zwjSequenceKeepsEveryJoinerAndQualifiesEachTextDefaultPart() {
// woman technologist: both parts are emoji-presentation, so nothing is added
assertThat(EmojiSequences.fullyQualified("1f469-200d-1f4bb"))
.isEqualTo("👩‍💻");
// man health worker: the staff of Aesculapius is text-default
assertThat(EmojiSequences.fullyQualified("1f468-200d-2695"))
.isEqualTo("👨‍⚕️");
// eye in speech bubble: both parts are text-default
assertThat(EmojiSequences.fullyQualified("1f441-200d-1f5e8"))
.isEqualTo("👁️‍🗨️");
}

@Test
void skinToneModifierQualifiesTheCharacterItFollows() {
// index pointing up with a light skin tone: no selector between base and modifier
assertThat(EmojiSequences.fullyQualified("261d-1f3fb")).isEqualTo("☝🏻");
}

@Test
void keycapBaseIsQualifiedBeforeTheEnclosingKeycap() {
assertThat(EmojiSequences.fullyQualified("0023-20e3")).isEqualTo("#️⃣");
}

@Test
void presentationSelectorAlreadyInTheKeyIsNotDoubled() {
assertThat(EmojiSequences.fullyQualified("2764-fe0f")).isEqualTo("❤️");
assertThat(EmojiSequences.fullyQualified("2764-fe0e")).isEqualTo("❤︎");
}

@Test
void keyThatIsNotHexCodepointsSpellsNothing() {
assertThat(EmojiSequences.fullyQualified(null)).isNull();
assertThat(EmojiSequences.fullyQualified(" ")).isNull();
assertThat(EmojiSequences.fullyQualified("company-logo")).isNull();
assertThat(EmojiSequences.fullyQualified("1f680--1f680")).isNull();
assertThat(EmojiSequences.fullyQualified("110000")).isNull();
}

@Test
void textDefaultTableIsStrictlyAscending() {
int[] table = EmojiSequences.TEXT_DEFAULT;
assertThat(IntStream.range(1, table.length).allMatch(i -> table[i - 1] < table[i]))
.as("binarySearch needs a strictly ascending table")
.isTrue();
}

/**
* The table is Unicode data copied into source; this is what keeps it true.
* JDK 21 added the two emoji property methods, so on an older JDK there is
* nothing to compare with and the check is skipped; CI's JDK matrix runs it.
*/
@Test
void textDefaultTableMatchesTheRunningJdksUnicodeData() throws Throwable {
MethodHandle isEmoji = emojiProperty("isEmoji");
MethodHandle isEmojiPresentation = emojiProperty("isEmojiPresentation");
assumeTrue(isEmoji != null && isEmojiPresentation != null,
"Character.isEmoji / isEmojiPresentation need JDK 21+");

List<Integer> expected = new ArrayList<>();
for (int codepoint = 0; codepoint <= Character.MAX_CODE_POINT; codepoint++) {
if ((boolean) isEmoji.invokeExact(codepoint) && !(boolean) isEmojiPresentation.invokeExact(codepoint)) {
expected.add(codepoint);
}
}

assertThat(EmojiSequences.TEXT_DEFAULT)
.as("Emoji=Yes and Emoji_Presentation=No on JDK %s", Runtime.version())
.containsExactly(expected.stream().mapToInt(Integer::intValue).toArray());
}

private static MethodHandle emojiProperty(String name) {
try {
return MethodHandles.publicLookup().findStatic(Character.class, name,
MethodType.methodType(boolean.class, int.class));
} catch (NoSuchMethodException | IllegalAccessException e) {
return null;
}
}
}
Loading
Loading