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.
- package/lib/color.js +86 -3
- package/lib/dash.js +209 -0
- package/lib/rasterize.js +40 -3
- package/lib/renderingcontext_2d.js +133 -100
- package/lib/text/fontmanager.js +56 -4
- package/lib/text/fontshorthand.js +213 -0
- package/lib/text/glyphs.js +44 -4
- package/lib/text/layout.js +173 -21
- package/lib/trapezoid.js +66 -0
- package/package.json +1 -2
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
4315
|
+
const parsed = parseFontCached(val);
|
|
4339
4316
|
if (!parsed) return;
|
|
4340
4317
|
const style = {
|
|
4341
|
-
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
|
-
|
|
4517
|
-
|
|
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
|
-
|
|
4528
|
-
|
|
4529
|
-
|
|
4530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5205
|
-
[
|
|
5206
|
-
[
|
|
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
|
-
|
|
5213
|
-
[
|
|
5214
|
-
[
|
|
5215
|
-
|
|
5216
|
-
|
|
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
|
-
|
|
5223
|
-
[
|
|
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
|
-
|
|
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] ||
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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),
|
|
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,
|