@weasel-js/text 1.2.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,589 @@
1
+ import { FillStyle, Stroke } from '@weasel-js/paint';
2
+
3
+ /**
4
+ * Canonical inline-styling primitive for text nodes. A node's text is
5
+ * either a plain `string` (treated as a single-run, default-styled fragment)
6
+ * or `StyledRun[]` for rich content. `toRuns` is the funnel that normalizes
7
+ * either form into the array shape used by the renderer.
8
+ *
9
+ * Every field except `text` is optional; missing fields fall back to the
10
+ * node-level `TextStyle`. `bold`/`italic` are toggles; richer weight axes
11
+ * (300/500/900) are out of scope for slice 1.
12
+ */
13
+
14
+ /** A span of text with its own styling, as authored. Fields left absent
15
+ * inherit from the node's text style — this is the difference between a run
16
+ * and a fully resolved one. */
17
+ interface StyledRun {
18
+ text: string;
19
+ bold?: boolean;
20
+ italic?: boolean;
21
+ fontFamily?: string;
22
+ fontSize?: number;
23
+ fill?: FillStyle;
24
+ /** Outline over this run's glyphs. Overrides the node style's stroke;
25
+ * absent inherits it. Only painted on the outline tier — see
26
+ * {@link TextStyle.stroke}. */
27
+ stroke?: Stroke;
28
+ letterSpacing?: number;
29
+ underline?: boolean;
30
+ strikethrough?: boolean;
31
+ /** Draw a rule above this run's ascent. Additive over the node style. */
32
+ overline?: boolean;
33
+ /**
34
+ * Set this run as a superscript or subscript — a raised or lowered baseline
35
+ * and a smaller size together, which is the pair `<sup>` and `<sub>` imply.
36
+ *
37
+ * A preset over the two primitives below, not a third mechanism: it supplies
38
+ * a `baselineShift` and a `fontScale`, and naming either of those directly
39
+ * overrides that half while leaving the other alone. The numbers are in
40
+ * {@link SCRIPT_METRICS}.
41
+ *
42
+ * There is no node-level counterpart. A whole text node set as a superscript
43
+ * is a smaller node moved up, which the pose already says better.
44
+ */
45
+ script?: 'super' | 'sub';
46
+ /**
47
+ * Raise (positive) or lower (negative) this run off the line's shared
48
+ * baseline, in ems of the *inherited* font size — so a run's rise does not
49
+ * shrink along with the run when `fontScale` also applies.
50
+ */
51
+ baselineShift?: number;
52
+ /**
53
+ * Multiplier on the inherited font size. The relative counterpart to
54
+ * `fontSize`, which pins an absolute size and wins over this when both are
55
+ * present.
56
+ */
57
+ fontScale?: number;
58
+ }
59
+ /** Normalize the two accepted spellings of text content — a plain string or
60
+ * an array of runs — to runs. Throws on a run with no string `text`. */
61
+ declare function toRuns(input: string | StyledRun[]): StyledRun[];
62
+ /** Concatenate runs, dropping all styling. */
63
+ declare function runsToPlainText(runs: readonly StyledRun[]): string;
64
+ /** A run flag an inline marker can toggle. The additive style toggles on
65
+ * `StyledRun`; the valued fields (family, size, paint, `script`) have no
66
+ * inline spelling and are not addressable this way. */
67
+ type RunFlag = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'overline';
68
+ /** One inline marker: a delimiter repeated `repeat` times, and the flags it
69
+ * turns on between its opening and closing occurrence. */
70
+ interface RunMarker {
71
+ delimiter: string;
72
+ repeat: number;
73
+ flags: readonly RunFlag[];
74
+ }
75
+ /**
76
+ * The inline grammar `runsToMarkdown` writes and `markdownToRuns` reads.
77
+ *
78
+ * Markers sharing a delimiter are matched longest-first, which is what lets
79
+ * `***` mean something other than `**` followed by `*`. A backslash escapes
80
+ * any delimiter character and itself, in both directions.
81
+ */
82
+ interface RunGrammar {
83
+ markers: readonly RunMarker[];
84
+ }
85
+ /**
86
+ * The markdown subset the kit reads and writes: `**bold**`, `*italic*`,
87
+ * `***both***`.
88
+ *
89
+ * Deliberately not the whole of markdown, and deliberately silent on
90
+ * `underline` / `strikethrough` — the two run flags with no entry here are
91
+ * dropped by `runsToMarkdown`, as they always have been. A grammar that wants
92
+ * `~~struck~~` adds one marker; nothing else has to change.
93
+ */
94
+ declare const MARKDOWN_RUN_GRAMMAR: RunGrammar;
95
+ /**
96
+ * Render runs in `grammar`, defaulting to the markdown subset. The flavor
97
+ * written to the clipboard alongside the plain-text one.
98
+ *
99
+ * A run whose flags exactly match one marker takes it; otherwise the markers
100
+ * that cover them nest. Flags no marker spells are dropped — the text still
101
+ * round-trips, without that styling.
102
+ */
103
+ declare function runsToMarkdown(runs: readonly StyledRun[], grammar?: RunGrammar): string;
104
+ /**
105
+ * Parse `input` in `grammar`, defaulting to the markdown subset, into styled
106
+ * runs. Newlines are preserved as literal characters inside a run — they are
107
+ * not run-boundary markers in this format.
108
+ */
109
+ declare function markdownToRuns(input: string, grammar?: RunGrammar): StyledRun[];
110
+
111
+ /**
112
+ * Typography for `TextPose` and friends. Every field is optional; consumers
113
+ * pass `{}` or override the few they care about. Defaults live in
114
+ * `DEFAULT_TEXT_STYLE` and are applied at render/measure time, never written
115
+ * back to the pose.
116
+ *
117
+ * Paint is not typography and does not live here. A text node carries its
118
+ * fill and stroke in `data.fill` / `data.stroke`, the slots every other node
119
+ * kind uses; `resolveTextStyle` takes them as its second argument and
120
+ * `StyledRun.fill` / `.stroke` override them per range.
121
+ */
122
+
123
+ /** User-facing text style. All fields optional; defaults applied at render time via `resolveTextStyle`. */
124
+ interface TextStyle {
125
+ /** Font size in world units. Default 16. */
126
+ fontSize?: number;
127
+ /** Default `'sans-serif'`. */
128
+ fontFamily?: string;
129
+ /** Default 400. */
130
+ fontWeight?: number | string;
131
+ /** Default `'normal'`. */
132
+ fontStyle?: 'normal' | 'italic';
133
+ /** Default `'left'`. */
134
+ align?: 'left' | 'center' | 'right';
135
+ /** Multiplier applied to `fontSize`. Default 1.2. */
136
+ lineHeight?: number;
137
+ /**
138
+ * Caret color used by the edit overlay. Defaults to the node's fill when
139
+ * that fill is solid; falls back to `#000` for non-solid paints.
140
+ */
141
+ caretColor?: string;
142
+ /**
143
+ * Selection background color used by the edit overlay's `::selection`
144
+ * pseudo-element. Defaults to a 25%-opacity tint of `caretColor` via CSS
145
+ * `color-mix`. Pass `'none'` to fall back to the browser-native highlight.
146
+ */
147
+ selectionBackground?: string;
148
+ /** Selection text color paired with `selectionBackground`. Default: inherits text color. */
149
+ selectionColor?: string;
150
+ /** Extra advance added after each glyph, in world units. Default 0. */
151
+ letterSpacing?: number;
152
+ /** Default `false`. */
153
+ underline?: boolean;
154
+ /** Default `false`. */
155
+ strikethrough?: boolean;
156
+ /** Default `false`. */
157
+ overline?: boolean;
158
+ }
159
+ /** `TextStyle` with all fields filled in from defaults — what the renderer actually consumes. */
160
+ interface ResolvedTextStyle {
161
+ fontSize: number;
162
+ fontFamily: string;
163
+ fontWeight: number | string;
164
+ fontStyle: 'normal' | 'italic';
165
+ align: 'left' | 'center' | 'right';
166
+ lineHeight: number;
167
+ fill: FillStyle;
168
+ caretColor: string;
169
+ selectionBackground: string | null;
170
+ selectionColor: string | null;
171
+ letterSpacing: number;
172
+ underline: boolean;
173
+ strikethrough: boolean;
174
+ overline: boolean;
175
+ /** Absent means no outline — unlike the other fields, this one has no
176
+ * default to fall back to. See {@link TextPaint.stroke}. */
177
+ stroke?: Stroke;
178
+ }
179
+ /** Default resolved style used when a `TextPose` omits `style`. */
180
+ declare const DEFAULT_TEXT_STYLE: ResolvedTextStyle;
181
+ /**
182
+ * The paint a text node hands its glyphs — `data.fill` and `data.stroke`,
183
+ * read straight off the node. Runs inherit these when they name none of
184
+ * their own.
185
+ *
186
+ * `fill: null` is not yet distinguishable from absent: a `ResolvedRun` must
187
+ * name a concrete fill, so unfilled-but-stroked text has nowhere to say so
188
+ * and falls back to the default black. See `docs/TODO.md`.
189
+ */
190
+ interface TextPaint {
191
+ fill?: FillStyle | null;
192
+ /**
193
+ * Outline painted over the glyph fill. Absent means no outline — there is
194
+ * no such thing as a default text stroke.
195
+ *
196
+ * Only glyphs on the outline tier are stroked: above
197
+ * `textOutlineMinScreenSize` a glyph is a real `PolygonPath`, so it gets
198
+ * the ordinary tessellated ribbon with real joins, caps and miters, in any
199
+ * paint. Below it a glyph is a sampled distance field with no geometry to
200
+ * stroke, and it renders unstroked rather than approximated. `width` is in
201
+ * world units, like every other stroke in the kit — it does not scale with
202
+ * `fontSize`.
203
+ */
204
+ stroke?: Stroke | null;
205
+ }
206
+ /** Fill in a partial `TextStyle` with defaults from `DEFAULT_TEXT_STYLE`,
207
+ * taking the glyph paint from the node rather than from the style. */
208
+ declare function resolveTextStyle(style?: TextStyle, paint?: TextPaint): ResolvedTextStyle;
209
+ /** Build a CSS `font` shorthand suitable for `ctx.font`. */
210
+ declare function fontString(s: ResolvedTextStyle): string;
211
+
212
+ /**
213
+ * Apply node-level `ResolvedTextStyle` defaults to each `StyledRun`,
214
+ * producing a fully-resolved run with every styling field set. Downstream
215
+ * layout and draw never re-resolve defaults — `ResolvedRun` is the
216
+ * canonical shape the renderer consumes.
217
+ *
218
+ * `bold`/`italic` toggles on a run are folded into `fontWeight`/`fontStyle`:
219
+ * `bold: true` → fontWeight 700, `italic: true` → fontStyle 'italic'.
220
+ * Explicit `fontFamily` / `fontSize` / `fill` / `letterSpacing` on the run
221
+ * override the node-level value (`letterSpacing: 0` on a run is an override,
222
+ * not an absence — it zeroes inherited tracking).
223
+ *
224
+ * `underline` / `strikethrough` / `overline` are *additive*, like
225
+ * `bold`/`italic`: a run can turn a decoration on but never off, so they
226
+ * resolve as `run.x || style.x` and not `run.x ?? style.x`. See the header of
227
+ * `runs/rangeStyle.ts` for why the model collapses the tri-state.
228
+ *
229
+ * `script` is folded the same way the toggles are, into the two primitives it
230
+ * is a preset over: `baselineShift` and `fontScale`. Both come out as one
231
+ * world-unit `baselineShift` and a final `fontSize`, so layout never learns
232
+ * that superscripts exist — it places a run against a baseline and an offset.
233
+ */
234
+
235
+ /** A run with every style resolved against the node's text style — no
236
+ * optional inheritance left. This is what layout and painting consume. */
237
+ interface ResolvedRun {
238
+ text: string;
239
+ fontFamily: string;
240
+ fontSize: number;
241
+ fontWeight: number;
242
+ fontStyle: 'normal' | 'italic';
243
+ fill: FillStyle;
244
+ /** Outline over this run's glyphs, or absent for none. Painted only on the
245
+ * outline tier — a distance field has no geometry to stroke. */
246
+ stroke?: Stroke;
247
+ /** Extra advance added after each glyph of this run, in world units. */
248
+ letterSpacing: number;
249
+ /** Draw a rule below this run's baseline. Additive over the node style. */
250
+ underline: boolean;
251
+ /** Draw a rule through this run's x-height. Additive over the node style. */
252
+ strikethrough: boolean;
253
+ /** Draw a rule above this run's ascent. Additive over the node style. */
254
+ overline: boolean;
255
+ /**
256
+ * How far this run sits off the line's shared baseline, in world units;
257
+ * positive raises. 0 for ordinary text.
258
+ *
259
+ * Already multiplied out against the inherited font size, and already
260
+ * carrying whatever `script` asked for — layout adds it to a baseline and
261
+ * asks nothing about where it came from.
262
+ */
263
+ baselineShift: number;
264
+ }
265
+ /**
266
+ * What `script: 'super'` and `script: 'sub'` expand to, as fractions of the
267
+ * inherited font size: `size` scales it, `shift` raises (positive) or lowers
268
+ * (negative) the baseline.
269
+ *
270
+ * These are Adobe's defaults — InDesign and Illustrator ship 58.3% size and
271
+ * 33.3% position for both — chosen because a drawing tool's users have those
272
+ * numbers in their fingers already. They are not read from the font: the
273
+ * `OS/2` table carries real `ySuperscript*` / `ySubscript*` metrics, but the
274
+ * baked atlas tier has no slot for them (see `layoutRuns`'s note on the
275
+ * decoration constants, which are derived for the same reason), and metrics
276
+ * that applied on one glyph tier and not the other would reflow text as it
277
+ * crossed the size threshold.
278
+ *
279
+ * Exported so a consumer building a character panel can show the percentages
280
+ * it is about to apply. Override either half per run with `baselineShift` /
281
+ * `fontScale`.
282
+ */
283
+ declare const SCRIPT_METRICS: Readonly<Record<'super' | 'sub', Readonly<{
284
+ size: number;
285
+ shift: number;
286
+ }>>>;
287
+ /** Resolve each run's styling against the node's text style, filling in
288
+ * everything the run left inherited. */
289
+ declare function resolveRuns(runs: readonly StyledRun[], style: ResolvedTextStyle): ResolvedRun[];
290
+
291
+ /**
292
+ * Runs-aware MSDF layout. Walks `ResolvedRun[]` codepoint-by-codepoint,
293
+ * switching atlas per run via `resolveFontVariant`, applying kerning
294
+ * (using the left glyph's atlas table and size scaling, including across
295
+ * run boundaries), and bucketing emitted quads by
296
+ * `(family, resolvedWeight, resolvedStyle, syntheticBold, syntheticItalic, fillKey)`
297
+ * so the renderer issues one draw call per atlas+color group.
298
+ *
299
+ * A run's `letterSpacing` (world units, so it does not scale with `fontSize`)
300
+ * is added to the advance *after* every character of that run, including the
301
+ * last one on a line — the CSS `letter-spacing` rule, chosen so a DOM overlay
302
+ * rendering the same text can be made to agree. Trailing tracking therefore
303
+ * widens the measured line width and counts toward wrapping. Spaces are
304
+ * tracked like any other character; a newline is not (it consumes no advance).
305
+ * One caveat against CSS: tracking is applied per *code point*, not per
306
+ * grapheme cluster, so `e` + U+0301 takes tracking twice where CSS would
307
+ * space the cluster once.
308
+ *
309
+ * Word wrap is applied when `maxWidth` is finite: words are committed to
310
+ * a new line when they would exceed the current line width. Forced line
311
+ * breaks are emitted for `\n` codepoints. Every run on a line shares one
312
+ * baseline, sunk to clear the tallest run's ascent, so mixing sizes or faces
313
+ * aligns them the way inline text aligns everywhere else; line height is
314
+ * `max(fontSize * lineHeight)` across the line.
315
+ *
316
+ * A run may also sit off that shared baseline: `ResolvedRun.baselineShift`
317
+ * displaces it, which is what `script: 'super' | 'sub'` resolves to. The shift
318
+ * moves the run's glyphs, its outline geometry and its own decoration rules
319
+ * together, and deliberately does not feed back into the line's baseline or
320
+ * its height — a superscript rides on the line rather than reflowing it.
321
+ *
322
+ * Underline, strikethrough and overline come out on a second channel,
323
+ * `decorations` —
324
+ * solid rectangles, not textured glyphs, so they cannot ride in a group's
325
+ * `quads` (which upload UVs into an MSDF program). They are accumulated
326
+ * during the same per-line pen walk that emits quads, *not* reconstructed
327
+ * from quad extents afterwards: quads exist only for glyphs with ink, so a
328
+ * decorated span's spaces would punch holes in a rule derived from them.
329
+ *
330
+ * ### Coordinates are origin-relative
331
+ *
332
+ * Every coordinate out of here is measured from the text's own top-left, not
333
+ * from anywhere on the page: the caller translates. That is what lets a text
334
+ * node be dragged without re-laying out — `layoutCache` would otherwise need
335
+ * the position in its key and miss on every frame of the drag — and it costs
336
+ * the caller one addition per vertex, inside loops already walking every one.
337
+ * Alignment, wrapping, tracking and decoration placement all read widths and
338
+ * pen deltas, never an absolute coordinate, so the translation is exact.
339
+ */
340
+
341
+ /** One textured glyph quad, origin-relative — see the header. */
342
+ interface LaidOutQuad {
343
+ x0: number;
344
+ y0: number;
345
+ x1: number;
346
+ y1: number;
347
+ u0: number;
348
+ v0: number;
349
+ u1: number;
350
+ v1: number;
351
+ /** Y coordinate of the line's baseline (penY at quad emission). Used by the
352
+ * synthetic-italic vertex skew so above-baseline vertices lean right. */
353
+ baselineY: number;
354
+ }
355
+ /**
356
+ * One glyph the renderer should tessellate rather than sample: real font
357
+ * outline geometry, for text large enough that a distance field shows the
358
+ * raster it was reconstructed from.
359
+ *
360
+ * `d` is em-space SVG path data straight from `@weasel-js/font`'s outline
361
+ * registry — 1 unit is 1 em, y grows down, origin on the baseline at the pen.
362
+ * Placing it is therefore a uniform scale by `scale` (world units per em)
363
+ * followed by a translate to `(x, baselineY)`, and nothing here depends on
364
+ * zoom: the same glyph at the same size in two places is the same geometry
365
+ * twice, which is what lets the renderer tessellate it once and cache it.
366
+ *
367
+ * `key` identifies that cached tessellation — face identity plus codepoint,
368
+ * assembled here because layout is where both are in hand.
369
+ */
370
+ interface LaidOutOutlineGlyph {
371
+ d: string;
372
+ key: string;
373
+ /** Pen position: where em-space x = 0 lands, in world units. */
374
+ x: number;
375
+ /** The line's baseline: where em-space y = 0 lands, in world units. */
376
+ baselineY: number;
377
+ /** World units per em — the run's `fontSize`, since em space is unit-scale. */
378
+ scale: number;
379
+ }
380
+ interface LaidOutGroup {
381
+ /** Resolved atlas family — may differ from the requested family when the
382
+ * cross-family fallback policy substituted a default. This is what the
383
+ * renderer looks the atlas up by, so it must be the one that resolves. */
384
+ family: string;
385
+ /** Resolved variant — matches the registered atlas and the texture-cache key. */
386
+ weight: number;
387
+ style: 'normal' | 'italic';
388
+ /** Gap between the request and the resolved match. Drives shader uniforms. */
389
+ synthetic: {
390
+ bold: boolean;
391
+ italic: boolean;
392
+ };
393
+ /** Which glyph source (and therefore shader/texture) this group binds:
394
+ * baked MSDF atlas, the runtime canvas-SDF dynamic atlas, or tessellated
395
+ * font outlines. */
396
+ source: 'atlas' | 'canvas' | 'outline';
397
+ /** Dynamic-atlas page index for 'canvas' groups; 0 for the others. */
398
+ page: number;
399
+ fill: FillStyle;
400
+ /** Outline painted over the glyphs, or absent for none. Only ever set on
401
+ * an `'outline'` group — the SDF tiers have no geometry to stroke, which
402
+ * is why a stroke pulls its run onto the outline tier at any size. A run
403
+ * the tier cannot serve at all (no registered outlines, synthetic bold)
404
+ * stays on the atlas and carries the request no further. */
405
+ stroke?: Stroke;
406
+ /** Textured glyph quads. Always empty for an `'outline'` group. */
407
+ quads: LaidOutQuad[];
408
+ /** Outline geometry. Always empty for an `'atlas'` / `'canvas'` group —
409
+ * the two channels are exclusive, since the group is also the draw call
410
+ * and one draw call binds one program. */
411
+ glyphs: LaidOutOutlineGlyph[];
412
+ }
413
+ /**
414
+ * One decoration rule: an axis-aligned solid rectangle in the same world
415
+ * space as `LaidOutQuad`. Never spans a line break, and carries the fill of
416
+ * the run(s) it decorates so the rule follows the text colour.
417
+ */
418
+ interface LaidOutDecoration {
419
+ kind: 'underline' | 'strikethrough' | 'overline';
420
+ x0: number;
421
+ y0: number;
422
+ x1: number;
423
+ y1: number;
424
+ fill: FillStyle;
425
+ }
426
+ /**
427
+ * One laid-out line's box, in the same world space as `LaidOutQuad`.
428
+ *
429
+ * `[x0, x1]` is the line's own advance width *after* alignment — not the
430
+ * wrap box — so a short centered line reports the span it actually occupies.
431
+ * `[y0, y1]` is the full line box: `y0` is the pen's line top, `y1` is
432
+ * `y0 + max(fontSize * lineHeight)` over the line's runs. Ink can escape it
433
+ * vertically (a tall accent, a deep descender at a lineHeight below ~1.06 —
434
+ * see the `bounds` note at the end of `layoutRuns`); this is the typographic
435
+ * box, not an ink bounding box.
436
+ *
437
+ * Emitted for every line including empty ones, so indices line up with the
438
+ * wrap. An empty line has `x0 === x1`.
439
+ */
440
+ interface LaidOutLineBox {
441
+ x0: number;
442
+ y0: number;
443
+ x1: number;
444
+ y1: number;
445
+ /** The line's baseline, for callers placing carets or rules against it. */
446
+ baselineY: number;
447
+ /**
448
+ * Caret stops along this line, left to right: `caretXs[i]` is the left edge
449
+ * of the i-th advance cell, and the last entry is the line's right edge, so
450
+ * there is always one more stop than cell. Post-alignment, in the same
451
+ * origin-relative space as `x0`/`x1`.
452
+ *
453
+ * A cell spans one code point *plus* the kerning that precedes the next
454
+ * one, which is why a caret snapped to these midpoints lands where the
455
+ * glyphs actually were painted rather than where an unkerned re-measure
456
+ * would put them.
457
+ */
458
+ caretXs: number[];
459
+ /**
460
+ * Source offset for each stop in `caretXs`, as a UTF-16 index into the
461
+ * runs' concatenated text. Not contiguous: a code point the face cannot
462
+ * serve occupies no cell, and the wrap swallows the break between lines.
463
+ */
464
+ caretIndices: number[];
465
+ }
466
+ interface LaidOutRuns {
467
+ groups: LaidOutGroup[];
468
+ /** Decoration rules, in line order; within a span, underline then
469
+ * strikethrough then overline. Empty when nothing is decorated. */
470
+ decorations: LaidOutDecoration[];
471
+ /** Per-line boxes in layout order. Lets a caller reason about where the
472
+ * text actually sits inside its wrap box without re-running the wrap —
473
+ * `textLineBoxes` builds the text silhouette from these, so picking and
474
+ * painting cannot drift apart. */
475
+ lines: LaidOutLineBox[];
476
+ bounds: {
477
+ width: number;
478
+ height: number;
479
+ };
480
+ }
481
+ interface LayoutRunsOpts {
482
+ maxWidth: number;
483
+ lineHeight: number;
484
+ align: 'left' | 'center' | 'right';
485
+ /**
486
+ * World-space `fontSize` at or above which glyphs are emitted as outline
487
+ * geometry, when the resolved face has outlines registered. Omit (the
488
+ * default) to keep every glyph on its SDF tier.
489
+ *
490
+ * A *world* size rather than a screen size because layout knows nothing
491
+ * about the view; `drawText` divides its screen-pixel threshold by the view
492
+ * scale before calling, so zooming in lowers the world size that qualifies.
493
+ *
494
+ * This is a rendering switch only. Advances, kerning, wrapping and
495
+ * baselines are identical either way — the outline tier changes what a
496
+ * glyph looks like, never where it sits — so measurement callers
497
+ * (`measureTextBounds`, `textLineBoxes`) leave it unset and still agree
498
+ * with the paint, and crossing the threshold cannot reflow text.
499
+ *
500
+ * Ignored for a family that resolved to the outline tier because it has no
501
+ * atlas: there the geometry is not an upgrade over another rendering, it is
502
+ * the only one, so the threshold has nothing to choose between.
503
+ */
504
+ outlineMinSize?: number;
505
+ }
506
+ declare function layoutRuns(runs: readonly ResolvedRun[], opts: LayoutRunsOpts): LaidOutRuns;
507
+
508
+ /**
509
+ * Laid-out text, memoized. The one entry point every caller of `layoutRuns`
510
+ * should reach for — the renderer's paint path, the silhouette measurement
511
+ * behind picking, and caret hit-testing all answer from this.
512
+ *
513
+ * `layoutRuns` is the most expensive derivation in the kit — it walks every
514
+ * codepoint, resolves a face per run, measures, wraps, aligns, and places a
515
+ * quad or an outline glyph for each — and `drawText` ran it per text command
516
+ * per frame. Measured: 2.0 ms/frame for 200 short labels, 9.3 for 1000, and
517
+ * 25.9 for 200 wrapped paragraphs. That last figure is the entire frame
518
+ * budget spent before a single triangle is drawn.
519
+ *
520
+ * ### Two keys, tried in order
521
+ *
522
+ * **Array identity**, in a `WeakMap`, the same contract as the renderer's
523
+ * `WeakMap<Path, Mesh>` — and for the same reason: it makes the entry
524
+ * collectable with whatever owns the runs, and the lookup is a pointer
525
+ * compare. `kit:text`'s memoized `paint` hands back a stable array per node,
526
+ * so the renderer stays on this path.
527
+ *
528
+ * **Structure**, in a bounded `Map`, for every caller that cannot. Measuring
529
+ * a pose resolves its style and allocates a fresh `ResolvedRun[]` each call,
530
+ * so an identity-keyed cache could never hold anything for it — and those
531
+ * callers run on the drag path, once per pose change. Serializing the runs is
532
+ * real work, unlike the pointer compare above, which is exactly why it is
533
+ * second: an identity hit never pays for it.
534
+ *
535
+ * Under either, one entry per distinct `(maxWidth, lineHeight, align, outline
536
+ * threshold)`. Position is deliberately absent: `layoutRuns` emits
537
+ * origin-relative geometry and `drawText` translates at upload, so a text node
538
+ * dragged across the page keeps hitting the same entry.
539
+ *
540
+ * ### The outline threshold
541
+ *
542
+ * `outlineMinSize` is derived from the view scale, so recording it literally
543
+ * would miss on every frame of a zoom. It reaches `layoutRuns` through exactly
544
+ * one comparison (`run.fontSize < min`, in `outlineFor`), so what the layout
545
+ * actually depends on is *which runs clear the bar* — and since the test is a
546
+ * plain `>=` on size, that set is pinned by how many of the distinct run sizes
547
+ * clear it. Recording that count is exact, not a quantization: the entry stays
548
+ * valid across every zoom that doesn't cross a glyph size, and is dropped the
549
+ * moment one does.
550
+ *
551
+ * ### What the keys cannot see
552
+ *
553
+ * The font set. A face landing changes metrics with no change to the runs, so
554
+ * every lookup compares `glyphGeneration()` — advanced by the dynamic SDF
555
+ * atlas, by the outline registry, and by `registerFont` — and drops
556
+ * everything when it moves. That is the same escape hatch `nodeMemo`'s
557
+ * generation counter provides, for the same class of failure. Polled rather
558
+ * than subscribed: a module-load subscription is a cross-package side effect
559
+ * that makes importing this package require the font package's whole surface.
560
+ *
561
+ * ### Bounds
562
+ *
563
+ * Variants per runs array are capped and evicted wholesale, matching
564
+ * `outlineMeshCache`: the refill cost is one layout, and the bookkeeping would
565
+ * cost more than the misses it avoids. The structural map cannot ride an
566
+ * array's lifetime the way the `WeakMap` does — nothing collects its keys —
567
+ * so it is a real LRU with a hard entry count.
568
+ */
569
+
570
+ /** Distinct option combinations held for one runs array before the whole set
571
+ * is dropped. Sized for "a few views onto the same text". */
572
+ declare const LAYOUT_CACHE_VARIANT_LIMIT = 8;
573
+ /** Layouts held against the structural key before the least recently used one
574
+ * is dropped. Sized for "every text node visible on a page, plus slack". */
575
+ declare const LAYOUT_CACHE_STRUCTURAL_LIMIT = 64;
576
+ /**
577
+ * Lay `runs` out, reusing the previous result when nothing it depends on has
578
+ * changed. Drop-in for `layoutRuns` — same arguments, same return value.
579
+ *
580
+ * **The result is shared and must be treated as immutable.** `drawText`
581
+ * applies position and `verticalAlign` while packing vertices rather than by
582
+ * shifting the layout, precisely so it can be handed the same object every
583
+ * frame.
584
+ */
585
+ declare function cachedLayoutRuns(runs: readonly ResolvedRun[], opts: LayoutRunsOpts): LaidOutRuns;
586
+ /** Test helper. Do not call from product code. */
587
+ declare function _resetLayoutCacheForTests(): void;
588
+
589
+ export { DEFAULT_TEXT_STYLE as D, LAYOUT_CACHE_STRUCTURAL_LIMIT as L, MARKDOWN_RUN_GRAMMAR as M, type ResolvedTextStyle as R, type StyledRun as S, type TextStyle as T, _resetLayoutCacheForTests as _, LAYOUT_CACHE_VARIANT_LIMIT as a, type LaidOutDecoration as b, type LaidOutGroup as c, type LaidOutLineBox as d, type LaidOutOutlineGlyph as e, type LaidOutQuad as f, type LaidOutRuns as g, type LayoutRunsOpts as h, type ResolvedRun as i, type RunFlag as j, type RunGrammar as k, type RunMarker as l, SCRIPT_METRICS as m, type TextPaint as n, cachedLayoutRuns as o, fontString as p, layoutRuns as q, markdownToRuns as r, resolveRuns as s, resolveTextStyle as t, runsToMarkdown as u, runsToPlainText as v, toRuns as w };
@@ -0,0 +1,2 @@
1
+ export { _ as _resetLayoutCacheForTests } from './test-seams-nUU2u_Pt.js';
2
+ import '@weasel-js/paint';
@@ -0,0 +1,3 @@
1
+ export { _resetLayoutCacheForTests } from './chunk-4OGXTESY.js';
2
+ //# sourceMappingURL=test-seams.js.map
3
+ //# sourceMappingURL=test-seams.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"names":[],"mappings":"","file":"test-seams.js"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@weasel-js/text",
3
+ "version": "1.2.0",
4
+ "description": "Typography for weasel: styled runs, style resolution, kerned glyph layout, wrap and measurement. No scene graph, no React.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ },
16
+ "./test-seams": {
17
+ "import": "./dist/test-seams.js",
18
+ "types": "./dist/test-seams.d.ts"
19
+ },
20
+ "./package.json": "./package.json"
21
+ },
22
+ "dependencies": {
23
+ "@weasel-js/font": "1.2.0",
24
+ "@weasel-js/geom": "1.2.0",
25
+ "@weasel-js/paint": "1.2.0"
26
+ },
27
+ "scripts": {
28
+ "test": "vitest run",
29
+ "build": "tsup"
30
+ },
31
+ "author": "orochi235",
32
+ "homepage": "https://orochi235.github.io/weasel/",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/orochi235/weasel.git",
36
+ "directory": "packages/text"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/orochi235/weasel/issues"
40
+ },
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
44
+ "files": [
45
+ "dist",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "provenance": true
52
+ }
53
+ }