@vectojs/core 1.13.0 → 1.15.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.
@@ -1,760 +0,0 @@
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 _chunk4AR425ARjs = require('./chunk-4AR425AR.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
- * Horizontal alignment. `'justify'` stretches inter-word spaces (or, for
37
- * space-less CJK lines, inter-character gaps) so wrapped lines end flush at
38
- * `maxWidth`; the last line of each paragraph stays ragged. Only applies to
39
- * the object layout path without exclusion shapes.
40
- */
41
- __init() {this.textAlign = "left"}
42
-
43
- __init2() {this.preserveLeadingSpaces = false}
44
-
45
-
46
- __init3() {this.wordCache = /* @__PURE__ */ new Map()}
47
- __init4() {this.graphemeCache = /* @__PURE__ */ new Map()}
48
- // Paragraph-level memo so re-`prepare()` of mostly-unchanged text (streaming
49
- // append, live logs) reuses untouched paragraphs by reference instead of
50
- // re-segmenting/re-measuring the whole document — turning per-token cost from
51
- // O(document) into O(changed paragraph). Keyed by fontSize + text; invalidated
52
- // when the font atlas (which drives glyph widths) changes.
53
- __init5() {this.paragraphCache = /* @__PURE__ */ new Map()}
54
- // Same memo for the rich path ({@link prepareRich}); keyed by fontSize + text +
55
- // a per-paragraph *value* signature of the inline styles, so a streaming
56
- // typewriter that appends styled runs reuses its untouched paragraphs.
57
- __init6() {this.richParagraphCache = /* @__PURE__ */ new Map()}
58
- __init7() {this.lastAtlas = null}
59
-
60
- __init8() {this._hyphenate = null}
61
- /**
62
- * Optional hyphenator: given a word, return its break parts (e.g.
63
- * `['hyphen', 'ation']`). Used at wrap time when a word doesn't fit; a
64
- * visible '-' is drawn at the chosen break. Soft hyphens (U+00AD) in the
65
- * source work without any hyphenator. Setting this clears the prepared
66
- * caches (break opportunities are baked in during prepare()).
67
- */
68
- get hyphenate() {
69
- return this._hyphenate;
70
- }
71
- set hyphenate(fn) {
72
- this._hyphenate = fn;
73
- this.paragraphCache.clear();
74
- this.richParagraphCache.clear();
75
- }
76
- 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);_class.prototype.__init7.call(this);_class.prototype.__init8.call(this);
77
- this.maxWidth = maxWidth;
78
- this.maxHeight = maxHeight;
79
- this.measurer = _nullishCoalesce(measurer, () => ( null));
80
- const locale = typeof navigator !== "undefined" ? navigator.language : "en-US";
81
- this.wordSegmenter = new Intl.Segmenter(locale, { granularity: "word" });
82
- this.charSegmenter = new Intl.Segmenter(locale, { granularity: "grapheme" });
83
- }
84
- getWordSegments(paragraph) {
85
- const cached = this.wordCache.get(paragraph);
86
- if (cached) return cached;
87
- const fresh = Array.from(this.wordSegmenter.segment(paragraph)).map((s) => ({
88
- segment: s.segment,
89
- isWordLike: s.isWordLike
90
- }));
91
- if (this.wordCache.size > 500) this.wordCache.clear();
92
- this.wordCache.set(paragraph, fresh);
93
- return fresh;
94
- }
95
- /**
96
- * Resolve a grapheme's advance width at `fontSize`, in priority order:
97
- * pre-baked atlas entry → injected {@link GlyphMeasurer} → `0.5em` fallback.
98
- */
99
- glyphWidth(char, fontAtlas, fontSize) {
100
- const glyphInfo = fontAtlas[char];
101
- if (glyphInfo) return glyphInfo.width * (fontSize / glyphInfo.baseSize);
102
- if (this.measurer) return this.measurer.measure(char, fontSize);
103
- return fontSize * 0.5;
104
- }
105
- glyphKeyFor(grapheme, fontAtlas) {
106
- if (fontAtlas[grapheme]) return grapheme;
107
- const firstCodePoint = Array.from(grapheme)[0];
108
- if (firstCodePoint && fontAtlas[firstCodePoint]) return firstCodePoint;
109
- return grapheme;
110
- }
111
- getGraphemes(word) {
112
- const cached = this.graphemeCache.get(word);
113
- if (cached) return cached;
114
- const fresh = Array.from(this.charSegmenter.segment(word)).map((g) => g.segment);
115
- if (this.graphemeCache.size > 2e3) this.graphemeCache.clear();
116
- this.graphemeCache.set(word, fresh);
117
- return fresh;
118
- }
119
- /**
120
- * Lay out a Unicode string into a list of positioned {@link LayoutNode} glyphs.
121
- *
122
- * Uses `Intl.Segmenter` to correctly handle CJK, emoji, and Western word
123
- * boundaries. An optional `exclusionMask` callback allows glyphs to flow
124
- * around arbitrary shapes (e.g. physics bodies or video regions).
125
- *
126
- * @param text - The raw text string to lay out (newlines force paragraph breaks).
127
- * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
128
- * @param fontSize - Target font size in pixels (default: `32`).
129
- * @param exclusionMask - Optional callback returning `true` when a candidate
130
- * glyph bounding box overlaps a forbidden region; the engine skips that
131
- * position and advances horizontally.
132
- * @returns A {@link LayoutResult} with all positioned glyph nodes and total dimensions.
133
- * @example
134
- * const result = engine.layoutText('Hello 世界', atlas, 24);
135
- * result.nodes.forEach(n => console.log(n.char, n.x, n.y));
136
- */
137
- layoutText(text, fontAtlas, fontSize = 32, exclusionMask) {
138
- return this.layoutPrepared(this.prepare(text, fontAtlas, fontSize), exclusionMask);
139
- }
140
- /**
141
- * **Cold pass.** Segment and measure `text` once into a reusable
142
- * {@link PreparedText}. Runs `Intl.Segmenter` (word + grapheme) and resolves
143
- * each grapheme's advance width — the expensive work. The result is
144
- * independent of `maxWidth`/`maxHeight`/exclusion masks, so it can be re-laid
145
- * out cheaply by {@link layoutPrepared} on resize / reposition / animation.
146
- *
147
- * @param text - The raw text string (newlines force paragraph breaks).
148
- * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
149
- * @param fontSize - Target font size in pixels (default: `32`).
150
- */
151
- prepare(text, fontAtlas, fontSize = 32) {
152
- if (fontAtlas !== this.lastAtlas) {
153
- this.paragraphCache.clear();
154
- this.richParagraphCache.clear();
155
- this.lastAtlas = fontAtlas;
156
- }
157
- const paragraphs = [];
158
- let offset = 0;
159
- let fallbackToCanvas = false;
160
- for (const paragraph of text.split("\n")) {
161
- if (paragraph.length === 0) {
162
- paragraphs.push({ words: [], isEmpty: true });
163
- offset += 1;
164
- continue;
165
- }
166
- const key = `${fontSize} ${paragraph}`;
167
- const cached = this.paragraphCache.get(key);
168
- if (cached) {
169
- paragraphs.push(cached);
170
- if (cached.fallbackToCanvas) fallbackToCanvas = true;
171
- offset += paragraph.length + 1;
172
- continue;
173
- }
174
- const { shapedText, indexMap } = _chunk4AR425ARjs.ArabicShaper.shapeArabic(paragraph);
175
- const levels = _chunk4AR425ARjs.BidiResolver.resolveLevels(shapedText);
176
- const words = [];
177
- let shapedCharIdx = 0;
178
- let pFallback = false;
179
- for (const segment of this.getWordSegments(shapedText)) {
180
- const word = segment.segment;
181
- const glyphs = [];
182
- let width = 0;
183
- let breakPoints;
184
- for (const char of this.getGraphemes(word)) {
185
- if (char === "\xAD") {
186
- (breakPoints ??= []).push(glyphs.length);
187
- shapedCharIdx += char.length;
188
- continue;
189
- }
190
- const visualStart = shapedCharIdx;
191
- const visualEnd = shapedCharIdx + char.length;
192
- const rawStart = indexMap[visualStart];
193
- const rawEnd = visualEnd === shapedText.length ? paragraph.length : indexMap[visualEnd];
194
- const sourceIndex = offset + rawStart;
195
- const sourceLength = rawEnd - rawStart;
196
- const glyphKey = this.glyphKeyFor(char, fontAtlas);
197
- const level = levels[visualStart];
198
- const hasGlyph = !!fontAtlas[glyphKey];
199
- if (char.trim().length > 0 && !hasGlyph) {
200
- pFallback = true;
201
- fallbackToCanvas = true;
202
- }
203
- const w = this.glyphWidth(glyphKey, fontAtlas, fontSize);
204
- glyphs.push({
205
- char,
206
- width: w,
207
- level,
208
- sourceIndex,
209
- sourceLength
210
- });
211
- width += w;
212
- shapedCharIdx += char.length;
213
- }
214
- if (!breakPoints && this._hyphenate && segment.isWordLike && glyphs.length > 3) {
215
- const parts = this._hyphenate(word);
216
- if (parts.length > 1) {
217
- breakPoints = [];
218
- let count = 0;
219
- for (let pi = 0; pi < parts.length - 1; pi++) {
220
- for (const _g of this.getGraphemes(parts[pi])) count++;
221
- breakPoints.push(count);
222
- }
223
- }
224
- }
225
- words.push({
226
- glyphs,
227
- width,
228
- isWordLike: segment.isWordLike,
229
- isWhitespace: word.trim().length === 0,
230
- breakPoints
231
- });
232
- }
233
- const prepared = {
234
- words,
235
- isEmpty: false,
236
- fallbackToCanvas: pFallback || void 0,
237
- baseLevel: _chunk4AR425ARjs.BidiResolver.getBaseLevel(shapedText)
238
- };
239
- if (this.paragraphCache.size > 1e3) this.paragraphCache.clear();
240
- this.paragraphCache.set(key, prepared);
241
- paragraphs.push(prepared);
242
- offset += paragraph.length + 1;
243
- }
244
- return {
245
- paragraphs,
246
- fontSize,
247
- fallbackToCanvas: fallbackToCanvas || void 0,
248
- hyphenWidth: this.glyphWidth(this.glyphKeyFor("-", fontAtlas), fontAtlas, fontSize)
249
- };
250
- }
251
- /**
252
- * **Cold pass for rich text.** Like {@link prepare}, but takes an array of
253
- * {@link StyledSpan}s so different inline runs (bold / italic / color / size /
254
- * links) compose on the same wrapped lines. Each grapheme carries the
255
- * (base-merged) style of the span it came from — so a style change *mid-word*
256
- * (e.g. `He` + **`llo`**) is honored. Run `fontSize` affects measured width and
257
- * line height; the rest is rendering metadata carried through to the nodes.
258
- *
259
- * The result feeds the same {@link layoutPrepared} as plain text.
260
- *
261
- * @param spans - The styled runs, in document order.
262
- * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
263
- * @param baseFontSize - Size for runs without an explicit `fontSize` (default 32).
264
- * @param baseStyle - Style inherited by every run (each run's own style wins).
265
- */
266
- prepareRich(spans, fontAtlas, baseFontSize = 32, baseStyle) {
267
- if (fontAtlas !== this.lastAtlas) {
268
- this.paragraphCache.clear();
269
- this.richParagraphCache.clear();
270
- this.lastAtlas = fontAtlas;
271
- }
272
- let fullText = "";
273
- const styleAt = [];
274
- for (const span of spans) {
275
- const merged = span.style || baseStyle ? { ...baseStyle, ...span.style } : void 0;
276
- fullText += span.text;
277
- for (let i = 0; i < span.text.length; i++) styleAt.push(merged);
278
- }
279
- const styleSig = (start, len) => {
280
- let sig = "";
281
- let i = 0;
282
- while (i < len) {
283
- const s = styleAt[start + i];
284
- const fp = s ? `${_nullishCoalesce(s.fontSize, () => ( ""))}/${_nullishCoalesce(s.color, () => ( ""))}/${s.bold ? 1 : 0}/${s.italic ? 1 : 0}/${_nullishCoalesce(s.href, () => ( ""))}` : "";
285
- let run = 1;
286
- while (i + run < len) {
287
- const t = styleAt[start + i + run];
288
- const tfp = t ? `${_nullishCoalesce(t.fontSize, () => ( ""))}/${_nullishCoalesce(t.color, () => ( ""))}/${t.bold ? 1 : 0}/${t.italic ? 1 : 0}/${_nullishCoalesce(t.href, () => ( ""))}` : "";
289
- if (tfp !== fp) break;
290
- run++;
291
- }
292
- sig += `${fp}:${run};`;
293
- i += run;
294
- }
295
- return sig;
296
- };
297
- const paragraphs = [];
298
- let offset = 0;
299
- let fallbackToCanvas = false;
300
- for (const paragraph of fullText.split("\n")) {
301
- if (paragraph.length === 0) {
302
- paragraphs.push({ words: [], isEmpty: true });
303
- offset += 1;
304
- continue;
305
- }
306
- const key = `${baseFontSize} ${paragraph} ${styleSig(offset, paragraph.length)}`;
307
- const cached = this.richParagraphCache.get(key);
308
- if (cached) {
309
- paragraphs.push(cached);
310
- if (cached.fallbackToCanvas) fallbackToCanvas = true;
311
- offset += paragraph.length + 1;
312
- continue;
313
- }
314
- const { shapedText, indexMap } = _chunk4AR425ARjs.ArabicShaper.shapeArabic(paragraph);
315
- const levels = _chunk4AR425ARjs.BidiResolver.resolveLevels(shapedText);
316
- const words = [];
317
- let shapedCharIdx = 0;
318
- let pFallback = false;
319
- for (const segment of this.getWordSegments(shapedText)) {
320
- const word = segment.segment;
321
- const glyphs = [];
322
- let width = 0;
323
- let breakPoints;
324
- for (const char of this.getGraphemes(word)) {
325
- if (char === "\xAD") {
326
- (breakPoints ??= []).push(glyphs.length);
327
- shapedCharIdx += char.length;
328
- continue;
329
- }
330
- const visualStart = shapedCharIdx;
331
- const visualEnd = shapedCharIdx + char.length;
332
- const rawStart = indexMap[visualStart];
333
- const rawEnd = visualEnd === shapedText.length ? paragraph.length : indexMap[visualEnd];
334
- const sourceIndex = offset + rawStart;
335
- const sourceLength = rawEnd - rawStart;
336
- const glyphKey = this.glyphKeyFor(char, fontAtlas);
337
- const level = levels[visualStart];
338
- const style = styleAt[offset + rawStart];
339
- const gfs = _nullishCoalesce(_optionalChain([style, 'optionalAccess', _ => _.fontSize]), () => ( baseFontSize));
340
- const hasGlyph = !!fontAtlas[glyphKey];
341
- if (char.trim().length > 0 && !hasGlyph) {
342
- pFallback = true;
343
- fallbackToCanvas = true;
344
- }
345
- const w = this.glyphWidth(glyphKey, fontAtlas, gfs);
346
- glyphs.push({
347
- char,
348
- width: w,
349
- style,
350
- level,
351
- sourceIndex,
352
- sourceLength
353
- });
354
- width += w;
355
- shapedCharIdx += char.length;
356
- }
357
- if (!breakPoints && this._hyphenate && segment.isWordLike && glyphs.length > 3) {
358
- const parts = this._hyphenate(word);
359
- if (parts.length > 1) {
360
- breakPoints = [];
361
- let count = 0;
362
- for (let pi = 0; pi < parts.length - 1; pi++) {
363
- for (const _g of this.getGraphemes(parts[pi])) count++;
364
- breakPoints.push(count);
365
- }
366
- }
367
- }
368
- words.push({
369
- glyphs,
370
- width,
371
- isWordLike: segment.isWordLike,
372
- isWhitespace: word.trim().length === 0,
373
- breakPoints
374
- });
375
- }
376
- const prepared = {
377
- words,
378
- isEmpty: false,
379
- fallbackToCanvas: pFallback || void 0,
380
- baseLevel: _chunk4AR425ARjs.BidiResolver.getBaseLevel(shapedText)
381
- };
382
- if (this.richParagraphCache.size > 1e3) this.richParagraphCache.clear();
383
- this.richParagraphCache.set(key, prepared);
384
- paragraphs.push(prepared);
385
- offset += paragraph.length + 1;
386
- }
387
- return {
388
- paragraphs,
389
- fontSize: baseFontSize,
390
- fallbackToCanvas: fallbackToCanvas || void 0
391
- };
392
- }
393
- /**
394
- * **Hot pass.** Place an already-measured {@link PreparedText} into positioned
395
- * glyphs. Does only wrap/positioning arithmetic — no `Intl.Segmenter`, no
396
- * re-measurement — so it is cheap enough to call every frame or on every
397
- * resize. Reads the engine's current `maxWidth`/`maxHeight`, so changing those
398
- * and re-calling reflows the same prepared text.
399
- *
400
- * @param prepared - Output of {@link prepare}.
401
- * @param exclusionMask - Optional per-glyph collision callback (see {@link layoutText}).
402
- * @param exclusions - Optional rect regions text flows around (exclusion shapes); each
403
- * line is split into the free x-segments left after subtracting them. Omitting
404
- * it (or passing `[]`) leaves the single-column path byte-for-byte unchanged.
405
- */
406
- layoutPrepared(prepared, exclusionMask, exclusions) {
407
- const layoutNodes = [];
408
- const fontSize = prepared.fontSize;
409
- let currentX = 0;
410
- let currentY = 0;
411
- let maxLineWidth = 0;
412
- const hasEx = !!(exclusions && exclusions.length);
413
- let segs = [{ x0: 0, x1: this.maxWidth }];
414
- let si = 0;
415
- let currentLineNodes = [];
416
- let paragraphBaseLevel = 0;
417
- const commitLine = (justifyTo) => {
418
- if (currentLineNodes.length === 0) return;
419
- const runs = [];
420
- let currentRun = [];
421
- for (let j = 0; j < currentLineNodes.length; j++) {
422
- const node = currentLineNodes[j];
423
- const prev = currentLineNodes[j - 1];
424
- if (prev && Math.abs(node.x - (prev.x + prev.width)) > 1e-3) {
425
- runs.push(currentRun);
426
- currentRun = [];
427
- }
428
- currentRun.push(node);
429
- }
430
- if (currentRun.length > 0) {
431
- runs.push(currentRun);
432
- }
433
- for (const run of runs) {
434
- const runStartX = run[0].x;
435
- _chunk4AR425ARjs.BidiResolver.reorderVisual(run, paragraphBaseLevel);
436
- let x = runStartX;
437
- for (const node of run) {
438
- node.x = x;
439
- node.isRTL = node.level % 2 === 1;
440
- x += node.width;
441
- }
442
- if (justifyTo !== void 0 && runs.length === 1) {
443
- let lastContent = run.length - 1;
444
- while (lastContent >= 0 && run[lastContent].char.trim() === "") lastContent--;
445
- if (lastContent > 0) {
446
- const contentEnd = run[lastContent].x + run[lastContent].width;
447
- const slack = justifyTo - contentEnd;
448
- if (slack > 0 && slack <= (justifyTo - runStartX) * 0.5) {
449
- const spaceIdx = [];
450
- for (let k = 1; k < lastContent; k++) {
451
- if (run[k].char.trim() === "") spaceIdx.push(k);
452
- }
453
- if (spaceIdx.length > 0) {
454
- const extra = slack / spaceIdx.length;
455
- let shift = 0;
456
- let nextSpace = 0;
457
- for (let k = 0; k <= lastContent; k++) {
458
- run[k].x += shift;
459
- if (nextSpace < spaceIdx.length && k === spaceIdx[nextSpace]) {
460
- run[k].width += extra;
461
- shift += extra;
462
- nextSpace++;
463
- }
464
- }
465
- } else {
466
- const extra = slack / lastContent;
467
- for (let k = 1; k <= lastContent; k++) run[k].x += extra * k;
468
- }
469
- if (justifyTo > maxLineWidth) maxLineWidth = justifyTo;
470
- }
471
- }
472
- }
473
- for (const node of run) {
474
- layoutNodes.push(node);
475
- }
476
- }
477
- currentLineNodes = [];
478
- };
479
- const startLine = (lineHeight) => {
480
- while (currentY < this.maxHeight) {
481
- const s = hasEx ? computeLineSegments(currentY, currentY + lineHeight, this.maxWidth, exclusions) : segs;
482
- if (s.length > 0) {
483
- segs = s;
484
- si = 0;
485
- currentX = segs[0].x0;
486
- return true;
487
- }
488
- currentY += lineHeight;
489
- }
490
- return false;
491
- };
492
- const justifyTarget = this.textAlign === "justify" && !hasEx ? this.maxWidth : void 0;
493
- const hyphenWidth = _nullishCoalesce(prepared.hyphenWidth, () => ( fontSize * 0.3));
494
- for (const paragraph of prepared.paragraphs) {
495
- if (paragraph.isEmpty) {
496
- commitLine();
497
- currentY += fontSize * 1.5;
498
- currentX = 0;
499
- continue;
500
- }
501
- paragraphBaseLevel = _nullishCoalesce(paragraph.baseLevel, () => ( 0));
502
- let pMax = fontSize;
503
- for (const word of paragraph.words) {
504
- for (const glyph of word.glyphs) {
505
- const gfs = _nullishCoalesce(_optionalChain([glyph, 'access', _2 => _2.style, 'optionalAccess', _3 => _3.fontSize]), () => ( fontSize));
506
- if (gfs > pMax) pMax = gfs;
507
- }
508
- }
509
- const lineHeight = pMax * 1.5;
510
- if (!startLine(lineHeight)) break;
511
- const wordQueue = paragraph.words.slice();
512
- for (let qi = 0; qi < wordQueue.length; qi++) {
513
- const word = wordQueue[qi];
514
- if (currentX + word.width > segs[si].x1) {
515
- if (!hasEx && word.breakPoints && word.breakPoints.length > 0) {
516
- const avail = segs[si].x1 - currentX;
517
- let chosen = -1;
518
- let prefixWidth = 0;
519
- let acc = 0;
520
- let bpIdx = 0;
521
- for (let g = 0; g < word.glyphs.length && bpIdx < word.breakPoints.length; g++) {
522
- acc += word.glyphs[g].width;
523
- if (g + 1 === word.breakPoints[bpIdx]) {
524
- if (acc + hyphenWidth <= avail) {
525
- chosen = word.breakPoints[bpIdx];
526
- prefixWidth = acc;
527
- }
528
- bpIdx++;
529
- }
530
- }
531
- if (chosen > 0) {
532
- const anchorGlyph = word.glyphs[chosen - 1];
533
- const prefix = {
534
- glyphs: [
535
- ...word.glyphs.slice(0, chosen),
536
- {
537
- char: "-",
538
- width: hyphenWidth,
539
- level: anchorGlyph.level,
540
- sourceIndex: anchorGlyph.sourceIndex,
541
- sourceLength: 0
542
- }
543
- ],
544
- width: prefixWidth + hyphenWidth,
545
- isWordLike: true,
546
- isWhitespace: false
547
- };
548
- const rest = {
549
- glyphs: word.glyphs.slice(chosen),
550
- width: word.width - prefixWidth,
551
- isWordLike: true,
552
- isWhitespace: false,
553
- breakPoints: word.breakPoints.filter((bp) => bp > chosen).map((bp) => bp - chosen)
554
- };
555
- wordQueue.splice(qi, 1, prefix, rest);
556
- qi--;
557
- continue;
558
- }
559
- }
560
- if (currentX > segs[si].x0) {
561
- if (word.isWordLike === false && word.isWhitespace) continue;
562
- if (si < segs.length - 1) {
563
- si++;
564
- currentX = segs[si].x0;
565
- } else {
566
- commitLine(justifyTarget);
567
- currentY += lineHeight;
568
- if (!startLine(lineHeight)) break;
569
- }
570
- }
571
- }
572
- for (const glyph of word.glyphs) {
573
- const charWidth = glyph.width;
574
- const gfs = _nullishCoalesce(_optionalChain([glyph, 'access', _4 => _4.style, 'optionalAccess', _5 => _5.fontSize]), () => ( fontSize));
575
- let foundSpot = false;
576
- while (currentY < this.maxHeight) {
577
- if (currentX + charWidth > segs[si].x1 && currentX > segs[si].x0) {
578
- if (si < segs.length - 1) {
579
- si++;
580
- currentX = segs[si].x0;
581
- } else {
582
- commitLine(justifyTarget);
583
- currentY += lineHeight;
584
- if (!startLine(lineHeight)) break;
585
- }
586
- continue;
587
- }
588
- if (exclusionMask && exclusionMask(currentX, currentY, charWidth, gfs)) {
589
- currentX += charWidth;
590
- continue;
591
- }
592
- foundSpot = true;
593
- break;
594
- }
595
- if (!foundSpot || currentY >= this.maxHeight) break;
596
- if (currentX === segs[si].x0 && glyph.char.trim().length === 0 && !this.preserveLeadingSpaces)
597
- continue;
598
- currentLineNodes.push({
599
- char: glyph.char,
600
- x: currentX,
601
- // Canvas text is positioned by baseline, while `y` is the local
602
- // top used by the renderer. Offset smaller runs by their baseline
603
- // delta, not by their full em-box delta, so mixed-size glyphs share
604
- // one real baseline in every Canvas 2D implementation.
605
- y: currentY + (pMax - gfs) * 0.8,
606
- width: charWidth,
607
- height: gfs,
608
- style: glyph.style,
609
- level: glyph.level,
610
- sourceIndex: glyph.sourceIndex,
611
- sourceLength: glyph.sourceLength,
612
- combining: glyph.combining
613
- });
614
- currentX += charWidth;
615
- if (currentX > maxLineWidth) maxLineWidth = currentX;
616
- }
617
- }
618
- commitLine();
619
- currentX = 0;
620
- currentY += lineHeight;
621
- }
622
- return {
623
- nodes: layoutNodes,
624
- totalWidth: maxLineWidth,
625
- totalHeight: currentY,
626
- fallbackToCanvas: prepared.fallbackToCanvas
627
- };
628
- }
629
- /**
630
- * Lay out a Unicode string directly into a pre-allocated {@link LayoutResultBuffer}.
631
- *
632
- * Avoids GC allocations by writing results directly to flat typed arrays in the buffer.
633
- *
634
- * @param text - The raw text string to lay out.
635
- * @param fontAtlas - Pre-measured glyph metrics keyed by grapheme character.
636
- * @param fontSize - Target font size in pixels.
637
- * @param buffer - The pre-allocated buffer to write layout results into.
638
- * @param exclusionMask - Optional collision-detection callback.
639
- */
640
- layoutTextIntoBuffer(text, fontAtlas, fontSize, buffer, exclusionMask) {
641
- this.layoutPreparedIntoBuffer(this.prepare(text, fontAtlas, fontSize), buffer, exclusionMask);
642
- }
643
- /**
644
- * **Hot pass, zero-GC variant.** Place an already-measured {@link PreparedText}
645
- * directly into a pre-allocated {@link LayoutResultBuffer}. Like
646
- * {@link layoutPrepared} but writes flat typed arrays instead of allocating
647
- * {@link LayoutNode} objects — the per-frame path for large dynamic scenes.
648
- */
649
- layoutPreparedIntoBuffer(prepared, buffer, exclusionMask) {
650
- buffer.reset();
651
- const fontSize = prepared.fontSize;
652
- const lineHeight = fontSize * 1.5;
653
- let currentX = 0;
654
- let currentY = 0;
655
- for (const paragraph of prepared.paragraphs) {
656
- if (paragraph.isEmpty) {
657
- currentY += lineHeight;
658
- currentX = 0;
659
- continue;
660
- }
661
- for (const word of paragraph.words) {
662
- if (currentX + word.width > this.maxWidth && currentX > 0) {
663
- if (word.isWordLike === false && word.isWhitespace) continue;
664
- currentX = 0;
665
- currentY += lineHeight;
666
- }
667
- for (const glyph of word.glyphs) {
668
- if (buffer.count >= LayoutResultBuffer.CAPACITY) break;
669
- const charWidth = glyph.width;
670
- let foundSpot = false;
671
- while (currentY < this.maxHeight) {
672
- if (currentX + charWidth > this.maxWidth && currentX > 0) {
673
- currentX = 0;
674
- currentY += lineHeight;
675
- continue;
676
- }
677
- if (exclusionMask && exclusionMask(currentX, currentY, charWidth, fontSize)) {
678
- currentX += charWidth;
679
- continue;
680
- }
681
- foundSpot = true;
682
- break;
683
- }
684
- if (!foundSpot || currentY >= this.maxHeight) break;
685
- if (currentX === 0 && glyph.char.trim().length === 0) continue;
686
- const idx = buffer.count;
687
- buffer.chars[idx] = glyph.char;
688
- buffer.xs[idx] = currentX;
689
- buffer.ys[idx] = currentY;
690
- buffer.ws[idx] = charWidth;
691
- buffer.hs[idx] = fontSize;
692
- buffer.count++;
693
- currentX += charWidth;
694
- }
695
- }
696
- currentX = 0;
697
- currentY += lineHeight;
698
- }
699
- }
700
- }, _class);
701
- var LayoutResultBuffer = (_class2 = class _LayoutResultBuffer {constructor() { _class2.prototype.__init9.call(this);_class2.prototype.__init10.call(this);_class2.prototype.__init11.call(this);_class2.prototype.__init12.call(this);_class2.prototype.__init13.call(this);_class2.prototype.__init14.call(this); }
702
- static __initStatic() {this.CAPACITY = 16384}
703
- /** X positions of each glyph. */
704
- __init9() {this.xs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
705
- /** Y positions of each glyph. */
706
- __init10() {this.ys = new Float32Array(_LayoutResultBuffer.CAPACITY)}
707
- /** Widths of each glyph. */
708
- __init11() {this.ws = new Float32Array(_LayoutResultBuffer.CAPACITY)}
709
- /** Heights of each glyph. */
710
- __init12() {this.hs = new Float32Array(_LayoutResultBuffer.CAPACITY)}
711
- /** Character for each glyph slot. */
712
- __init13() {this.chars = Array.from({ length: _LayoutResultBuffer.CAPACITY })}
713
- /** Number of valid glyphs written in this buffer. */
714
- __init14() {this.count = 0}
715
- /** Reset the buffer for reuse. Does NOT free memory. */
716
- reset() {
717
- this.count = 0;
718
- }
719
- /** Convert to the standard LayoutResult format (allocates — use sparingly). */
720
- toLayoutResult() {
721
- const nodes = [];
722
- for (let i = 0; i < this.count; i++) {
723
- nodes.push({
724
- char: this.chars[i],
725
- x: this.xs[i],
726
- y: this.ys[i],
727
- width: this.ws[i],
728
- height: this.hs[i]
729
- });
730
- }
731
- return { nodes, totalWidth: 0, totalHeight: 0 };
732
- }
733
- }, _class2.__initStatic(), _class2);
734
-
735
- // src/layout/measure.ts
736
- function createCanvasMeasurer(fontFamily = "sans-serif", baseSize = 100) {
737
- if (typeof document === "undefined") return null;
738
- const ctx = document.createElement("canvas").getContext("2d");
739
- if (!ctx) return null;
740
- const font = `${baseSize}px ${fontFamily}`;
741
- const cache = /* @__PURE__ */ new Map();
742
- return {
743
- measure(char, fontSize) {
744
- let base = cache.get(char);
745
- if (base === void 0) {
746
- ctx.font = font;
747
- base = ctx.measureText(char).width;
748
- cache.set(char, base);
749
- }
750
- return base * (fontSize / baseSize);
751
- }
752
- };
753
- }
754
-
755
-
756
-
757
-
758
-
759
-
760
- exports.computeLineSegments = computeLineSegments; exports.LayoutEngine = LayoutEngine; exports.LayoutResultBuffer = LayoutResultBuffer; exports.createCanvasMeasurer = createCanvasMeasurer;