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.
- package/README.md +156 -151
- package/lib/app.js +64 -21
- package/lib/cleanup.js +14 -0
- package/lib/drawable.js +12 -18
- package/lib/events_map.js +92 -98
- package/lib/fontconfig.js +123 -17
- package/lib/glyphset.js +40 -21
- package/lib/image.js +138 -0
- package/lib/index.js +129 -57
- package/lib/path.js +734 -0
- package/lib/picture.js +84 -41
- package/lib/pixmap.js +50 -39
- package/lib/rasterize.js +178 -0
- package/lib/renderingcontext_2d.js +1078 -695
- package/lib/renderingcontext_opengl.js +53 -46
- package/lib/renderingcontext_x11.js +59 -67
- package/lib/text/font.js +196 -0
- package/lib/text/fontmanager.js +233 -0
- package/lib/text/fontsource.js +179 -0
- package/lib/text/glyphs.js +456 -0
- package/lib/text/layout.js +356 -0
- package/lib/text/shape.js +141 -0
- package/lib/trapezoid.js +131 -0
- package/lib/widgets/css.js +676 -0
- package/lib/widgets/highlight.js +97 -0
- package/lib/widgets/htmlview.js +879 -0
- package/lib/widgets/markdown.js +148 -0
- package/lib/widgets/markdownview.js +487 -0
- package/lib/widgets/mermaid.js +720 -0
- package/lib/widgets/svgview.js +528 -0
- package/lib/widgets/tex.js +681 -0
- package/lib/window.js +691 -308
- package/package.json +60 -56
- package/examples/example-await.js +0 -23
- package/examples/example-co.js +0 -13
- package/examples/example.js +0 -14
- package/examples/fill-styles.js +0 -39
- package/examples/glclock.js +0 -100
- package/examples/glxpixmap.js +0 -12
- package/examples/gradient.js +0 -41
- package/examples/package.json +0 -17
- package/examples/simpletex.js +0 -72
- package/examples/teapot.js +0 -104
- package/examples/teapot.json +0 -6
- package/examples/text-rendering.js +0 -44
- package/lib/fontface.js +0 -103
- package/lib/widgets/webview.js +0 -33
|
@@ -1,46 +1,53 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
coords.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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;
|
package/lib/text/font.js
ADDED
|
@@ -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
|
+
}
|