diff --git a/CHANGELOG.md b/CHANGELOG.md index be894a7fe..7dc651b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. +

+ 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.** @@ -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 diff --git a/core/src/main/java/com/demcha/compose/document/emoji/EmojiLibrary.java b/core/src/main/java/com/demcha/compose/document/emoji/EmojiLibrary.java index d5abce32b..d916dd092 100644 --- a/core/src/main/java/com/demcha/compose/document/emoji/EmojiLibrary.java +++ b/core/src/main/java/com/demcha/compose/document/emoji/EmojiLibrary.java @@ -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.

* + *

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.

+ * * @author Artem Demchyshyn * @since 1.9.0 */ @@ -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. diff --git a/core/src/main/java/com/demcha/compose/document/emoji/EmojiSequences.java b/core/src/main/java/com/demcha/compose/document/emoji/EmojiSequences.java new file mode 100644 index 000000000..8b74a2d97 --- /dev/null +++ b/core/src/main/java/com/demcha/compose/document/emoji/EmojiSequences.java @@ -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. + * + *

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.

+ * + *

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.

+ */ +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; + } +} diff --git a/core/src/main/java/com/demcha/compose/document/svg/SvgIcon.java b/core/src/main/java/com/demcha/compose/document/svg/SvgIcon.java index f2afa5bae..98a7e76e9 100644 --- a/core/src/main/java/com/demcha/compose/document/svg/SvgIcon.java +++ b/core/src/main/java/com/demcha/compose/document/svg/SvgIcon.java @@ -62,6 +62,12 @@ * The XML reader refuses DOCTYPEs, so external-entity tricks cannot reach the * file system.

* + *

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 "โœ“"}.

+ * *
{@code
  * SvgIcon logo = SvgIcon.read(Path.of("assets/logo.svg"));
  * flow.addSvgIcon(logo, 48);          // flow sugar
@@ -76,11 +82,17 @@ public final class SvgIcon {
     private final List layers;
     private final double sourceWidth;
     private final double sourceHeight;
+    private final String text;
 
     SvgIcon(List layers, double sourceWidth, double sourceHeight) {
+        this(layers, sourceWidth, sourceHeight, null);
+    }
+
+    private SvgIcon(List layers, double sourceWidth, double sourceHeight, String text) {
         this.layers = List.copyOf(layers);
         this.sourceWidth = sourceWidth;
         this.sourceHeight = sourceHeight;
+        this.text = text;
     }
 
     /**
@@ -147,6 +159,44 @@ public double aspectRatio() {
         return sourceWidth / sourceHeight;
     }
 
+    /**
+     * Returns the text this icon stands for when the page is read as text.
+     *
+     * 

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.

+ * + *

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.

+ * + * @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. + * + *
{@code
+     * SvgIcon check = SvgIcon.read(Path.of("icons/check.svg")).withText("โœ“");
+     * }
+ * + * @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. diff --git a/core/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java b/core/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java index 4081419b3..ee3ecf6b3 100644 --- a/core/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java +++ b/core/src/test/java/com/demcha/compose/document/emoji/EmojiLibraryTest.java @@ -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"), + ""); + 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(); diff --git a/core/src/test/java/com/demcha/compose/document/emoji/EmojiSequencesTest.java b/core/src/test/java/com/demcha/compose/document/emoji/EmojiSequencesTest.java new file mode 100644 index 000000000..3be909757 --- /dev/null +++ b/core/src/test/java/com/demcha/compose/document/emoji/EmojiSequencesTest.java @@ -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 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; + } + } +} diff --git a/core/src/test/java/com/demcha/compose/document/svg/SvgIconTextTest.java b/core/src/test/java/com/demcha/compose/document/svg/SvgIconTextTest.java new file mode 100644 index 000000000..40bb360e6 --- /dev/null +++ b/core/src/test/java/com/demcha/compose/document/svg/SvgIconTextTest.java @@ -0,0 +1,50 @@ +package com.demcha.compose.document.svg; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link SvgIcon#withText(String)} attaches the text an icon stands for without + * touching the drawing, and leaves the icon it was called on as it was. + */ +class SvgIconTextTest { + + private static final String CHECK = + ""; + + @Test + void parsedIconStatesNoText() { + assertThat(SvgIcon.parse(CHECK).text()).isNull(); + } + + @Test + void withTextReturnsACopyWithTheSameDrawing() { + SvgIcon plain = SvgIcon.parse(CHECK); + + SvgIcon check = plain.withText("โœ“"); + + assertThat(check).isNotSameAs(plain); + assertThat(check.text()).isEqualTo("โœ“"); + assertThat(check.layers()).isEqualTo(plain.layers()); + assertThat(check.sourceWidth()).isEqualTo(plain.sourceWidth()); + assertThat(check.sourceHeight()).isEqualTo(plain.sourceHeight()); + assertThat(plain.text()).as("the original icon is unchanged").isNull(); + } + + @Test + void nullOrBlankTextClearsIt() { + SvgIcon check = SvgIcon.parse(CHECK).withText("โœ“"); + + assertThat(check.withText(null).text()).isNull(); + assertThat(check.withText(" ").text()).isNull(); + } + + @Test + void textIsKeptVerbatim() { + // surrounding spaces and multi-codepoint sequences are the caller's to choose + String family = "๐Ÿ‘ฉโ€๐Ÿ‘ง "; + + assertThat(SvgIcon.parse(CHECK).withText(family).text()).isEqualTo(family); + } +} diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json index 214c8f336..efa73fdce 100644 --- a/knowledge/api/authoring.json +++ b/knowledge/api/authoring.json @@ -23,7 +23,7 @@ ], "counts": { "types": 237, - "methods": 2123, + "methods": 2125, "constants": 236, "generated": 1129 }, @@ -31793,6 +31793,29 @@ "returns": "double", "params": [] }, + { + "kind": "method", + "name": "text", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "String", + "params": [] + }, + { + "kind": "method", + "name": "withText", + "static": false, + "origin": "source", + "typeParameters": null, + "returns": "SvgIcon", + "params": [ + { + "type": "String", + "name": "text" + } + ] + }, { "kind": "method", "name": "node", diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md index 9da29c7cc..728dfd242 100644 --- a/knowledge/api/authoring.md +++ b/knowledge/api/authoring.md @@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se **GraphCompose version:** 2.4.1-SNAPSHOT -Types: 237 ยท methods: 2123 ยท constants: 236 ยท compiler-generated members: 1129 +Types: 237 ยท methods: 2125 ยท constants: 236 ยท compiler-generated members: 1129 ## com.demcha.compose @@ -2403,6 +2403,8 @@ Types: 237 ยท methods: 2123 ยท constants: 236 ยท compiler-generated members: 112 - `double sourceWidth()` - `double sourceHeight()` - `double aspectRatio()` +- `String text()` +- `SvgIcon withText(String text)` - `LayerStackNode node(double width)` ### SvgIcon.Layer (record)