@kubohiroya/turbowarp-svg-text 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,112 +1,84 @@
1
1
  # TurboWarp SVG Text
2
2
 
3
- TurboWarp SVG Text provides responsive text with reusable named styles. The same style can be shared by say/think bubbles and SVG skins that turn a sprite itself into a text actor. Text remains proportional to the stage when its dimensions change.
3
+ TurboWarp SVG Text provides responsive named styles and SVG text actors. It is a text provider for host extensions such as `turbowarp-bubble`; Bubble shape, placement, portraits, and animation belong to the host.
4
4
 
5
- See the [English guide](https://kubohiroya.github.io/turbowarp-svg-text/) or [Japanese guide](https://kubohiroya.github.io/turbowarp-svg-text/ja/) for usage and examples. The extension palette also opens the English guide through TurboWarp's `docsURI` feature.
5
+ ## Responsibilities
6
6
 
7
- ## Features
7
+ - Define reusable text styles for font, size, color, background, and alignment.
8
+ - Create, replace, measure, and release SVG text skins.
9
+ - Keep text actors proportional to the stage.
10
+ - Restyle visible text actors when a named style changes.
8
11
 
9
- - Define background color, text color, font, relative font size, and left/center/right alignment under any style name.
10
- - Choose one of sixteen bubble directions, including the eight intermediate 22.5-degree positions.
11
- - Display say and think bubbles with a named style.
12
- - Replace the current sprite's skin with multiline SVG text using `set this sprite text`.
13
- - Apply the redefinable `default` style to standard say, think, and ask bubbles.
14
- - Convert literal `\n`, `\r\n`, and `\r` sequences in displayed strings into line breaks.
15
- - Immediately update visible bubbles and SVG text when a style is redefined or the stage size changes.
16
- - Keep legacy size-based say/think opcodes hidden but executable for saved-project compatibility.
17
-
18
- Animation is not included in version 0.1.0.
12
+ This package does not provide `say` or `think` bubbles, bubble tails, actor-relative placement, portrait images, or bubble animation. Those responsibilities belong to the host package and its capabilities.
19
13
 
20
14
  ## Blocks
21
15
 
22
- ### `define text style [STYLE] background [BACKGROUND] text [TEXT_COLOR] font [FONT] size [SIZE] align [ALIGN] bubble direction [DIRECTION]`
23
-
24
- Defines or replaces a named style. `ALIGN` accepts `left`, `center`, or `right`. `DIRECTION` accepts the following sixteen canonical values and their compass aliases; direction applies only to bubbles.
25
-
26
- | Canonical value | Compass alias |
27
- | ------------------ | ----------------- |
28
- | `up` | `north` |
29
- | `up-up-right` | `north-northeast` |
30
- | `up-right` | `northeast` |
31
- | `right-up-right` | `east-northeast` |
32
- | `right` | `east` |
33
- | `right-down-right` | `east-southeast` |
34
- | `down-right` | `southeast` |
35
- | `down-down-right` | `south-southeast` |
36
- | `down` | `south` |
37
- | `down-down-left` | `south-southwest` |
38
- | `down-left` | `southwest` |
39
- | `left-down-left` | `west-southwest` |
40
- | `left` | `west` |
41
- | `left-up-left` | `west-northwest` |
42
- | `up-left` | `northwest` |
43
- | `up-up-left` | `north-northwest` |
44
-
45
- Direction input is trimmed and case-insensitive. Direction is measured from the actor center to the bubble body center. Changing the bubble width or height does not change the requested angle, although stage-edge clamping can move the bubble when necessary.
16
+ ### `define text style [STYLE] background [BACKGROUND] text [TEXT_COLOR] font [FONT] size [SIZE] align [ALIGN]`
46
17
 
47
- `DIRECTION` also accepts any number from `0` through `360` and uses Scratch sprite direction semantics: `0` is up, `90` is right, `180` is down, `270` is left, and `360` is up again. Values between the named directions are placed continuously without rounding to the nearest of the sixteen menu positions. Values outside this range fall back to the initial `up-right` direction.
48
-
49
- Redefining a name immediately redraws visible bubbles and SVG text actors that use it. Styles are runtime state, so projects should normally define them immediately after the green flag. A blank or unknown style name falls back to `default`.
18
+ Defines or replaces a named text style. Alignment accepts `left`, `center`, or `right`. The background is the rectangle behind an SVG text actor; a host Bubble can define a transparent background and render its own outer shape.
50
19
 
51
20
  ### `set this sprite text [TEXT] with style [STYLE]`
52
21
 
53
- Creates an SVG skin and applies it to the current sprite's drawable, making the sprite itself a text actor. Background color, text color, font, size, and alignment come from the selected style. A string containing `\n` is rendered as multiple safely escaped SVG `<tspan>` elements.
54
-
55
- ### `say [MESSAGE] with style [STYLE]`
22
+ Creates an SVG skin and applies it to the current sprite's drawable. Newline characters render as separate lines. Reusing the same style name after redefinition redraws existing text actors.
56
23
 
57
- Displays a say bubble using the selected named style.
24
+ ## Composition API
58
25
 
59
- ### `think [MESSAGE] with style [STYLE]`
26
+ The composition API does not register the standalone extension. The host supplies a TurboWarp runtime and explicit targets.
60
27
 
61
- Displays a think bubble using the selected named style.
28
+ ```ts
29
+ import { createSvgTextComposition } from "@kubohiroya/turbowarp-svg-text/composition";
62
30
 
63
- ## Responsive sizing
31
+ const svgText = createSvgTextComposition({ runtime: Scratch.vm.runtime });
64
32
 
65
- Size 100 is TurboWarp's default 14-pixel bubble font on a 480×360 stage. Bubbles and SVG text actors use the same formula:
33
+ svgText.defineStyle({
34
+ name: "dialogue-text",
35
+ alignment: "left",
36
+ backgroundColor: "transparent",
37
+ font: "Noto Sans JP",
38
+ fontPercent: 100,
39
+ textColor: "#332200",
40
+ });
66
41
 
67
- ```text
68
- stageScale = min(stageWidth / 480, stageHeight / 360)
69
- fontSize = 14 × stageScale × styleSize / 100
70
- ```
42
+ svgText.setText({
43
+ styleName: "dialogue-text",
44
+ target,
45
+ text: "The End",
46
+ });
71
47
 
72
- | Stage | Style size | Font size |
73
- | ------- | ---------: | --------: |
74
- | 480×360 | 100 | 14px |
75
- | 960×720 | 100 | 28px |
76
- | 960×720 | 150 | 42px |
48
+ const width = svgText.measureText({
49
+ styleName: "dialogue-text",
50
+ text: "The End",
51
+ });
77
52
 
78
- Style sizes are clamped to 1–1000. A blank or non-finite value uses 100.
53
+ svgText.releaseTarget(target);
54
+ svgText.releaseAll();
55
+ ```
79
56
 
80
- ## Usage
57
+ `setText` creates or replaces the SVG skin owned by the composition. `measureText` returns the maximum measured line width in stage-relative pixels. `releaseTarget` and `releaseAll` destroy skins owned by the composition.
81
58
 
82
- Load the following URL as a TurboWarp custom extension and allow it to run unsandboxed:
59
+ `SvgTextComposition` is intentionally suitable for a host Capability adapter:
83
60
 
84
- ```text
85
- https://cdn.jsdelivr.net/npm/@kubohiroya/turbowarp-svg-text@0.1.0/dist/svg-text.js
61
+ ```ts
62
+ interface TextCapability {
63
+ setText(input: {
64
+ styleName: string;
65
+ target: { drawableID: number };
66
+ text: string;
67
+ }): void;
68
+ measureText(input: { styleName: string; text: string }): number;
69
+ releaseTarget(target: { drawableID: number }): void;
70
+ }
86
71
  ```
87
72
 
88
- ```text
89
- define text style [narration] background [#fff4cc] text [#332200] font [Noto Sans JP] size [125] align [center] bubble direction [up]
90
- say [Once upon a time...\nIn a village by the sea...] with style [narration]
91
- set this sprite text [Act One\nUrashima Taro] with style [narration]
92
- ```
73
+ ## Integration with TurboWarp Bubble
74
+
75
+ `@kubohiroya/turbowarp-bubble` owns the Bubble outer shape, placement, portrait layers, and animation. It can use this package as its `BubbleTextCapability`, but the Bubble core does not depend on SVG Text itself.
93
76
 
94
77
  ## Development
95
78
 
96
- ```bash
97
- corepack enable
98
- pnpm install --frozen-lockfile
79
+ ```sh
80
+ pnpm install
99
81
  pnpm check
100
82
  ```
101
83
 
102
- The TypeScript source is built by `@kubohiroya/vite-plugin-turbowarp-extension` into a single `dist/svg-text.js` file. The build also generates `dist/extension-manifest.json` for saved-project API compatibility checks.
103
-
104
- ## Compatibility and limitations
105
-
106
- The extension runs unsandboxed because it uses the TurboWarp VM and renderer. Bubble support feature-detects the `SAY` and `STAGE_SIZE_CHANGED` events, the `Scratch.looks` custom state, drawable positioning, and scratch-render's `TextBubbleSkin.setStyle()`. SVG text actors use `createSVGSkin`, `updateDrawableSkinId`, and `destroySkin`.
107
-
108
- If compatible private drawing hooks are unavailable, center/right bubble alignment falls back to left alignment. If drawable positioning is unavailable, TurboWarp's standard bubble placement is retained. `setText` reports an explicit error when SVG skin APIs are unavailable. Standard Scratch Looks continues to handle bubble stopping, replacement, target tracking, the 330-character limit, and number formatting.
109
-
110
- ## License
111
-
112
- MPL-2.0
84
+ The package is distributed under MPL-2.0. See `LICENSE` for the source code terms.
@@ -0,0 +1,424 @@
1
+ var block_definitions_default = {
2
+ extensionName: "SVG Text",
3
+ blocks: [{
4
+ "opcode": "defineStyle",
5
+ "blockType": "COMMAND",
6
+ "text": "define text style [STYLE] background [BACKGROUND] text [TEXT_COLOR] font [FONT] size [SIZE] align [ALIGN]",
7
+ "description": "Defines or replaces a named text style for SVG text actors. Bubble shape and placement are owned by the host Bubble layer.",
8
+ "arguments": {
9
+ "STYLE": {
10
+ "type": "STRING",
11
+ "defaultValue": "default"
12
+ },
13
+ "BACKGROUND": {
14
+ "type": "COLOR",
15
+ "defaultValue": "#ffffff"
16
+ },
17
+ "TEXT_COLOR": {
18
+ "type": "COLOR",
19
+ "defaultValue": "#575e75"
20
+ },
21
+ "FONT": {
22
+ "type": "STRING",
23
+ "defaultValue": "Helvetica"
24
+ },
25
+ "SIZE": {
26
+ "type": "NUMBER",
27
+ "defaultValue": 100
28
+ },
29
+ "ALIGN": {
30
+ "type": "STRING",
31
+ "defaultValue": "left",
32
+ "menu": "alignment"
33
+ }
34
+ }
35
+ }, {
36
+ "opcode": "setText",
37
+ "blockType": "COMMAND",
38
+ "text": "set this sprite text [TEXT] with style [STYLE]",
39
+ "description": "Replaces this sprite's skin with responsive styled SVG text.",
40
+ "arguments": {
41
+ "TEXT": {
42
+ "type": "STRING",
43
+ "defaultValue": "Title\\nSubtitle"
44
+ },
45
+ "STYLE": {
46
+ "type": "STRING",
47
+ "defaultValue": "default"
48
+ }
49
+ }
50
+ }],
51
+ menus: { "alignment": {
52
+ "acceptReporters": true,
53
+ "items": [
54
+ "left",
55
+ "center",
56
+ "right"
57
+ ]
58
+ } }
59
+ };
60
+ //#endregion
61
+ //#region src/config.ts
62
+ var extensionConfig = {
63
+ id: "kubohiroyasvgtext",
64
+ slug: "svg-text",
65
+ name: "SVG Text",
66
+ description: "Responsive named-style SVG text actors for TurboWarp.",
67
+ author: "Hiroya Kubo",
68
+ license: "MPL-2.0",
69
+ unsandboxed: true
70
+ };
71
+ //#endregion
72
+ //#region src/extension.ts
73
+ var blockDefinitions = block_definitions_default.blocks;
74
+ var definitionMenus = block_definitions_default.menus;
75
+ var EXTENSION_DOCS_URI = "https://kubohiroya.github.io/turbowarp-svg-text/";
76
+ var defaultStyleName$1 = "default";
77
+ var defaultFontPercent = 100;
78
+ var minimumFontPercent = 1;
79
+ var maximumFontPercent = 1e3;
80
+ var maximumFontNameLength = 128;
81
+ var baseStageWidth = 480;
82
+ var baseStageHeight = 360;
83
+ var textStyle = {
84
+ fontSize: 14,
85
+ lineHeight: 16,
86
+ padding: 12,
87
+ cornerRadius: 8
88
+ };
89
+ var initialDefaultStyle = {
90
+ alignment: "left",
91
+ backgroundColor: "#ffffff",
92
+ font: "Helvetica",
93
+ fontPercent: defaultFontPercent,
94
+ textColor: "#575e75"
95
+ };
96
+ var SvgTextExtension = class {
97
+ constructor(runtime = Scratch.vm?.runtime, options = {}) {
98
+ this.styles = /* @__PURE__ */ new Map([[defaultStyleName$1, initialDefaultStyle]]);
99
+ this.textActors = /* @__PURE__ */ new Map();
100
+ if (!runtime) throw new Error("SVG Text requires the TurboWarp VM.");
101
+ this.runtime = runtime;
102
+ this.castToString = options.castToString ?? Scratch.Cast.toString;
103
+ if (options.listenForRuntimeEvents ?? true) this.runtime.on("STAGE_SIZE_CHANGED", () => {
104
+ this.restyleTextActors();
105
+ });
106
+ }
107
+ getInfo() {
108
+ return {
109
+ id: extensionConfig.id,
110
+ name: Scratch.translate(block_definitions_default.extensionName),
111
+ docsURI: EXTENSION_DOCS_URI,
112
+ color1: "#9966ff",
113
+ blocks: blockDefinitions.map((block) => this.toScratchBlock(block)),
114
+ menus: definitionMenus
115
+ };
116
+ }
117
+ defineStyle(args) {
118
+ const styleName = this.normalizeStyleName(args.STYLE);
119
+ this.styles.set(styleName, {
120
+ alignment: this.normalizeAlignment(args.ALIGN),
121
+ backgroundColor: this.normalizeColor(args.BACKGROUND, initialDefaultStyle.backgroundColor),
122
+ font: this.normalizeFont(args.FONT),
123
+ fontPercent: this.normalizeFontPercent(args.SIZE),
124
+ textColor: this.normalizeColor(args.TEXT_COLOR, initialDefaultStyle.textColor)
125
+ });
126
+ this.restyleTextActors(styleName);
127
+ }
128
+ setText(args, util) {
129
+ this.applyTextActor(util.target, this.normalizeMessage(args.TEXT), this.resolveStyle(args.STYLE));
130
+ }
131
+ measureText(styleName, text) {
132
+ const selection = this.resolveStyle(styleName);
133
+ const normalizedText = this.normalizeMessage(text);
134
+ const stageScale = this.getStageScale();
135
+ const fontSize = textStyle.fontSize * stageScale * (selection.definition.fontPercent / defaultFontPercent);
136
+ return Math.max(0, ...normalizedText.split("\n").map((line) => this.measureTextWidth(line, fontSize)));
137
+ }
138
+ releaseTextActor(target) {
139
+ const state = this.textActors.get(target);
140
+ if (!state) return false;
141
+ this.textActors.delete(target);
142
+ this.runtime.renderer?.destroySkin?.(state.skinId);
143
+ this.runtime.requestRedraw?.();
144
+ return true;
145
+ }
146
+ toScratchBlock(block) {
147
+ return {
148
+ opcode: block.opcode,
149
+ blockType: Scratch.BlockType[block.blockType],
150
+ text: Scratch.translate(block.text),
151
+ hideFromPalette: block.hideFromPalette ?? false,
152
+ arguments: Object.fromEntries(Object.entries(block.arguments).map(([name, argument]) => [name, {
153
+ type: Scratch.ArgumentType[argument.type],
154
+ defaultValue: argument.defaultValue,
155
+ ...argument.menu === void 0 ? {} : { menu: argument.menu }
156
+ }]))
157
+ };
158
+ }
159
+ normalizeStyleName(value) {
160
+ return this.castToString(value).trim() || defaultStyleName$1;
161
+ }
162
+ normalizeFontPercent(value) {
163
+ if (typeof value === "string" && value.trim() === "") return defaultFontPercent;
164
+ const numericValue = Number(value);
165
+ if (!Number.isFinite(numericValue)) return defaultFontPercent;
166
+ return Math.min(maximumFontPercent, Math.max(minimumFontPercent, numericValue));
167
+ }
168
+ normalizeMessage(value) {
169
+ return this.castToString(value).replace(/\\r\\n|\\n|\\r/gu, "\n");
170
+ }
171
+ normalizeAlignment(value) {
172
+ const alignment = this.castToString(value).trim().toLowerCase();
173
+ if (alignment === "center" || alignment === "right") return alignment;
174
+ return "left";
175
+ }
176
+ normalizeColor(value, fallback) {
177
+ const color = this.castToString(value).trim();
178
+ if (color === "") return fallback;
179
+ if (color.toLowerCase() === "transparent") return color;
180
+ if (/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/iu.test(color)) return color;
181
+ if (globalThis.CSS?.supports?.("color", color)) return color;
182
+ return fallback;
183
+ }
184
+ normalizeFont(value) {
185
+ const font = this.castToString(value).trim();
186
+ const hasUnsafeCharacter = [...font].some((character) => {
187
+ const codePoint = character.codePointAt(0) ?? 0;
188
+ return codePoint <= 31 || codePoint === 127 || ",;{}".includes(character);
189
+ });
190
+ if (font === "" || font.length > maximumFontNameLength || hasUnsafeCharacter) return initialDefaultStyle.font;
191
+ return font;
192
+ }
193
+ getStageScale() {
194
+ const nativeSize = this.runtime.renderer?.getNativeSize?.();
195
+ if (!Array.isArray(nativeSize) || nativeSize.length < 2) return 1;
196
+ const width = Number(nativeSize[0]);
197
+ const height = Number(nativeSize[1]);
198
+ if (!(width > 0) || !(height > 0)) return 1;
199
+ return Math.min(width / baseStageWidth, height / baseStageHeight);
200
+ }
201
+ createTextActorSvg(text, definition) {
202
+ const stageScale = this.getStageScale();
203
+ const fontScale = stageScale * (definition.fontPercent / defaultFontPercent);
204
+ const fontSize = textStyle.fontSize * fontScale;
205
+ const lineHeight = textStyle.lineHeight * fontScale;
206
+ const padding = textStyle.padding * stageScale;
207
+ const cornerRadius = textStyle.cornerRadius * stageScale;
208
+ const lines = text.split("\n");
209
+ const contentWidth = Math.max(1, ...lines.map((line) => this.measureTextWidth(line, fontSize)));
210
+ const width = Math.max(1, Math.ceil(contentWidth + padding * 2));
211
+ const height = Math.max(1, Math.ceil(lineHeight * lines.length + padding * 2));
212
+ const textAnchor = definition.alignment === "center" ? "middle" : definition.alignment === "right" ? "end" : "start";
213
+ const x = definition.alignment === "center" ? width / 2 : definition.alignment === "right" ? width - padding : padding;
214
+ const title = this.escapeXml(text);
215
+ const tspans = lines.map((line, index) => {
216
+ const y = padding + fontSize + lineHeight * index;
217
+ return `<tspan x="${this.formatSvgNumber(x)}" y="${this.formatSvgNumber(y)}">${this.escapeXml(line)}</tspan>`;
218
+ }).join("");
219
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img"><title>${title}</title><rect width="${width}" height="${height}" rx="${this.formatSvgNumber(cornerRadius)}" fill="${this.escapeXml(definition.backgroundColor)}"/><text xml:space="preserve" fill="${this.escapeXml(definition.textColor)}" font-family="${this.escapeXml(definition.font)}" font-size="${this.formatSvgNumber(fontSize)}" text-anchor="${textAnchor}">${tspans}</text></svg>`;
220
+ }
221
+ measureTextWidth(text, fontSize) {
222
+ let units = 0;
223
+ for (const character of text) {
224
+ if (/\p{Mark}/u.test(character)) continue;
225
+ if (/\s/u.test(character)) {
226
+ units += .35;
227
+ continue;
228
+ }
229
+ const codePoint = character.codePointAt(0) ?? 0;
230
+ units += codePoint <= 127 ? .62 : 1;
231
+ }
232
+ return units * fontSize;
233
+ }
234
+ escapeXml(value) {
235
+ return value.replace(/[&<>"']/gu, (character) => {
236
+ switch (character) {
237
+ case "&": return "&amp;";
238
+ case "<": return "&lt;";
239
+ case ">": return "&gt;";
240
+ case "\"": return "&quot;";
241
+ default: return "&apos;";
242
+ }
243
+ });
244
+ }
245
+ formatSvgNumber(value) {
246
+ return String(Math.round(value * 1e3) / 1e3);
247
+ }
248
+ applyTextActor(target, text, selection) {
249
+ const renderer = this.runtime.renderer;
250
+ if (typeof target.drawableID !== "number" || typeof renderer?.createSVGSkin !== "function" || typeof renderer.updateDrawableSkinId !== "function") throw new Error("SVG Text requires SVG skin APIs from TurboWarp.");
251
+ const skinId = renderer.createSVGSkin(this.createTextActorSvg(text, selection.definition));
252
+ if (!Number.isInteger(skinId) || skinId < 0) throw new Error("TurboWarp did not create an SVG text skin.");
253
+ try {
254
+ renderer.updateDrawableSkinId(target.drawableID, skinId);
255
+ } catch (error) {
256
+ renderer.destroySkin?.(skinId);
257
+ throw error;
258
+ }
259
+ const previous = this.textActors.get(target);
260
+ this.textActors.set(target, {
261
+ skinId,
262
+ styleName: selection.styleName,
263
+ text
264
+ });
265
+ if (previous && previous.skinId !== skinId) renderer.destroySkin?.(previous.skinId);
266
+ this.runtime.requestRedraw?.();
267
+ }
268
+ resolveStyle(value) {
269
+ const requestedName = this.normalizeStyleName(value);
270
+ const definition = this.styles.get(requestedName);
271
+ if (definition) return {
272
+ definition,
273
+ styleName: requestedName
274
+ };
275
+ return {
276
+ definition: this.styles.get(defaultStyleName$1) ?? initialDefaultStyle,
277
+ styleName: defaultStyleName$1
278
+ };
279
+ }
280
+ restyleTextActors(styleName) {
281
+ for (const [target, state] of [...this.textActors]) {
282
+ if (styleName !== void 0 && state.styleName !== styleName) continue;
283
+ this.applyTextActor(target, state.text, this.resolveStyle(state.styleName));
284
+ }
285
+ }
286
+ };
287
+ //#endregion
288
+ //#region src/composition.ts
289
+ var defaultStyleName = "default";
290
+ function isRecord(value) {
291
+ return typeof value === "object" && value !== null && !Array.isArray(value);
292
+ }
293
+ function compositionError(code, message) {
294
+ const error = new Error(message);
295
+ Object.defineProperty(error, "code", { value: code });
296
+ return error;
297
+ }
298
+ function requireExactKeys(value, required, optional, label) {
299
+ const allowed = /* @__PURE__ */ new Set([...required, ...optional]);
300
+ if (required.some((key) => !Object.prototype.hasOwnProperty.call(value, key)) || Object.keys(value).some((key) => !allowed.has(key))) throw compositionError("SVG-TEXT-COMPOSITION-001", `${label} has missing or unknown properties.`);
301
+ }
302
+ function requireName(value, label) {
303
+ if (typeof value !== "string" || value.trim().length === 0) throw compositionError("SVG-TEXT-COMPOSITION-001", `${label} must be a non-empty string.`);
304
+ return value.trim();
305
+ }
306
+ function validateRuntime(value) {
307
+ if (!isRecord(value) || !isRecord(value.renderer)) throw new TypeError("SVG Text composition runtime must provide a renderer.");
308
+ const renderer = value.renderer;
309
+ const methods = [
310
+ "createSVGSkin",
311
+ "destroySkin",
312
+ "updateDrawableSkinId"
313
+ ];
314
+ if (methods.some((method) => typeof renderer[method] !== "function")) throw new TypeError(`SVG Text composition renderer must provide ${methods.join(", ")}.`);
315
+ if (value.requestRedraw !== void 0 && typeof value.requestRedraw !== "function") throw new TypeError("SVG Text composition requestRedraw must be a function.");
316
+ return value;
317
+ }
318
+ function validateTarget(value) {
319
+ if (!isRecord(value) || typeof value.drawableID !== "number" || !Number.isInteger(value.drawableID) || value.drawableID < 0) throw compositionError("SVG-TEXT-COMPOSITION-002", "SVG Text target must provide a non-negative integer drawableID.");
320
+ return value;
321
+ }
322
+ function validateOptionalString(value, label) {
323
+ if (value !== void 0 && (typeof value !== "string" || value.length === 0)) throw compositionError("SVG-TEXT-COMPOSITION-001", `${label} must be a non-empty string when provided.`);
324
+ }
325
+ function validateStyle(value) {
326
+ if (!isRecord(value)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text style must be an object.");
327
+ requireExactKeys(value, ["name"], [
328
+ "alignment",
329
+ "backgroundColor",
330
+ "font",
331
+ "fontPercent",
332
+ "textColor"
333
+ ], "SVG Text style");
334
+ const name = requireName(value.name, "SVG Text style name");
335
+ if (value.alignment !== void 0 && value.alignment !== "left" && value.alignment !== "center" && value.alignment !== "right") throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text alignment is invalid.");
336
+ validateOptionalString(value.backgroundColor, "SVG Text backgroundColor");
337
+ validateOptionalString(value.font, "SVG Text font");
338
+ validateOptionalString(value.textColor, "SVG Text textColor");
339
+ if (value.fontPercent !== void 0 && (typeof value.fontPercent !== "number" || !Number.isFinite(value.fontPercent) || value.fontPercent < 1 || value.fontPercent > 1e3)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text fontPercent must be a finite number from 1 to 1000.");
340
+ return {
341
+ ...value,
342
+ name
343
+ };
344
+ }
345
+ function createSvgTextComposition(options) {
346
+ if (!isRecord(options)) throw new TypeError("SVG Text composition options must be an object.");
347
+ const extension = new SvgTextExtension(validateRuntime(options.runtime), {
348
+ castToString: (value) => String(value),
349
+ listenForRuntimeEvents: false
350
+ });
351
+ const styles = /* @__PURE__ */ new Set([defaultStyleName]);
352
+ const targets = /* @__PURE__ */ new Set();
353
+ let disposed = false;
354
+ function ensureActive() {
355
+ if (disposed) throw compositionError("SVG-TEXT-COMPOSITION-004", "SVG Text composition has been released.");
356
+ }
357
+ return Object.freeze({
358
+ defineStyle(input) {
359
+ ensureActive();
360
+ const style = validateStyle(input);
361
+ extension.defineStyle({
362
+ ALIGN: style.alignment ?? "",
363
+ BACKGROUND: style.backgroundColor ?? "",
364
+ FONT: style.font ?? "",
365
+ SIZE: style.fontPercent ?? "",
366
+ STYLE: style.name,
367
+ TEXT_COLOR: style.textColor ?? ""
368
+ });
369
+ styles.add(style.name);
370
+ },
371
+ measureText(input) {
372
+ ensureActive();
373
+ if (!isRecord(input)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text measure input is invalid.");
374
+ requireExactKeys(input, ["styleName", "text"], [], "SVG Text measure input");
375
+ const styleName = requireName(input.styleName, "SVG Text styleName");
376
+ if (!styles.has(styleName)) throw compositionError("SVG-TEXT-COMPOSITION-003", `SVG Text style is not defined: ${styleName}`);
377
+ if (typeof input.text !== "string") throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text text must be a string.");
378
+ return extension.measureText(styleName, input.text);
379
+ },
380
+ setText(input) {
381
+ ensureActive();
382
+ if (!isRecord(input)) throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text actor input is invalid.");
383
+ requireExactKeys(input, [
384
+ "styleName",
385
+ "target",
386
+ "text"
387
+ ], [], "SVG Text actor input");
388
+ const target = validateTarget(input.target);
389
+ const styleName = requireName(input.styleName, "SVG Text styleName");
390
+ if (!styles.has(styleName)) throw compositionError("SVG-TEXT-COMPOSITION-003", `SVG Text style is not defined: ${styleName}`);
391
+ if (typeof input.text !== "string") throw compositionError("SVG-TEXT-COMPOSITION-001", "SVG Text text must be a string.");
392
+ extension.setText({
393
+ STYLE: styleName,
394
+ TEXT: input.text
395
+ }, { target });
396
+ targets.add(target);
397
+ },
398
+ releaseTarget(value) {
399
+ ensureActive();
400
+ const target = validateTarget(value);
401
+ if (!targets.delete(target)) throw compositionError("SVG-TEXT-COMPOSITION-005", "SVG Text target is not owned by this composition.");
402
+ if (!extension.releaseTextActor(target)) throw compositionError("SVG-TEXT-COMPOSITION-005", "SVG Text target ownership is inconsistent.");
403
+ },
404
+ releaseAll() {
405
+ if (disposed) return;
406
+ disposed = true;
407
+ const errors = [];
408
+ for (const target of targets) try {
409
+ extension.releaseTextActor(target);
410
+ } catch (error) {
411
+ errors.push(error);
412
+ }
413
+ targets.clear();
414
+ styles.clear();
415
+ if (errors.length > 0) {
416
+ const error = compositionError("SVG-TEXT-COMPOSITION-006", "SVG Text composition failed to release one or more skins.");
417
+ Object.defineProperty(error, "errors", { value: Object.freeze([...errors]) });
418
+ throw error;
419
+ }
420
+ }
421
+ });
422
+ }
423
+ //#endregion
424
+ export { createSvgTextComposition };
@@ -15,11 +15,6 @@
15
15
  "id": "BACKGROUND",
16
16
  "type": "COLOR"
17
17
  },
18
- {
19
- "id": "DIRECTION",
20
- "type": "STRING",
21
- "menu": "direction"
22
- },
23
18
  {
24
19
  "id": "FONT",
25
20
  "type": "STRING"
@@ -38,34 +33,6 @@
38
33
  }
39
34
  ]
40
35
  },
41
- {
42
- "opcode": "say",
43
- "blockType": "COMMAND",
44
- "arguments": [
45
- {
46
- "id": "MESSAGE",
47
- "type": "STRING"
48
- },
49
- {
50
- "id": "SIZE",
51
- "type": "NUMBER"
52
- }
53
- ]
54
- },
55
- {
56
- "opcode": "sayWithStyle",
57
- "blockType": "COMMAND",
58
- "arguments": [
59
- {
60
- "id": "MESSAGE",
61
- "type": "STRING"
62
- },
63
- {
64
- "id": "STYLE",
65
- "type": "STRING"
66
- }
67
- ]
68
- },
69
36
  {
70
37
  "opcode": "setText",
71
38
  "blockType": "COMMAND",
@@ -79,44 +46,12 @@
79
46
  "type": "STRING"
80
47
  }
81
48
  ]
82
- },
83
- {
84
- "opcode": "think",
85
- "blockType": "COMMAND",
86
- "arguments": [
87
- {
88
- "id": "MESSAGE",
89
- "type": "STRING"
90
- },
91
- {
92
- "id": "SIZE",
93
- "type": "NUMBER"
94
- }
95
- ]
96
- },
97
- {
98
- "opcode": "thinkWithStyle",
99
- "blockType": "COMMAND",
100
- "arguments": [
101
- {
102
- "id": "MESSAGE",
103
- "type": "STRING"
104
- },
105
- {
106
- "id": "STYLE",
107
- "type": "STRING"
108
- }
109
- ]
110
49
  }
111
50
  ],
112
51
  "menus": [
113
52
  {
114
53
  "id": "alignment",
115
54
  "acceptReporters": true
116
- },
117
- {
118
- "id": "direction",
119
- "acceptReporters": true
120
55
  }
121
56
  ]
122
57
  }