ntk 8.13.0 → 8.14.0

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/text/font.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fontkit from 'fontkit';
2
2
 
3
3
  import { flatten, rasterizePath } from '../rasterize.js';
4
+ import { marksCover, standInMarks } from './marks.js';
4
5
 
5
6
  // Axis coordinates are rounded to this many decimals before anything is
6
7
  // instantiated or cached. Every distinct coordinate is a font in its own
@@ -122,6 +123,10 @@ export default class Font {
122
123
  this._upem = 0;
123
124
  this._vertical = null;
124
125
  this._space = -1;
126
+ // the face's mark lookups while they are stood in for (./marks.js), set
127
+ // at its first shaping; null once the real ones are back, or never were
128
+ // stood in for
129
+ this._marks = undefined;
125
130
  }
126
131
 
127
132
  static loadSync(path, postscriptName) {
@@ -339,7 +344,7 @@ export default class Font {
339
344
  * ax = advance, dx/dy = drawing offset from pen position (y up = positive dy)
340
345
  */
341
346
  shape(text, size, opts = {}) {
342
- const run = this.fk.layout(text, opts.features, opts.script, opts.language, opts.direction);
347
+ const run = this._layout(text, opts);
343
348
  const s = this.scale(size);
344
349
  const glyphs = new Array(run.glyphs.length);
345
350
  let width = 0;
@@ -358,6 +363,31 @@ export default class Font {
358
363
  return { font: this, size, direction: run.direction, width, glyphs };
359
364
  }
360
365
 
366
+ /**
367
+ * fontkit's layout, with the face's mark lookups stood in for until a run
368
+ * holds a glyph they act at.
369
+ *
370
+ * fontkit decodes a feature's lookups the first time a run asks for it,
371
+ * and a face's mark attachment can be most of its tables: 14 ms of Noto
372
+ * Sans' first shaping, in the first frame. Until a run needs them the
373
+ * lookups are empty stand-ins (./marks.js), which a run holding none of
374
+ * their glyphs comes out the same with. A run that holds one puts the
375
+ * real ones back and is shaped again, and so is every run after it.
376
+ */
377
+ _layout(text, opts) {
378
+ const { features, script, language, direction } = opts;
379
+ if (this._marks === undefined) this._marks = standInMarks(this.fk);
380
+ const marks = this._marks;
381
+ // fontkit adds to the features object it is handed: a second shaping
382
+ // gets what the caller asked for, not what the first left there
383
+ const again = marks === null || features == null ? features : Array.isArray(features) ? [...features] : { ...features };
384
+ const run = this.fk.layout(text, features, script, language, direction);
385
+ if (marks === null || !run.glyphs.some((glyph) => marksCover(marks.bits, glyph.id))) return run;
386
+ marks.restore();
387
+ this._marks = null;
388
+ return this.fk.layout(text, again, script, language, direction);
389
+ }
390
+
361
391
  /** nominal (unshaped) advance of a glyph id, in pixels */
362
392
  advanceOf(glyphId, size) {
363
393
  return this.fk.getGlyph(glyphId).advanceWidth * this.scale(size);
@@ -227,6 +227,32 @@ export default class FontManager {
227
227
  return this._source ?? defaultFontSource();
228
228
  }
229
229
 
230
+ /**
231
+ * Have `family` ready before a layout asks for it: a source that resolves
232
+ * families by asking the system (fontconfig) starts on its faces now, off
233
+ * the event loop, so the first layout in that family takes the answer
234
+ * instead of stalling for it. For a family the caller knows is coming — a
235
+ * code editor's monospace, set before it renders. A source with nothing to
236
+ * look up ignores it, and it never throws.
237
+ *
238
+ * `faces`, each `{ weight, style }`, names the faces to start on instead
239
+ * of the family's regular, bold, italic and bold italic: a face outside
240
+ * those that the caller knows text will be set in, such as a menu's
241
+ * medium. The family is spelled the way `match` spells it, so a list or a
242
+ * quoted name warms the pattern a layout will ask for.
243
+ */
244
+ prewarm(family, faces) {
245
+ const spelled = familiesOf(family).join(',');
246
+ if (!spelled) return;
247
+ this.source.prewarm?.(
248
+ spelled,
249
+ faces?.map(({ weight, style }) => ({
250
+ weight: numWeight(weight),
251
+ style: style?.includes('italic') ? 'italic' : 'normal'
252
+ }))
253
+ );
254
+ }
255
+
230
256
  /** open (and cache) a match candidate — see fontsource.js for the shape */
231
257
  _open(candidate) {
232
258
  const key = candidate.key ?? `${candidate.path}#${candidate.postscriptName || ''}`;
@@ -333,9 +359,14 @@ export default class FontManager {
333
359
  italic
334
360
  );
335
361
  if (!face) {
336
- // sources understand comma-separated family lists natively
337
- const candidates = this.source.matchSorted(patternOf(family, weight, italic));
338
- face = this._open(candidates[0]);
362
+ // sources understand comma-separated family lists natively; one that
363
+ // can answer the best face alone saves reading the fallback chain the
364
+ // face may never need
365
+ const pattern = patternOf(family, weight, italic);
366
+ const source = this.source;
367
+ face = this._open(
368
+ typeof source.matchFirst === 'function' ? source.matchFirst(pattern) : source.matchSorted(pattern)[0]
369
+ );
339
370
  }
340
371
  this._matches.set(cacheKey, face);
341
372
  // A long-lived app can name a lot of families. Same sweep as the shaping
@@ -48,10 +48,12 @@
48
48
  import { builtin } from '../builtin.js';
49
49
  import {
50
50
  charsetHas,
51
+ matchFirstSync,
51
52
  matchSorted,
52
53
  matchSortedSync,
53
54
  noFontsError,
54
55
  prewarmFaces,
56
+ prewarmPatterns,
55
57
  supported
56
58
  } from '../fontconfig.js';
57
59
  import Font from './font.js';
@@ -129,6 +131,29 @@ export class FontconfigFontSource {
129
131
  return matchSortedSync(pattern);
130
132
  }
131
133
 
134
+ /**
135
+ * `matchSorted(pattern)[0]`, reading only the head of a prewarm's answer
136
+ * where one is waiting (`matchFirstSync` in fontconfig.js): a face's
137
+ * match needs its first candidate, and the fallback chain behind it is
138
+ * read when a character first falls back.
139
+ */
140
+ matchFirst(pattern) {
141
+ return matchFirstSync(pattern);
142
+ }
143
+
144
+ /**
145
+ * The family's faces — regular, bold, and both in italic, or the `faces`
146
+ * named, each `{ weight, style }` — matched off the event loop, as the
147
+ * constructor does for sans-serif, and answerable by a layout that asks
148
+ * before the loop runs again (see `prewarm` in fontconfig.js). A pattern
149
+ * already cached or running is not asked again; never rejects and never
150
+ * reports.
151
+ */
152
+ prewarm(family, faces) {
153
+ if (faces) prewarmPatterns(faces.map(({ weight, style }) => ({ family, weight, style })));
154
+ else prewarmFaces({ family });
155
+ }
156
+
132
157
  /**
133
158
  * Non-blocking sibling: the fc-match spawn runs off the event loop and
134
159
  * seeds the same cache, so a later synchronous layout for the pattern is
@@ -310,6 +310,13 @@ export class TextLayout {
310
310
  // and every line still needs a style to take its metrics from, so
311
311
  // stand one in rather than crashing on the first empty line.
312
312
  const given = typeof content === 'string' ? [{ text: content }] : content;
313
+ // What a span is shaped with, one per distinct style rather than one a
314
+ // span (`shapingStyleOf`): the spans of a highlighted source file are its
315
+ // tokens, half a million for twenty thousand lines over a handful of
316
+ // styles, and the font lookup and the shaping memo's key, worked out
317
+ // afresh for each, were most of what laying such a file out cost.
318
+ const styles = [];
319
+ const shapings = [];
313
320
  const spans = (given.length ? given : [{ text: '' }]).map((s) => {
314
321
  const merged = {
315
322
  ...s,
@@ -327,7 +334,9 @@ export class TextLayout {
327
334
  letterSpacing: s.letterSpacing ?? style.letterSpacing,
328
335
  color: s.color ?? style.color ?? null
329
336
  };
330
- merged.font = s.font ?? style.font ?? fonts.match(merged.family, merged);
337
+ const shaping = shapingStyleOf(styles, merged, s.font ?? style.font, fonts);
338
+ merged.font = shaping.font;
339
+ shapings.push(shaping);
331
340
  return merged;
332
341
  });
333
342
 
@@ -355,6 +364,7 @@ export class TextLayout {
355
364
  }
356
365
  return {
357
366
  span: spans[lo],
367
+ shaping: shapings[lo],
358
368
  end: lo + 1 < spanStarts.length ? spanStarts[lo + 1] : text.length
359
369
  };
360
370
  };
@@ -388,16 +398,16 @@ export class TextLayout {
388
398
  let width = 0;
389
399
  let pos = start;
390
400
  while (pos < end) {
391
- const { span, end: spanEnd } = spanAt(pos);
401
+ const { span, shaping, end: spanEnd } = spanAt(pos);
392
402
  const fragEnd = Math.min(end, spanEnd);
393
403
  // hard-break controls only terminate the line; never shape them
394
404
  const fragText = text.slice(pos, fragEnd).replace(HARD_BREAKS, '');
395
405
  if (fragText.length > 0) {
396
406
  const fragLevels = normalizedLevels(levels, pos, pos + fragText.length);
397
- const shaped = this.fonts._shapeCached(fragText, span, fragLevels);
407
+ const shaped = this.fonts._shapeCached(fragText, shaping, fragLevels);
398
408
  // the levels ride along so a later split can re-shape a piece at the
399
409
  // level it actually has, rather than assuming ltr
400
- fragments.push({ text: fragText, span, shaped, start: pos, levels: fragLevels });
410
+ fragments.push({ text: fragText, span, shaping, shaped, start: pos, levels: fragLevels });
401
411
  width += shaped.width;
402
412
  }
403
413
  pos = fragEnd;
@@ -487,33 +497,47 @@ export class TextLayout {
487
497
  used += frag.shaped.width;
488
498
  continue;
489
499
  }
490
- // binary search the longest grapheme prefix of this fragment that fits.
500
+ // shaped at the bidi level this text actually has: assuming level 0
501
+ // here re-shapes an rtl word as ltr, which lays its glyphs out
502
+ // backwards and hands reorderRuns an even level that stops it from
503
+ // being reordered at all
504
+ const shaping = frag.shaping ?? frag.span;
505
+ const shape = (text, from, to) =>
506
+ this.fonts._shapeCached(text, shaping, sliceLevels(frag.levels, from, to));
507
+ let best = null;
491
508
  // Graphemes rather than code points: cutting between a base character
492
509
  // and its combining mark, or inside an emoji ZWJ sequence, leaves a
493
510
  // dotted circle or a pair of half-emoji on the two sides of the break.
494
- const cps = graphemes(frag.text);
495
- let lo = 0;
496
- let hi = cps.length - 1;
497
- let best = null;
498
- while (lo <= hi) {
499
- const mid = (lo + hi) >> 1;
500
- const prefix = cps.slice(0, mid + 1).join('');
501
- // shaped at the bidi level this text actually has: assuming level 0
502
- // here re-shapes an rtl word as ltr, which lays its glyphs out
503
- // backwards and hands reorderRuns an even level that stops it from
504
- // being reordered at all
505
- const shaped = this.fonts._shapeCached(prefix, frag.span, sliceLevels(frag.levels, 0, prefix.length));
506
- if (used + shaped.width <= maxWidth) {
507
- best = { len: prefix.length, shaped, text: prefix };
508
- lo = mid + 1;
509
- } else {
510
- hi = mid - 1;
511
+ const lead = firstGrapheme(frag.text);
512
+ if (used + shape(lead, 0, lead.length).width <= maxWidth) {
513
+ // binary search the longest grapheme prefix of this fragment that fits
514
+ const cps = graphemes(frag.text);
515
+ let lo = 0;
516
+ let hi = cps.length - 1;
517
+ while (lo <= hi) {
518
+ const mid = (lo + hi) >> 1;
519
+ const prefix = cps.slice(0, mid + 1).join('');
520
+ const shaped = shape(prefix, 0, prefix.length);
521
+ if (used + shaped.width <= maxWidth) {
522
+ best = { len: prefix.length, shaped, text: prefix };
523
+ lo = mid + 1;
524
+ } else {
525
+ hi = mid - 1;
526
+ }
511
527
  }
528
+ } else if (!headFrags.length) {
529
+ // Not even its first cluster fits, so nothing of the token does: the
530
+ // caller lets it overflow whole. That is every word at width 0, where
531
+ // a layout is asked for the narrowest it can be, and the search found
532
+ // it out by segmenting the whole word and shaping a dozen prefixes of
533
+ // it, each a word the memo had never seen.
534
+ return [null, token];
512
535
  }
513
536
  if (best) {
514
537
  headFrags.push({
515
538
  text: best.text,
516
539
  span: frag.span,
540
+ shaping,
517
541
  shaped: best.shaped,
518
542
  start: frag.start,
519
543
  levels: sliceLevels(frag.levels, 0, best.len)
@@ -528,7 +552,8 @@ export class TextLayout {
528
552
  restFrags.push({
529
553
  text: restText,
530
554
  span: frag.span,
531
- shaped: this.fonts._shapeCached(restText, frag.span, restLevels),
555
+ shaping,
556
+ shaped: this.fonts._shapeCached(restText, shaping, restLevels),
532
557
  start: frag.start + cut,
533
558
  levels: restLevels
534
559
  });
@@ -595,7 +620,20 @@ export class TextLayout {
595
620
  }
596
621
  batch = [];
597
622
  };
623
+ // Only the lines the context can show. A layout taller than the view it
624
+ // is drawn in — a code block of twenty thousand lines in a scroll pane —
625
+ // built every line's glyphs, sent them, and had the server clip them
626
+ // away, on every paint. A line is skipped when its box, grown by its
627
+ // height and its font's extent again on either side for ink that
628
+ // overhangs the box, is outside the rows the clip lets through: nothing
629
+ // of it could have landed on a pixel.
630
+ const rows = visibleRows(ctx, y);
598
631
  for (const line of this.lines) {
632
+ if (rows) {
633
+ const reach = line.height + line.ascent + line.descent;
634
+ if (line.y + line.height + reach < rows.top) continue;
635
+ if (line.y - reach > rows.bottom) continue;
636
+ }
599
637
  for (const r of line.runs) {
600
638
  const color = r.span.color;
601
639
  if (batch.length && color !== batchColor) flush();
@@ -935,19 +973,48 @@ export class TextLayout {
935
973
  // somehow absent, code points are the old behaviour and still safe for the
936
974
  // scripts that reach a force-break most often.
937
975
  let segmenter;
938
- function graphemes(text) {
976
+ function graphemeSegmenter() {
939
977
  if (segmenter === undefined) {
940
978
  segmenter =
941
979
  typeof Intl !== 'undefined' && Intl.Segmenter
942
980
  ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
943
981
  : null;
944
982
  }
945
- if (!segmenter) return Array.from(text);
983
+ return segmenter;
984
+ }
985
+
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) {
992
+ for (let i = 0; i < text.length; i++) {
993
+ const c = text.charCodeAt(i);
994
+ if (c >= 0x80 || (c === 13 && text.charCodeAt(i + 1) === 10)) return false;
995
+ }
996
+ return true;
997
+ }
998
+
999
+ function graphemes(text) {
1000
+ if (plainAscii(text)) return text.split('');
1001
+ const segments = graphemeSegmenter();
1002
+ if (!segments) return Array.from(text);
946
1003
  const out = [];
947
- for (const { segment } of segmenter.segment(text)) out.push(segment);
1004
+ for (const { segment } of segments.segment(text)) out.push(segment);
948
1005
  return out;
949
1006
  }
950
1007
 
1008
+ /** The first grapheme cluster of `text`, without segmenting the rest. */
1009
+ function firstGrapheme(text) {
1010
+ const c0 = text.charCodeAt(0);
1011
+ if (c0 < 0x80 && c0 !== 13 && !(text.charCodeAt(1) >= 0x80)) return text.slice(0, 1);
1012
+ const segments = graphemeSegmenter();
1013
+ if (!segments) return String.fromCodePoint(text.codePointAt(0));
1014
+ for (const { segment } of segments.segment(text)) return segment;
1015
+ return '';
1016
+ }
1017
+
951
1018
  // UTF-16 length of a glyph cluster's codePoints array
952
1019
  function cuLength(codePoints) {
953
1020
  let len = 0;
@@ -1015,3 +1082,77 @@ function sliceLevels(key, start, end) {
1015
1082
  if (!key.includes(',')) return key;
1016
1083
  return key.split(',').slice(start, end).join(',');
1017
1084
  }
1085
+
1086
+ /**
1087
+ * The rows of a layout drawn at `y` that the context can show — its clip's
1088
+ * box, or the whole surface with no clip — in the layout's own coordinates.
1089
+ * Null where that cannot be said, and the whole layout is drawn: a context
1090
+ * that is not ntk's, a transform that is more than a translation, or a
1091
+ * shadow, which lands where the text is not.
1092
+ */
1093
+ function visibleRows(ctx, y) {
1094
+ if (typeof ctx.drawGlyphs !== 'function') return null;
1095
+ if (typeof ctx._clipExtents !== 'function') return null;
1096
+ const m = ctx._m;
1097
+ if (!m || m[0] !== 1 || m[1] !== 0 || m[2] !== 0 || m[3] !== 1) return null;
1098
+ if (typeof ctx._shadowed !== 'function' || ctx._shadowed()) return null;
1099
+ const height = ctx.height;
1100
+ if (!(height > 0)) return null;
1101
+ // the clip's extents skip a region clip, so they are never smaller than
1102
+ // what the server lets through
1103
+ const box = ctx._clipExtents() ?? { y: 0, h: height };
1104
+ const top = box.y - (y + m[5]);
1105
+ return { top, bottom: top + box.h };
1106
+ }
1107
+
1108
+ /**
1109
+ * How many distinct styles one layout shares shaping styles among. Past it a
1110
+ * span is shaped with a style of its own, as every span once was, rather
1111
+ * than the search growing with the document.
1112
+ */
1113
+ const MAX_SHAPING_STYLES = 16;
1114
+
1115
+ /**
1116
+ * The style `span` is shaped with: one already made for a span whose every
1117
+ * property shaping reads is the same (`===`), or a new one. Its font is the
1118
+ * one the span or the layout named (`given`), else the one its family
1119
+ * matches, looked up once for all the spans that share it. What shaping
1120
+ * reads is what `shapeText`, `fallbackFor` and the memo key read: the face
1121
+ * and what picks it, the size, and features, language and spacing.
1122
+ */
1123
+ function shapingStyleOf(known, span, given, fonts) {
1124
+ for (let i = 0; i < known.length; i++) {
1125
+ const k = known[i];
1126
+ if (
1127
+ k.given === given &&
1128
+ k.family === span.family &&
1129
+ k.size === span.size &&
1130
+ k.weight === span.weight &&
1131
+ k.style === span.style &&
1132
+ k.variations === span.variations &&
1133
+ k.opticalSize === span.opticalSize &&
1134
+ k.opticalSizing === span.opticalSizing &&
1135
+ k.features === span.features &&
1136
+ k.language === span.language &&
1137
+ k.letterSpacing === span.letterSpacing
1138
+ ) {
1139
+ return k;
1140
+ }
1141
+ }
1142
+ const k = {
1143
+ given,
1144
+ font: given ?? fonts.match(span.family, span),
1145
+ family: span.family,
1146
+ size: span.size,
1147
+ weight: span.weight,
1148
+ style: span.style,
1149
+ variations: span.variations,
1150
+ opticalSize: span.opticalSize,
1151
+ opticalSizing: span.opticalSizing,
1152
+ features: span.features,
1153
+ language: span.language,
1154
+ letterSpacing: span.letterSpacing
1155
+ };
1156
+ if (known.length < MAX_SHAPING_STYLES) known.push(k);
1157
+ return k;
1158
+ }
@@ -0,0 +1,212 @@
1
+ // The glyphs a face's mark positioning can move, read from the GPOS table's
2
+ // own bytes, so that its lookups need not be decoded until a run holds one.
3
+ //
4
+ // fontkit decodes a lookup whole the first time a run asks for its feature,
5
+ // and `mark` is the one that costs: Noto Sans' mark-to-base subtable holds an
6
+ // anchor for every base glyph in every mark class, and decoding it took 14 ms
7
+ // of each face's first shaping (21 ms in all), inside the first frame of
8
+ // every app whose sans-serif is Noto. A line of Latin UI text has no glyph
9
+ // any of those lookups acts on.
10
+ //
11
+ // So each `mark` and `mkmk` lookup is stood in for, in fontkit's cache of the
12
+ // face's lookups, by an empty one (`standInMarks`): a run applies the stand-in
13
+ // and decodes nothing, and fontkit is handed the caller's features as they
14
+ // are. A run that turns out to hold a glyph one of the real lookups acts at
15
+ // puts them back and is shaped again (`Font#_layout`).
16
+ //
17
+ // That is exact rather than a guess about scripts. Every GPOS lookup acts
18
+ // only at a glyph in its first coverage — the mark coverage of a mark
19
+ // attachment, the first glyph's of a pair, the first input's of a context —
20
+ // wherever it is reached from: its feature, another feature that names it,
21
+ // the feature table a variation swaps in, a context that nests it. A run
22
+ // holding no glyph from the coverage of any stood-in lookup is one the real
23
+ // lookups would have left as it is.
24
+ //
25
+ // An earlier cut left the two features out through fontkit's own feature
26
+ // overrides instead. That was exact too, and cost every shaping a pass over
27
+ // the overrides that deletes the tags from the plan: 1-3 us a word, 6% of a
28
+ // frame scrolling a long document into text it had not shaped.
29
+ //
30
+ // Null — shape as fontkit shapes — for a face that has nothing to stand in
31
+ // for, that fontkit shapes through AAT's morx rather than GPOS, or whose
32
+ // tables this cannot read to the end.
33
+
34
+ const TAGS = new Set(['mark', 'mkmk']);
35
+
36
+ /**
37
+ * @param {object} fk a fontkit font
38
+ * @returns {{ bits: Uint8Array, lookups: Set<number> }|null} the `mark` and
39
+ * `mkmk` lookups, and a bit per glyph id, set for every glyph one of them
40
+ * can act at; null where none can be stood in for
41
+ */
42
+ export function markGlyphs(fk) {
43
+ try {
44
+ if (fk.directory?.tables?.morx) return null;
45
+ const gpos = table(fk, 'GPOS');
46
+ if (!gpos) return null;
47
+ const lookups = markLookups(gpos);
48
+ if (lookups.size === 0) return null;
49
+ const bits = new Uint8Array(8192);
50
+ const lookupList = gpos.offset(8, 0);
51
+ for (const index of lookups) {
52
+ if (!coverLookup(gpos, lookupList, index, bits)) return null;
53
+ }
54
+ return { bits, lookups };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Stand an empty lookup in for each `mark` and `mkmk` lookup of a face, in
62
+ * fontkit's cache of its GPOS lookups (restructure's lazy array, which
63
+ * decodes an index the first time it is asked and keeps it in `items`).
64
+ * Applied, a stand-in has no subtables and moves nothing.
65
+ *
66
+ * @param {object} fk a fontkit font, before its first layout
67
+ * @returns {{ bits: Uint8Array, restore: () => void }|null} `restore` puts the
68
+ * real lookups back, to be decoded by the next run that asks; null where
69
+ * there is nothing to stand in for, where fontkit's cache is not the shape
70
+ * this knows, or where a lookup was decoded already
71
+ */
72
+ export function standInMarks(fk) {
73
+ const marks = markGlyphs(fk);
74
+ if (marks === null) return null;
75
+ let list;
76
+ try {
77
+ list = fk.GPOS?.lookupList;
78
+ } catch {
79
+ return null;
80
+ }
81
+ if (!list || typeof list.get !== 'function' || !Array.isArray(list.items)) return null;
82
+ for (const index of marks.lookups) if (list.items[index] != null) return null;
83
+ const standIn = { lookupType: 0, flags: {}, subTableCount: 0, subTables: [] };
84
+ for (const index of marks.lookups) list.items[index] = standIn;
85
+ return {
86
+ bits: marks.bits,
87
+ restore() {
88
+ for (const index of marks.lookups) {
89
+ if (list.items[index] === standIn) list.items[index] = null;
90
+ }
91
+ }
92
+ };
93
+ }
94
+
95
+ /** Whether `bits` (from `markGlyphs`) has glyph `id`. */
96
+ export function marksCover(bits, id) {
97
+ return (bits[id >> 3] & (1 << (id & 7))) !== 0;
98
+ }
99
+
100
+ /**
101
+ * A table's bytes, read big-endian at positions from its start. `offset(pos,
102
+ * base)` reads the 16-bit offset stored at `pos` and answers where it
103
+ * points, `base` being what the format measures it from.
104
+ */
105
+ function table(fk, tag) {
106
+ const entry = fk.directory?.tables?.[tag];
107
+ if (!entry) return null;
108
+ const stream = fk._getTableStream(tag);
109
+ if (!stream) return null;
110
+ const buf = stream.buffer;
111
+ const start = stream.pos;
112
+ const end = Math.min(start + entry.length, buf.length);
113
+ const u16 = (pos) => {
114
+ const p = start + pos;
115
+ if (pos < 0 || p + 2 > end) throw new RangeError(`${tag} read past its end`);
116
+ return (buf[p] << 8) | buf[p + 1];
117
+ };
118
+ const u32 = (pos) => u16(pos) * 65536 + u16(pos + 2);
119
+ const offset = (pos, base) => base + u16(pos);
120
+ return { u16, u32, offset };
121
+ }
122
+
123
+ /**
124
+ * The lookup indices of every `mark` and `mkmk` feature record, in any
125
+ * script: a superset of what fontkit applies, which only widens what
126
+ * counts as a mark.
127
+ */
128
+ function markLookups(t) {
129
+ const list = t.offset(6, 0);
130
+ const lookups = new Set();
131
+ for (let i = 0, n = t.u16(list); i < n; i++) {
132
+ const rec = list + 2 + i * 6;
133
+ const tag = String.fromCharCode(t.u16(rec) >> 8, t.u16(rec) & 255, t.u16(rec + 2) >> 8, t.u16(rec + 2) & 255);
134
+ if (!TAGS.has(tag)) continue;
135
+ const feature = t.offset(rec + 4, list);
136
+ for (let j = 0, m = t.u16(feature + 2); j < m; j++) lookups.add(t.u16(feature + 4 + j * 2));
137
+ }
138
+ return lookups;
139
+ }
140
+
141
+ /** Adds the first coverage of each of a lookup's subtables; false for one it cannot read. */
142
+ function coverLookup(t, lookupList, index, bits) {
143
+ if (index >= t.u16(lookupList)) return false;
144
+ const lookup = t.offset(lookupList + 2 + index * 2, lookupList);
145
+ const type = t.u16(lookup);
146
+ for (let i = 0, n = t.u16(lookup + 4); i < n; i++) {
147
+ let sub = t.offset(lookup + 6 + i * 2, lookup);
148
+ let subType = type;
149
+ if (subType === 9) {
150
+ // an extension: the real subtable, at a 32-bit offset from here
151
+ if (t.u16(sub) !== 1) return false;
152
+ subType = t.u16(sub + 2);
153
+ sub += t.u32(sub + 4);
154
+ }
155
+ const coverage = firstCoverage(t, subType, sub);
156
+ if (coverage < 0) return false;
157
+ addCoverage(t, coverage, bits);
158
+ }
159
+ return true;
160
+ }
161
+
162
+ /**
163
+ * Where the coverage a subtable is entered from starts, or -1 for a
164
+ * subtable this does not know.
165
+ */
166
+ function firstCoverage(t, type, sub) {
167
+ const format = t.u16(sub);
168
+ switch (type) {
169
+ case 1: // single adjustment
170
+ case 2: // pair adjustment: the pair's first glyph
171
+ return format === 1 || format === 2 ? t.offset(sub + 2, sub) : -1;
172
+ case 3: // cursive attachment
173
+ case 4: // mark to base: the mark
174
+ case 5: // mark to ligature: the mark
175
+ case 6: // mark to mark: the mark that attaches
176
+ return format === 1 ? t.offset(sub + 2, sub) : -1;
177
+ case 7: // context
178
+ if (format === 1 || format === 2) return t.offset(sub + 2, sub);
179
+ // the first input glyph's, after the glyph and lookup counts
180
+ return format === 3 && t.u16(sub + 2) > 0 ? t.offset(sub + 6, sub) : -1;
181
+ case 8: {
182
+ // chained context
183
+ if (format === 1 || format === 2) return t.offset(sub + 2, sub);
184
+ if (format !== 3) return -1;
185
+ // past the backtrack coverages to the input's count, then its first
186
+ const input = sub + 4 + t.u16(sub + 2) * 2;
187
+ return t.u16(input) > 0 ? t.offset(input + 2, sub) : -1;
188
+ }
189
+ default:
190
+ return -1;
191
+ }
192
+ }
193
+
194
+ function addCoverage(t, coverage, bits) {
195
+ const format = t.u16(coverage);
196
+ const count = t.u16(coverage + 2);
197
+ if (format === 1) {
198
+ for (let i = 0; i < count; i++) set(bits, t.u16(coverage + 4 + i * 2));
199
+ } else if (format === 2) {
200
+ for (let i = 0; i < count; i++) {
201
+ const rec = coverage + 4 + i * 6;
202
+ const last = t.u16(rec + 2);
203
+ for (let id = t.u16(rec); id <= last; id++) set(bits, id);
204
+ }
205
+ } else {
206
+ throw new RangeError(`coverage format ${format}`);
207
+ }
208
+ }
209
+
210
+ function set(bits, id) {
211
+ bits[id >> 3] |= 1 << (id & 7);
212
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ntk",
3
- "version": "8.13.0",
3
+ "version": "8.14.0",
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",