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 +86 -3
- package/lib/dash.js +209 -0
- package/lib/rasterize.js +40 -3
- package/lib/renderingcontext_2d.js +133 -100
- package/lib/sharedglyphs.js +10 -3
- package/lib/text/fontmanager.js +21 -4
- package/lib/text/fontshorthand.js +213 -0
- package/lib/text/glyphs.js +44 -4
- package/lib/text/layout.js +25 -10
- 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;
|
|
@@ -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/sharedglyphs.js
CHANGED
|
@@ -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
|
-
*
|
|
45
|
+
* ntkg2:<sha1 of font file bytes>:<face>:<variation coords>:<px size>:<format>
|
|
46
46
|
*
|
|
47
|
-
* The `
|
|
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
|
-
|
|
77
|
+
const face = base.fk?.postscriptName ?? '';
|
|
78
|
+
return `ntkg2:${base._ntkSha1}:${face}:${variationKey(font.variationCoords)}:${size}:a8`;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
// ---------------------------------------------------------------------------
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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),
|
|
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
|
+
}
|
package/lib/text/glyphs.js
CHANGED
|
@@ -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
|
-
|
|
699
|
-
|
|
711
|
+
let bx = Math.floor(minX) - 1;
|
|
712
|
+
let by = Math.floor(minY) - 1;
|
|
700
713
|
|
|
701
|
-
|
|
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
|
|
package/lib/text/layout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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.
|
|
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",
|