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/app.js +30 -2
- package/lib/clipboard.js +16 -5
- package/lib/cliprect.js +128 -0
- package/lib/color.js +21 -0
- package/lib/fontconfig.js +273 -55
- package/lib/renderingcontext_2d.js +111 -16
- package/lib/text/font.js +31 -1
- package/lib/text/fontmanager.js +34 -3
- package/lib/text/fontsource.js +25 -0
- package/lib/text/layout.js +167 -26
- package/lib/text/marks.js +212 -0
- package/package.json +1 -1
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.
|
|
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);
|
package/lib/text/fontmanager.js
CHANGED
|
@@ -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
|
-
|
|
338
|
-
face
|
|
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
|
package/lib/text/fontsource.js
CHANGED
|
@@ -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
|
package/lib/text/layout.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|