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
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
|
-
|
|
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
|
-
|
|
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,
|
|
79
|
-
//
|
|
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;
|