ntk 2.2.0 → 3.1.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.
Files changed (47) hide show
  1. package/README.md +156 -151
  2. package/lib/app.js +64 -21
  3. package/lib/cleanup.js +14 -0
  4. package/lib/drawable.js +12 -18
  5. package/lib/events_map.js +92 -98
  6. package/lib/fontconfig.js +123 -17
  7. package/lib/glyphset.js +40 -21
  8. package/lib/image.js +138 -0
  9. package/lib/index.js +129 -57
  10. package/lib/path.js +734 -0
  11. package/lib/picture.js +84 -41
  12. package/lib/pixmap.js +50 -39
  13. package/lib/rasterize.js +178 -0
  14. package/lib/renderingcontext_2d.js +1078 -695
  15. package/lib/renderingcontext_opengl.js +53 -46
  16. package/lib/renderingcontext_x11.js +59 -67
  17. package/lib/text/font.js +196 -0
  18. package/lib/text/fontmanager.js +233 -0
  19. package/lib/text/fontsource.js +179 -0
  20. package/lib/text/glyphs.js +456 -0
  21. package/lib/text/layout.js +356 -0
  22. package/lib/text/shape.js +141 -0
  23. package/lib/trapezoid.js +131 -0
  24. package/lib/widgets/css.js +676 -0
  25. package/lib/widgets/highlight.js +97 -0
  26. package/lib/widgets/htmlview.js +879 -0
  27. package/lib/widgets/markdown.js +148 -0
  28. package/lib/widgets/markdownview.js +487 -0
  29. package/lib/widgets/mermaid.js +720 -0
  30. package/lib/widgets/svgview.js +528 -0
  31. package/lib/widgets/tex.js +681 -0
  32. package/lib/window.js +691 -308
  33. package/package.json +60 -56
  34. package/examples/example-await.js +0 -23
  35. package/examples/example-co.js +0 -13
  36. package/examples/example.js +0 -14
  37. package/examples/fill-styles.js +0 -39
  38. package/examples/glclock.js +0 -100
  39. package/examples/glxpixmap.js +0 -12
  40. package/examples/gradient.js +0 -41
  41. package/examples/package.json +0 -17
  42. package/examples/simpletex.js +0 -72
  43. package/examples/teapot.js +0 -104
  44. package/examples/teapot.json +0 -6
  45. package/examples/text-rendering.js +0 -44
  46. package/lib/fontface.js +0 -103
  47. package/lib/widgets/webview.js +0 -33
