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
|
@@ -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
|
@@ -40,12 +40,16 @@ function isWsGlyph(g) {
|
|
|
40
40
|
*
|
|
41
41
|
* Content is a plain string or an array of spans
|
|
42
42
|
* `{ text, family?, size?, weight?, style?, features?, language?,
|
|
43
|
-
* letterSpacing?, color?, nowrap? }`;
|
|
43
|
+
* letterSpacing?, color?, nowrap?, shapeApart? }`;
|
|
44
44
|
* span fields override the base style. Spans that share a truthy `nowrap` —
|
|
45
45
|
* `true`, or any value a caller tells its groups apart by — have no break
|
|
46
46
|
* opportunity inside them or between them, as the text of an element with
|
|
47
47
|
* CSS's `white-space: nowrap` has none: the break after the last of them is
|
|
48
|
-
* the next span's to allow.
|
|
48
|
+
* the next span's to allow. Neighbouring spans shaped alike — a colour
|
|
49
|
+
* apart — are shaped as one text, so a word across them keeps its kerning
|
|
50
|
+
* and its joining; a span with a truthy `shapeApart` is shaped on its own,
|
|
51
|
+
* as CSS breaks the shaping at an inline box with a margin, border or
|
|
52
|
+
* padding. Options:
|
|
49
53
|
*
|
|
50
54
|
* - `maxWidth` — target container width (default: unlimited)
|
|
51
55
|
* - `align` — 'left' | 'right' | 'center' | 'start' | 'end'
|
|
@@ -154,7 +158,10 @@ export class TextLayout {
|
|
|
154
158
|
|
|
155
159
|
// ---- assemble lines: strip trailing ws, bidi-reorder, position ----
|
|
156
160
|
const baseSpan = spans[0];
|
|
157
|
-
|
|
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;
|
|
158
165
|
this.lines = [];
|
|
159
166
|
let y = 0;
|
|
160
167
|
let layoutWidth = 0;
|
|
@@ -393,24 +400,80 @@ export class TextLayout {
|
|
|
393
400
|
return { spans, text, baseLevel, tokens };
|
|
394
401
|
}
|
|
395
402
|
|
|
403
|
+
/**
|
|
404
|
+
* Whether the letters that meet at an edge between two spans of a group
|
|
405
|
+
* shape otherwise together than apart: their glyphs or their advances
|
|
406
|
+
* change. Compared as a set and a sum, which holds in a right-to-left
|
|
407
|
+
* pair as in a left-to-right one.
|
|
408
|
+
*/
|
|
409
|
+
_meets(text, group, levels, shaping) {
|
|
410
|
+
for (let i = 1; i < group.length; i++) {
|
|
411
|
+
const at = group[i].start;
|
|
412
|
+
const low = text.charCodeAt(at - 1);
|
|
413
|
+
const before = low >= 0xdc00 && low < 0xe000 && at >= 2 ? 2 : 1;
|
|
414
|
+
const after = text.codePointAt(at) > 0xffff ? 2 : 1;
|
|
415
|
+
const from = at - before;
|
|
416
|
+
const to = at + after;
|
|
417
|
+
if (this.fonts._meets(text.slice(from, to), before, shaping, normalizedLevels(levels, from, to))) return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
396
422
|
_makeToken(text, start, end, levels, spanAt, required) {
|
|
397
423
|
const fragments = [];
|
|
398
424
|
let width = 0;
|
|
399
425
|
let pos = start;
|
|
400
426
|
while (pos < end) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
427
|
+
// The spans of the token shaped alike — a colour apart, say — are
|
|
428
|
+
// shaped as one text and the glyphs shared out among them after, so
|
|
429
|
+
// a word that crosses from one to the next keeps the kerning between
|
|
430
|
+
// its letters there, and a script its joining, as a browser shapes
|
|
431
|
+
// it: an opening quote an `::before` makes kerned with nothing, and
|
|
432
|
+
// an Arabic word in two colours came apart into isolated letters.
|
|
433
|
+
// A span marked `shapeApart` is shaped on its own, as CSS breaks the
|
|
434
|
+
// shaping at an inline box with a margin, border or padding.
|
|
435
|
+
const group = [];
|
|
436
|
+
let shaping = null;
|
|
437
|
+
for (let at = pos; at < end; ) {
|
|
438
|
+
const next = spanAt(at);
|
|
439
|
+
if (shaping && (next.shaping !== shaping || next.span.shapeApart || group[group.length - 1].span.shapeApart))
|
|
440
|
+
break;
|
|
441
|
+
shaping = next.shaping;
|
|
442
|
+
const fragEnd = Math.min(end, next.end);
|
|
443
|
+
group.push({ span: next.span, start: at, end: fragEnd });
|
|
444
|
+
at = fragEnd;
|
|
445
|
+
}
|
|
446
|
+
const groupEnd = group[group.length - 1].end;
|
|
447
|
+
// Only where the letters either side of a span's edge shape otherwise
|
|
448
|
+
// together than apart — a kerning pair, a joining script, a mark on
|
|
449
|
+
// the letter before — is the word shaped whole: two letters are a
|
|
450
|
+
// lookup the memo almost always holds, and a syntax-coloured line of
|
|
451
|
+
// code, a span to a token, shaped each of its words afresh otherwise,
|
|
452
|
+
// at twice the cost of the line.
|
|
453
|
+
let pieces = null;
|
|
454
|
+
if (group.length > 1) {
|
|
455
|
+
const joined = text.slice(pos, groupEnd);
|
|
456
|
+
if (!HARD_BREAKS.test(joined) && this._meets(text, group, levels, shaping)) {
|
|
457
|
+
const whole = this.fonts._shapeCached(joined, shaping, normalizedLevels(levels, pos, groupEnd));
|
|
458
|
+
pieces = splitShapedOnce(
|
|
459
|
+
whole,
|
|
460
|
+
group.slice(1).map((g) => g.start - pos)
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
for (let i = 0; i < group.length; i++) {
|
|
465
|
+
const { span, start: fragStart, end: fragEnd } = group[i];
|
|
466
|
+
// hard-break controls only terminate the line; never shape them
|
|
467
|
+
const fragText = text.slice(fragStart, fragEnd).replace(HARD_BREAKS, '');
|
|
468
|
+
if (fragText.length === 0) continue;
|
|
469
|
+
const fragLevels = normalizedLevels(levels, fragStart, fragStart + fragText.length);
|
|
470
|
+
const shaped = pieces ? pieces[i] : this.fonts._shapeCached(fragText, shaping, fragLevels);
|
|
408
471
|
// the levels ride along so a later split can re-shape a piece at the
|
|
409
472
|
// level it actually has, rather than assuming ltr
|
|
410
|
-
fragments.push({ text: fragText, span, shaping, shaped, start:
|
|
473
|
+
fragments.push({ text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels });
|
|
411
474
|
width += shaped.width;
|
|
412
475
|
}
|
|
413
|
-
pos =
|
|
476
|
+
pos = groupEnd;
|
|
414
477
|
}
|
|
415
478
|
// trailing whitespace does not count against the container width
|
|
416
479
|
let wsWidth = 0;
|
|
@@ -983,21 +1046,31 @@ function graphemeSegmenter() {
|
|
|
983
1046
|
return segmenter;
|
|
984
1047
|
}
|
|
985
1048
|
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
//
|
|
990
|
-
//
|
|
991
|
-
|
|
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) {
|
|
992
1065
|
for (let i = 0; i < text.length; i++) {
|
|
993
1066
|
const c = text.charCodeAt(i);
|
|
994
|
-
if (c
|
|
1067
|
+
if (!standsAlone(c) || (c === 13 && text.charCodeAt(i + 1) === 10)) return false;
|
|
995
1068
|
}
|
|
996
1069
|
return true;
|
|
997
1070
|
}
|
|
998
1071
|
|
|
999
1072
|
function graphemes(text) {
|
|
1000
|
-
if (
|
|
1073
|
+
if (standsApart(text)) return text.split('');
|
|
1001
1074
|
const segments = graphemeSegmenter();
|
|
1002
1075
|
if (!segments) return Array.from(text);
|
|
1003
1076
|
const out = [];
|
|
@@ -1008,7 +1081,9 @@ function graphemes(text) {
|
|
|
1008
1081
|
/** The first grapheme cluster of `text`, without segmenting the rest. */
|
|
1009
1082
|
function firstGrapheme(text) {
|
|
1010
1083
|
const c0 = text.charCodeAt(0);
|
|
1011
|
-
if (c0
|
|
1084
|
+
if (c0 !== 13 && standsAlone(c0) && (text.length === 1 || standsAlone(text.charCodeAt(1)))) {
|
|
1085
|
+
return text.slice(0, 1);
|
|
1086
|
+
}
|
|
1012
1087
|
const segments = graphemeSegmenter();
|
|
1013
1088
|
if (!segments) return String.fromCodePoint(text.codePointAt(0));
|
|
1014
1089
|
for (const { segment } of segments.segment(text)) return segment;
|
|
@@ -1077,6 +1152,83 @@ function stripTrailingWhitespace(entries) {
|
|
|
1077
1152
|
|
|
1078
1153
|
// The levels key for a code-unit slice of a fragment. A uniform key covers
|
|
1079
1154
|
// any slice of itself; a per-character one has to be cut to match.
|
|
1155
|
+
/**
|
|
1156
|
+
* A shaped text (`shapeText`) cut at `cuts` — code-unit offsets inside it,
|
|
1157
|
+
* ascending — into as many shaped texts, each of its own piece of the text
|
|
1158
|
+
* with its runs and their glyphs, or null where a cut falls inside what a
|
|
1159
|
+
* glyph was made from: a ligature, or a cluster the font set as one, which
|
|
1160
|
+
* no cut can share out. A glyph keeps the advance shaping gave it, so the
|
|
1161
|
+
* kerning between the last letter before a cut and the first after it
|
|
1162
|
+
* stays with the first piece. A right-to-left run's glyphs are in visual
|
|
1163
|
+
* order, and are walked from their end.
|
|
1164
|
+
*/
|
|
1165
|
+
/** `splitShaped`, kept per shaped text and cut, since a shaped text is the
|
|
1166
|
+
* memo's and never changes, and a word a paragraph lays out again is cut
|
|
1167
|
+
* the same way. */
|
|
1168
|
+
const SPLITS = new WeakMap();
|
|
1169
|
+
function splitShapedOnce(shaped, cuts) {
|
|
1170
|
+
const key = cuts.join(',');
|
|
1171
|
+
let byCut = SPLITS.get(shaped);
|
|
1172
|
+
if (!byCut) {
|
|
1173
|
+
byCut = new Map();
|
|
1174
|
+
SPLITS.set(shaped, byCut);
|
|
1175
|
+
}
|
|
1176
|
+
if (byCut.has(key)) return byCut.get(key);
|
|
1177
|
+
const pieces = splitShaped(shaped, cuts);
|
|
1178
|
+
byCut.set(key, pieces);
|
|
1179
|
+
return pieces;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function splitShaped(shaped, cuts) {
|
|
1183
|
+
const bounds = [0, ...cuts, shaped.text.length];
|
|
1184
|
+
const pieces = cuts.concat(shaped.text.length).map((to, i) => ({
|
|
1185
|
+
text: shaped.text.slice(bounds[i], to),
|
|
1186
|
+
width: 0,
|
|
1187
|
+
baseLevel: shaped.baseLevel,
|
|
1188
|
+
runs: []
|
|
1189
|
+
}));
|
|
1190
|
+
for (const run of shaped.runs) {
|
|
1191
|
+
const rtl = (run.level & 1) === 1;
|
|
1192
|
+
const logical = rtl ? [...run.glyphs].reverse() : run.glyphs;
|
|
1193
|
+
let piece = 0;
|
|
1194
|
+
while (bounds[piece + 1] <= run.start) piece++;
|
|
1195
|
+
let at = run.start;
|
|
1196
|
+
let from = at;
|
|
1197
|
+
let glyphs = [];
|
|
1198
|
+
const close = () => {
|
|
1199
|
+
if (at === from && glyphs.length === 0) return;
|
|
1200
|
+
const offset = bounds[piece];
|
|
1201
|
+
let width = 0;
|
|
1202
|
+
for (const g of glyphs) width += g.ax;
|
|
1203
|
+
pieces[piece].runs.push({
|
|
1204
|
+
...run,
|
|
1205
|
+
glyphs: rtl ? glyphs.reverse() : glyphs,
|
|
1206
|
+
width,
|
|
1207
|
+
text: shaped.text.slice(from, at),
|
|
1208
|
+
start: from - offset,
|
|
1209
|
+
end: at - offset
|
|
1210
|
+
});
|
|
1211
|
+
pieces[piece].width += width;
|
|
1212
|
+
};
|
|
1213
|
+
for (const g of logical) {
|
|
1214
|
+
let units = 0;
|
|
1215
|
+
for (const cp of g.codePoints ?? []) units += cp > 0xffff ? 2 : 1;
|
|
1216
|
+
if (units > 0 && at >= bounds[piece + 1]) {
|
|
1217
|
+
close();
|
|
1218
|
+
while (at >= bounds[piece + 1]) piece++;
|
|
1219
|
+
from = at;
|
|
1220
|
+
glyphs = [];
|
|
1221
|
+
}
|
|
1222
|
+
if (at + units > bounds[piece + 1]) return null;
|
|
1223
|
+
glyphs.push(g);
|
|
1224
|
+
at += units;
|
|
1225
|
+
}
|
|
1226
|
+
if (at !== run.end) return null;
|
|
1227
|
+
close();
|
|
1228
|
+
}
|
|
1229
|
+
return pieces;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1080
1232
|
function sliceLevels(key, start, end) {
|
|
1081
1233
|
if (key === undefined) return '0';
|
|
1082
1234
|
if (!key.includes(',')) return key;
|
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.3",
|
|
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",
|