ntk 8.14.2 → 8.14.4

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 CHANGED
@@ -42,6 +42,85 @@ function parseHex(value) {
42
42
  return [part(0) / 255, part(1) / 255, part(2) / 255, hasAlpha ? part(3) / 255 : 1];
43
43
  }
44
44
 
45
+ // CSS Color 4's rgb() and hsl(), in both the syntaxes it defines: the legacy
46
+ // one with a comma between every component, and the modern one with spaces
47
+ // and an optional `/ alpha` — `rgb(255 128 0 / 50%)`, which is what a design
48
+ // tool copies out today. parse-color reads neither right. The modern syntax
49
+ // not at all; a percentage as though it were a byte, so `rgb(100%, 0%, 0%)`
50
+ // came out at 39% red; and nothing out of range clamped, so `rgb(300, 0, 0)`
51
+ // reached XRender as 1.18. A spelling this does not read still goes to
52
+ // parse-color, so nothing it accepted is refused.
53
+ const FUNCTIONAL = /^(rgba?|hsla?)\(([^()]*)\)$/i;
54
+ const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
55
+ const HUE = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(deg|grad|rad|turn)?$/i;
56
+ const DEGREES = { deg: 1, grad: 0.9, rad: 180 / Math.PI, turn: 360 };
57
+
58
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
59
+
60
+ /** A number, or a percentage, as a fraction of `whole`, clamped to 0..1;
61
+ * NaN when it is neither. */
62
+ function fraction(text, whole) {
63
+ if (text.endsWith('%')) {
64
+ const n = text.slice(0, -1);
65
+ return NUMBER.test(n) ? clamp01(Number(n) / 100) : NaN;
66
+ }
67
+ return NUMBER.test(text) ? clamp01(Number(text) / whole) : NaN;
68
+ }
69
+
70
+ function parseFunctional(value) {
71
+ const m = FUNCTIONAL.exec(value);
72
+ if (!m) return null;
73
+ const body = m[2].trim();
74
+ let parts;
75
+ let alpha = null;
76
+ if (body.includes(',')) {
77
+ parts = body.split(',').map((p) => p.trim());
78
+ if (parts.length === 4) alpha = parts.pop();
79
+ else if (parts.length !== 3) return null;
80
+ } else {
81
+ const halves = body.split('/');
82
+ if (halves.length > 2) return null;
83
+ parts = halves[0].trim().split(/\s+/);
84
+ if (parts.length !== 3) return null;
85
+ if (halves.length === 2) alpha = halves[1].trim();
86
+ // `none` is the modern syntax's alone: a missing component, which is 0
87
+ const none = (p) => (p.toLowerCase() === 'none' ? '0' : p);
88
+ parts = parts.map(none);
89
+ if (alpha !== null) alpha = none(alpha);
90
+ }
91
+ const a = alpha === null ? 1 : fraction(alpha, 1);
92
+ let rgb;
93
+ if (m[1][0].toLowerCase() === 'r') {
94
+ rgb = parts.map((p) => fraction(p, 255));
95
+ } else {
96
+ const hue = HUE.exec(parts[0]);
97
+ if (!hue) return null;
98
+ // saturation and lightness are percentages, which the modern syntax
99
+ // also takes as bare numbers
100
+ rgb = hslToRgb(
101
+ Number(hue[1]) * DEGREES[(hue[2] ?? 'deg').toLowerCase()],
102
+ fraction(parts[1], 100),
103
+ fraction(parts[2], 100)
104
+ );
105
+ }
106
+ const out = [rgb[0], rgb[1], rgb[2], a];
107
+ return out.every(Number.isFinite) ? out : null;
108
+ }
109
+
110
+ /** hsl() to sRGB, rounded to bytes as a browser rounds it. parse-color did
111
+ * the same, give or take one where the exact value is a half and its float
112
+ * error fell below it — `hsl(-30, …)` and `hsl(330, …)` could differ. */
113
+ function hslToRgb(h, s, l) {
114
+ const hue = ((h % 360) + 360) % 360;
115
+ const chroma = s * Math.min(l, 1 - l);
116
+ const channel = (n) => {
117
+ const k = (n + hue / 30) % 12;
118
+ const v = l - chroma * Math.max(-1, Math.min(k - 3, 9 - k, 1));
119
+ return Math.round(v * 255) / 255;
120
+ };
121
+ return [channel(0), channel(8), channel(4)];
122
+ }
123
+
45
124
  // Each spelling is parsed once. A UI draws with a few dozen colours and sets
46
125
  // them again on every paint — every box's background, every border, every
47
126
  // edge of a graph — and a parse is a regular expression and an allocation
@@ -84,17 +163,21 @@ function parseStraight(value) {
84
163
 
85
164
  // '#' is ours alone: falling back to parse-color for a hex form we reject
86
165
  // is how '#1234567' turns into rgba [18, 52, 86, 7, 1] -> alpha 7.
87
- const rgba = v.startsWith('#') ? parseHex(v) : rawRgba(v);
166
+ const rgba = v.startsWith('#') ? parseHex(v) : (parseFunctional(v) ?? rawRgba(v));
88
167
  if (!rgba) return null;
89
168
  // NaN would otherwise reach the wire as a garbage fixed-point value
90
169
  return rgba.every((c) => Number.isFinite(c)) ? rgba : null;
91
170
  }
92
171
 
93
172
  function rawRgba(value) {
94
- const c = parseColorRaw(value);
173
+ // CSS's names and functions are case-insensitive, and parse-color's
174
+ // tables are lower case: `TOMATO` was not a colour
175
+ const c = parseColorRaw(value.toLowerCase());
95
176
  if (!c || !c.rgba) return null;
96
177
  const [r, g, b, a] = c.rgba;
97
- return [r / 255, g / 255, b / 255, a];
178
+ // clamped as CSS clamps: a component past 1 is not a brighter colour,
179
+ // and XRender warns on one
180
+ return [clamp01(r / 255), clamp01(g / 255), clamp01(b / 255), clamp01(a)];
98
181
  }