@@ -1,46 +1,53 @@
1
- module.exports = function(window, visual) {
2
- // TODO: 1) use glx api to get best visual; 2) cache
3
- //require('child_process').exec('glxinfo -i -b', function(error, stdout, stderr) {
4
- var gl = new RenderingContextOpenGL(window, visual);
5
- return gl;
6
- }
7
-
8
- function RenderingContextOpenGL(window, visual) {
9
-
10
- if (typeof visual == 'undefined')
11
- visual = parseInt(require('child_process').execSync('glxinfo -i -b').toString());
12
-
13
- var X = window.X;
14
- var GLX = window.display.GLX;
15
- this.window = window;
16
-
17
- var ctx = X.AllocID();
18
- GLX.CreateContext(ctx, visual, 0, 0, 0);
19
- GLX.MakeCurrent(window.id, ctx, 0, function() {});
20
-
21
- var gl = GLX.renderPipeline(ctx);
22
- for(key in gl) {
23
- if (key !== 'SwapBuffers') {
24
- var val = gl[key];
25
- if (typeof val == 'function')
26
- this[key] = val.bind(gl);
27
- else
28
- this[key] = val;
29
- }
30
- }
31
- swapBuffers = gl.SwapBuffers;
32
- var id = window.id;
33
- this.SwapBuffers = function() { swapBuffers.call(gl, id); };
34
-
35
- this.CreateGLXPixmap = function(pixmapId) {
36
- var glxpixmapId = X.AllocID();
37
- GLX.CreateGLXPixmap(0, visual, pixmapId, glxpixmapId);
38
- return glxpixmapId;
39
- };
40
- this.BindTexImage = function(pixmap, buffer) {
41
- GLX.BindTexImage(ctx, pixmap, buffer);
42
- };
43
- }
44
-
45
- // register context
46
- require('./drawable').prototype.renderingContextFactory['opengl'] = module.exports;
1
+ import Drawable from './drawable.js';
2
+
3
+ // Indirect GLX rendering context exposing an OpenGL 1.4-style api.
4
+ // Note: many modern X servers ship with indirect GLX disabled — see README.
5
+ class RenderingContextOpenGL {
6
+ constructor(window, visual) {
7
+ // TODO: use glx api to query the best visual instead of shelling out
8
+ if (typeof visual === 'undefined') visual = window.app?.options?.glxVisual;
9
+ if (typeof visual === 'undefined') {
10
+ // lazy builtin lookup: browser bundles must not depend on child_process
11
+ const cp = globalThis.process?.getBuiltinModule?.('node:child_process');
12
+ if (!cp) {
13
+ throw new Error(
14
+ "getContext('opengl'): no visual id — pass one explicitly or via createClient({ glxVisual }) when glxinfo cannot be run"
15
+ );
16
+ }
17
+ visual = parseInt(cp.execSync('glxinfo -i -b').toString(), 10);
18
+ }
19
+
20
+ const X = window.X;
21
+ const GLX = window.display.GLX;
22
+ this.window = window;
23
+
24
+ const ctx = X.AllocID();
25
+ GLX.CreateContext(ctx, visual, 0, 0, 0);
26
+ GLX.MakeCurrent(window.id, ctx, 0, () => {});
27
+
28
+ const gl = GLX.renderPipeline(ctx);
29
+ for (const key in gl) {
30
+ if (key === 'SwapBuffers') continue;
31
+ const val = gl[key];
32
+ this[key] = typeof val === 'function' ? val.bind(gl) : val;
33
+ }
34
+
35
+ const windowId = window.id;
36
+ this.SwapBuffers = () => gl.SwapBuffers.call(gl, windowId);
37
+
38
+ this.CreateGLXPixmap = (pixmapId) => {
39
+ const glxpixmapId = X.AllocID();
40
+ GLX.CreateGLXPixmap(0, visual, pixmapId, glxpixmapId);
41
+ return glxpixmapId;
42
+ };
43
+ this.BindTexImage = (pixmap, buffer) => {
44
+ GLX.BindTexImage(ctx, pixmap, buffer);
45
+ };
46
+ }
47
+ }
48
+
49
+ // register context
50
+ Drawable.renderingContextFactory['opengl'] = (window, visual) =>
51
+ new RenderingContextOpenGL(window, visual);
52
+
53
+ export default RenderingContextOpenGL;
@@ -1,67 +1,59 @@
1
- module.exports = function(window) {
2
- //callback(null, new RenderingContextX11(window));
3
- return new RenderingContextX11(window);
4
- }
5
-
6
- function RenderingContextX11(window) {
7
- var X = window.X;
8
- this.gc = X.AllocID();
9
-
10
- var white = X.display.screen[0].white_pixel;
11
- var black = X.display.screen[0].black_pixel;
12
- X.CreateGC(this.gc, window.id, { foreground: white, background: black });
13
- this.window = window;
14
- }
15
-
16
- var RC = RenderingContextX11.prototype;
17
-
18
- RC.putImage = function(data, x, y) {
19
- this.window.X.PutImage(2, this.window.id, this.gc, data.width, data.height, x, y, 0, 24, data.data);
20
- }
21
-
22
- RC.point = function(p) {
23
- this.window.X.PolyPoint(0, this.window.id, this.gc, [p.x, p.y]);
24
- }
25
-
26
- RC.setForeground = function(color) {
27
- this.window.X.ChangeGC(this.gc, { foreground: color });
28
- }
29
-
30
- RC.setBackground = function(color) {
31
- this.window.X.ChangeGC(this.gc, { background: color });
32
- }
33
-
34
- RC.polyLine = function(pts) {
35
- var coords = [];
36
- pts.forEach(function(p) {
37
- coords.push(p.x);
38
- coords.push(p.y);
39
- });
40
- this.window.X.PolyLine(0, this.window.id, this.gc, coords);
41
- }
42
-
43
- RC.fillRectangle = function(r) {
44
- //console.log('fillRectangle', r);
45
- var coords = [parseInt(r.left), parseInt(r.top), parseInt(r.width), parseInt(r.height)];
46
- this.window.X.PolyFillRectangle(this.window.id, this.gc, coords);
47
- }
48
-
49
- RC.rectangle = function(r) {
50
- //console.log('rectangle', r);
51
- var coords = [parseInt(r.left), parseInt(r.top), parseInt(r.width), parseInt(r.height)];
52
- this.window.X.PolyRectangle(this.window.id, this.gc, coords);
53
- }
54
-
55
- RC.copy = function(dst) {
56
- this.window.X.CopyArea(this.window.id, dst.id, this.gc, 0, 0, 0, 0, 1000, 1000);
57
- }
58
-
59
- RC.drawText = function(text, x, y) {
60
- //console.log('drawText', x, y, text);
61
- this.window.X.PolyText8(this.window.id, this.gc, x, y, [text]);
62
- }
63
-
64
- // TODO: add poly point, poly rect, color functions
65
-
66
- // register context
67
- require('./drawable').prototype.renderingContextFactory['x11'] = module.exports;
1
+ import Drawable from './drawable.js';
2
+
3
+ // Thin wrapper over core X drawing requests (no XRender). Mostly useful for
4
+ // debugging; prefer the '2d' context.
5
+ class RenderingContextX11 {
6
+ constructor(window) {
7
+ const X = window.X;
8
+ this.gc = X.AllocID();
9
+ const white = X.display.screen[0].white_pixel;
10
+ const black = X.display.screen[0].black_pixel;
11
+ X.CreateGC(this.gc, window.id, { foreground: white, background: black });
12
+ this.window = window;
13
+ }
14
+
15
+ putImage(data, x, y) {
16
+ this.window.X.PutImage(2, this.window.id, this.gc, data.width, data.height, x, y, 0, 24, data.data);
17
+ }
18
+
19
+ point(p) {
20
+ this.window.X.PolyPoint(0, this.window.id, this.gc, [p.x, p.y]);
21
+ }
22
+
23
+ setForeground(color) {
24
+ this.window.X.ChangeGC(this.gc, { foreground: color });
25
+ }
26
+
27
+ setBackground(color) {
28
+ this.window.X.ChangeGC(this.gc, { background: color });
29
+ }
30
+
31
+ polyLine(pts) {
32
+ const coords = [];
33
+ for (const p of pts) coords.push(p.x, p.y);
34
+ this.window.X.PolyLine(0, this.window.id, this.gc, coords);
35
+ }
36
+
37
+ fillRectangle(r) {
38
+ const coords = [Math.trunc(r.left), Math.trunc(r.top), Math.trunc(r.width), Math.trunc(r.height)];
39
+ this.window.X.PolyFillRectangle(this.window.id, this.gc, coords);
40
+ }
41
+
42
+ rectangle(r) {
43
+ const coords = [Math.trunc(r.left), Math.trunc(r.top), Math.trunc(r.width), Math.trunc(r.height)];
44
+ this.window.X.PolyRectangle(this.window.id, this.gc, coords);
45
+ }
46
+
47
+ copy(dst) {
48
+ this.window.X.CopyArea(this.window.id, dst.id, this.gc, 0, 0, 0, 0, 1000, 1000);
49
+ }
50
+
51
+ drawText(text, x, y) {
52
+ this.window.X.PolyText8(this.window.id, this.gc, x, y, [text]);
53
+ }
54
+ }
55
+
56
+ // register context
57
+ Drawable.renderingContextFactory['x11'] = (window) => new RenderingContextX11(window);
58
+
59
+ export default RenderingContextX11;
@@ -0,0 +1,196 @@
1
+ import * as fontkit from 'fontkit';
2
+
3
+ import { flatten, rasterizePath } from '../rasterize.js';
4
+
5
+ /**
6
+ * A single font face (one entry of a .ttc collection, or a whole .ttf/.otf),
7
+ * parsed with fontkit. Wraps everything the text pipeline needs:
8
+ *
9
+ * - metrics scaled to a pixel size
10
+ * - unicode coverage queries (`hasGlyph`)
11
+ * - shaping (`shape`): OpenType GSUB/GPOS — kerning, ligatures, contextual
12
+ * forms for complex scripts — via fontkit's layout engine
13
+ * - glyph rasterization to a8 bitmaps for XRender upload
14
+ *
15
+ * Fonts are cheap-ish to open (tables parse lazily) and are cached by the
16
+ * FontManager — always obtain them via `app.fonts`.
17
+ */
18
+ export default class Font {
19
+ constructor(fkFont, path, postscriptName) {
20
+ this.fk = fkFont;
21
+ this.path = path;
22
+ /** stable cache key for glyph pages and font caches */
23
+ this.key = `${path}#${postscriptName || fkFont.postscriptName || ''}`;
24
+ }
25
+
26
+ static loadSync(path, postscriptName) {
27
+ let fk = fontkit.openSync(path);
28
+ // select the face of a .ttc/.dfont collection (first face by default);
29
+ // don't pass the name to openSync — for single variable fonts it would
30
+ // be misread as a named variation instance
31
+ if (fk.fonts) {
32
+ fk =
33
+ (postscriptName && fk.fonts.find((f) => f.postscriptName === postscriptName)) ||
34
+ fk.fonts[0];
35
+ }
36
+ return new Font(fk, path, postscriptName);
37
+ }
38
+
39
+ /**
40
+ * Open a font from in-memory bytes (no filesystem — works in browser
41
+ * bundles). `key` becomes the stable cache key; defaults to a key derived
42
+ * from the font's postscript name.
43
+ *
44
+ * @param {Uint8Array|Buffer} data font file bytes
45
+ * @param {object} [opts] { key, postscriptName (face of a .ttc) }
46
+ */
47
+ static fromData(data, { key, postscriptName } = {}) {
48
+ const buf = Buffer.isBuffer(data)
49
+ ? data
50
+ : Buffer.from(data.buffer ?? data, data.byteOffset ?? 0, data.byteLength ?? data.length);
51
+ let fk = fontkit.create(buf);
52
+ if (fk.fonts) {
53
+ fk =
54
+ (postscriptName && fk.fonts.find((f) => f.postscriptName === postscriptName)) ||
55
+ fk.fonts[0];
56
+ }
57
+ const font = new Font(fk, null, postscriptName);
58
+ font.key = key ?? `mem:#${fk.postscriptName || ''}`;
59
+ return font;
60
+ }
61
+
62
+ get familyName() {
63
+ return this.fk.familyName;
64
+ }
65
+
66
+ get postscriptName() {
67
+ return this.fk.postscriptName;
68
+ }
69
+
70
+ get unitsPerEm() {
71
+ return this.fk.unitsPerEm;
72
+ }
73
+
74
+ scale(size) {
75
+ return size / this.fk.unitsPerEm;
76
+ }
77
+
78
+ /**
79
+ * Pixel-space metrics for a font size. `ascent`/`capHeight`/`xHeight` are
80
+ * positive (above baseline), `descent` is positive (below baseline).
81
+ */
82
+ metrics(size) {
83
+ const s = this.scale(size);
84
+ const ascent = this.fk.ascent * s;
85
+ const descent = -this.fk.descent * s;
86
+ const lineGap = this.fk.lineGap * s;
87
+ return {
88
+ ascent,
89
+ descent,
90
+ lineGap,
91
+ lineHeight: ascent + descent + lineGap,
92
+ capHeight: this.fk.capHeight * s,
93
+ xHeight: this.fk.xHeight * s
94
+ };
95
+ }
96
+
97
+ hasGlyph(codepoint) {
98
+ return this.fk.hasGlyphForCodePoint(codepoint);
99
+ }
100
+
101
+ /**
102
+ * Shape a run of text: returns glyphs with pixel-space positioning.
103
+ * RTL runs come back in visual (left-to-right drawing) order.
104
+ *
105
+ * @param {string} text single-direction, single-font run
106
+ * @param {number} size pixel size
107
+ * @param {object} [opts] { direction: 'ltr'|'rtl', features, script, language }
108
+ * @returns {{font, size, direction, width, glyphs: Array<{id, ax, dx, dy, codePoints}>}}
109
+ * ax = advance, dx/dy = drawing offset from pen position (y up = positive dy)
110
+ */
111
+ shape(text, size, opts = {}) {
112
+ const run = this.fk.layout(text, opts.features, opts.script, opts.language, opts.direction);
113
+ const s = this.scale(size);
114
+ const glyphs = new Array(run.glyphs.length);
115
+ let width = 0;
116
+ for (let i = 0; i < run.glyphs.length; i++) {
117
+ const pos = run.positions[i];
118
+ const g = {
119
+ id: run.glyphs[i].id,
120
+ ax: pos.xAdvance * s,
121
+ dx: pos.xOffset * s,
122
+ dy: pos.yOffset * s,
123
+ codePoints: run.glyphs[i].codePoints
124
+ };
125
+ width += g.ax;
126
+ glyphs[i] = g;
127
+ }
128
+ return { font: this, size, direction: run.direction, width, glyphs };
129
+ }
130
+
131
+ /** nominal (unshaped) advance of a glyph id, in pixels */
132
+ advanceOf(glyphId, size) {
133
+ return this.fk.getGlyph(glyphId).advanceWidth * this.scale(size);
134
+ }
135
+
136
+ /** glyph bounding box in pixels relative to the baseline origin, y-down */
137
+ glyphExtents(glyphId, size) {
138
+ const s = this.scale(size);
139
+ const b = this.fk.getGlyph(glyphId).cbox;
140
+ return { minX: b.minX * s, maxX: b.maxX * s, minY: -b.maxY * s, maxY: -b.minY * s };
141
+ }
142
+
143
+ /**
144
+ * Rasterize a glyph to an 8-bit alpha bitmap (or null for blank glyphs).
145
+ * Converts fontkit's y-up font-unit path to the y-down pixel commands the
146
+ * scanline rasterizer expects. Above 96px supersampling drops from 4x4 to
147
+ * 2x2 — visually indistinguishable at that size, 4x cheaper to rasterize.
148
+ */
149
+ rasterize(glyphId, size) {
150
+ return rasterizePath(this._pathCommands(glyphId, size), size > 96 ? 2 : 4);
151
+ }
152
+
153
+ /**
154
+ * Glyph outline as flattened closed polygons in y-down pixel space
155
+ * relative to the baseline origin — input for the vector (trapezoid)
156
+ * glyph path. Empty array for blank glyphs.
157
+ */
158
+ outline(glyphId, size) {
159
+ return flatten(this._pathCommands(glyphId, size));
160
+ }
161
+
162
+ /** fontkit y-up font-unit path -> y-down pixel-space M/L/Q/C/Z commands */
163
+ _pathCommands(glyphId, size) {
164
+ const s = this.scale(size);
165
+ const commands = [];
166
+ for (const c of this.fk.getGlyph(glyphId).path.commands) {
167
+ const a = c.args;
168
+ switch (c.command) {
169
+ case 'moveTo':
170
+ commands.push({ type: 'M', x: a[0] * s, y: -a[1] * s });
171
+ break;
172
+ case 'lineTo':
173
+ commands.push({ type: 'L', x: a[0] * s, y: -a[1] * s });
174
+ break;
175
+ case 'quadraticCurveTo':
176
+ commands.push({ type: 'Q', x1: a[0] * s, y1: -a[1] * s, x: a[2] * s, y: -a[3] * s });
177
+ break;
178
+ case 'bezierCurveTo':
179
+ commands.push({
180
+ type: 'C',
181
+ x1: a[0] * s,
182
+ y1: -a[1] * s,
183
+ x2: a[2] * s,
184
+ y2: -a[3] * s,
185
+ x: a[4] * s,
186
+ y: -a[5] * s
187
+ });
188
+ break;
189
+ case 'closePath':
190
+ commands.push({ type: 'Z' });
191
+ break;
192
+ }
193
+ }
194
+ return commands;
195
+ }
196
+ }
@@ -0,0 +1,233 @@
1
+ import Font from './font.js';
2
+ import { defaultFontSource, detectStyle, numericWeight as numWeight } from './fontsource.js';
3
+ import { shapeText } from './shape.js';
4
+ import { TextLayout } from './layout.js';
5
+
6
+ /**
7
+ * Font lookup, loading and caching — the entry point of the text API,
8
+ * available as `app.fonts` (and constructible standalone for headless
9
+ * measurement/layout: `new FontManager()`).
10
+ *
11
+ * - `match(family, {weight, style})` resolves CSS-ish patterns via the
12
+ * manager's FontSource (fontconfig/fc-match by default), including
13
+ * comma-separated family lists.
14
+ * - `load(path)` registers a font file (custom/bundled fonts); registered
15
+ * families take priority over system lookup.
16
+ * - Per-codepoint font fallback uses the source's coverage data, so text a
17
+ * font can't display automatically borrows glyphs from the best system
18
+ * font that can.
19
+ * - `shape()` and `layout()` are the shaping/layout front doors; see
20
+ * docs/text.md.
21
+ *
22
+ * All system lookup goes through a pluggable FontSource (see
23
+ * text/fontsource.js) — pass `{ source }` to use something other than
24
+ * fontconfig, e.g. a StaticFontSource in a browser bundle.
25
+ */
26
+ export default class FontManager {
27
+ constructor({ source } = {}) {
28
+ this._source = source ?? null; // resolved lazily so the process-wide default can be set late
29
+ this._fonts = new Map(); // candidate key -> Font
30
+ this._matches = new Map(); // family|weight|style -> Font
31
+ this._fallbacks = new Map(); // family|weight|style -> Map(codepoint -> Font|null)
32
+ this._registered = []; // { font, family (lowercase), weight, italic }
33
+ this._shapeCache = new Map(); // word-level shaping memo (bounded)
34
+ }
35
+
36
+ /** the FontSource in effect (explicit, else the process-wide default) */
37
+ get source() {
38
+ return this._source ?? defaultFontSource();
39
+ }
40
+
41
+ /** open (and cache) a match candidate — see fontsource.js for the shape */
42
+ _open(candidate) {
43
+ const key = candidate.key ?? `${candidate.path}#${candidate.postscriptName || ''}`;
44
+ let font = this._fonts.get(key);
45
+ if (!font) {
46
+ if (candidate.font) {
47
+ font = candidate.font;
48
+ } else if (candidate.data) {
49
+ font = Font.fromData(candidate.data, { key, postscriptName: candidate.postscriptName });
50
+ } else {
51
+ font = Font.loadSync(candidate.path, candidate.postscriptName);
52
+ }
53
+ this._fonts.set(key, font);
54
+ }
55
+ return font;
56
+ }
57
+
58
+ /**
59
+ * Load and register a font. Registered fonts win over system fonts in
60
+ * `match()` and fallback, keyed by their family name (or an alias).
61
+ *
62
+ * @param {string|Uint8Array|Buffer} source path to a .ttf/.otf/.woff/.ttc
63
+ * file, or the font file's bytes (environments without a filesystem)
64
+ * @param {object} [opts] { postscriptName (face of a .ttc), family (alias),
65
+ * weight, style } — weight/style override the values detected from the file
66
+ * @returns {Font}
67
+ */
68
+ load(source, opts = {}) {
69
+ const candidate =
70
+ typeof source === 'string'
71
+ ? { path: source, postscriptName: opts.postscriptName }
72
+ : {
73
+ data: source,
74
+ postscriptName: opts.postscriptName,
75
+ key: `loaded:${this._fonts.size}:${opts.family || opts.postscriptName || ''}`
76
+ };
77
+ const font = this._open(candidate);
78
+ const detected = detectStyle(font);
79
+ this._registered.push({
80
+ font,
81
+ family: (opts.family || font.familyName || '').toLowerCase(),
82
+ weight: opts.weight !== undefined ? numWeight(opts.weight) : detected.weight,
83
+ italic: opts.style !== undefined ? opts.style.includes('italic') : detected.italic
84
+ });
85
+ this._matches.clear();
86
+ this._fallbacks.clear();
87
+ return font;
88
+ }
89
+
90
+ _matchRegistered(families, weight, italic) {
91
+ let best = null;
92
+ let bestScore = Infinity;
93
+ for (const family of families) {
94
+ for (const r of this._registered) {
95
+ if (r.family !== family) continue;
96
+ const score = Math.abs(r.weight - weight) + (r.italic !== italic ? 1000 : 0);
97
+ if (score < bestScore) {
98
+ bestScore = score;
99
+ best = r.font;
100
+ }
101
+ }
102
+ if (best) return best; // earlier families in the list win
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Resolve a family (or CSS-style comma-separated family list) to a Font.
109
+ * Registered fonts are consulted first, then fontconfig.
110
+ *
111
+ * @param {string} family e.g. `'Ubuntu Mono', monospace`
112
+ * @param {object} [opts] { weight: 400|'bold'|…, style: 'normal'|'italic' }
113
+ */
114
+ match(family = 'sans-serif', opts = {}) {
115
+ const weight = numWeight(opts.weight);
116
+ const italic = !!(opts.style && opts.style.includes('italic'));
117
+ const cacheKey = `${family}|${weight}|${italic}`;
118
+ let font = this._matches.get(cacheKey);
119
+ if (font) return font;
120
+
121
+ const families = String(family)
122
+ .split(',')
123
+ .map((f) => f.trim().replace(/^["']|["']$/g, ''))
124
+ .filter(Boolean);
125
+
126
+ font = this._matchRegistered(
127
+ families.map((f) => f.toLowerCase()),
128
+ weight,
129
+ italic
130
+ );
131
+ if (!font) {
132
+ // sources understand comma-separated family lists natively
133
+ const candidates = this.source.matchSorted({
134
+ family: families.join(','),
135
+ weight,
136
+ style: italic ? 'italic' : 'normal'
137
+ });
138
+ font = this._open(candidates[0]);
139
+ }
140
+ this._matches.set(cacheKey, font);
141
+ return font;
142
+ }
143
+
144
+ /**
145
+ * Find a font that has a glyph for `codepoint`, for use when the primary
146
+ * font doesn't. Registered fonts first, then the source's fallback chain
147
+ * (filtered by the source's coverage data — font files are only opened to
148
+ * confirm). Returns null when nothing on the system covers the codepoint.
149
+ */
150
+ fallbackFor(codepoint, family = 'sans-serif', opts = {}) {
151
+ const cacheKey = `${family}|${numWeight(opts.weight)}|${opts.style || ''}`;
152
+ let perCp = this._fallbacks.get(cacheKey);
153
+ if (!perCp) {
154
+ perCp = new Map();
155
+ this._fallbacks.set(cacheKey, perCp);
156
+ }
157
+ if (perCp.has(codepoint)) return perCp.get(codepoint);
158
+
159
+ let found = null;
160
+ for (const r of this._registered) {
161
+ if (r.font.hasGlyph(codepoint)) {
162
+ found = r.font;
163
+ break;
164
+ }
165
+ }
166
+ if (!found) {
167
+ const source = this.source;
168
+ const candidates = source.matchSorted({ family, weight: opts.weight, style: opts.style });
169
+ for (const c of candidates) {
170
+ if (source.covers && !source.covers(c, codepoint)) continue;
171
+ try {
172
+ const font = this._open(c);
173
+ if (font.hasGlyph(codepoint)) {
174
+ found = font;
175
+ break;
176
+ }
177
+ } catch {
178
+ // unparseable candidate — try the next one
179
+ }
180
+ }
181
+ }
182
+ perCp.set(codepoint, found);
183
+ return found;
184
+ }
185
+
186
+ /**
187
+ * Shape text through the full pipeline (bidi → font fallback → OpenType
188
+ * shaping). See shape.js for the run format.
189
+ *
190
+ * @param {string} text
191
+ * @param {object} style { font | family/weight/style, size, features,
192
+ * language, direction }
193
+ */
194
+ shape(text, style) {
195
+ return shapeText(this, text, style);
196
+ }
197
+
198
+ /**
199
+ * Lay text out into lines for a target container width.
200
+ *
201
+ * @param {string|Array} content plain string or spans
202
+ * [{ text, ...style overrides, color }]
203
+ * @param {object} style base style ({ family, size, weight, … })
204
+ * @param {object} options { maxWidth, align, lineHeight, direction }
205
+ * @returns {TextLayout}
206
+ */
207
+ layout(content, style, options) {
208
+ return new TextLayout(this, content, style, options);
209
+ }
210
+
211
+ /**
212
+ * Memoized shaping used by TextLayout (bounded cache). `levelsKey` is a
213
+ * compact embedding-levels encoding: a single number when uniform for the
214
+ * whole fragment (the common case), else comma-separated per-char levels.
215
+ */
216
+ _shapeCached(text, style, levelsKey = '0') {
217
+ const font = style.font;
218
+ const key = `${font ? font.key : style.family}|${style.size}|${style.weight}|${style.style}|${levelsKey}|${text}`;
219
+ let shaped = this._shapeCache.get(key);
220
+ if (!shaped) {
221
+ let levels;
222
+ if (levelsKey.includes(',')) {
223
+ levels = levelsKey.split(',').map(Number);
224
+ } else {
225
+ levels = new Uint8Array(text.length).fill(Number(levelsKey));
226
+ }
227
+ shaped = shapeText(this, text, style, levels);
228
+ if (this._shapeCache.size > 4000) this._shapeCache.clear();
229
+ this._shapeCache.set(key, shaped);
230
+ }
231
+ return shaped;
232
+ }
233
+ }