vgpu-render 0.0.0-stage → 0.1.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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +555 -2
  3. package/dist/assets/fira-mono-medium-subset.d.ts +4 -0
  4. package/dist/assets/fira-mono-medium-subset.js +889 -0
  5. package/dist/assets/fira-mono-msdf-atlas.d.ts +13 -0
  6. package/dist/assets/fira-mono-msdf-atlas.js +6222 -0
  7. package/dist/camera-2d.d.ts +43 -0
  8. package/dist/camera-2d.js +75 -0
  9. package/dist/color-space.d.ts +32 -0
  10. package/dist/color-space.js +45 -0
  11. package/dist/curve.d.ts +16 -0
  12. package/dist/curve.js +46 -0
  13. package/dist/debug-draw-3d.d.ts +14 -0
  14. package/dist/debug-draw-3d.js +36 -0
  15. package/dist/frame-recorder-2d.d.ts +177 -0
  16. package/dist/frame-recorder-2d.js +378 -0
  17. package/dist/frame-recorder.d.ts +361 -0
  18. package/dist/frame-recorder.js +515 -0
  19. package/dist/geometry-generators.d.ts +101 -0
  20. package/dist/geometry-generators.js +481 -0
  21. package/dist/gpu-debug-draw-2d.d.ts +48 -0
  22. package/dist/gpu-debug-draw-2d.js +450 -0
  23. package/dist/gpu-debug-draw-3d.d.ts +52 -0
  24. package/dist/gpu-debug-draw-3d.js +890 -0
  25. package/dist/gpu-instancing.d.ts +34 -0
  26. package/dist/gpu-instancing.js +93 -0
  27. package/dist/gpu-text-3d.d.ts +98 -0
  28. package/dist/gpu-text-3d.js +467 -0
  29. package/dist/index.d.ts +21 -0
  30. package/dist/index.js +21 -0
  31. package/dist/infinite-grid-2d.d.ts +73 -0
  32. package/dist/infinite-grid-2d.js +253 -0
  33. package/dist/infinite-grid-3d.d.ts +134 -0
  34. package/dist/infinite-grid-3d.js +407 -0
  35. package/dist/layout-3d.d.ts +227 -0
  36. package/dist/layout-3d.js +226 -0
  37. package/dist/mesh-batch-3d.d.ts +10 -0
  38. package/dist/mesh-batch-3d.js +67 -0
  39. package/dist/scene-examples.d.ts +39 -0
  40. package/dist/scene-examples.js +200 -0
  41. package/dist/scenes-2d.d.ts +18 -0
  42. package/dist/scenes-2d.js +160 -0
  43. package/dist/scenes-3d.d.ts +46 -0
  44. package/dist/scenes-3d.js +1994 -0
  45. package/dist/shape2d-shader.d.ts +14 -0
  46. package/dist/shape2d-shader.js +287 -0
  47. package/dist/text-atlas.d.ts +174 -0
  48. package/dist/text-atlas.js +495 -0
  49. package/dist/text-font.d.ts +149 -0
  50. package/dist/text-font.js +209 -0
  51. package/dist/text-layout.d.ts +116 -0
  52. package/dist/text-layout.js +340 -0
  53. package/dist/text-msdf-baked.d.ts +36 -0
  54. package/dist/text-msdf-baked.js +112 -0
  55. package/dist/text-msdf-font.d.ts +79 -0
  56. package/dist/text-msdf-font.js +153 -0
  57. package/dist/text-msdf.d.ts +70 -0
  58. package/dist/text-msdf.js +242 -0
  59. package/dist/text-raster.d.ts +40 -0
  60. package/dist/text-raster.js +60 -0
  61. package/dist/text-scenes.d.ts +85 -0
  62. package/dist/text-scenes.js +338 -0
  63. package/dist/text-sdf.d.ts +28 -0
  64. package/dist/text-sdf.js +119 -0
  65. package/package.json +80 -4
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Browser-side font loading and glyph rasterisation for the text renderer.
3
+ *
4
+ * reference rasterises glyphs with swash into a `FontAtlas`
5
+ * (`the reference implementation`); the equivalent rasteriser in a browser is
6
+ * Canvas2D, so `fillText` plays the role of `swash::scale::Render` and the browser's font
7
+ * stack stands in for font discovery. The bundled font is inlined exactly the way reference
8
+ * inlines `FiraMono-subset.ttf` with `include_bytes!`, so nothing is fetched at runtime.
9
+ *
10
+ * The subset deliberately has no CJK coverage; pass a different family through the text
11
+ * options when those are needed.
12
+ *
13
+ * Everything here is browser-only. Layout depends on the `TextMeasurer` interface alone, so
14
+ * unit tests keep running in plain Node, and the GPU tests inject their own rasteriser.
15
+ */
16
+ import { FIRA_MONO_MEDIUM_SUBSET_BASE64, FIRA_MONO_MEDIUM_SUBSET_TTF_BASE64 } from "./assets/fira-mono-medium-subset.js";
17
+ import { coverageToSdf, DEFAULT_SDF_RANGE } from "./text-sdf.js";
18
+ /**
19
+ * Family name the bundled font is registered under. It is deliberately not `"Fira Mono"` so
20
+ * a locally installed face can never shadow the inlined one.
21
+ */
22
+ export const DEFAULT_FONT_FAMILY = "vgpu-render-fira-mono";
23
+ /**
24
+ * Fira Mono's hhea metrics are 935 / -265 per 1000 upem, i.e. exactly the 1.2 em line box
25
+ * reference default `LineHeight` assumes. They are the fallback when a browser reports no font
26
+ * bounding box.
27
+ */
28
+ const FALLBACK_ASCENT_RATIO = 0.935;
29
+ const FALLBACK_DESCENT_RATIO = 0.265;
30
+ const INITIAL_SURFACE_SIZE = 256;
31
+ /** Decodes a base64 payload into bytes. Shared by the inlined font and the prebaked atlas. */
32
+ export function decodeBase64Bytes(base64) {
33
+ const binary = atob(base64);
34
+ const bytes = new Uint8Array(binary.length);
35
+ for (let index = 0; index < binary.length; index++)
36
+ bytes[index] = binary.charCodeAt(index);
37
+ return bytes;
38
+ }
39
+ /** Decodes the inlined WOFF2 bytes of the bundled font, which is what `FontFace` takes. */
40
+ export function decodeEmbeddedFont() {
41
+ return decodeBase64Bytes(FIRA_MONO_MEDIUM_SUBSET_BASE64);
42
+ }
43
+ /**
44
+ * Decodes the inlined TrueType bytes of the same subset. The outline-based encoders need this one:
45
+ * `opentype.js` reads glyph outlines, and it cannot decompress the WOFF2 container.
46
+ */
47
+ export function decodeEmbeddedTrueTypeFont() {
48
+ return decodeBase64Bytes(FIRA_MONO_MEDIUM_SUBSET_TTF_BASE64);
49
+ }
50
+ let defaultFont;
51
+ /**
52
+ * Registers the bundled font with the document, once per page. Measuring or drawing with
53
+ * `DEFAULT_FONT_FAMILY` before this resolves falls back to another face, so await it before
54
+ * the first frame that draws text.
55
+ */
56
+ export function loadDefaultFont() {
57
+ defaultFont ??= registerDefaultFont();
58
+ return defaultFont;
59
+ }
60
+ async function registerDefaultFont() {
61
+ const fonts = typeof document === "undefined" ? undefined : document.fonts;
62
+ if (fonts === undefined || typeof FontFace === "undefined") {
63
+ throw new Error("loading the bundled text font requires a browser environment");
64
+ }
65
+ const face = new FontFace(DEFAULT_FONT_FAMILY, decodeEmbeddedFont());
66
+ await face.load();
67
+ fonts.add(face);
68
+ }
69
+ /**
70
+ * Canvas2D glyph source, doubling as the layout `TextMeasurer` so the canvas is only set up
71
+ * once per canvas-configured face and every `measureText` result is cached.
72
+ *
73
+ * Metrics follow Canvas2D's convention: the pen sits at the origin and the baseline is y = 0
74
+ * with y growing downwards. `actualBoundingBoxLeft` is a *leftward* distance, so a glyph that
75
+ * starts right of the pen reports a negative value — negating it yields the ink's left edge
76
+ * in pen space, which is what the atlas stores.
77
+ */
78
+ export class CanvasGlyphRasterizer {
79
+ encoding;
80
+ distanceRange;
81
+ rasterScale;
82
+ /**
83
+ * Whether the renderer drives this source's raster size from the size a block covers on screen.
84
+ * A Canvas2D face can be asked for any size, so it defaults to `true`; a source that needs a fixed
85
+ * raster — a pixel-accurate comparison against another renderer, say — turns it off.
86
+ */
87
+ dynamicRaster;
88
+ #surface;
89
+ #metrics = new Map();
90
+ #vertical = new Map();
91
+ constructor(options = {}) {
92
+ this.encoding = options.encoding ?? "bitmap";
93
+ this.distanceRange = options.distanceRange ?? DEFAULT_SDF_RANGE;
94
+ this.rasterScale = options.rasterScale ?? 1;
95
+ this.dynamicRaster = options.dynamicRaster ?? true;
96
+ }
97
+ #ensureSurface() {
98
+ if (this.#surface === undefined) {
99
+ if (typeof document === "undefined") {
100
+ throw new Error("glyph rasterisation requires a browser environment");
101
+ }
102
+ const canvas = document.createElement("canvas");
103
+ canvas.width = INITIAL_SURFACE_SIZE;
104
+ canvas.height = INITIAL_SURFACE_SIZE;
105
+ const context = canvas.getContext("2d", { willReadFrequently: true });
106
+ if (context === null)
107
+ throw new Error("a 2D canvas context is required to rasterise glyphs");
108
+ this.#surface = { canvas, context };
109
+ }
110
+ return this.#surface;
111
+ }
112
+ #font(fontFamily, fontSize) {
113
+ return `${fontSize}px "${fontFamily}"`;
114
+ }
115
+ verticalMetrics(fontFamily, fontSize) {
116
+ const key = `${fontFamily}|${fontSize}`;
117
+ const cached = this.#vertical.get(key);
118
+ if (cached !== undefined)
119
+ return cached;
120
+ const context = this.#ensureSurface().context;
121
+ context.font = this.#font(fontFamily, fontSize);
122
+ const metrics = context.measureText("M");
123
+ const ascent = metrics.fontBoundingBoxAscent > 0 ? metrics.fontBoundingBoxAscent : fontSize * FALLBACK_ASCENT_RATIO;
124
+ const descent = metrics.fontBoundingBoxDescent > 0 ? metrics.fontBoundingBoxDescent : fontSize * FALLBACK_DESCENT_RATIO;
125
+ const value = { ascent, descent };
126
+ this.#vertical.set(key, value);
127
+ return value;
128
+ }
129
+ glyphMetrics(character, fontFamily, fontSize) {
130
+ const key = `${fontFamily}|${fontSize}|${character}`;
131
+ const cached = this.#metrics.get(key);
132
+ if (cached !== undefined)
133
+ return cached;
134
+ const context = this.#ensureSurface().context;
135
+ context.font = this.#font(fontFamily, fontSize);
136
+ const metrics = context.measureText(character);
137
+ const bearingLeft = -metrics.actualBoundingBoxLeft;
138
+ const value = {
139
+ advance: metrics.width,
140
+ width: metrics.actualBoundingBoxRight - bearingLeft,
141
+ height: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
142
+ bearingLeft,
143
+ bearingTop: -metrics.actualBoundingBoxAscent,
144
+ };
145
+ this.#metrics.set(key, value);
146
+ return value;
147
+ }
148
+ rasterize(character, fontFamily, rasterSize) {
149
+ // The raster size *is* the face size here: `measureText` and the fill both work in the texels the
150
+ // atlas will store, and the layout's own measurements never pass through this path.
151
+ const glyph = this.glyphMetrics(character, fontFamily, rasterSize);
152
+ if (!(glyph.width > 0) || !(glyph.height > 0))
153
+ return undefined;
154
+ // The ink box is fractional; the mask rounds up so no edge pixel is lost.
155
+ const width = Math.max(1, Math.ceil(glyph.width));
156
+ const height = Math.max(1, Math.ceil(glyph.height));
157
+ const { canvas, context } = this.#ensureSurface();
158
+ if (canvas.width < width || canvas.height < height) {
159
+ canvas.width = Math.max(canvas.width, width);
160
+ canvas.height = Math.max(canvas.height, height);
161
+ }
162
+ context.clearRect(0, 0, width, height);
163
+ context.font = this.#font(fontFamily, rasterSize);
164
+ context.textAlign = "left";
165
+ context.textBaseline = "alphabetic";
166
+ context.fillStyle = "#fff";
167
+ // Place the pen so the ink box starts at (0, 0): the baseline sits `-bearingTop` down.
168
+ context.fillText(character, -glyph.bearingLeft, -glyph.bearingTop);
169
+ const pixels = context.getImageData(0, 0, width, height).data;
170
+ const coverage = new Uint8Array(width * height);
171
+ for (let index = 0; index < coverage.length; index++)
172
+ coverage[index] = pixels[index * 4 + 3];
173
+ return {
174
+ width,
175
+ height,
176
+ texels: encodeCoverage(coverage, width, height, this.encoding, this.distanceRange),
177
+ inkWidth: glyph.width,
178
+ inkHeight: glyph.height,
179
+ bearingLeft: glyph.bearingLeft,
180
+ bearingTop: glyph.bearingTop,
181
+ advance: glyph.advance,
182
+ };
183
+ }
184
+ }
185
+ /** Packs Canvas2D coverage into the RGBA layout the chosen encoding expects. */
186
+ export function encodeCoverage(coverage, width, height, encoding, range) {
187
+ const texels = new Uint8Array(coverage.length * 4);
188
+ if (encoding === "bitmap") {
189
+ // A tintable alpha mask: RGB stays 255 so the shader can colour it.
190
+ for (let index = 0; index < coverage.length; index++) {
191
+ const target = index * 4;
192
+ texels[target] = 255;
193
+ texels[target + 1] = 255;
194
+ texels[target + 2] = 255;
195
+ texels[target + 3] = coverage[index];
196
+ }
197
+ return texels;
198
+ }
199
+ // A signed distance in red, and the same distance in alpha so the shader can outline the glyph.
200
+ // It comes from the mask's coverage rather than from the outline, which makes it a little coarser
201
+ // than the msdf encoder's — fine for a ring, not what the edge itself is rebuilt from.
202
+ const field = coverageToSdf(coverage, width, height, range);
203
+ for (let index = 0; index < field.length; index++) {
204
+ const target = index * 4;
205
+ texels[target] = field[index];
206
+ texels[target + 3] = field[index];
207
+ }
208
+ return texels;
209
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Text layout for the debug text renderer.
3
+ *
4
+ * Ported from reference `TextPipeline::update_text_layout_info`
5
+ * (`the reference implementation`): a text block is wrapped to its `TextBounds`, split
6
+ * into lines, justified, and turned into one positioned glyph per character. Each glyph
7
+ * carries its ink box and resolved colour, which is what the GPU pass later instantiates as
8
+ * a quad.
9
+ *
10
+ * Two deliberate differences from reference:
11
+ *
12
+ * - There is no shaping. Characters advance one at a time and never form ligatures or
13
+ * reorder runs, which suits the monospaced debug font the library bundles.
14
+ * - Every metric comes from an injected `TextMeasurer`. reference reads parley and swash here;
15
+ * staying behind an interface keeps this module free of browser APIs and unit-testable in
16
+ * plain Node, and it is also the seam the GPU tests use to feed synthetic glyphs.
17
+ *
18
+ * Layout happens in one local space: origin at the block's anchor point, x to the right and
19
+ * **y down**, matching Canvas2D. The renderer flips y for spaces where up is +y.
20
+ */
21
+ export type Rgba = readonly [number, number, number, number];
22
+ /** Vertical font metrics, in the same units as the font size the measurer was asked for. */
23
+ export type VerticalMetrics = {
24
+ readonly ascent: number;
25
+ readonly descent: number;
26
+ };
27
+ /**
28
+ * Per-character metrics, mirroring the `Placement` swash reports to reference: an advance along
29
+ * the baseline plus the ink box relative to the pen.
30
+ */
31
+ export type GlyphMetrics = {
32
+ /** Pen advance for this character. */
33
+ readonly advance: number;
34
+ /** Ink box width. Zero for whitespace. */
35
+ readonly width: number;
36
+ /** Ink box height. Zero for whitespace. */
37
+ readonly height: number;
38
+ /** Ink box left edge relative to the pen, y down: positive moves right. */
39
+ readonly bearingLeft: number;
40
+ /** Ink box top edge relative to the baseline, y down: positive is below the baseline. */
41
+ readonly bearingTop: number;
42
+ };
43
+ /** Source of glyph metrics. The browser implementation measures through Canvas2D. */
44
+ export interface TextMeasurer {
45
+ /** Vertical metrics for a `fontSize`-sized face of `fontFamily`. */
46
+ verticalMetrics(fontFamily: string, fontSize: number): VerticalMetrics;
47
+ /** Metrics for one character. Whitespace returns a zero-size ink box. */
48
+ glyphMetrics(character: string, fontFamily: string, fontSize: number): GlyphMetrics;
49
+ }
50
+ /** Reference `Justify`. `start`/`end` resolve to left/right in this LTR-only layout. */
51
+ export type TextJustify = "left" | "center" | "right" | "justified" | "start" | "end";
52
+ /** Reference `LineBreak`, minus the Unicode-correct word segmentation reference gets from parley. */
53
+ export type TextLineBreak = "word" | "character" | "word-or-character" | "none";
54
+ /** Normalized anchor: x 0 = left, 1 = right; y 0 = top, 1 = bottom (local y grows down). */
55
+ export type TextAnchorPoint = readonly [number, number];
56
+ /** Reference `Anchor` presets, named for the block corner that lands on the position. */
57
+ export type TextAnchor = "center" | "top-left" | "top-center" | "top-right" | "center-left" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right";
58
+ export declare function resolveTextAnchor(anchor: TextAnchor): TextAnchorPoint;
59
+ /** One section of a text block, the equivalent of a reference `TextSpan`. */
60
+ export type TextSpanInput = {
61
+ readonly text: string;
62
+ readonly color?: Rgba;
63
+ readonly fontSize?: number;
64
+ readonly fontFamily?: string;
65
+ };
66
+ /** reference `LineHeight` default, `RelativeToFont(1.2)`. */
67
+ export declare const DEFAULT_LINE_HEIGHT_SCALE = 1.2;
68
+ export type TextLayoutOptions = {
69
+ readonly color: Rgba;
70
+ readonly fontSize: number;
71
+ readonly fontFamily: string;
72
+ /**
73
+ * Absolute line height in layout units, reference `LineHeight::Px`. When omitted the line height
74
+ * is `lineHeightScale` times the line's own largest font size, which is reference
75
+ * `LineHeight::RelativeToFont` — the default, and the reason a line holding a larger span gets
76
+ * a taller box rather than being clipped to the block's own size.
77
+ */
78
+ readonly lineHeight?: number | undefined;
79
+ /** Multiple of the line's largest font size, reference `LineHeight::RelativeToFont`. Default `1.2`. */
80
+ readonly lineHeightScale?: number | undefined;
81
+ /**
82
+ * Added after every character, the last one on a line included: parley adds it to each cluster's
83
+ * advance, and the trailing whitespace a line breaks at carries its share into the block width.
84
+ */
85
+ readonly letterSpacing: number;
86
+ readonly justify: TextJustify;
87
+ readonly lineBreak: TextLineBreak;
88
+ /**
89
+ * `TextBounds`. Only `width` feeds layout: it is the wrap width. `height` is not a layout input,
90
+ * matching reference, whose `Text2d` never truncates lines against it (it only sizes the node).
91
+ */
92
+ readonly bounds?: readonly [number, number] | undefined;
93
+ readonly anchor: TextAnchorPoint;
94
+ };
95
+ /** One laid-out glyph, ready to become an instance once its atlas rectangle is known. */
96
+ export type PositionedGlyph = {
97
+ readonly character: string;
98
+ readonly fontFamily: string;
99
+ readonly fontSize: number;
100
+ readonly color: Rgba;
101
+ /** Ink box centre relative to the block's anchor point, y down. */
102
+ readonly center: readonly [number, number];
103
+ readonly width: number;
104
+ readonly height: number;
105
+ };
106
+ export type TextLayoutResult = {
107
+ readonly glyphs: readonly PositionedGlyph[];
108
+ /** Block size before the anchor offset is applied. */
109
+ readonly size: readonly [number, number];
110
+ readonly lineCount: number;
111
+ };
112
+ /**
113
+ * Lays out one text block. `options.anchor` decides which part of the block lands on the
114
+ * position the renderer is given; the returned glyph centres are already offset for it.
115
+ */
116
+ export declare function layoutText(spans: readonly TextSpanInput[], options: TextLayoutOptions, measurer: TextMeasurer): TextLayoutResult;
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Text layout for the debug text renderer.
3
+ *
4
+ * Ported from reference `TextPipeline::update_text_layout_info`
5
+ * (`the reference implementation`): a text block is wrapped to its `TextBounds`, split
6
+ * into lines, justified, and turned into one positioned glyph per character. Each glyph
7
+ * carries its ink box and resolved colour, which is what the GPU pass later instantiates as
8
+ * a quad.
9
+ *
10
+ * Two deliberate differences from reference:
11
+ *
12
+ * - There is no shaping. Characters advance one at a time and never form ligatures or
13
+ * reorder runs, which suits the monospaced debug font the library bundles.
14
+ * - Every metric comes from an injected `TextMeasurer`. reference reads parley and swash here;
15
+ * staying behind an interface keeps this module free of browser APIs and unit-testable in
16
+ * plain Node, and it is also the seam the GPU tests use to feed synthetic glyphs.
17
+ *
18
+ * Layout happens in one local space: origin at the block's anchor point, x to the right and
19
+ * **y down**, matching Canvas2D. The renderer flips y for spaces where up is +y.
20
+ */
21
+ const TEXT_ANCHORS = {
22
+ center: [0.5, 0.5],
23
+ "top-left": [0, 0],
24
+ "top-center": [0.5, 0],
25
+ "top-right": [1, 0],
26
+ "center-left": [0, 0.5],
27
+ "center-right": [1, 0.5],
28
+ "bottom-left": [0, 1],
29
+ "bottom-center": [0.5, 1],
30
+ "bottom-right": [1, 1],
31
+ };
32
+ export function resolveTextAnchor(anchor) {
33
+ const point = TEXT_ANCHORS[anchor];
34
+ if (point === undefined) {
35
+ throw new RangeError(`unknown text anchor "${anchor}"`);
36
+ }
37
+ return point;
38
+ }
39
+ /** reference `LineHeight` default, `RelativeToFont(1.2)`. */
40
+ export const DEFAULT_LINE_HEIGHT_SCALE = 1.2;
41
+ function isWhitespace(character) {
42
+ return character === " " || character === "\t";
43
+ }
44
+ /**
45
+ * Characters that break between themselves rather than at spaces.
46
+ *
47
+ * UAX #14 calls these classes ID (ideographic) and the punctuation around them. Without this a whole
48
+ * Chinese sentence arrives as one word token, so `word` breaking can only move it as a block and a
49
+ * bounded block overflows its bounds instead of wrapping.
50
+ */
51
+ function isCjkCharacter(character) {
52
+ const code = character.codePointAt(0) ?? 0;
53
+ return ((code >= 0x2e80 && code <= 0x303f) || // CJK radicals, symbols and punctuation
54
+ (code >= 0x3040 && code <= 0x30ff) || // hiragana, katakana
55
+ (code >= 0x3400 && code <= 0x4dbf) || // CJK unified ideographs extension A
56
+ (code >= 0x4e00 && code <= 0x9fff) || // CJK unified ideographs
57
+ (code >= 0xf900 && code <= 0xfaff) || // CJK compatibility ideographs
58
+ (code >= 0xff00 && code <= 0xffef) || // full-width forms
59
+ (code >= 0xac00 && code <= 0xd7af) || // hangul syllables
60
+ (code >= 0x20000 && code <= 0x3ffff) // extensions B and beyond
61
+ );
62
+ }
63
+ /**
64
+ * Kinsoku (禁则) sets: punctuation that may not open a line, and brackets that may not close one.
65
+ *
66
+ * The line breaker only knows how to move a token, so the rule is expressed by gluing the character
67
+ * to its neighbour instead: a closing mark joins the run before it, an opening mark the run after.
68
+ */
69
+ const NO_LINE_START = new Set([
70
+ ",", ".", "!", "?", ":", ";", ")", "]", "}", ".", ")", "]", "}", "%",
71
+ "\u3001", "\u3002", "\uff0c", "\uff0e", "\uff01", "\uff1f", "\uff1a", "\uff1b", "\uff09", "\uff3d", "\uff5d", "\uff05",
72
+ "\u300d", "\u300f", "\u3011", "\u300b", "\u3009", "\u3015", "\u2019", "\u201d",
73
+ ]);
74
+ const NO_LINE_END = new Set(["(", "[", "{", "\uff08", "\uff3b", "\uff5b", "\u300c", "\u300e", "\u3010", "\u300a", "\u3008", "\u3014", "\u2018", "\u201c"]);
75
+ /** Appends one character to the token that is already open, or starts a new one. */
76
+ function glueToLastToken(tokens, character) {
77
+ const last = tokens[tokens.length - 1];
78
+ if (last === undefined || last.kind === "break") {
79
+ tokens.push({ kind: "word", characters: [character] });
80
+ return;
81
+ }
82
+ tokens[tokens.length - 1] = { kind: last.kind, characters: [...last.characters, character] };
83
+ }
84
+ /** Splits the flattened block into word / spaces / hard-break tokens. */
85
+ function tokenize(items) {
86
+ const tokens = [];
87
+ let current = [];
88
+ let kind;
89
+ const flush = () => {
90
+ if (kind !== undefined && current.length > 0)
91
+ tokens.push({ kind, characters: current });
92
+ current = [];
93
+ kind = undefined;
94
+ };
95
+ for (const item of items) {
96
+ if (item.character === "\n") {
97
+ flush();
98
+ tokens.push({ kind: "break", characters: [] });
99
+ continue;
100
+ }
101
+ // CJK text carries no spaces to break at, so each ideograph becomes its own token and the greedy
102
+ // breaker can wrap between any two of them. Kinsoku is applied here rather than in the breaker
103
+ // because it can only move a whole token: a mark that may not open a line is glued to the run it
104
+ // belongs to, and one that may not close a line to the character that follows it.
105
+ if (!item.isSpace && isCjkCharacter(item.character)) {
106
+ const previous = current[current.length - 1] ?? tokens[tokens.length - 1]?.characters.at(-1);
107
+ const glue = previous !== undefined && (NO_LINE_START.has(item.character) || NO_LINE_END.has(previous.character));
108
+ if (glue) {
109
+ if (current.length > 0)
110
+ current.push(item);
111
+ else
112
+ glueToLastToken(tokens, item);
113
+ kind = "word";
114
+ continue;
115
+ }
116
+ flush();
117
+ tokens.push({ kind: "word", characters: [item] });
118
+ continue;
119
+ }
120
+ const next = item.isSpace ? "space" : "word";
121
+ if (kind !== undefined && next !== kind)
122
+ flush();
123
+ kind = next;
124
+ current.push(item);
125
+ }
126
+ flush();
127
+ return tokens;
128
+ }
129
+ /** Advance of a run. `letterSpacing` lands after every character, the last one included. */
130
+ function runWidth(characters, letterSpacing) {
131
+ let width = 0;
132
+ for (const character of characters)
133
+ width += character.advance + letterSpacing;
134
+ return width;
135
+ }
136
+ /** Measures one broken line into the two advances and the line box size reference works from. */
137
+ function measureLine(characters, letterSpacing) {
138
+ // UAX #14 never leaves a space at the start of a line, and the breakers reference runs drop it there.
139
+ let start = 0;
140
+ while (start < characters.length && characters[start].isSpace)
141
+ start++;
142
+ const content = start === 0 ? characters : characters.slice(start);
143
+ let end = content.length;
144
+ while (end > 0 && content[end - 1].isSpace)
145
+ end--;
146
+ let fontSize = 0;
147
+ for (const character of content)
148
+ fontSize = Math.max(fontSize, character.fontSize);
149
+ return {
150
+ characters: content.slice(0, end),
151
+ advance: runWidth(content.slice(0, end), letterSpacing),
152
+ fullAdvance: runWidth(content, letterSpacing),
153
+ fontSize,
154
+ isEmpty: content.length === 0,
155
+ };
156
+ }
157
+ /** Splits a word token one character at a time, the fallback for `word-or-character`. */
158
+ function splitCharacters(token) {
159
+ return token.characters.map((character) => ({ kind: "word", characters: [character] }));
160
+ }
161
+ /**
162
+ * Greedy line breaker. Words move whole to the next line when they no longer fit, matching
163
+ * reference `LineBreak::WordBoundary`; `character` strategies hand single characters to the same
164
+ * loop, and `none` only honours explicit newlines.
165
+ */
166
+ function breakLines(tokens, maxWidth, lineBreak, letterSpacing) {
167
+ const lines = [];
168
+ let line = [];
169
+ const pushLine = () => {
170
+ lines.push(line);
171
+ line = [];
172
+ };
173
+ const addCharacters = (characters) => {
174
+ for (const character of characters) {
175
+ if (maxWidth !== undefined && line.length > 0 && lineWidth(line, letterSpacing) + character.advance + letterSpacing > maxWidth) {
176
+ // The space a line breaks at stays on that line as trailing whitespace — it may even take the
177
+ // line past its bounds, which is why a bounded block can report a wider width. Carrying it to
178
+ // the next line instead would push the following characters right by one space.
179
+ if (character.isSpace)
180
+ line.push(character);
181
+ lines.push(line);
182
+ line = [];
183
+ if (character.isSpace)
184
+ continue;
185
+ }
186
+ line.push(character);
187
+ }
188
+ };
189
+ for (const token of tokens) {
190
+ if (token.kind === "break") {
191
+ pushLine();
192
+ continue;
193
+ }
194
+ if (maxWidth === undefined || lineBreak === "none") {
195
+ line.push(...token.characters);
196
+ continue;
197
+ }
198
+ if (lineBreak === "character") {
199
+ addCharacters(token.characters);
200
+ continue;
201
+ }
202
+ const width = lineWidth(line, letterSpacing) + runWidth(token.characters, letterSpacing);
203
+ const fits = width <= maxWidth || lineWidth(line, letterSpacing) === 0;
204
+ if (fits) {
205
+ line.push(...token.characters);
206
+ continue;
207
+ }
208
+ if (token.kind === "space") {
209
+ // A space that no longer fits ends the line and belongs to it, as the trailing whitespace
210
+ // parley keeps for its width even though it is not drawn.
211
+ line.push(...token.characters);
212
+ lines.push(line);
213
+ line = [];
214
+ continue;
215
+ }
216
+ // A word too wide for an empty line falls back to per-character breaking.
217
+ lines.push(line);
218
+ line = [];
219
+ const splittable = lineBreak === "word-or-character" || runWidth(token.characters, letterSpacing) > maxWidth;
220
+ if (splittable)
221
+ addCharacters(token.characters);
222
+ else
223
+ line.push(...token.characters);
224
+ }
225
+ pushLine();
226
+ return lines.map((characters) => measureLine(characters, letterSpacing));
227
+ }
228
+ function lineWidth(characters, letterSpacing) {
229
+ return runWidth(characters, letterSpacing);
230
+ }
231
+ function countSpaces(characters) {
232
+ let count = 0;
233
+ for (const character of characters)
234
+ if (character.isSpace)
235
+ count++;
236
+ return count;
237
+ }
238
+ /**
239
+ * Lays out one text block. `options.anchor` decides which part of the block lands on the
240
+ * position the renderer is given; the returned glyph centres are already offset for it.
241
+ */
242
+ export function layoutText(spans, options, measurer) {
243
+ const items = [];
244
+ for (const span of spans) {
245
+ const fontSize = span.fontSize ?? options.fontSize;
246
+ const fontFamily = span.fontFamily ?? options.fontFamily;
247
+ const color = span.color ?? options.color;
248
+ // Iterating the string yields code points, so surrogate pairs stay whole.
249
+ for (const character of span.text) {
250
+ if (character === "\n") {
251
+ items.push({ character, fontFamily, fontSize, color, advance: 0, width: 0, height: 0, bearingLeft: 0, bearingTop: 0, isSpace: false });
252
+ continue;
253
+ }
254
+ const metrics = measurer.glyphMetrics(character, fontFamily, fontSize);
255
+ items.push({
256
+ character,
257
+ fontFamily,
258
+ fontSize,
259
+ color,
260
+ advance: metrics.advance,
261
+ width: metrics.width,
262
+ height: metrics.height,
263
+ bearingLeft: metrics.bearingLeft,
264
+ bearingTop: metrics.bearingTop,
265
+ isSpace: isWhitespace(character),
266
+ });
267
+ }
268
+ }
269
+ const maxWidth = options.bounds?.[0];
270
+ const lines = breakLines(tokenize(items), maxWidth, options.lineBreak, options.letterSpacing);
271
+ // reference resolves the line box per line, from that line's own largest run: a relative
272
+ // `LineHeight` multiplies the run's size, so a line holding a larger span grows its box
273
+ // instead of being held to the block's default size.
274
+ const boxes = lines.map((line) => {
275
+ const fontSize = line.fontSize > 0 ? line.fontSize : options.fontSize;
276
+ const base = measurer.verticalMetrics(options.fontFamily, fontSize);
277
+ const height = options.lineHeight ?? (options.lineHeightScale ?? DEFAULT_LINE_HEIGHT_SCALE) * fontSize;
278
+ return { base, height, halfLeading: (height - (base.ascent + base.descent)) / 2 };
279
+ });
280
+ // The block is what reference reports and anchors on: `Layout::full_width`, which keeps the
281
+ // whitespace each line broke at, over the summed line boxes. Both are rounded up.
282
+ const blockWidth = Math.ceil(lines.reduce((widest, line) => Math.max(widest, line.fullAdvance), 0));
283
+ let blockHeight = boxes.reduce((sum, box) => sum + box.height, 0);
284
+ // parley leaves a trailing empty line's box out of the layout height.
285
+ if (lines.length > 1 && lines[lines.length - 1].isEmpty)
286
+ blockHeight -= boxes[boxes.length - 1].height;
287
+ blockHeight = Math.ceil(blockHeight);
288
+ const offsetX = -options.anchor[0] * blockWidth;
289
+ const offsetY = -options.anchor[1] * blockHeight;
290
+ // `Alignment` works inside parley's `max_advance`: the bounds width when bounded, otherwise the
291
+ // widest line. That is a smaller box than `full_width` whenever a line breaks at a space.
292
+ const alignBox = maxWidth ?? lines.reduce((widest, line) => Math.max(widest, line.advance), 0);
293
+ const glyphs = [];
294
+ let penY = 0;
295
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
296
+ const line = lines[lineIndex];
297
+ const box = boxes[lineIndex];
298
+ const isLastLine = lineIndex === lines.length - 1;
299
+ let penX = alignPenX(options.justify, line.advance, alignBox);
300
+ let stretch = 0;
301
+ if (options.justify === "justified" && !isLastLine && maxWidth !== undefined) {
302
+ const spaces = countSpaces(line.characters);
303
+ if (spaces > 0 && alignBox > line.advance) {
304
+ stretch = (alignBox - line.advance) / spaces;
305
+ penX = 0;
306
+ }
307
+ }
308
+ const baseline = penY + box.halfLeading + box.base.ascent;
309
+ for (const character of line.characters) {
310
+ if (character.width > 0 && character.height > 0) {
311
+ glyphs.push({
312
+ character: character.character,
313
+ fontFamily: character.fontFamily,
314
+ fontSize: character.fontSize,
315
+ color: character.color,
316
+ center: [offsetX + penX + character.bearingLeft + character.width / 2, offsetY + baseline + character.bearingTop + character.height / 2],
317
+ width: character.width,
318
+ height: character.height,
319
+ });
320
+ }
321
+ penX += character.advance + options.letterSpacing;
322
+ if (character.isSpace)
323
+ penX += stretch;
324
+ }
325
+ penY += box.height;
326
+ }
327
+ return { glyphs, size: [blockWidth, blockHeight], lineCount: lines.length };
328
+ }
329
+ /** Reference `Justify` → the pen offset for one line inside the box `Alignment` gets. */
330
+ function alignPenX(justify, lineWidth, box) {
331
+ switch (justify) {
332
+ case "center":
333
+ return (box - lineWidth) / 2;
334
+ case "right":
335
+ case "end":
336
+ return box - lineWidth;
337
+ default:
338
+ return 0;
339
+ }
340
+ }