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 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;