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.
@@ -0,0 +1,213 @@
1
+ // The CSS `font` shorthand, as `ctx.font` takes it:
2
+ //
3
+ // [ <style> || <variant> || <weight> || <stretch> ]? <size> [ / <line-height> ]? <family>#
4
+ //
5
+ // canvas-fontstyle read this before, with a regular expression from an old
6
+ // node-canvas that allowed no space at all after a weight — so `2000px` was
7
+ // weight 200 at size 0, and every size from 1000 up that begins with a weight
8
+ // set no text — required the weight before the style, took no line height,
9
+ // and cached every string it was handed as a property on an LRU object it
10
+ // never evicted from, which grew without bound under an animated size.
11
+
12
+ // What 1em is. Canvas resolves a relative size against the canvas element's
13
+ // font; a context here has no element, so it is the size of the font a
14
+ // context starts with (DEFAULT_FONT in renderingcontext_2d.js).
15
+ const EM = 20;
16
+
17
+ const ABSOLUTE_SIZES = {
18
+ 'xx-small': 9,
19
+ 'x-small': 10,
20
+ small: 13,
21
+ medium: 16,
22
+ large: 18,
23
+ 'x-large': 24,
24
+ 'xx-large': 32,
25
+ 'xxx-large': 48
26
+ };
27
+
28
+ const UNITS = {
29
+ px: 1,
30
+ pt: 4 / 3,
31
+ pc: 16,
32
+ in: 96,
33
+ cm: 96 / 2.54,
34
+ mm: 96 / 25.4,
35
+ q: 96 / 101.6,
36
+ em: EM,
37
+ rem: EM,
38
+ '%': EM / 100
39
+ };
40
+
41
+ const STYLES = new Set(['italic', 'oblique']);
42
+ const WEIGHTS = new Set(['bold', 'bolder', 'lighter']);
43
+ const STRETCHES = new Set([
44
+ 'ultra-condensed',
45
+ 'extra-condensed',
46
+ 'condensed',
47
+ 'semi-condensed',
48
+ 'semi-expanded',
49
+ 'expanded',
50
+ 'extra-expanded',
51
+ 'ultra-expanded'
52
+ ]);
53
+
54
+ const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
55
+ const LENGTH = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z]+|%)$/i;
56
+ const ANGLE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:deg|grad|rad|turn)$/i;
57
+
58
+ /** A font size in pixels, or null when `token` is not one. */
59
+ function sizeOf(token) {
60
+ const word = token.toLowerCase();
61
+ if (Object.hasOwn(ABSOLUTE_SIZES, word)) return ABSOLUTE_SIZES[word];
62
+ if (word === 'larger') return EM * 1.2;
63
+ if (word === 'smaller') return EM / 1.2;
64
+ const m = LENGTH.exec(word);
65
+ if (!m || !Object.hasOwn(UNITS, m[2])) return null;
66
+ const px = Number(m[1]) * UNITS[m[2]];
67
+ return px >= 0 && px < Infinity ? px : null;
68
+ }
69
+
70
+ /** A line height; only its shape matters, since a canvas ignores it. */
71
+ const isLineHeight = (text) =>
72
+ text.toLowerCase() === 'normal' || NUMBER.test(text) || (LENGTH.test(text) && sizeOf(text) !== null);
73
+
74
+ /**
75
+ * The family list, first family first: quoted names as written, unquoted ones
76
+ * as the identifiers they are with the white space between them collapsed.
77
+ * Null when it is not a list of families.
78
+ */
79
+ function familiesOf(text) {
80
+ const families = [];
81
+ let i = 0;
82
+ while (i < text.length) {
83
+ while (i < text.length && /\s/.test(text[i])) i++;
84
+ let name;
85
+ if (text[i] === '"' || text[i] === "'") {
86
+ const quote = text[i];
87
+ const end = text.indexOf(quote, i + 1);
88
+ if (end === -1) return null;
89
+ name = text.slice(i + 1, end);
90
+ i = end + 1;
91
+ while (i < text.length && /\s/.test(text[i])) i++;
92
+ } else {
93
+ const end = text.indexOf(',', i);
94
+ const raw = (end === -1 ? text.slice(i) : text.slice(i, end)).trim();
95
+ // a run of identifiers: `Times New Roman`, not `12px` or `"x`
96
+ if (!/^-?[a-z_\u00a0-\uffff][\w\u00a0-\uffff-]*(?:\s+-?[a-z_\u00a0-\uffff][\w\u00a0-\uffff-]*)*$/i.test(raw)) {
97
+ return null;
98
+ }
99
+ name = raw.replace(/\s+/g, ' ');
100
+ i = end === -1 ? text.length : end;
101
+ }
102
+ if (!name) return null;
103
+ families.push(name);
104
+ if (i < text.length) {
105
+ if (text[i] !== ',') return null;
106
+ i++;
107
+ }
108
+ }
109
+ return families.length ? families : null;
110
+ }
111
+
112
+ /**
113
+ * Parse a CSS `font` shorthand into `{ weight, style, size, unit, family,
114
+ * families }`, the size in pixels, or undefined when it is not one — which
115
+ * `ctx.font` ignores, keeping the font it had, as canvas says.
116
+ *
117
+ * `weight` and `style` are the words written (`'normal'`, `'bold'`, `'600'`,
118
+ * `'oblique 10deg'`), `family` the first family and `families` all of them.
119
+ */
120
+ export function parseFont(value) {
121
+ if (typeof value !== 'string') return undefined;
122
+ const text = value.trim();
123
+ if (!text) return undefined;
124
+
125
+ let weight;
126
+ let style;
127
+ let variant;
128
+ let stretch;
129
+ let normals = 0;
130
+ let i = 0;
131
+ let size = null;
132
+ // the keywords before the size, each at most once, in any order; `normal`
133
+ // is whichever of them is left unset, so it only counts against the four
134
+ for (;;) {
135
+ while (i < text.length && /\s/.test(text[i])) i++;
136
+ let end = i;
137
+ while (end < text.length && !/[\s/,"']/.test(text[end])) end++;
138
+ const token = text.slice(i, end);
139
+ if (!token) return undefined;
140
+ const word = token.toLowerCase();
141
+ if (STYLES.has(word) && style === undefined) {
142
+ style = word;
143
+ // `oblique` may carry its angle
144
+ if (word === 'oblique') {
145
+ let j = end;
146
+ while (j < text.length && /\s/.test(text[j])) j++;
147
+ let k = j;
148
+ while (k < text.length && !/[\s/,]/.test(text[k])) k++;
149
+ if (ANGLE.test(text.slice(j, k))) {
150
+ style = `oblique ${text.slice(j, k).toLowerCase()}`;
151
+ end = k;
152
+ }
153
+ }
154
+ } else if (WEIGHTS.has(word) && weight === undefined) {
155
+ weight = word;
156
+ } else if (NUMBER.test(word) && weight === undefined && Number(word) >= 1 && Number(word) <= 1000) {
157
+ weight = word;
158
+ } else if (word === 'small-caps' && variant === undefined) {
159
+ variant = word;
160
+ } else if (STRETCHES.has(word) && stretch === undefined) {
161
+ stretch = word;
162
+ } else if (word === 'normal') {
163
+ normals++;
164
+ } else {
165
+ size = sizeOf(token);
166
+ if (size === null) return undefined;
167
+ i = end;
168
+ break;
169
+ }
170
+ const named = [style, weight, variant, stretch].filter((v) => v !== undefined).length;
171
+ if (named + normals > 4) return undefined;
172
+ i = end;
173
+ }
174
+
175
+ while (i < text.length && /\s/.test(text[i])) i++;
176
+ if (text[i] === '/') {
177
+ i++;
178
+ while (i < text.length && /\s/.test(text[i])) i++;
179
+ let end = i;
180
+ while (end < text.length && !/[\s,"']/.test(text[end])) end++;
181
+ if (!isLineHeight(text.slice(i, end))) return undefined;
182
+ i = end;
183
+ }
184
+
185
+ const families = familiesOf(text.slice(i));
186
+ if (!families) return undefined;
187
+ return {
188
+ weight: weight ?? 'normal',
189
+ style: style ?? 'normal',
190
+ size,
191
+ unit: 'px',
192
+ family: families[0],
193
+ families
194
+ };
195
+ }
196
+
197
+ // Each spelling is parsed once: a context sets its font on every draw, and
198
+ // the same few strings come round again. Bounded, and dropped whole rather
199
+ // than evicted — the old cache held every string it was ever handed.
200
+ const parsed = new Map();
201
+ const PARSED_MAX = 256;
202
+
203
+ /** `parseFont`, remembered per spelling. */
204
+ export function parseFontCached(value) {
205
+ if (typeof value !== 'string') return undefined;
206
+ let font = parsed.get(value);
207
+ if (font === undefined && !parsed.has(value)) {
208
+ font = parseFont(value);
209
+ if (parsed.size >= PARSED_MAX) parsed.clear();
210
+ parsed.set(value, font);
211
+ }
212
+ return font;
213
+ }
@@ -2,7 +2,7 @@ import GlyphSet from '../glyphset.js';
2
2
  import Picture from '../picture.js';
3
3
  import Pixmap from '../pixmap.js';
4
4
  import { fontPageToken, sharedGlyphsFor } from '../sharedglyphs.js';
5
- import { trapezoidize } from '../trapezoid.js';
5
+ import { clipTraps, trapezoidize } from '../trapezoid.js';
6
6
 
7
7
  /**
8
8
  * Text rendering policy — the bitmap/vector routing thresholds and the
@@ -201,6 +201,13 @@ export class GlyphPage {
201
201
  /** per-app page cache: pages (and their server glyphsets) are shared by all
202
202
  * windows/pixmaps of a connection. Access refreshes LRU order. */
203
203
  export function getGlyphPage(app, font, size) {
204
+ // The page the last run was drawn from, asked for again — a paragraph's
205
+ // spans, an editor's tokens, a table's cells are runs of one face at one
206
+ // size — is the most recent already: no key to spell and no move to make,
207
+ // where both were most of what drawing a run cost here. Only this
208
+ // function orders the pages, and an eviction forgets it (trimGlyphPages).
209
+ const last = app._lastGlyphPage;
210
+ if (last && last.font === font && last.size === size) return last.page;
204
211
  if (!app._glyphPages) app._glyphPages = new Map();
205
212
  const key = `${font.key}@${size}`;
206
213
  let page = app._glyphPages.get(key);
@@ -211,6 +218,7 @@ export function getGlyphPage(app, font, size) {
211
218
  page = new GlyphPage(app, font, size);
212
219
  }
213
220
  app._glyphPages.set(key, page);
221
+ app._lastGlyphPage = { font, size, page };
214
222
  return page;
215
223
  }
216
224
 
@@ -266,6 +274,7 @@ export function trimGlyphPages(app, policy = policyOf(app), inUse = null) {
266
274
  if (inUse && inUse.has(page)) continue;
267
275
  pages.delete(key);
268
276
  total -= page.bytes;
277
+ if (app._lastGlyphPage?.page === page) app._lastGlyphPage = null;
269
278
  page.destroy();
270
279
  }
271
280
  }
@@ -679,6 +688,8 @@ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
679
688
  const placed = [];
680
689
  let minX = Infinity;
681
690
  let minY = Infinity;
691
+ let maxX = -Infinity;
692
+ let maxY = -Infinity;
682
693
  for (const { run, x, y } of positioned) {
683
694
  let cursor = x;
684
695
  for (const g of run.glyphs) {
@@ -691,17 +702,46 @@ function drawVectorGlyphRuns(app, op, srcId, dstId, positioned, bounds = null) {
691
702
  placed.push({ run, glyph: g, x: gx, y: gy });
692
703
  if (gx + e.minX < minX) minX = gx + e.minX;
693
704
  if (gy + e.minY < minY) minY = gy + e.minY;
705
+ if (gx + e.maxX > maxX) maxX = gx + e.maxX;
706
+ if (gy + e.maxY > maxY) maxY = gy + e.maxY;
694
707
  }
695
708
  }
696
709
  if (placed.length === 0) return;
697
710
 
698
- const bx = Math.floor(minX) - 1;
699
- const by = Math.floor(minY) - 1;
711
+ let bx = Math.floor(minX) - 1;
712
+ let by = Math.floor(minY) - 1;
700
713
 
701
- const traps = [];
714
+ let traps = [];
702
715
  for (const p of placed) {
703
716
  trapezoidize(p.run.font.outline(p.glyph.id, p.run.size), p.x - bx, p.y - by, traps);
704
717
  }
718
+ // Only the part the destination can show, when the ink reaches past it: a
719
+ // mask is the size of what it covers, and a glyph bigger than the surface
720
+ // — a size a zoom or a style can reach — asked for one the size of the
721
+ // glyph, which past 32767 pixels is not a pixmap X can make.
722
+ if (
723
+ bounds !== null &&
724
+ (minX < bounds.x || minY < bounds.y || maxX > bounds.x + bounds.w || maxY > bounds.y + bounds.h)
725
+ ) {
726
+ const x0 = Math.max(bx, Math.floor(bounds.x) - 1);
727
+ const y0 = Math.max(by, Math.floor(bounds.y) - 1);
728
+ const x1 = Math.ceil(bounds.x + bounds.w) + 1;
729
+ const y1 = Math.ceil(bounds.y + bounds.h) + 1;
730
+ if (x1 <= x0 || y1 <= y0) return;
731
+ const cut = clipTraps(traps, x0 - bx, y0 - by, x1 - bx, y1 - by);
732
+ // …from the cut's corner, which is the mask's now
733
+ for (let i = 0; i < cut.length; i += 6) {
734
+ cut[i] -= x0 - bx;
735
+ cut[i + 1] -= x0 - bx;
736
+ cut[i + 2] -= y0 - by;
737
+ cut[i + 3] -= x0 - bx;
738
+ cut[i + 4] -= x0 - bx;
739
+ cut[i + 5] -= y0 - by;
740
+ }
741
+ traps = cut;
742
+ bx = x0;
743
+ by = y0;
744
+ }
705
745
  compositeTraps(app, op, srcId, dstId, traps, bx, by);
706
746
  }
707
747
 
@@ -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. Options:
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
- const lineHeightMul = options.lineHeight ?? 1;
161
+ // a multiplier, as a number or a numeric string; anything else is the
162
+ // font's own line height — `'24px'` made every line NaN pixels tall
163
+ const asked = options.lineHeight == null ? NaN : Number(options.lineHeight);
164
+ const lineHeightMul = asked >= 0 && asked < Infinity ? asked : 1;
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
- const { span, shaping, end: spanEnd } = spanAt(pos);
402
- const fragEnd = Math.min(end, spanEnd);
403
- // hard-break controls only terminate the line; never shape them
404
- const fragText = text.slice(pos, fragEnd).replace(HARD_BREAKS, '');
405
- if (fragText.length > 0) {
406
- const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
407
- const shaped = this.fonts._shapeCached(fragText, shaping, fragLevels);
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: pos, levels: fragLevels });
473
+ fragments.push({ text: fragText, span, shaping, shaped, start: fragStart, levels: fragLevels });
411
474
  width += shaped.width;
412
475
  }
413
- pos = fragEnd;
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
- // Two ASCII characters are never one cluster, bar a CR before a LF (UAX#29
987
- // GB3): whatever extends a cluster or joins into one — marks, joiners,
988
- // spacing marks, prepends — sits above U+02FF. So text of nothing else,
989
- // which is most of what a document cuts, needs no segmenter: it costs a
990
- // microsecond a call, and the width floors ask once a word.
991
- function plainAscii(text) {
1049
+ // A character that never joins the ones beside it into a cluster (UAX#29):
1050
+ // whatever extends a cluster or joins into one — marks, joiners, spacing
1051
+ // marks, prepends — sits at U+0300 or above, and the general punctuation
1052
+ // block has two of them, the zero-width joiner and non-joiner. Checked pair
1053
+ // by pair against Intl.Segmenter, all 770,884 pairs of these: every one is
1054
+ // two clusters but a CR before a LF (GB3). The stateful rules — emoji
1055
+ // sequences, flags, Indic conjuncts — are about characters outside it.
1056
+ const standsAlone = (c) =>
1057
+ c < 0x300 || (c >= 0x2000 && c <= 0x206f && c !== 0x200c && c !== 0x200d);
1058
+
1059
+ // So text of nothing else, which is most of what a document cuts — Latin
1060
+ // letters, accented or not, and the dashes, quotes and ellipses around them
1061
+ // — needs no segmenter. One costs a microsecond a call, and the width floors
1062
+ // ask once a word; and the first one made costs 10 ms, loading ICU's break
1063
+ // rules, which a first frame of any text with a curly quote in it paid.
1064
+ function standsApart(text) {
992
1065
  for (let i = 0; i < text.length; i++) {
993
1066
  const c = text.charCodeAt(i);
994
- if (c >= 0x80 || (c === 13 && text.charCodeAt(i + 1) === 10)) return false;
1067
+ if (!standsAlone(c) || (c === 13 && text.charCodeAt(i + 1) === 10)) return false;
995
1068
  }
996
1069
  return true;
997
1070
  }
998
1071
 
999
1072
  function graphemes(text) {
1000
- if (plainAscii(text)) return text.split('');
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 < 0x80 && c0 !== 13 && !(text.charCodeAt(1) >= 0x80)) return text.slice(0, 1);
1084
+ if (c0 !== 13 && standsAlone(c0) && (text.length === 1 || standsAlone(text.charCodeAt(1)))) {
1085
+ return text.slice(0, 1);
1086
+ }
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.1",
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",