ntk 8.14.1 → 8.14.3

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,4 +1,3 @@
1
- import parseFontStyle from "canvas-fontstyle";
2
1
  import extrudePolyline from "extrude-polyline";
3
2
 
4
3
  import { safeRelease } from "./cleanup.js";
@@ -36,6 +35,7 @@ import {
36
35
  WIRE_REACH,
37
36
  withinRect,
38
37
  } from "./cliprect.js";
38
+ import { dashPolyline } from "./dash.js";
39
39
  import {
40
40
  blurCoverage,
41
41
  cachedShadow,
@@ -66,6 +66,7 @@ import {
66
66
  runId,
67
67
  snapOrigin,
68
68
  } from "./text/glyphs.js";
69
+ import { parseFontCached } from "./text/fontshorthand.js";
69
70
  import { TextLayout } from "./text/layout.js";
70
71
  import { reorderRuns } from "./text/shape.js";
71
72
  import { trapezoidize } from "./trapezoid.js";
@@ -445,80 +446,6 @@ const PIECE_SLACK = 2;
445
446
  const LINE_CAP = { butt: "butt", square: "square", round: "butt" };
446
447
  const LINE_JOIN = { miter: "miter", bevel: "bevel", round: "bevel" };
447
448
 
448
- /**
449
- * Split a device-space polyline into dash "on" runs by arc length.
450
- *
451
- * `pts` is [[x, y], ...] with no consecutive duplicates; for closed subpaths
452
- * the closing point is already appended, and the pattern continues around the
453
- * loop as one uninterrupted walk. Returns null when the pattern cannot
454
- * produce gaps (all-zero), otherwise { runs, closedLoop }: `runs` is a list
455
- * of [[x, y], ...] open polylines (caps apply to each), `closedLoop` marks a
456
- * closed subpath the pattern never split — stroke it closed, with no caps.
457
- */
458
- function dashPolyline(pts, closed, pattern, offset) {
459
- let total = 0;
460
- for (const d of pattern) total += d;
461
- if (!(total > 0)) return null;
462
-
463
- // starting phase: offset into the pattern, wrapped into [0, total)
464
- let phase = offset % total;
465
- if (phase < 0) phase += total;
466
- let idx = 0;
467
- while (phase > 0 && phase >= pattern[idx]) {
468
- phase -= pattern[idx];
469
- idx = (idx + 1) % pattern.length;
470
- }
471
-
472
- let on = idx % 2 === 0; // even entries are "on", odd are gaps
473
- const startedOn = on;
474
- let toggled = false;
475
- let remain = pattern[idx] - phase;
476
- const runs = [];
477
- let cur = on ? [pts[0]] : null;
478
- let x0 = pts[0][0];
479
- let y0 = pts[0][1];
480
-
481
- for (let i = 1; i < pts.length; i++) {
482
- const x1 = pts[i][0];
483
- const y1 = pts[i][1];
484
- let len = Math.hypot(x1 - x0, y1 - y0);
485
- while (len > remain) {
486
- // cross a dash boundary inside this segment
487
- const t = len > 0 ? remain / len : 0;
488
- const bx = x0 + (x1 - x0) * t;
489
- const by = y0 + (y1 - y0) * t;
490
- if (on) {
491
- cur.push([bx, by]);
492
- runs.push(cur);
493
- cur = null;
494
- } else {
495
- cur = [[bx, by]];
496
- }
497
- on = !on;
498
- toggled = true;
499
- x0 = bx;
500
- y0 = by;
501
- len = Math.hypot(x1 - x0, y1 - y0);
502
- idx = (idx + 1) % pattern.length;
503
- remain = pattern[idx];
504
- }
505
- remain -= len;
506
- if (on) cur.push([x1, y1]);
507
- x0 = x1;
508
- y0 = y1;
509
- }
510
- if (cur) runs.push(cur);
511
-
512
- if (closed && !toggled) return { runs, closedLoop: startedOn };
513
- // closed subpath with dashes on both sides of the seam: merge the last
514
- // run into the first so no caps appear at the seam
515
- if (closed && startedOn && on && runs.length > 1) {
516
- const last = runs.pop();
517
- runs[0] = last.concat(runs[0].slice(1));
518
- }
519
- return { runs, closedLoop: false };
520
- }
521
-
522
449
  // An array is taken as already-premultiplied `[r, g, b, a]` in 0..1 (the
523
450
  // documented form in docs/context-2d.md), so it passes through untouched; a
524
451
  // string is a CSS colour and gets premultiplied on the way in. Both end up in
@@ -530,6 +457,17 @@ function parseColor(value) {
530
457
  return c;
531
458
  }
532
459
 
460
+ /**
461
+ * Whether a rectangle call draws at all: canvas draws nothing for one with
462
+ * a side that is not finite. The callers then turn a negative size round
463
+ * into the same rectangle drawn the other way, as canvas does. X takes a
464
+ * rectangle's size unsigned and its corner in 16 bits, so a negative size
465
+ * that reached the wire threw from inside the paint.
466
+ */
467
+ function finiteRect(x, y, w, h) {
468
+ return Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(w) && Number.isFinite(h);
469
+ }
470
+
533
471
  /**
534
472
  * Canvas-like 2d rendering context backed by the XRender extension: most
535
473
  * operations (composition, gradients, text composition) happen server-side.
@@ -1058,8 +996,10 @@ class RenderingContext2d {
1058
996
  }
1059
997
 
1060
998
  set fillStyle(value) {
999
+ // the picture first: a value that throws leaves the style as it was
1000
+ const picture = this._stylePicture(value);
1061
1001
  this._fillStyle = value;
1062
- this._backgroundPicture = this._stylePicture(value);
1002
+ this._backgroundPicture = picture;
1063
1003
  }
1064
1004
 
1065
1005
  get fillStyle() {
@@ -1067,8 +1007,10 @@ class RenderingContext2d {
1067
1007
  }
1068
1008
 
1069
1009
  set strokeStyle(value) {
1010
+ // the picture first: a value that throws leaves the style as it was
1011
+ const picture = this._stylePicture(value);
1070
1012
  this._strokeStyle = value;
1071
- this._strokePicture = this._stylePicture(value);
1013
+ this._strokePicture = picture;
1072
1014
  }
1073
1015
 
1074
1016
  get strokeStyle() {
@@ -2236,6 +2178,11 @@ class RenderingContext2d {
2236
2178
  ? this._lineDash.map((d) => d * scale)
2237
2179
  : null;
2238
2180
  const dashOffset = this._lineDashOffset * scale;
2181
+ // what a dash beyond cannot be seen: the surface, grown by how far a
2182
+ // miter or a square cap reaches from the point it is drawn at
2183
+ // (lib/dash.js)
2184
+ const reach = (thickness / 2) * Math.max(this.miterLimit, 1.5) + 2;
2185
+ const view = [-reach, -reach, this.width + reach, this.height + reach];
2239
2186
 
2240
2187
  const tris = [];
2241
2188
  // round caps/joins: extrude-polyline extrudes them as butt/bevel (see
@@ -2491,7 +2438,7 @@ class RenderingContext2d {
2491
2438
  extrudeRun(pts, poly.closed);
2492
2439
  continue;
2493
2440
  }
2494
- const dashed = dashPolyline(pts, poly.closed, dash, dashOffset);
2441
+ const dashed = dashPolyline(pts, poly.closed, dash, dashOffset, view);
2495
2442
  if (!dashed) {
2496
2443
  extrudeRun(pts, poly.closed);
2497
2444
  } else if (dashed.closedLoop) {
@@ -3050,13 +2997,25 @@ class RenderingContext2d {
3050
2997
  // clearing stays opaque white — the paper an opaque window starts from,
3051
2998
  // and what every caller predating ARGB windows expects.
3052
2999
  clearRect(x, y, w, h) {
3000
+ if (!finiteRect(x, y, w, h)) return;
3001
+ if (w < 0) {
3002
+ x += w;
3003
+ w = -w;
3004
+ }
3005
+ if (h < 0) {
3006
+ y += h;
3007
+ h = -h;
3008
+ }
3053
3009
  const alpha = this._hasAlpha;
3054
3010
  if (matIsIdentity(this._m) && !this._hasPolyClip && !this._clipRect()) {
3011
+ // the part of it the surface has, as `_fillRect` fills it
3012
+ const onSurface = rectsOnSurface([x, y, w, h], this.width, this.height);
3013
+ if (!onSurface.length) return;
3055
3014
  this.Render.FillRectangles(
3056
3015
  this.Render.PictOp.Src,
3057
3016
  this._dst(),
3058
3017
  alpha ? [0, 0, 0, 0] : [1, 1, 1, 1],
3059
- [x, y, w, h],
3018
+ onSurface,
3060
3019
  );
3061
3020
  this._markDirty();
3062
3021
  return;
@@ -3080,6 +3039,15 @@ class RenderingContext2d {
3080
3039
  }
3081
3040
 
3082
3041
  fillRect(x, y, w, h) {
3042
+ if (!finiteRect(x, y, w, h)) return;
3043
+ if (w < 0) {
3044
+ x += w;
3045
+ w = -w;
3046
+ }
3047
+ if (h < 0) {
3048
+ y += h;
3049
+ h = -h;
3050
+ }
3083
3051
  if (this._shadowed()) this._shadowOfRect(x, y, w, h, false);
3084
3052
  this._fillRect(x, y, w, h);
3085
3053
  }
@@ -3324,6 +3292,15 @@ class RenderingContext2d {
3324
3292
  }
3325
3293
 
3326
3294
  strokeRect(x, y, w, h) {
3295
+ if (!finiteRect(x, y, w, h)) return;
3296
+ if (w < 0) {
3297
+ x += w;
3298
+ w = -w;
3299
+ }
3300
+ if (h < 0) {
3301
+ y += h;
3302
+ h = -h;
3303
+ }
3327
3304
  if (this._shadowed()) this._shadowOfRect(x, y, w, h, true);
3328
3305
  const m = this._m;
3329
3306
  if (m[0] === 1 && m[1] === 0 && m[2] === 0 && m[3] === 1) {
@@ -4335,10 +4312,12 @@ class RenderingContext2d {
4335
4312
  */
4336
4313
  set font(val) {
4337
4314
  if (!val || typeof val !== "string") return;
4338
- const parsed = parseFontStyle(val);
4315
+ const parsed = parseFontCached(val);
4339
4316
  if (!parsed) return;
4340
4317
  const style = {
4341
- family: parsed.family,
4318
+ // the whole list, so a letter the first family lacks is set in the
4319
+ // next one the font names, as CSS sets it (`FontManager.match`)
4320
+ family: parsed.families.join(","),
4342
4321
  weight: parsed.weight,
4343
4322
  style: parsed.style,
4344
4323
  size: parsed.size,
@@ -4513,8 +4492,15 @@ class RenderingContext2d {
4513
4492
  `putImageData: data must be ${width * height * 4} RGBA bytes for ${width}x${height}`,
4514
4493
  );
4515
4494
  }
4516
- dirtyWidth ??= width;
4517
- dirtyHeight ??= height;
4495
+ // Every position and extent is a WebIDL `long`, as `| 0` makes one: NaN
4496
+ // and infinities are 0, a fraction goes toward 0. A fraction reached
4497
+ // the typed-array copies below and threw.
4498
+ x |= 0;
4499
+ y |= 0;
4500
+ dirtyX |= 0;
4501
+ dirtyY |= 0;
4502
+ dirtyWidth = dirtyWidth === undefined ? width : dirtyWidth | 0;
4503
+ dirtyHeight = dirtyHeight === undefined ? height : dirtyHeight | 0;
4518
4504
  // the spec normalises negative extents by moving the origin
4519
4505
  if (dirtyWidth < 0) {
4520
4506
  dirtyX += dirtyWidth;
@@ -4524,11 +4510,22 @@ class RenderingContext2d {
4524
4510
  dirtyY += dirtyHeight;
4525
4511
  dirtyHeight = -dirtyHeight;
4526
4512
  }
4527
- const sx = Math.max(0, dirtyX);
4528
- const sy = Math.max(0, dirtyY);
4529
- const sw = Math.min(width, dirtyX + dirtyWidth) - sx;
4530
- const sh = Math.min(height, dirtyY + dirtyHeight) - sy;
4513
+ let sx = Math.max(0, dirtyX);
4514
+ let sy = Math.max(0, dirtyY);
4515
+ let sw = Math.min(width, dirtyX + dirtyWidth) - sx;
4516
+ let sh = Math.min(height, dirtyY + dirtyHeight) - sy;
4531
4517
  if (!(sw > 0) || !(sh > 0)) return;
4518
+ // and only the part the surface has: X takes the corner in 16 bits, and
4519
+ // image data put a million pixels away threw
4520
+ const left = Math.max(x + sx, 0);
4521
+ const top = Math.max(y + sy, 0);
4522
+ const right = Math.min(x + sx + sw, this.width);
4523
+ const bottom = Math.min(y + sy + sh, this.height);
4524
+ if (!(right > left) || !(bottom > top)) return;
4525
+ sx = left - x;
4526
+ sy = top - y;
4527
+ sw = right - left;
4528
+ sh = bottom - top;
4532
4529
 
4533
4530
  let rgba = src;
4534
4531
  if (sx !== 0 || sy !== 0 || sw !== width || sh !== height) {
@@ -4724,6 +4721,11 @@ class RenderingContext2d {
4724
4721
  * rendered copy instead of each needing their own.
4725
4722
  */
4726
4723
  _drawCoverage(picture, sx, sy, sw, sh, dx, dy, dw, dh, op) {
4724
+ // Coverage the surface has none of is drawn as nothing. X takes the
4725
+ // destination's corner in 16 bits, and a shadow thrown past it — an
4726
+ // offset of 65,535, or text a million pixels away with its shadow —
4727
+ // threw out of the paint. A NaN destination fails the test too.
4728
+ if (!(dx < this.width && dy < this.height && dx + dw > 0 && dy + dh > 0)) return;
4727
4729
  const R = this.Render;
4728
4730
  // the coverage is painted in the current fillStyle, on both branches
4729
4731
  // below — a gradient/pattern whose transform collapsed paints nothing
@@ -5162,12 +5164,18 @@ class RenderingContext2d {
5162
5164
  * The picture is created on first use and freed by the GC, through
5163
5165
  * `Picture`'s finalizer.
5164
5166
  */
5167
+ /** How far from the origin a gradient's own points and radii may reach and
5168
+ * still be carried in 16.16 fixed point, with room to spare. */
5169
+ const GRADIENT_REACH = 16384;
5170
+
5165
5171
  class CanvasGradient {
5166
5172
  constructor(type, ctx, p0, p1, p2, p3, p4, p5) {
5167
5173
  this.type = type;
5168
5174
  this.stops = [];
5169
5175
  this.ctx = ctx;
5170
5176
  this._id = null;
5177
+ // how much smaller than asked the picture was made, to fit the wire
5178
+ this._scale = 1;
5171
5179
  this._picture = null;
5172
5180
  // what the server currently holds: a fresh gradient picture is
5173
5181
  // untransformed, so an untransformed fill costs no extra request
@@ -5197,37 +5205,55 @@ class CanvasGradient {
5197
5205
  const Render = this.ctx.Render;
5198
5206
  const X = this.ctx.X;
5199
5207
 
5200
- this._id = X.AllocID();
5208
+ // XRender takes a gradient's points and radii in 16.16 fixed point, and
5209
+ // a coordinate past 32,767 overflows the word it is written into. A
5210
+ // gradient that reaches further is made at a scale that fits, and
5211
+ // `_sync` scales the picture transform to match: the same colours land
5212
+ // on the same pixels.
5213
+ // Geometry that is not finite, which canvas refuses when the gradient
5214
+ // is made, is taken as 0 rather than thrown out of the paint using it.
5215
+ const f = (v) => (Number.isFinite(v) ? v : 0);
5216
+ const [x0, y0, x1, y1, r0, r1] = [this.x0, this.y0, this.x1, this.y1, this.r0, this.r1].map(f);
5217
+ let reach = Math.max(Math.abs(x0), Math.abs(y0));
5218
+ if (this.type !== "conical") reach = Math.max(reach, Math.abs(x1), Math.abs(y1));
5219
+ if (this.type === "radial") reach = Math.max(reach, Math.abs(r0), Math.abs(r1));
5220
+ const k = reach > GRADIENT_REACH ? reach / GRADIENT_REACH : 1;
5221
+ // the id is the gradient's only once the picture exists: one kept after
5222
+ // the request threw named a picture the server never made, in every
5223
+ // fill after it
5224
+ const id = X.AllocID();
5201
5225
  switch (this.type) {
5202
5226
  case "linear":
5203
5227
  Render.LinearGradient(
5204
- this._id,
5205
- [this.x0, this.y0],
5206
- [this.x1, this.y1],
5228
+ id,
5229
+ [x0 / k, y0 / k],
5230
+ [x1 / k, y1 / k],
5207
5231
  this.stops,
5208
5232
  );
5209
5233
  break;
5210
5234
  case "radial":
5211
5235
  Render.RadialGradient(
5212
- this._id,
5213
- [this.x0, this.y0],
5214
- [this.x1, this.y1],
5215
- this.r0,
5216
- this.r1,
5236
+ id,
5237
+ [x0 / k, y0 / k],
5238
+ [x1 / k, y1 / k],
5239
+ r0 / k,
5240
+ r1 / k,
5217
5241
  this.stops,
5218
5242
  );
5219
5243
  break;
5220
5244
  case "conical":
5221
5245
  Render.ConicalGradient(
5222
- this._id,
5223
- [this.x0, this.y0],
5224
- this.angle,
5246
+ id,
5247
+ [x0 / k, y0 / k],
5248
+ f(this.angle),
5225
5249
  this.stops,
5226
5250
  );
5227
5251
  break;
5228
5252
  default:
5229
5253
  throw new Error("unknown gradient type");
5230
5254
  }
5255
+ this._id = id;
5256
+ this._scale = k;
5231
5257
  // Past the outermost stop a gradient clamps to that stop's colour, as
5232
5258
  // the canvas and CSS specs say — which is XRender's RepeatPad, not the
5233
5259
  // RepeatNone (transparent) a gradient picture is born with. Without it
@@ -5250,9 +5276,16 @@ class CanvasGradient {
5250
5276
  * nothing, exactly as the canvas spec says.
5251
5277
  */
5252
5278
  _sync(ctm) {
5253
- const inv = matInvert(ctm);
5279
+ let inv = matInvert(ctm);
5254
5280
  if (!inv) return false;
5255
5281
  const id = this.id; // lazily creates the picture
5282
+ // a gradient made smaller to fit the wire (see `id`) is sampled that
5283
+ // much nearer its origin
5284
+ const k = this._scale;
5285
+ if (k !== 1) inv = inv.map((v) => v / k);
5286
+ // the transform is 16.16 fixed point too: one that cannot be carried
5287
+ // fills nothing, rather than throwing the rest of the paint away
5288
+ if (!inv.every((v) => Math.abs(v) < 32767)) return false;
5256
5289
  const a = this._applied;
5257
5290
  if (
5258
5291
  inv[0] !== a[0] ||
@@ -82,6 +82,13 @@ const shapePrefixes = new WeakMap();
82
82
  /** The key a style's words are memoized under, with the embedding levels
83
83
  * they were shaped at: one string per style and level, kept, so a lookup
84
84
  * hashes nothing it has not hashed before. */
85
+ /** A levels key (`normalizedLevels`) for part of the text it was made for. */
86
+ function sliceKey(key, start, end) {
87
+ if (!key.includes(',')) return key;
88
+ const levels = key.split(',').slice(start, end);
89
+ return levels.every((l) => l === levels[0]) ? levels[0] : levels.join(',');
90
+ }
91
+
85
92
  function shapeGroupOf(style, levelsKey) {
86
93
  const font = style.font;
87
94
  let known = shapePrefixes.get(style);
@@ -186,6 +193,10 @@ function instantiate(font, weight, variations, opticalSize) {
186
193
  * fontconfig, e.g. a StaticFontSource in a browser bundle. `source` also
187
194
  * takes a font spec: a path, a directory of faces, or font bytes.
188
195
  */
196
+ /** Whether a style asks for italics. Anything but a string asks for nothing:
197
+ * a style of `7` threw from here, out of whatever paint was measuring. */
198
+ const italicIn = (style) => typeof style === 'string' && style.includes('italic');
199
+
189
200
  /** A CSS family list's names, unquoted. */
190
201
  function familiesOf(family) {
191
202
  return String(family)
@@ -214,6 +225,7 @@ export default class FontManager {
214
225
  this._source = createFontSource(source) ?? null;
215
226
  this._fonts = new Map(); // candidate key -> Font
216
227
  this._matches = new Map(); // family|weight|style -> Font
228
+ this._lastMatch = null; // the last of them asked for (`match`)
217
229
  this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
218
230
  this._registered = []; // { font, family (lowercase), weight, italic }
219
231
  // word-level shaping memo, in two generations (`_shapeCached`): each a
@@ -221,6 +233,8 @@ export default class FontManager {
221
233
  this._shapeCache = new Map();
222
234
  this._shapeCacheBefore = new Map();
223
235
  this._shapeCount = 0;
236
+ // whether two letters shape otherwise together than apart (`_meets`)
237
+ this._meetings = new Map();
224
238
  // a paragraph's layout up to the line fill, for its next width
225
239
  // (text/paragraphs.js, TextLayout)
226
240
  this._paragraphs = new ParagraphCache();
@@ -321,9 +335,10 @@ export default class FontManager {
321
335
  font,
322
336
  family: (opts.family || font.familyName || '').toLowerCase(),
323
337
  weight: opts.weight !== undefined ? numWeight(opts.weight) : detected.weight,
324
- italic: opts.style !== undefined ? opts.style.includes('italic') : detected.italic
338
+ italic: typeof opts.style === 'string' ? opts.style.includes('italic') : detected.italic
325
339
  });
326
340
  this._matches.clear();
341
+ this._lastMatch = null;
327
342
  this._fallbacks.clear();
328
343
  // a kept paragraph's spans hold the faces their families matched before
329
344
  this._paragraphs.clear();
@@ -367,13 +382,23 @@ export default class FontManager {
367
382
  */
368
383
  match(family = 'sans-serif', opts = {}) {
369
384
  const weight = numWeight(opts.weight);
370
- const italic = !!(opts.style && opts.style.includes('italic'));
385
+ const italic = italicIn(opts.style);
386
+ // The face the last call matched, asked for again — a paragraph's spans,
387
+ // a list's rows of one style — is the most recent in the map already: no
388
+ // key to spell and no move to make, where both were most of the call.
389
+ // Only a miss below changes the map, and it sets this; `load()` clears
390
+ // both.
391
+ const last = this._lastMatch;
392
+ if (last !== null && last.family === family && last.weight === weight && last.italic === italic) {
393
+ return instantiate(last.face, weight, opts.variations, opticalSizeFor(opts));
394
+ }
371
395
  const cacheKey = `${family}|${weight}|${italic}`;
372
396
  let face = this._matches.get(cacheKey);
373
397
  if (face) {
374
398
  // insertion order is LRU order; re-inserting a hit moves it to the tail
375
399
  this._matches.delete(cacheKey);
376
400
  this._matches.set(cacheKey, face);
401
+ this._lastMatch = { family, weight, italic, face };
377
402
  return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
378
403
  }
379
404
 
@@ -402,6 +427,7 @@ export default class FontManager {
402
427
  face = head?.drawable ? head : this._firstDrawable(source.matchSorted(pattern));
403
428
  }
404
429
  this._matches.set(cacheKey, face);
430
+ this._lastMatch = { family, weight, italic, face };
405
431
  // A long-lived app can name a lot of families. Same sweep as the shaping
406
432
  // memo: drop the stale half in one pass rather than one entry per insert.
407
433
  if (this._matches.size > MAX_MATCHES) {
@@ -447,7 +473,7 @@ export default class FontManager {
447
473
  let found = null;
448
474
  if (this._registered.length) {
449
475
  const weight = numWeight(opts.weight);
450
- const italic = !!opts.style?.includes('italic');
476
+ const italic = italicIn(opts.style);
451
477
  for (const name of familiesOf(family)) {
452
478
  const font = this._matchRegistered([name.toLowerCase()], weight, italic);
453
479
  if (font && font.hasGlyph(codepoint)) {
@@ -473,7 +499,7 @@ export default class FontManager {
473
499
  // raw weight — undefined for regular text — it was another pattern,
474
500
  // and the first character to need a fallback waited on a fc-match
475
501
  // of its own.
476
- candidates = source.matchSorted(patternOf(family, numWeight(opts.weight), !!opts.style?.includes('italic')));
502
+ candidates = source.matchSorted(patternOf(family, numWeight(opts.weight), italicIn(opts.style)));
477
503
  } catch (err) {
478
504
  // no system fonts to fall back to is an answer, not a crash; a source
479
505
  // that failed for any other reason is a real bug and still propagates
@@ -528,6 +554,32 @@ export default class FontManager {
528
554
  * embedding-levels encoding: a single number when uniform for the whole
529
555
  * fragment (the common case), else comma-separated per-char levels.
530
556
  */
557
+ /**
558
+ * Whether the two letters of `pair`, cut at `cut`, shape otherwise
559
+ * together than apart in this style: their glyphs or their advances
560
+ * change — a kerning pair, a ligature, a joining script, a mark on the
561
+ * letter before. Compared as a set and a sum, so a right-to-left pair,
562
+ * whose glyphs come in visual order, is read as a left-to-right one is.
563
+ * Kept, since the pairs a paragraph's spans meet at are few and repeat.
564
+ */
565
+ _meets(pair, cut, style, levelsKey = '0') {
566
+ const key = `${shapeGroupOf(style, levelsKey)}|${cut}|${pair}`;
567
+ let meets = this._meetings.get(key);
568
+ if (meets !== undefined) return meets;
569
+ const whole = this._shapeCached(pair, style, levelsKey);
570
+ const left = this._shapeCached(pair.slice(0, cut), style, sliceKey(levelsKey, 0, cut));
571
+ const right = this._shapeCached(pair.slice(cut), style, sliceKey(levelsKey, cut, pair.length));
572
+ const ids = (...shaped) =>
573
+ shaped
574
+ .flatMap((s) => s.runs.flatMap((r) => r.glyphs.map((g) => g.id)))
575
+ .sort((a, b) => a - b)
576
+ .join(',');
577
+ meets = Math.abs(whole.width - left.width - right.width) > 1e-6 || ids(whole) !== ids(left, right);
578
+ if (this._meetings.size >= SHAPE_GENERATION) this._meetings.clear();
579
+ this._meetings.set(key, meets);
580
+ return meets;
581
+ }
582
+
531
583
  _shapeCached(text, style, levelsKey = '0') {
532
584
  // A hit in this generation is the whole cost of a word a paragraph has
533
585
  // shaped before — a relayout at a new width asks for every word again,