99
182
 
100
183
  /**
package/lib/dash.js ADDED
@@ -0,0 +1,209 @@
1
+ // A stroke's dash pattern, walked along its path as far as the surface can
2
+ // show it.
3
+ //
4
+ // Walking a pattern costs a run for every dash: the path's length over the
5
+ // pattern's. A dashed border round a box 380,000 pixels tall — a code block
6
+ // in a scroll pane — was 95,000 runs and 78 ms a stroke for the handful the
7
+ // surface shows, and a pattern a billionth of a pixel long ran the heap out.
8
+ //
9
+ // So each segment is walked only where it lies inside `view`: the surface,
10
+ // grown by what a join or a cap can reach past a point. Everywhere else the
11
+ // pattern is advanced by the length passed, arithmetically, which leaves
12
+ // every dash that is drawn where a walk of the whole path puts it. A run is
13
+ // ended where its segment leaves the view and begun again where one comes
14
+ // back, both out of sight. And where what is left would still be more than
15
+ // `MAX_BOUNDARIES` dashes, the stroke is drawn solid, as Skia draws one past
16
+ // its kMaxDashCount: dashes that fine are a tone, not a pattern.
17
+
18
+ /** Past this many dash boundaries in what the surface shows, the stroke is
19
+ * drawn solid instead. */
20
+ export const MAX_BOUNDARIES = 100000;
21
+
22
+ // the part of a segment inside the view, as parameters along it (Liang-
23
+ // Barsky): written here rather than returned, since the walk asks once a
24
+ // segment and an array a segment is garbage a stroke
25
+ let clipFrom = 0;
26
+ let clipTo = 1;
27
+
28
+ /** Whether segment a→b meets `view` ([x0, y0, x1, y1]), and if so the part of
29
+ * it that does in `clipFrom`..`clipTo`. */
30
+ function clipSegment(ax, ay, bx, by, view) {
31
+ const [x0, y0, x1, y1] = view;
32
+ if (ax >= x0 && ax <= x1 && ay >= y0 && ay <= y1 && bx >= x0 && bx <= x1 && by >= y0 && by <= y1) {
33
+ clipFrom = 0;
34
+ clipTo = 1;
35
+ return true;
36
+ }
37
+ const dx = bx - ax;
38
+ const dy = by - ay;
39
+ clipFrom = 0;
40
+ clipTo = 1;
41
+ // each edge of the view as p·t ≤ q
42
+ if (!clipEdge(-dx, ax - x0) || !clipEdge(dx, x1 - ax) || !clipEdge(-dy, ay - y0) || !clipEdge(dy, y1 - ay)) {
43
+ return false;
44
+ }
45
+ return clipTo > clipFrom;
46
+ }
47
+
48
+ /** Narrow `clipFrom`..`clipTo` to where p·t ≤ q; false where nothing is left. */
49
+ function clipEdge(p, q) {
50
+ if (p === 0) return q >= 0; // parallel to this edge: inside it or not at all
51
+ const t = q / p;
52
+ if (p < 0) {
53
+ if (t > clipTo) return false;
54
+ if (t > clipFrom) clipFrom = t;
55
+ } else {
56
+ if (t < clipFrom) return false;
57
+ if (t < clipTo) clipTo = t;
58
+ }
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Split a device-space polyline into dash "on" runs by arc length.
64
+ *
65
+ * `pts` is [[x, y], ...] with no consecutive duplicates; for closed subpaths
66
+ * the closing point is already appended, and the pattern continues around the
67
+ * loop as one uninterrupted walk. Returns null when the pattern cannot
68
+ * produce gaps (all-zero), or would make more than `MAX_BOUNDARIES` of them
69
+ * in what the view shows — stroke it solid — otherwise { runs, closedLoop }:
70
+ * `runs` is a list of [[x, y], ...] open polylines (caps apply to each),
71
+ * `closedLoop` marks a closed subpath the pattern never split — stroke it
72
+ * closed, with no caps.
73
+ *
74
+ * `view` is [x0, y0, x1, y1], outside which nothing drawn can be seen; null
75
+ * walks the whole path.
76
+ */
77
+ export function dashPolyline(pts, closed, pattern, offset, view = null) {
78
+ const n = pattern.length;
79
+ let total = 0;
80
+ for (const d of pattern) total += d;
81
+ if (!(total > 0)) return null;
82
+
83
+ // what the walk would visit, and so how many boundaries it would make
84
+ let visible = 0;
85
+ for (let i = 1; i < pts.length; i++) {
86
+ const [ax, ay] = pts[i - 1];
87
+ const [bx, by] = pts[i];
88
+ const len = Math.hypot(bx - ax, by - ay);
89
+ if (!view) visible += len;
90
+ else if (clipSegment(ax, ay, bx, by, view)) visible += len * (clipTo - clipFrom);
91
+ }
92
+ if ((visible / total) * n > MAX_BOUNDARIES) return null;
93
+
94
+ // starting phase: offset into the pattern, wrapped into [0, total)
95
+ let phase = offset % total;
96
+ if (phase < 0) phase += total;
97
+ let idx = 0;
98
+ while (phase > 0 && phase >= pattern[idx]) {
99
+ phase -= pattern[idx];
100
+ idx = (idx + 1) % n;
101
+ }
102
+
103
+ let on = idx % 2 === 0; // even entries are "on", odd are gaps
104
+ const startedOn = on;
105
+ let toggled = false;
106
+ let remain = pattern[idx] - phase;
107
+
108
+ // Move along the pattern by `d` without drawing. A boundary is crossed
109
+ // only once `d` is past it, as the walk below crosses one.
110
+ const advance = (d) => {
111
+ if (d <= remain) {
112
+ remain -= d;
113
+ return;
114
+ }
115
+ d -= remain;
116
+ idx = (idx + 1) % n;
117
+ on = !on;
118
+ toggled = true;
119
+ // whole periods leave the entry where it was, and `on` too where the
120
+ // pattern has an even number of entries, as canvas makes it
121
+ const periods = Math.floor(d / total);
122
+ d -= periods * total;
123
+ if (n % 2 === 1 && periods % 2 === 1) on = !on;
124
+ while (d > pattern[idx]) {
125
+ d -= pattern[idx];
126
+ idx = (idx + 1) % n;
127
+ on = !on;
128
+ }
129
+ remain = pattern[idx] - d;
130
+ };
131
+
132
+ const runs = [];
133
+ let cur = null;
134
+ // whether the first run starts at the path's start and the last ends at
135
+ // its end: only then does a closed path's seam join them
136
+ let openedAtStart = false;
137
+ let openAtEnd = false;
138
+ for (let i = 1; i < pts.length; i++) {
139
+ const a = pts[i - 1];
140
+ const b = pts[i];
141
+ const segLen = Math.hypot(b[0] - a[0], b[1] - a[1]);
142
+ let from = 0;
143
+ let to = 1;
144
+ if (view) {
145
+ if (!clipSegment(a[0], a[1], b[0], b[1], view)) {
146
+ if (cur) runs.push(cur);
147
+ cur = null;
148
+ advance(segLen);
149
+ continue;
150
+ }
151
+ from = clipFrom;
152
+ to = clipTo;
153
+ }
154
+ if (from > 0) {
155
+ if (cur) runs.push(cur);
156
+ cur = null;
157
+ advance(segLen * from);
158
+ }
159
+ let px = from > 0 ? a[0] + (b[0] - a[0]) * from : a[0];
160
+ let py = from > 0 ? a[1] + (b[1] - a[1]) * from : a[1];
161
+ const ex = to < 1 ? a[0] + (b[0] - a[0]) * to : b[0];
162
+ const ey = to < 1 ? a[1] + (b[1] - a[1]) * to : b[1];
163
+ if (on && !cur) {
164
+ cur = [from > 0 ? [px, py] : a];
165
+ if (i === 1 && from === 0) openedAtStart = true;
166
+ }
167
+ let len = to < 1 || from > 0 ? Math.hypot(ex - px, ey - py) : segLen;
168
+ while (len > remain) {
169
+ // cross a dash boundary inside this segment
170
+ const t = len > 0 ? remain / len : 0;
171
+ const bx = px + (ex - px) * t;
172
+ const by = py + (ey - py) * t;
173
+ if (on) {
174
+ cur.push([bx, by]);
175
+ runs.push(cur);
176
+ cur = null;
177
+ } else {
178
+ cur = [[bx, by]];
179
+ }
180
+ on = !on;
181
+ toggled = true;
182
+ px = bx;
183
+ py = by;
184
+ len = Math.hypot(ex - px, ey - py);
185
+ idx = (idx + 1) % n;
186
+ remain = pattern[idx];
187
+ }
188
+ remain -= len;
189
+ if (on) cur.push(to < 1 ? [ex, ey] : b);
190
+ if (to < 1) {
191
+ if (cur) runs.push(cur);
192
+ cur = null;
193
+ advance(segLen * (1 - to));
194
+ }
195
+ }
196
+ if (cur) {
197
+ runs.push(cur);
198
+ openAtEnd = true;
199
+ }
200
+
201
+ if (closed && !toggled) return { runs, closedLoop: startedOn };
202
+ // closed subpath with dashes on both sides of the seam: merge the last
203
+ // run into the first so no caps appear at the seam
204
+ if (closed && openedAtStart && openAtEnd && runs.length > 1) {
205
+ const last = runs.pop();
206
+ runs[0] = last.concat(runs[0].slice(1));
207
+ }
208
+ return { runs, closedLoop: false };
209
+ }
package/lib/rasterize.js CHANGED
@@ -48,6 +48,39 @@ export class CoverageAccumulator {
48
48
  * split across the pixels the edge passes through.
49
49
  */
50
50
  edge(ax, ay, bx, by) {
51
+ if (ay === by) return;
52
+ const { width } = this;
53
+ if (ax < 0 || bx < 0 || ax > width || bx > width) {
54
+ // Cut where the edge crosses the grid's left and right borders. What
55
+ // lies left of the grid is all of it left of every pixel in the row,
56
+ // so it counts as an edge up the border itself; what lies right of
57
+ // it is left of none of them, and counts for nothing. Squashing each
58
+ // row's span into the grid instead spread the part past a border
59
+ // over the pixels the rest crossed: a triangle poking past the right
60
+ // edge left its last column at a tenth of its coverage.
61
+ const tl = (0 - ax) / (bx - ax);
62
+ const tr = (width - ax) / (bx - ax);
63
+ let t0 = 0;
64
+ let px = ax;
65
+ let py = ay;
66
+ for (const t of tl < tr ? [tl, tr, 1] : [tr, tl, 1]) {
67
+ if (!(t > t0) || t > 1) continue;
68
+ const qx = t === 1 ? bx : ax + (bx - ax) * t;
69
+ const qy = t === 1 ? by : ay + (by - ay) * t;
70
+ const mid = (px + qx) / 2;
71
+ if (mid < 0) this._edge(0, py, 0, qy);
72
+ else if (mid <= width) this._edge(px, py, qx, qy);
73
+ t0 = t;
74
+ px = qx;
75
+ py = qy;
76
+ }
77
+ return;
78
+ }
79
+ this._edge(ax, ay, bx, by);
80
+ }
81
+
82
+ /** `edge` for a piece already between the borders. */
83
+ _edge(ax, ay, bx, by) {
51
84
  if (ay === by) return;
52
85
  let dir = 1;
53
86
  let x0 = ax;
@@ -62,6 +95,11 @@ export class CoverageAccumulator {
62
95
  y1 = ay;
63
96
  }
64
97
  const { width, stride, acc, height } = this;
98
+ // An edge with no row in the grid adds nothing any pixel reads, and one
99
+ // past 2^31 would start its rows at `y0 | 0`, which wraps negative: a
100
+ // fill under a transform that put it a trillion pixels away ran the
101
+ // loop below over billions of rows. NaN fails both tests too.
102
+ if (!(y1 > 0 && y0 < height)) return;
65
103
  const dxdy = (x1 - x0) / (y1 - y0);
66
104
  let x = x0;
67
105
  if (y0 < 0) {
@@ -75,9 +113,8 @@ export class CoverageAccumulator {
75
113
  const dy = Math.min(y + 1, y1) - Math.max(y, y0);
76
114
  const xnext = x + dxdy * dy;
77
115
  const d = dy * dir;
78
- // the row's x span, clamped into the grid — geometry outside the
79
- // bitmap piles its coverage onto the border column, which is what a
80
- // clipped fill should look like
116
+ // the row's x span, kept in the grid: `edge` has cut the edge at the
117
+ // borders already, and this only catches what rounding leaves over
81
118
  let xa = x < xnext ? x : xnext;
82
119
  let xb = x < xnext ? xnext : x;
83
120
  if (xa < 0) xa = 0;
@@ -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] ||
@@ -42,14 +42,20 @@ const RPC_TIMEOUT = 2000;
42
42
  * The token naming one shared font page: content-addressed, because the
43
43
  * generator's input is a font file far too big to name literally.
44
44
  *
45
- * ntkg1:<sha1 of font file bytes>:<variation coords>:<px size>:<format>
45
+ * ntkg2:<sha1 of font file bytes>:<face>:<variation coords>:<px size>:<format>
46
46
  *
47
- * The `ntkg1` version prefix is load-bearing, not hygiene: advances are
47
+ * The `ntkg2` version prefix is load-bearing, not hygiene: advances are
48
48
  * baked into glyphs at AddGlyphs time and idempotent duplicate uploads
49
49
  * assume byte-identical rasterization, both of which hold only among
50
50
  * processes running the same rasterizer and rounding. Change either — bump
51
51
  * the version, and two ntk versions on one display simply share nothing.
52
52
  *
53
+ * `<face>` is the face's PostScript name, because a file is not a face. A
54
+ * collection — Helvetica.ttc, a .dfont — holds several, which number their
55
+ * glyphs alike, and `ntkg1` named a page by the file alone: Helvetica and
56
+ * Helvetica-Bold were one page, and whichever face uploaded a glyph first
57
+ * was what both drew, in every ntk app on the display.
58
+ *
53
59
  * The sha1 is computed from the loaded font buffer, lazily on first shared
54
60
  * use, and cached on the base face (a variation instance hashes the same
55
61
  * file its base was cut from). `null` — no buffer to hash, or no crypto in
@@ -68,7 +74,8 @@ export function fontPageToken(font, size) {
68
74
  base._ntkSha1 = crypto ? crypto.createHash('sha1').update(buffer).digest('hex') : null;
69
75
  }
70
76
  if (!base._ntkSha1) return null;
71
- return `ntkg1:${base._ntkSha1}:${variationKey(font.variationCoords)}:${size}:a8`;
77
+ const face = base.fk?.postscriptName ?? '';
78
+ return `ntkg2:${base._ntkSha1}:${face}:${variationKey(font.variationCoords)}:${size}:a8`;
72
79
  }
73
80
 
74
81
  // ---------------------------------------------------------------------------
@@ -193,6 +193,10 @@ function instantiate(font, weight, variations, opticalSize) {
193
193
  * fontconfig, e.g. a StaticFontSource in a browser bundle. `source` also
194
194
  * takes a font spec: a path, a directory of faces, or font bytes.
195
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
+
196
200
  /** A CSS family list's names, unquoted. */
197
201
  function familiesOf(family) {
198
202
  return String(family)
@@ -221,6 +225,7 @@ export default class FontManager {
221
225
  this._source = createFontSource(source) ?? null;
222
226
  this._fonts = new Map(); // candidate key -> Font
223
227
  this._matches = new Map(); // family|weight|style -> Font
228
+ this._lastMatch = null; // the last of them asked for (`match`)
224
229
  this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
225
230
  this._registered = []; // { font, family (lowercase), weight, italic }
226
231
  // word-level shaping memo, in two generations (`_shapeCached`): each a
@@ -330,9 +335,10 @@ export default class FontManager {
330
335
  font,
331
336
  family: (opts.family || font.familyName || '').toLowerCase(),
332
337
  weight: opts.weight !== undefined ? numWeight(opts.weight) : detected.weight,
333
- italic: opts.style !== undefined ? opts.style.includes('italic') : detected.italic
338
+ italic: typeof opts.style === 'string' ? opts.style.includes('italic') : detected.italic
334
339
  });
335
340
  this._matches.clear();
341
+ this._lastMatch = null;
336
342
  this._fallbacks.clear();
337
343
  // a kept paragraph's spans hold the faces their families matched before
338
344
  this._paragraphs.clear();
@@ -376,13 +382,23 @@ export default class FontManager {
376
382
  */
377
383
  match(family = 'sans-serif', opts = {}) {
378
384
  const weight = numWeight(opts.weight);
379
- 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
+ }
380
395
  const cacheKey = `${family}|${weight}|${italic}`;
381
396
  let face = this._matches.get(cacheKey);
382
397
  if (face) {
383
398
  // insertion order is LRU order; re-inserting a hit moves it to the tail
384
399
  this._matches.delete(cacheKey);
385
400
  this._matches.set(cacheKey, face);
401
+ this._lastMatch = { family, weight, italic, face };
386
402
  return instantiate(face, weight, opts.variations, opticalSizeFor(opts));
387
403
  }
388
404
 
@@ -411,6 +427,7 @@ export default class FontManager {
411
427
  face = head?.drawable ? head : this._firstDrawable(source.matchSorted(pattern));
412
428
  }
413
429
  this._matches.set(cacheKey, face);
430
+ this._lastMatch = { family, weight, italic, face };
414
431
  // A long-lived app can name a lot of families. Same sweep as the shaping
415
432
  // memo: drop the stale half in one pass rather than one entry per insert.
416
433
  if (this._matches.size > MAX_MATCHES) {
@@ -456,7 +473,7 @@ export default class FontManager {
456
473
  let found = null;
457
474
  if (this._registered.length) {
458
475
  const weight = numWeight(opts.weight);
459
- const italic = !!opts.style?.includes('italic');
476
+ const italic = italicIn(opts.style);
460
477
  for (const name of familiesOf(family)) {
461
478
  const font = this._matchRegistered([name.toLowerCase()], weight, italic);
462
479
  if (font && font.hasGlyph(codepoint)) {
@@ -482,7 +499,7 @@ export default class FontManager {
482
499
  // raw weight — undefined for regular text — it was another pattern,
483
500
  // and the first character to need a fallback waited on a fc-match
484
501
  // of its own.
485
- candidates = source.matchSorted(patternOf(family, numWeight(opts.weight), !!opts.style?.includes('italic')));
502
+ candidates = source.matchSorted(patternOf(family, numWeight(opts.weight), italicIn(opts.style)));
486
503
  } catch (err) {
487
504
  // no system fonts to fall back to is an answer, not a crash; a source
488
505
  // that failed for any other reason is a real bug and still propagates
@@ -0,0 +1,213 @@
1
+ // The CSS `font` shorthand, as `ctx.font` takes it:
2
+ //
3
+ // [ <style> || <variant> || <weight> || <stretch> ]? <size> [ / <line-height> ]? <family>#
4
+ //
5
+ // canvas-fontstyle read this before, with a regular expression from an old
6
+ // node-canvas that allowed no space at all after a weight — so `2000px` was
7
+ // weight 200 at size 0, and every size from 1000 up that begins with a weight
8
+ // set no text — required the weight before the style, took no line height,
9
+ // and cached every string it was handed as a property on an LRU object it
10
+ // never evicted from, which grew without bound under an animated size.
11
+
12
+ // What 1em is. Canvas resolves a relative size against the canvas element's
13
+ // font; a context here has no element, so it is the size of the font a
14
+ // context starts with (DEFAULT_FONT in renderingcontext_2d.js).
15
+ const EM = 20;
16
+
17
+ const ABSOLUTE_SIZES = {
18
+ 'xx-small': 9,
19
+ 'x-small': 10,
20
+ small: 13,
21
+ medium: 16,
22
+ large: 18,
23
+ 'x-large': 24,
24
+ 'xx-large': 32,
25
+ 'xxx-large': 48
26
+ };
27
+
28
+ const UNITS = {
29
+ px: 1,
30
+ pt: 4 / 3,
31
+ pc: 16,
32
+ in: 96,
33
+ cm: 96 / 2.54,
34
+ mm: 96 / 25.4,
35
+ q: 96 / 101.6,
36
+ em: EM,
37
+ rem: EM,
38
+ '%': EM / 100
39
+ };
40
+
41
+ const STYLES = new Set(['italic', 'oblique']);
42
+ const WEIGHTS = new Set(['bold', 'bolder', 'lighter']);
43
+ const STRETCHES = new Set([
44
+ 'ultra-condensed',
45
+ 'extra-condensed',
46
+ 'condensed',
47
+ 'semi-condensed',
48
+ 'semi-expanded',
49
+ 'expanded',
50
+ 'extra-expanded',
51
+ 'ultra-expanded'
52
+ ]);
53
+
54
+ const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
55
+ const LENGTH = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z]+|%)$/i;
56
+ const ANGLE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:deg|grad|rad|turn)$/i;
57
+
58
+ /** A font size in pixels, or null when `token` is not one. */
59
+ function sizeOf(token) {
60
+ const word = token.toLowerCase();
61
+ if (Object.hasOwn(ABSOLUTE_SIZES, word)) return ABSOLUTE_SIZES[word];
62
+ if (word === 'larger') return EM * 1.2;
63
+ if (word === 'smaller') return EM / 1.2;
64
+ const m = LENGTH.exec(word);
65
+ if (!m || !Object.hasOwn(UNITS, m[2])) return null;
66
+ const px = Number(m[1]) * UNITS[m[2]];
67
+ return px >= 0 && px < Infinity ? px : null;
68
+ }
69
+
70
+ /** A line height; only its shape matters, since a canvas ignores it. */
71
+ const isLineHeight = (text) =>
72
+ text.toLowerCase() === 'normal' || NUMBER.test(text) || (LENGTH.test(text) && sizeOf(text) !== null);
73
+
74
+ /**
75
+ * The family list, first family first: quoted names as written, unquoted ones
76
+ * as the identifiers they are with the white space between them collapsed.
77
+ * Null when it is not a list of families.
78
+ */
79
+ function familiesOf(text) {
80
+ const families = [];
81
+ let i = 0;
82
+ while (i < text.length) {
83
+ while (i < text.length && /\s/.test(text[i])) i++;
84
+ let name;
85
+ if (text[i] === '"' || text[i] === "'") {
86
+ const quote = text[i];
87
+ const end = text.indexOf(quote, i + 1);
88
+ if (end === -1) return null;
89
+ name = text.slice(i + 1, end);
90
+ i = end + 1;
91
+ while (i < text.length && /\s/.test(text[i])) i++;
92
+ } else {
93
+ const end = text.indexOf(',', i);
94
+ const raw = (end === -1 ? text.slice(i) : text.slice(i, end)).trim();
95
+ // a run of identifiers: `Times New Roman`, not `12px` or `"x`
96
+ if (!/^-?[a-z_\u00a0-\uffff][\w\u00a0-\uffff-]*(?:\s+-?[a-z_\u00a0-\uffff][\w\u00a0-\uffff-]*)*$/i.test(raw)) {
97
+ return null;
98
+ }
99
+ name = raw.replace(/\s+/g, ' ');
100
+ i = end === -1 ? text.length : end;
101
+ }
102
+ if (!name) return null;
103
+ families.push(name);
104
+ if (i < text.length) {
105
+ if (text[i] !== ',') return null;
106
+ i++;
107
+ }
108
+ }
109
+ return families.length ? families : null;
110
+ }
111
+
112
+ /**
113
+ * Parse a CSS `font` shorthand into `{ weight, style, size, unit, family,
114
+ * families }`, the size in pixels, or undefined when it is not one — which
115
+ * `ctx.font` ignores, keeping the font it had, as canvas says.
116
+ *
117
+ * `weight` and `style` are the words written (`'normal'`, `'bold'`, `'600'`,
118
+ * `'oblique 10deg'`), `family` the first family and `families` all of them.
119
+ */
120
+ export function parseFont(value) {
121
+ if (typeof value !== 'string') return undefined;
122
+ const text = value.trim();
123
+ if (!text) return undefined;
124
+
125
+ let weight;
126
+ let style;
127
+ let variant;
128
+ let stretch;
129
+ let normals = 0;
130
+ let i = 0;
131
+ let size = null;
132
+ // the keywords before the size, each at most once, in any order; `normal`
133
+ // is whichever of them is left unset, so it only counts against the four
134
+ for (;;) {
135
+ while (i < text.length && /\s/.test(text[i])) i++;
136
+ let end = i;
137
+ while (end < text.length && !/[\s/,"']/.test(text[end])) end++;
138
+ const token = text.slice(i, end);
139
+ if (!token) return undefined;
140
+ const word = token.toLowerCase();
141
+ if (STYLES.has(word) && style === undefined) {
142
+ style = word;
143
+ // `oblique` may carry its angle
144
+ if (word === 'oblique') {
145
+ let j = end;
146
+ while (j < text.length && /\s/.test(text[j])) j++;
147
+ let k = j;
148
+ while (k < text.length && !/[\s/,]/.test(text[k])) k++;
149
+ if (ANGLE.test(text.slice(j, k))) {
150
+ style = `oblique ${text.slice(j, k).toLowerCase()}`;
151
+ end = k;
152
+ }
153
+ }
154
+ } else if (WEIGHTS.has(word) && weight === undefined) {
155
+ weight = word;
156
+ } else if (NUMBER.test(word) && weight === undefined && Number(word) >= 1 && Number(word) <= 1000) {
157
+ weight = word;
158
+ } else if (word === 'small-caps' && variant === undefined) {
159
+ variant = word;
160
+ } else if (STRETCHES.has(word) && stretch === undefined) {
161
+ stretch = word;
162
+ } else if (word === 'normal') {
163
+ normals++;
164
+ } else {
165
+ size = sizeOf(token);
166
+ if (size === null) return undefined;
167
+ i = end;
168
+ break;
169
+ }
170
+ const named = [style, weight, variant, stretch].filter((v) => v !== undefined).length;
171
+ if (named + normals > 4) return undefined;
172
+ i = end;
173
+ }
174
+
175
+ while (i < text.length && /\s/.test(text[i])) i++;
176
+ if (text[i] === '/') {
177
+ i++;
178
+ while (i < text.length && /\s/.test(text[i])) i++;
179
+ let end = i;
180
+ while (end < text.length && !/[\s,"']/.test(text[end])) end++;
181
+ if (!isLineHeight(text.slice(i, end))) return undefined;
182
+ i = end;
183
+ }
184
+
185
+ const families = familiesOf(text.slice(i));
186
+ if (!families) return undefined;
187
+ return {
188
+ weight: weight ?? 'normal',
189
+ style: style ?? 'normal',
190
+ size,
191
+ unit: 'px',
192
+ family: families[0],
193
+ families
194
+ };
195
+ }
196
+
197
+ // Each spelling is parsed once: a context sets its font on every draw, and
198
+ // the same few strings come round again. Bounded, and dropped whole rather
199
+ // than evicted — the old cache held every string it was ever handed.
200
+ const parsed = new Map();
201
+ const PARSED_MAX = 256;
202
+
203
+ /** `parseFont`, remembered per spelling. */
204
+ export function parseFontCached(value) {
205
+ if (typeof value !== 'string') return undefined;
206
+ let font = parsed.get(value);
207
+ if (font === undefined && !parsed.has(value)) {
208
+ font = parseFont(value);
209
+ if (parsed.size >= PARSED_MAX) parsed.clear();
210
+ parsed.set(value, font);
211
+ }
212
+ return font;
213
+ }
@@ -2,7 +2,7 @@ import GlyphSet from '../glyphset.js';
2
2
  import Picture from '../picture.js';
3
3
  import Pixmap from '../pixmap.js';
4
4
  import { fontPageToken, sharedGlyphsFor } from '../sharedglyphs.js';
5
- import { trapezoidize } from '../trapezoid.js';
5
+ import { clipTraps, trapezoidize } from '../trapezoid.js';
6
6
 
7
7
  /**
8
8
  * Text rendering policy — the bitmap/vector routing thresholds and the
@@ -201,6 +201,13 @@ export class GlyphPage {
201
201
  /** per-app page cache: pages (and their server glyphsets) are shared by all
202
202
  * windows/pixmaps of a connection. Access refreshes LRU order. */
203
203
  export function getGlyphPage(app, font, size) {
204
+ // The page the last run was drawn from, asked for again — a paragraph's
205
+ // spans, an editor's tokens, a table's cells are runs of one face at one
206
+ // size — is the most recent already: no key to spell and no move to make,
207
+ // where both were most of what drawing a run cost here. Only this
208
+ // function orders the pages, and an eviction forgets it (trimGlyphPages).
209
+ const last = app._lastGlyphPage;
210
+ if (last && last.font === font && last.size === size) return last.page;
204
211
  if (!app._glyphPages) app._glyphPages = new Map();
205
212
  const key = `${font.key}@${size}`;
206
213
  let page = app._glyphPages.get(key);
@@ -211,6 +218,7 @@ export function getGlyphPage(app, font, size) {
211
218
  page = new GlyphPage(app, font, size);
212
219
  }
213
220
  app._glyphPages.set(key, page);
221
+ app._lastGlyphPage = { font, size, page };
214
222
  return page;
215
223
  }
216
224
 
@@ -266,6 +274,7 @@ export function trimGlyphPages(app, policy = policyOf(app), inUse = null) {
266
274
  if (inUse && inUse.has(page)) continue;
267
275
  pages.delete(key);
268
276
  total -= page.bytes;
277
+ if (app._lastGlyphPage?.page === page) app._lastGlyphPage = null;
269
278
  page.destroy();
270
279
  }
271
280
  }
@@ -679,6 +688,8 @@ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
679
688
  const placed = [];
680
689
  let minX = Infinity;
681
690
  let minY = Infinity;
691
+ let maxX = -Infinity;
692
+ let maxY = -Infinity;
682
693
  for (const { run, x, y } of positioned) {
683
694
  let cursor = x;
684
695
  for (const g of run.glyphs) {
@@ -691,17 +702,46 @@ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
691
702
  placed.push({ run, glyph: g, x: gx, y: gy });
692
703
  if (gx + e.minX < minX) minX = gx + e.minX;
693
704
  if (gy + e.minY < minY) minY = gy + e.minY;
705
+ if (gx + e.maxX > maxX) maxX = gx + e.maxX;
706
+ if (gy + e.maxY > maxY) maxY = gy + e.maxY;
694
707
  }
695
708
  }
696
709
  if (placed.length === 0) return;
697
710
 
698
- const bx = Math.floor(minX) - 1;
699
- const by = Math.floor(minY) - 1;
711
+ let bx = Math.floor(minX) - 1;
712
+ let by = Math.floor(minY) - 1;
700
713
 
701
- const traps = [];
714
+ let traps = [];
702
715
  for (const p of placed) {
703
716
  trapezoidize(p.run.font.outline(p.glyph.id, p.run.size), p.x - bx, p.y - by, traps);
704
717
  }
718
+ // Only the part the destination can show, when the ink reaches past it: a
719
+ // mask is the size of what it covers, and a glyph bigger than the surface
720
+ // — a size a zoom or a style can reach — asked for one the size of the
721
+ // glyph, which past 32767 pixels is not a pixmap X can make.
722
+ if (
723
+ bounds !== null &&
724
+ (minX < bounds.x || minY < bounds.y || maxX > bounds.x + bounds.w || maxY > bounds.y + bounds.h)
725
+ ) {
726
+ const x0 = Math.max(bx, Math.floor(bounds.x) - 1);
727
+ const y0 = Math.max(by, Math.floor(bounds.y) - 1);
728
+ const x1 = Math.ceil(bounds.x + bounds.w) + 1;
729
+ const y1 = Math.ceil(bounds.y + bounds.h) + 1;
730
+ if (x1 <= x0 || y1 <= y0) return;
731
+ const cut = clipTraps(traps, x0 - bx, y0 - by, x1 - bx, y1 - by);
732
+ // …from the cut's corner, which is the mask's now
733
+ for (let i = 0; i < cut.length; i += 6) {
734
+ cut[i] -= x0 - bx;
735
+ cut[i + 1] -= x0 - bx;
736
+ cut[i + 2] -= y0 - by;
737
+ cut[i + 3] -= x0 - bx;
738
+ cut[i + 4] -= x0 - bx;
739
+ cut[i + 5] -= y0 - by;
740
+ }
741
+ traps = cut;
742
+ bx = x0;
743
+ by = y0;
744
+ }
705
745
  compositeTraps(app, op, srcId, dstId, traps, bx, by);
706
746
  }
707
747
 
@@ -158,7 +158,10 @@ export class TextLayout {
158
158
 
159
159
  // ---- assemble lines: strip trailing ws, bidi-reorder, position ----
160
160
  const baseSpan = spans[0];
161
- const lineHeightMul = options.lineHeight ?? 1;
161
+ // a multiplier, as a number or a numeric string; anything else is the
162
+ // font's own line height — `'24px'` made every line NaN pixels tall
163
+ const asked = options.lineHeight == null ? NaN : Number(options.lineHeight);
164
+ const lineHeightMul = asked >= 0 && asked < Infinity ? asked : 1;
162
165
  this.lines = [];
163
166
  let y = 0;
164
167
  let layoutWidth = 0;
@@ -1043,21 +1046,31 @@ function graphemeSegmenter() {
1043
1046
  return segmenter;
1044
1047
  }
1045
1048
 
1046
- // Two ASCII characters are never one cluster, bar a CR before a LF (UAX#29
1047
- // GB3): whatever extends a cluster or joins into one — marks, joiners,
1048
- // spacing marks, prepends — sits above U+02FF. So text of nothing else,
1049
- // which is most of what a document cuts, needs no segmenter: it costs a
1050
- // microsecond a call, and the width floors ask once a word.
1051
- function plainAscii(text) {
1049
+ // A character that never joins the ones beside it into a cluster (UAX#29):
1050
+ // whatever extends a cluster or joins into one — marks, joiners, spacing
1051
+ // marks, prepends — sits at U+0300 or above, and the general punctuation
1052
+ // block has two of them, the zero-width joiner and non-joiner. Checked pair
1053
+ // by pair against Intl.Segmenter, all 770,884 pairs of these: every one is
1054
+ // two clusters but a CR before a LF (GB3). The stateful rules — emoji
1055
+ // sequences, flags, Indic conjuncts — are about characters outside it.
1056
+ const standsAlone = (c) =>
1057
+ c < 0x300 || (c >= 0x2000 && c <= 0x206f && c !== 0x200c && c !== 0x200d);
1058
+
1059
+ // So text of nothing else, which is most of what a document cuts — Latin
1060
+ // letters, accented or not, and the dashes, quotes and ellipses around them
1061
+ // — needs no segmenter. One costs a microsecond a call, and the width floors
1062
+ // ask once a word; and the first one made costs 10 ms, loading ICU's break
1063
+ // rules, which a first frame of any text with a curly quote in it paid.
1064
+ function standsApart(text) {
1052
1065
  for (let i = 0; i < text.length; i++) {
1053
1066
  const c = text.charCodeAt(i);
1054
- if (c >= 0x80 || (c === 13 && text.charCodeAt(i + 1) === 10)) return false;
1067
+ if (!standsAlone(c) || (c === 13 && text.charCodeAt(i + 1) === 10)) return false;
1055
1068
  }
1056
1069
  return true;
1057
1070
  }
1058
1071
 
1059
1072
  function graphemes(text) {
1060
- if (plainAscii(text)) return text.split('');
1073
+ if (standsApart(text)) return text.split('');
1061
1074
  const segments = graphemeSegmenter();
1062
1075
  if (!segments) return Array.from(text);
1063
1076
  const out = [];
@@ -1068,7 +1081,9 @@ function graphemes(text) {
1068
1081
  /** The first grapheme cluster of `text`, without segmenting the rest. */
1069
1082
  function firstGrapheme(text) {
1070
1083
  const c0 = text.charCodeAt(0);
1071
- if (c0 < 0x80 && c0 !== 13 && !(text.charCodeAt(1) >= 0x80)) return text.slice(0, 1);
1084
+ if (c0 !== 13 && standsAlone(c0) && (text.length === 1 || standsAlone(text.charCodeAt(1)))) {
1085
+ return text.slice(0, 1);
1086
+ }
1072
1087
  const segments = graphemeSegmenter();
1073
1088
  if (!segments) return String.fromCodePoint(text.codePointAt(0));
1074
1089
  for (const { segment } of segments.segment(text)) return segment;
package/lib/trapezoid.js CHANGED
@@ -129,3 +129,69 @@ export function trapArea(traps) {
129
129
  }
130
130
  return area;
131
131
  }
132
+
133
+ /**
134
+ * Trapezoids cut to the rectangle [x0, x1] × [y0, y1], with the coverage
135
+ * inside it unchanged — the part of a mask that can show anything. A glyph
136
+ * a million pixels high is a mask no X server will make and coordinates
137
+ * past what 16.16 fixed point carries, and every pixel of it outside the
138
+ * surface is a pixel rasterized for nothing.
139
+ *
140
+ * The cut is exact. Each trapezoid is clipped to the rows and then split
141
+ * into bands where either edge crosses x0 or x1, so that within a band an
142
+ * edge lies wholly on one side of each line: an edge outside the rectangle
143
+ * becomes the vertical just past it, which covers the same pixels inside,
144
+ * and a band with nothing inside is dropped.
145
+ *
146
+ * @param {Array<number>} traps AddTraps data, as `trapezoidize` makes it
147
+ * @returns {Array<number>} the cut trapezoids, in the same coordinates
148
+ */
149
+ export function clipTraps(traps, x0, y0, x1, y1) {
150
+ const out = [];
151
+ for (let i = 0; i < traps.length; i += 6) {
152
+ const tl = traps[i];
153
+ const tr = traps[i + 1];
154
+ const ty = traps[i + 2];
155
+ const bl = traps[i + 3];
156
+ const br = traps[i + 4];
157
+ const by = traps[i + 5];
158
+ if (!(by > y0 && ty < y1 && by > ty)) continue;
159
+ const top = Math.max(ty, y0);
160
+ const bottom = Math.min(by, y1);
161
+ // each edge's x at a height
162
+ const left = (y) => tl + (bl - tl) * ((y - ty) / (by - ty));
163
+ const right = (y) => tr + (br - tr) * ((y - ty) / (by - ty));
164
+ const cuts = [top, bottom];
165
+ for (const [a, b] of [
166
+ [tl, bl],
167
+ [tr, br]
168
+ ]) {
169
+ for (const x of [x0, x1]) {
170
+ if ((a - x) * (b - x) >= 0) continue; // does not cross this line
171
+ const y = ty + (by - ty) * ((x - a) / (b - a));
172
+ if (y > top && y < bottom) cuts.push(y);
173
+ }
174
+ }
175
+ cuts.sort((p, q) => p - q);
176
+ for (let k = 0; k + 1 < cuts.length; k++) {
177
+ const ya = cuts[k];
178
+ const yb = cuts[k + 1];
179
+ if (!(yb > ya)) continue;
180
+ const mid = (ya + yb) / 2;
181
+ const lm = left(mid);
182
+ const rm = right(mid);
183
+ if (lm >= x1 || rm <= x0) continue; // nothing of this band inside
184
+ const inL = lm >= x0;
185
+ const inR = rm <= x1;
186
+ out.push(
187
+ inL ? left(ya) : x0 - 1,
188
+ inR ? right(ya) : x1 + 1,
189
+ ya,
190
+ inL ? left(yb) : x0 - 1,
191
+ inR ? right(yb) : x1 + 1,
192
+ yb
193
+ );
194
+ }
195
+ }
196
+ return out;
197
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.14.2",
3
+ "version": "8.14.4",
4
4
  "description": "Desktop UI toolkit for X11 with canvas-like 2d and OpenGL rendering",
5
5
  "author": "Andrey Sidorov <sidorares@yandex.ru>",
6
6
  "license": "MIT",
@@ -43,7 +43,6 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "bidi-js": "^1.0.3",
46
- "canvas-fontstyle": "^1.0.1",
47
46
  "domutils": "^4.0.2",
48
47
  "extrude-polyline": "^1.0.6",
49
48
  "fontkit": "^2.0.4",