@vectojs/core 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.
@@ -0,0 +1,615 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; } var _class; var _class2;
2
+
3
+
4
+ var _chunkRW6NC4RBjs = require('./chunk-RW6NC4RB.js');
5
+
6
+ // src/layout/LayoutEngine.ts
7
+ function computeLineSegments(top, bottom, maxWidth, exclusions) {
8
+ const blocks = [];
9
+ for (const r of exclusions) {
10
+ if (r.y < bottom && r.y + r.height > top) {
11
+ const x0 = Math.max(0, r.x);
12
+ const x1 = Math.min(maxWidth, r.x + r.width);
13
+ if (x1 > x0) blocks.push([x0, x1]);
14
+ }
15
+ }
16
+ if (blocks.length === 0) return [{ x0: 0, x1: maxWidth }];
17
+ blocks.sort((a, b) => a[0] - b[0]);
18
+ const merged = [];
19
+ for (const b of blocks) {
20
+ const last = merged[merged.length - 1];
21
+ if (last && b[0] <= last[1]) last[1] = Math.max(last[1], b[1]);
22
+ else merged.push([b[0], b[1]]);
23
+ }
24
+ const segs = [];
25
+ let cursor = 0;
26
+ for (const [bx0, bx1] of merged) {
27
+ if (bx0 > cursor) segs.push({ x0: cursor, x1: bx0 });
28
+ cursor = Math.max(cursor, bx1);
29
+ }
30
+ if (cursor < maxWidth) segs.push({ x0: cursor, x1: maxWidth });
31
+ return segs;
32
+ }
33
+ var LayoutEngine = (_class = class {
34
+
35
+
36
+ __init() {this.preserveLeadingSpaces = false}
37
+
38
+
39
+ __init2() {this.wordCache = /* @__PURE__ */ new Map()}
40
+ __init3() {this.graphemeCache = /* @__PURE__ */ new Map()}
41
+ // Paragraph-level memo so re-`prepare()` of mostly-unchanged text (streaming
42
+ // append, live logs) reuses untouched paragraphs by reference instead of
43
+ // re-segmenting/re-measuring the whole document — turning per-token cost from
44
+ // O(document) into O(changed paragraph). Keyed by fontSize + text; invalidated
45
+ // when the font atlas (which drives glyph widths) changes.
46
+ __init4() {this.paragraphCache = /* @__PURE__ */ new Map()}
47
+ // Same memo for the rich path ({@link prepareRich}); keyed by fontSize + text +
48
+ // a per-paragraph *value* signature of the inline styles, so a streaming
49
+ // typewriter that appends styled runs reuses its untouched paragraphs.
50
+ __init5() {this.richParagraphCache = /* @__PURE__ */ new Map()}
51
+ __init6() {this.lastAtlas = null}
52
+
53
+ constructor(maxWidth, maxHeight, measurer) {;_class.prototype.__init.call(this);_class.prototype.__init2.call(this);_class.prototype.__init3.call(this);_class.prototype.__init4.call(this);_class.prototype.__init5.call(this);_class.prototype.__init6.call(this);
54
+ this.maxWidth = maxWidth;
55
+ this.maxHeight = maxHeight;
56
+ this.measurer = _nullishCoalesce(measurer, () => ( null));
57
+ const locale = typeof navigator !== "undefined" ? navigator.language : "en-US";
58
+ this.wordSegmenter = new Intl.Segmenter(locale, { granularity: "word" });
59
+ this.charSegmenter = new Intl.Segmenter(locale, { granularity: "grapheme" });
60
+ }
61
+ getWordSegments(paragraph) {
62
+ const cached = this.wordCache.get(paragraph);
63
+ if (cached) return cached;
64
+ const fresh = Array.from(this.wordSegmenter.segment(paragraph)).map((s) => ({
65
+ segment: s.segment,
66
+ isWordLike: s.isWordLike
67
+ }));
68
+ if (this.wordCache.size > 500) this.wordCache.clear();
69
+ this.wordCache.set(paragraph, fresh);
70
+ return fresh;
71
+ }
72
+ /**
73
+ * Resolve a grapheme's advance width at `fontSize`, in priority order:
74
+ * pre-baked atlas entry → injected {@link GlyphMeasurer} → `0.5em` fallback.
75
+ */
76
+ glyphWidth(char, fontAtlas, fontSize) {
77
+ const glyphInfo = fontAtlas[char];
78
+ if (glyphInfo) return glyphInfo.width * (fontSize / glyphInfo.baseSize);
79
+ if (this.measurer) return this.measurer.measure(char, fontSize);
80
+ return fontSize * 0.5;
81
+ }
82
+ getGraphemes(word) {
83
+ const cached = this.graphemeCache.get(word);
84
+ if (cached) return cached;
85
+ const fresh = Array.from(this.charSegmenter.segment(word)).map((g) => g.segment);
86
+ if (this.graphemeCache.size > 2e3) this.graphemeCache.clear();
87
+ this.graphemeCache.set(word, fresh);
88
+ return fresh;
89
+ }
90
+ /**
91
+ * Lay out a Unicode string into a list of positioned {@link LayoutNode} glyphs.
92
+ *
93
+ * Uses `Intl.Segmenter` to correctly handle CJK, emoji, and Western word
94
+ * boundaries. An optional `exclusionMask` callback allows glyphs to flow
95
+ * around arbitrary shapes (e.g. physics bodies or video regions).
96
+ *
97
+ * @param text - The raw text string to lay out (newlines force paragraph breaks).
98
+ * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
99
+ * @param fontSize - Target font size in pixels (default: `32`).
100
+ * @param exclusionMask - Optional callback returning `true` when a candidate
101
+ * glyph bounding box overlaps a forbidden region; the engine skips that
102
+ * position and advances horizontally.
103
+ * @returns A {@link LayoutResult} with all positioned glyph nodes and total dimensions.
104
+ * @example
105
+ * const result = engine.layoutText('Hello 世界', atlas, 24);
106
+ * result.nodes.forEach(n => console.log(n.char, n.x, n.y));
107
+ */
108
+ layoutText(text, fontAtlas, fontSize = 32, exclusionMask) {
109
+ return this.layoutPrepared(this.prepare(text, fontAtlas, fontSize), exclusionMask);
110
+ }
111
+ /**
112
+ * **Cold pass.** Segment and measure `text` once into a reusable
113
+ * {@link PreparedText}. Runs `Intl.Segmenter` (word + grapheme) and resolves
114
+ * each grapheme's advance width — the expensive work. The result is
115
+ * independent of `maxWidth`/`maxHeight`/exclusion masks, so it can be re-laid
116
+ * out cheaply by {@link layoutPrepared} on resize / reposition / animation.
117
+ *
118
+ * @param text - The raw text string (newlines force paragraph breaks).
119
+ * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
120
+ * @param fontSize - Target font size in pixels (default: `32`).
121
+ */
122
+ prepare(text, fontAtlas, fontSize = 32) {
123
+ if (fontAtlas !== this.lastAtlas) {
124
+ this.paragraphCache.clear();
125
+ this.richParagraphCache.clear();
126
+ this.lastAtlas = fontAtlas;
127
+ }
128
+ const paragraphs = [];
129
+ let offset = 0;
130
+ let fallbackToCanvas = false;
131
+ for (const paragraph of text.split("\n")) {
132
+ if (paragraph.length === 0) {
133
+ paragraphs.push({ words: [], isEmpty: true });
134
+ offset += 1;
135
+ continue;
136
+ }
137
+ const key = `${fontSize} ${paragraph}`;
138
+ const cached = this.paragraphCache.get(key);
139
+ if (cached) {
140
+ paragraphs.push(cached);
141
+ if (cached.fallbackToCanvas) fallbackToCanvas = true;
142
+ offset += paragraph.length + 1;
143
+ continue;
144
+ }
145
+ const { shapedText, indexMap } = _chunkRW6NC4RBjs.ArabicShaper.shapeArabic(paragraph);
146
+ const levels = _chunkRW6NC4RBjs.BidiResolver.resolveLevels(shapedText);
147
+ const words = [];
148
+ let shapedCharIdx = 0;
149
+ let pFallback = false;
150
+ for (const segment of this.getWordSegments(shapedText)) {
151
+ const word = segment.segment;
152
+ const glyphs = [];
153
+ let width = 0;
154
+ for (const char of this.getGraphemes(word)) {
155
+ const visualStart = shapedCharIdx;
156
+ const visualEnd = shapedCharIdx + char.length;
157
+ const rawStart = indexMap[visualStart];
158
+ const rawEnd = visualEnd === shapedText.length ? paragraph.length : indexMap[visualEnd];
159
+ const sourceIndex = offset + rawStart;
160
+ const sourceLength = rawEnd - rawStart;
161
+ const baseChar = char[0];
162
+ const level = levels[visualStart];
163
+ const hasGlyph = !!fontAtlas[char] || !!fontAtlas[baseChar];
164
+ if (char.trim().length > 0 && !hasGlyph) {
165
+ pFallback = true;
166
+ fallbackToCanvas = true;
167
+ }
168
+ const w = this.glyphWidth(baseChar, fontAtlas, fontSize);
169
+ const combining = [];
170
+ for (let cIdx = 1; cIdx < char.length; cIdx++) {
171
+ combining.push(char[cIdx]);
172
+ }
173
+ glyphs.push({
174
+ char: baseChar,
175
+ width: w,
176
+ level,
177
+ sourceIndex,
178
+ sourceLength,
179
+ combining: combining.length > 0 ? combining : void 0
180
+ });
181
+ width += w;
182
+ shapedCharIdx += char.length;
183
+ }
184
+ words.push({
185
+ glyphs,
186
+ width,
187
+ isWordLike: segment.isWordLike,
188
+ isWhitespace: word.trim().length === 0
189
+ });
190
+ }
191
+ const prepared = {
192
+ words,
193
+ isEmpty: false,
194
+ fallbackToCanvas: pFallback || void 0,
195
+ baseLevel: _chunkRW6NC4RBjs.BidiResolver.getBaseLevel(shapedText)
196
+ };
197
+ if (this.paragraphCache.size > 1e3) this.paragraphCache.clear();
198
+ this.paragraphCache.set(key, prepared);
199
+ paragraphs.push(prepared);
200
+ offset += paragraph.length + 1;
201
+ }
202
+ return { paragraphs, fontSize, fallbackToCanvas: fallbackToCanvas || void 0 };
203
+ }
204
+ /**
205
+ * **Cold pass for rich text.** Like {@link prepare}, but takes an array of
206
+ * {@link StyledSpan}s so different inline runs (bold / italic / color / size /
207
+ * links) compose on the same wrapped lines. Each grapheme carries the
208
+ * (base-merged) style of the span it came from — so a style change *mid-word*
209
+ * (e.g. `He` + **`llo`**) is honored. Run `fontSize` affects measured width and
210
+ * line height; the rest is rendering metadata carried through to the nodes.
211
+ *
212
+ * The result feeds the same {@link layoutPrepared} as plain text.
213
+ *
214
+ * @param spans - The styled runs, in document order.
215
+ * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
216
+ * @param baseFontSize - Size for runs without an explicit `fontSize` (default 32).
217
+ * @param baseStyle - Style inherited by every run (each run's own style wins).
218
+ */
219
+ prepareRich(spans, fontAtlas, baseFontSize = 32, baseStyle) {
220
+ if (fontAtlas !== this.lastAtlas) {
221
+ this.paragraphCache.clear();
222
+ this.richParagraphCache.clear();
223
+ this.lastAtlas = fontAtlas;
224
+ }
225
+ let fullText = "";
226
+ const styleAt = [];
227
+ for (const span of spans) {
228
+ const merged = span.style || baseStyle ? { ...baseStyle, ...span.style } : void 0;
229
+ fullText += span.text;
230
+ for (let i = 0; i < span.text.length; i++) styleAt.push(merged);
231
+ }
232
+ const styleSig = (start, len) => {
233
+ let sig = "";
234
+ let i = 0;
235
+ while (i < len) {
236
+ const s = styleAt[start + i];
237
+ const fp = s ? `${_nullishCoalesce(s.fontSize, () => ( ""))}/${_nullishCoalesce(s.color, () => ( ""))}/${s.bold ? 1 : 0}/${s.italic ? 1 : 0}/${_nullishCoalesce(s.href, () => ( ""))}` : "";
238
+ let run = 1;
239
+ while (i + run < len) {
240
+ const t = styleAt[start + i + run];
241
+ const tfp = t ? `${_nullishCoalesce(t.fontSize, () => ( ""))}/${_nullishCoalesce(t.color, () => ( ""))}/${t.bold ? 1 : 0}/${t.italic ? 1 : 0}/${_nullishCoalesce(t.href, () => ( ""))}` : "";
242
+ if (tfp !== fp) break;
243
+ run++;
244
+ }
245
+ sig += `${fp}:${run};`;
246
+ i += run;
247
+ }
248
+ return sig;
249
+ };
250
+ const paragraphs = [];
251
+ let offset = 0;
252
+ let fallbackToCanvas = false;
253
+ for (const paragraph of fullText.split("\n")) {
254
+ if (paragraph.length === 0) {
255
+ paragraphs.push({ words: [], isEmpty: true });
256
+ offset += 1;
257
+ continue;
258
+ }
259
+ const key = `${baseFontSize} ${paragraph} ${styleSig(offset, paragraph.length)}`;
260
+ const cached = this.richParagraphCache.get(key);
261
+ if (cached) {
262
+ paragraphs.push(cached);
263
+ if (cached.fallbackToCanvas) fallbackToCanvas = true;
264
+ offset += paragraph.length + 1;
265
+ continue;
266
+ }
267
+ const { shapedText, indexMap } = _chunkRW6NC4RBjs.ArabicShaper.shapeArabic(paragraph);
268
+ const levels = _chunkRW6NC4RBjs.BidiResolver.resolveLevels(shapedText);
269
+ const words = [];
270
+ let shapedCharIdx = 0;
271
+ let pFallback = false;
272
+ for (const segment of this.getWordSegments(shapedText)) {
273
+ const word = segment.segment;
274
+ const glyphs = [];
275
+ let width = 0;
276
+ for (const char of this.getGraphemes(word)) {
277
+ const visualStart = shapedCharIdx;
278
+ const visualEnd = shapedCharIdx + char.length;
279
+ const rawStart = indexMap[visualStart];
280
+ const rawEnd = visualEnd === shapedText.length ? paragraph.length : indexMap[visualEnd];
281
+ const sourceIndex = offset + rawStart;
282
+ const sourceLength = rawEnd - rawStart;
283
+ const baseChar = char[0];
284
+ const level = levels[visualStart];
285
+ const style = styleAt[offset + rawStart];
286
+ const gfs = _nullishCoalesce(_optionalChain([style, 'optionalAccess', _ => _.fontSize]), () => ( baseFontSize));
287
+ const hasGlyph = !!fontAtlas[char] || !!fontAtlas[baseChar];
288
+ if (char.trim().length > 0 && !hasGlyph) {
289
+ pFallback = true;
290
+ fallbackToCanvas = true;
291
+ }
292
+ const w = this.glyphWidth(baseChar, fontAtlas, gfs);
293
+ const combining = [];
294
+ for (let cIdx = 1; cIdx < char.length; cIdx++) {
295
+ combining.push(char[cIdx]);
296
+ }
297
+ glyphs.push({
298
+ char: baseChar,
299
+ width: w,
300
+ style,
301
+ level,
302
+ sourceIndex,
303
+ sourceLength,
304
+ combining: combining.length > 0 ? combining : void 0
305
+ });
306
+ width += w;
307
+ shapedCharIdx += char.length;
308
+ }
309
+ words.push({
310
+ glyphs,
311
+ width,
312
+ isWordLike: segment.isWordLike,
313
+ isWhitespace: word.trim().length === 0
314
+ });
315
+ }
316
+ const prepared = {
317
+ words,
318
+ isEmpty: false,
319
+ fallbackToCanvas: pFallback || void 0,
320
+ baseLevel: _chunkRW6NC4RBjs.BidiResolver.getBaseLevel(shapedText)
321
+ };
322
+ if (this.richParagraphCache.size > 1e3) this.richParagraphCache.clear();
323
+ this.richParagraphCache.set(key, prepared);
324
+ paragraphs.push(prepared);
325
+ offset += paragraph.length + 1;
326
+ }
327
+ return {
328
+ paragraphs,
329
+ fontSize: baseFontSize,
330
+ fallbackToCanvas: fallbackToCanvas || void 0
331
+ };
332
+ }
333
+ /**
334
+ * **Hot pass.** Place an already-measured {@link PreparedText} into positioned
335
+ * glyphs. Does only wrap/positioning arithmetic — no `Intl.Segmenter`, no
336
+ * re-measurement — so it is cheap enough to call every frame or on every
337
+ * resize. Reads the engine's current `maxWidth`/`maxHeight`, so changing those
338
+ * and re-calling reflows the same prepared text.
339
+ *
340
+ * @param prepared - Output of {@link prepare}.
341
+ * @param exclusionMask - Optional per-glyph collision callback (see {@link layoutText}).
342
+ * @param exclusions - Optional rect regions text flows around (exclusion shapes); each
343
+ * line is split into the free x-segments left after subtracting them. Omitting
344
+ * it (or passing `[]`) leaves the single-column path byte-for-byte unchanged.
345
+ */
346
+ layoutPrepared(prepared, exclusionMask, exclusions) {
347
+ const layoutNodes = [];
348
+ const fontSize = prepared.fontSize;
349
+ let currentX = 0;
350
+ let currentY = 0;
351
+ let maxLineWidth = 0;
352
+ const hasEx = !!(exclusions && exclusions.length);
353
+ let segs = [{ x0: 0, x1: this.maxWidth }];
354
+ let si = 0;
355
+ let currentLineNodes = [];
356
+ let paragraphBaseLevel = 0;
357
+ const commitLine = () => {
358
+ if (currentLineNodes.length === 0) return;
359
+ const runs = [];
360
+ let currentRun = [];
361
+ for (let j = 0; j < currentLineNodes.length; j++) {
362
+ const node = currentLineNodes[j];
363
+ const prev = currentLineNodes[j - 1];
364
+ if (prev && Math.abs(node.x - (prev.x + prev.width)) > 1e-3) {
365
+ runs.push(currentRun);
366
+ currentRun = [];
367
+ }
368
+ currentRun.push(node);
369
+ }
370
+ if (currentRun.length > 0) {
371
+ runs.push(currentRun);
372
+ }
373
+ for (const run of runs) {
374
+ const runStartX = run[0].x;
375
+ _chunkRW6NC4RBjs.BidiResolver.reorderVisual(run, paragraphBaseLevel);
376
+ let x = runStartX;
377
+ for (const node of run) {
378
+ node.x = x;
379
+ node.isRTL = node.level % 2 === 1;
380
+ x += node.width;
381
+ }
382
+ for (const node of run) {
383
+ layoutNodes.push(node);
384
+ }
385
+ }
386
+ currentLineNodes = [];
387
+ };
388
+ const startLine = (lineHeight) => {
389
+ while (currentY < this.maxHeight) {
390
+ const s = hasEx ? computeLineSegments(currentY, currentY + lineHeight, this.maxWidth, exclusions) : segs;
391
+ if (s.length > 0) {
392
+ segs = s;
393
+ si = 0;
394
+ currentX = segs[0].x0;
395
+ return true;
396
+ }
397
+ currentY += lineHeight;
398
+ }
399
+ return false;
400
+ };
401
+ for (const paragraph of prepared.paragraphs) {
402
+ if (paragraph.isEmpty) {
403
+ commitLine();
404
+ currentY += fontSize * 1.5;
405
+ currentX = 0;
406
+ continue;
407
+ }
408
+ paragraphBaseLevel = _nullishCoalesce(paragraph.baseLevel, () => ( 0));
409
+ let pMax = fontSize;
410
+ for (const word of paragraph.words) {
411
+ for (const glyph of word.glyphs) {
412
+ const gfs = _nullishCoalesce(_optionalChain([glyph, 'access', _2 => _2.style, 'optionalAccess', _3 => _3.fontSize]), () => ( fontSize));
413
+ if (gfs > pMax) pMax = gfs;
414
+ }
415
+ }
416
+ const lineHeight = pMax * 1.5;
417
+ if (!startLine(lineHeight)) break;
418
+ for (const word of paragraph.words) {
419
+ if (currentX + word.width > segs[si].x1 && currentX > segs[si].x0) {
420
+ if (word.isWordLike === false && word.isWhitespace) continue;
421
+ if (si < segs.length - 1) {
422
+ si++;
423
+ currentX = segs[si].x0;
424
+ } else {
425
+ commitLine();
426
+ currentY += lineHeight;
427
+ if (!startLine(lineHeight)) break;
428
+ }
429
+ }
430
+ for (const glyph of word.glyphs) {
431
+ const charWidth = glyph.width;
432
+ const gfs = _nullishCoalesce(_optionalChain([glyph, 'access', _4 => _4.style, 'optionalAccess', _5 => _5.fontSize]), () => ( fontSize));
433
+ let foundSpot = false;
434
+ while (currentY < this.maxHeight) {
435
+ if (currentX + charWidth > segs[si].x1 && currentX > segs[si].x0) {
436
+ if (si < segs.length - 1) {
437
+ si++;
438
+ currentX = segs[si].x0;
439
+ } else {
440
+ commitLine();
441
+ currentY += lineHeight;
442
+ if (!startLine(lineHeight)) break;
443
+ }
444
+ continue;
445
+ }
446
+ if (exclusionMask && exclusionMask(currentX, currentY, charWidth, gfs)) {
447
+ currentX += charWidth;
448
+ continue;
449
+ }
450
+ foundSpot = true;
451
+ break;
452
+ }
453
+ if (!foundSpot || currentY >= this.maxHeight) break;
454
+ if (currentX === segs[si].x0 && glyph.char.trim().length === 0 && !this.preserveLeadingSpaces)
455
+ continue;
456
+ currentLineNodes.push({
457
+ char: glyph.char,
458
+ x: currentX,
459
+ // Drop smaller glyphs to the shared baseline (no-op when gfs === pMax).
460
+ y: currentY + (pMax - gfs),
461
+ width: charWidth,
462
+ height: gfs,
463
+ style: glyph.style,
464
+ level: glyph.level,
465
+ sourceIndex: glyph.sourceIndex,
466
+ sourceLength: glyph.sourceLength,
467
+ combining: glyph.combining
468
+ });
469
+ currentX += charWidth;
470
+ if (currentX > maxLineWidth) maxLineWidth = currentX;
471
+ }
472
+ }
473
+ commitLine();
474
+ currentX = 0;
475
+ currentY += lineHeight;
476
+ }
477
+ return {
478
+ nodes: layoutNodes,
479
+ totalWidth: maxLineWidth,
480
+ totalHeight: currentY,
481
+ fallbackToCanvas: prepared.fallbackToCanvas
482
+ };
483
+ }
484
+ /**
485
+ * Lay out a Unicode string directly into a pre-allocated {@link LayoutResultBuffer}.
486
+ *
487
+ * Avoids GC allocations by writing results directly to flat typed arrays in the buffer.
488
+ *
489
+ * @param text - The raw text string to lay out.
490
+ * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
491
+ * @param fontSize - Target font size in pixels.
492
+ * @param buffer - The pre-allocated buffer to write layout results into.
493
+ * @param exclusionMask - Optional collision-detection callback.
494
+ */
495
+ layoutTextIntoBuffer(text, fontAtlas, fontSize, buffer, exclusionMask) {
496
+ this.layoutPreparedIntoBuffer(this.prepare(text, fontAtlas, fontSize), buffer, exclusionMask);
497
+ }
498
+ /**
499
+ * **Hot pass, zero-GC variant.** Place an already-measured {@link PreparedText}
500
+ * directly into a pre-allocated {@link LayoutResultBuffer}. Like
501
+ * {@link layoutPrepared} but writes flat typed arrays instead of allocating
502
+ * {@link LayoutNode} objects — the per-frame path for large dynamic scenes.
503
+ */
504
+ layoutPreparedIntoBuffer(prepared, buffer, exclusionMask) {
505
+ buffer.reset();
506
+ const fontSize = prepared.fontSize;
507
+ const lineHeight = fontSize * 1.5;
508
+ let currentX = 0;
509
+ let currentY = 0;
510
+ for (const paragraph of prepared.paragraphs) {
511
+ if (paragraph.isEmpty) {
512
+ currentY += lineHeight;
513
+ currentX = 0;
514
+ continue;
515
+ }
516
+ for (const word of paragraph.words) {
517
+ if (currentX + word.width > this.maxWidth && currentX > 0) {
518
+ if (word.isWordLike === false && word.isWhitespace) continue;
519
+ currentX = 0;
520
+ currentY += lineHeight;
521
+ }
522
+ for (const glyph of word.glyphs) {
523
+ if (buffer.count >= LayoutResultBuffer.CAPACITY) break;
524
+ const charWidth = glyph.width;
525
+ let foundSpot = false;
526
+ while (currentY < this.maxHeight) {
527
+ if (currentX + charWidth > this.maxWidth && currentX > 0) {
528
+ currentX = 0;
529
+ currentY += lineHeight;
530
+ continue;
531
+ }
532
+ if (exclusionMask && exclusionMask(currentX, currentY, charWidth, fontSize)) {
533
+ currentX += charWidth;
534
+ continue;
535
+ }
536
+ foundSpot = true;
537
+ break;
538
+ }
539
+ if (!foundSpot || currentY >= this.maxHeight) break;
540
+ if (currentX === 0 && glyph.char.trim().length === 0) continue;
541
+ const idx = buffer.count;
542
+ buffer.chars[idx] = glyph.char;
543
+ buffer.xs[idx] = currentX;
544
+ buffer.ys[idx] = currentY;
545
+ buffer.ws[idx] = charWidth;
546
+ buffer.hs[idx] = fontSize;
547
+ buffer.count++;
548
+ currentX += charWidth;
549
+ }
550
+ }
551
+ currentX = 0;
552
+ currentY += lineHeight;
553
+ }
554
+ }
555
+ }, _class);
556
+ var LayoutResultBuffer = (_class2 = class _LayoutResultBuffer {constructor() { _class2.prototype.__init7.call(this);_class2.prototype.__init8.call(this);_class2.prototype.__init9.call(this);_class2.prototype.__init10.call(this);_class2.prototype.__init11.call(this);_class2.prototype.__init12.call(this); }
557
+ static __initStatic() {this.CAPACITY = 16384}
558
+ /** X positions of each glyph. */
559
+ __init7() {this.xs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
560
+ /** Y positions of each glyph. */
561
+ __init8() {this.ys = new Float32Array(_LayoutResultBuffer.CAPACITY)}
562
+ /** Widths of each glyph. */
563
+ __init9() {this.ws = new Float32Array(_LayoutResultBuffer.CAPACITY)}
564
+ /** Heights of each glyph. */
565
+ __init10() {this.hs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
566
+ /** Character for each glyph slot. */
567
+ __init11() {this.chars = Array.from({ length: _LayoutResultBuffer.CAPACITY })}
568
+ /** Number of valid glyphs written in this buffer. */
569
+ __init12() {this.count = 0}
570
+ /** Reset the buffer for reuse. Does NOT free memory. */
571
+ reset() {
572
+ this.count = 0;
573
+ }
574
+ /** Convert to the standard LayoutResult format (allocates — use sparingly). */
575
+ toLayoutResult() {
576
+ const nodes = [];
577
+ for (let i = 0; i < this.count; i++) {
578
+ nodes.push({
579
+ char: this.chars[i],
580
+ x: this.xs[i],
581
+ y: this.ys[i],
582
+ width: this.ws[i],
583
+ height: this.hs[i]
584
+ });
585
+ }
586
+ return { nodes, totalWidth: 0, totalHeight: 0 };
587
+ }
588
+ }, _class2.__initStatic(), _class2);
589
+
590
+ // src/layout/measure.ts
591
+ function createCanvasMeasurer(fontFamily = "sans-serif", baseSize = 100) {
592
+ if (typeof document === "undefined") return null;
593
+ const ctx = document.createElement("canvas").getContext("2d");
594
+ if (!ctx) return null;
595
+ const font = `${baseSize}px ${fontFamily}`;
596
+ const cache = /* @__PURE__ */ new Map();
597
+ return {
598
+ measure(char, fontSize) {
599
+ let base = cache.get(char);
600
+ if (base === void 0) {
601
+ ctx.font = font;
602
+ base = ctx.measureText(char).width;
603
+ cache.set(char, base);
604
+ }
605
+ return base * (fontSize / baseSize);
606
+ }
607
+ };
608
+ }
609
+
610
+
611
+
612
+
613
+
614
+
615
+ exports.computeLineSegments = computeLineSegments; exports.LayoutEngine = LayoutEngine; exports.LayoutResultBuffer = LayoutResultBuffer; exports.createCanvasMeasurer = createCanvasMeasurer;