@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 +53 -81
- package/dist/composition.js +424 -0
- package/dist/extension-manifest.json +0 -65
- package/dist/svg-text.js +96 -565
- package/dist/types/composition.d.ts +42 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/extension.d.ts +56 -0
- package/package.json +18 -5
package/README.md
CHANGED
|
@@ -1,112 +1,84 @@
|
|
|
1
1
|
# TurboWarp SVG Text
|
|
2
2
|
|
|
3
|
-
TurboWarp SVG Text provides responsive
|
|
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
|
-
|
|
5
|
+
## Responsibilities
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
24
|
+
## Composition API
|
|
58
25
|
|
|
59
|
-
|
|
26
|
+
The composition API does not register the standalone extension. The host supplies a TurboWarp runtime and explicit targets.
|
|
60
27
|
|
|
61
|
-
|
|
28
|
+
```ts
|
|
29
|
+
import { createSvgTextComposition } from "@kubohiroya/turbowarp-svg-text/composition";
|
|
62
30
|
|
|
63
|
-
|
|
31
|
+
const svgText = createSvgTextComposition({ runtime: Scratch.vm.runtime });
|
|
64
32
|
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
42
|
+
svgText.setText({
|
|
43
|
+
styleName: "dialogue-text",
|
|
44
|
+
target,
|
|
45
|
+
text: "The End",
|
|
46
|
+
});
|
|
71
47
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
| 960×720 | 150 | 42px |
|
|
48
|
+
const width = svgText.measureText({
|
|
49
|
+
styleName: "dialogue-text",
|
|
50
|
+
text: "The End",
|
|
51
|
+
});
|
|
77
52
|
|
|
78
|
-
|
|
53
|
+
svgText.releaseTarget(target);
|
|
54
|
+
svgText.releaseAll();
|
|
55
|
+
```
|
|
79
56
|
|
|
80
|
-
|
|
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
|
-
|
|
59
|
+
`SvgTextComposition` is intentionally suitable for a host Capability adapter:
|
|
83
60
|
|
|
84
|
-
```
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
```
|
|
97
|
-
|
|
98
|
-
pnpm install --frozen-lockfile
|
|
79
|
+
```sh
|
|
80
|
+
pnpm install
|
|
99
81
|
pnpm check
|
|
100
82
|
```
|
|
101
83
|
|
|
102
|
-
The
|
|
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 "&";
|
|
238
|
+
case "<": return "<";
|
|
239
|
+
case ">": return ">";
|
|
240
|
+
case "\"": return """;
|
|
241
|
+
default: return "'";
|
|
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
|
}
|