buninu-linux 0.3.1 → 0.5.1

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 (53) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/LICENSES/BSD-3-Clause-Skia.txt +29 -0
  3. package/LICENSES/Bitstream-Vera.txt +44 -0
  4. package/LICENSES/MIT-xterm.js.txt +21 -0
  5. package/LICENSES/OFL-1.1.txt +91 -0
  6. package/NOTICE.md +18 -1
  7. package/README.md +101 -9
  8. package/fetch-alpine.sh +6 -0
  9. package/graphics.md +218 -0
  10. package/initramfs/bin/bunterm +70 -0
  11. package/initramfs/bin/chroot +213 -0
  12. package/initramfs/bin/stripansi +27 -0
  13. package/initramfs/bin/tar +189 -14
  14. package/initramfs/buninu/CHANGELOG.md +40 -0
  15. package/initramfs/buninu/apps/jsgotty/gotty.js +4 -3
  16. package/initramfs/buninu/apps/jsgotty/package.json +1 -1
  17. package/initramfs/buninu/package.json +1 -1
  18. package/initramfs/init.js +80 -1
  19. package/initramfs/lib/bunterm/glyphs.js +446 -0
  20. package/initramfs/lib/bunterm/images.js +225 -0
  21. package/initramfs/lib/bunterm/input.js +140 -0
  22. package/initramfs/lib/bunterm/mouse.js +222 -0
  23. package/initramfs/lib/bunterm/renderer.js +469 -0
  24. package/initramfs/lib/bunterm/terminal.js +324 -0
  25. package/initramfs/lib/canvas.js +181 -0
  26. package/initramfs/lib/canvaskit/LICENSE +29 -0
  27. package/initramfs/lib/canvaskit/canvaskit.js +264 -0
  28. package/initramfs/lib/canvaskit/canvaskit.wasm +0 -0
  29. package/initramfs/lib/dlopen.js +18 -2
  30. package/initramfs/lib/document.js +5 -1
  31. package/initramfs/lib/fbdev.js +581 -0
  32. package/initramfs/lib/xterm/LICENSE +21 -0
  33. package/initramfs/lib/xterm/addon-unicode-graphemes.mjs +44 -0
  34. package/initramfs/lib/xterm/xterm-headless.mjs +46 -0
  35. package/initramfs/usr/share/doc/buninu-linux/bunterm.md +112 -0
  36. package/initramfs/usr/share/doc/buninu-linux/canvas.md +91 -0
  37. package/initramfs/usr/share/doc/buninu-linux/chroot.md +105 -0
  38. package/initramfs/usr/share/doc/buninu-linux/fbdev.md +113 -0
  39. package/initramfs/usr/share/doc/buninu-linux/tar.md +30 -6
  40. package/initramfs/usr/share/fonts/DejaVuSansMono-Bold.ttf +0 -0
  41. package/initramfs/usr/share/fonts/DejaVuSansMono.ttf +0 -0
  42. package/initramfs/usr/share/fonts/LICENSE-Apache-2.0.txt +202 -0
  43. package/initramfs/usr/share/fonts/LICENSE-DejaVu.txt +44 -0
  44. package/initramfs/usr/share/fonts/LICENSE-OFL-1.1.txt +91 -0
  45. package/initramfs/usr/share/fonts/NotoColorEmoji.ttf +0 -0
  46. package/initramfs/usr/share/fonts/NotoColorEmojiFlags.ttf +0 -0
  47. package/initramfs/usr/share/fonts/NotoSansCJK-Regular.ttc +0 -0
  48. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted.ttf +0 -0
  49. package/initramfs/usr/share/fonts/NotoSansSymbols-Regular-Subsetted2.ttf +0 -0
  50. package/initramfs/usr/share/fonts/Roboto-Regular.ttf +0 -0
  51. package/initramfs/usr/share/fonts/fonts.json +28 -0
  52. package/package.json +2 -2
  53. package/run-qemu.sh +1 -1
@@ -0,0 +1,324 @@
1
+ // /lib/bunterm/terminal.js — a graphical terminal on the Linux framebuffer.
2
+ //
3
+ // Bun.spawn({ terminal }) ──── PTY output ────▶ KittyGraphicsParser
4
+ // ▲ │ plain │ APC
5
+ // │ keyboard / replies ▼ ▼
6
+ // jsgotty BunPtyBackend @xterm/headless Terminal ImageStore
7
+ // │ buffer │ placements
8
+ // ▼ ▼
9
+ // Renderer (CanvasKit) ──▶ /dev/fb0
10
+ //
11
+ // The PTY, the terminal emulation and the kitty packet parser are reused
12
+ // from jsgotty and xterm.js; only the cell renderer, the images and the
13
+ // keyboard are Buninu's own.
14
+
15
+ import { createRequire } from "node:module";
16
+ import { runGraphics } from "../fbdev.js";
17
+ import { loadCanvasKit, openCanvas, loadFontSet } from "../canvas.js";
18
+ import { Terminal } from "../xterm/xterm-headless.mjs";
19
+ import { UnicodeGraphemesAddon } from "../xterm/addon-unicode-graphemes.mjs";
20
+ import { Renderer, defaultTheme } from "./renderer.js";
21
+ import { ImageStore } from "./images.js";
22
+ import { openKeyboard, translateKeys } from "./input.js";
23
+
24
+ const require = createRequire(import.meta.url);
25
+
26
+ // jsgotty is CommonJS and guards its server start on require.main, so this
27
+ // only loads the classes.
28
+ export const loadJsgotty = () => {
29
+ const gotty = require("../../buninu/apps/jsgotty/gotty.js");
30
+ if (!gotty.BunPtyBackend) {
31
+ throw new Error("this jsgotty does not export BunPtyBackend; bunterm needs jsgotty ≥ 1.1.12");
32
+ }
33
+ return gotty;
34
+ };
35
+
36
+ const writeTerminal = (term, data) => new Promise((resolve) => term.write(data, resolve));
37
+
38
+ // One terminal session: a program on a PTY, the emulator fed from it, and a
39
+ // renderer painting onto `screen` (an open canvas from canvas.js). Frames
40
+ // are coalesced, so whatever arrives between two paints is drawn together,
41
+ // at most ~60 times a second. `exited` resolves with the program's status.
42
+ export const createSession = ({
43
+ CanvasKit,
44
+ screen,
45
+ fonts,
46
+ command = "/bin/sh",
47
+ argv = [],
48
+ fontSize = 16,
49
+ lineHeight = 1,
50
+ theme = defaultTheme,
51
+ scrollback = 1000,
52
+ cursorBlink = true,
53
+ mouse = true,
54
+ env = {},
55
+ onError = (error) => console.error(`bunterm: ${error?.stack ?? error}`),
56
+ }) => {
57
+ const { BunPtyBackend, KittyGraphicsParser } = loadJsgotty();
58
+ const renderer = new Renderer({ CanvasKit, screen, fonts, fontSize, lineHeight, theme });
59
+ const term = new Terminal({
60
+ cols: renderer.columns,
61
+ rows: renderer.rows,
62
+ allowProposedApi: true,
63
+ scrollback,
64
+ });
65
+ term.loadAddon(new UnicodeGraphemesAddon());
66
+ term.unicode.activeVersion = "15-graphemes";
67
+ const images = new ImageStore({ CanvasKit, term, renderer });
68
+ const kitty = new KittyGraphicsParser();
69
+
70
+ const backend = new BunPtyBackend({
71
+ command,
72
+ argv,
73
+ width: renderer.columns,
74
+ height: renderer.rows,
75
+ closeSignal: "SIGHUP",
76
+ closeTimeout: -1,
77
+ // JSMDCUI_KITTY_MODE=extended: jsmdcui sends Markdown images as kitty
78
+ // placements with the original JPEG/WebP/GIF bytes (jsgotty's U= MIME
79
+ // extension); Skia decodes those directly, so nothing is transcoded.
80
+ headerEnv: { COLORTERM: "truecolor", TERM_PROGRAM: "bunterm", JSMDCUI_KITTY_MODE: "extended", ...env },
81
+ });
82
+
83
+ let frameTimer = null;
84
+ let blinkOn = true;
85
+ const cursorHidden = () => Boolean(term._core?.coreService?.isCursorHidden);
86
+ // Only set when a pointer is attached; without one the renderer is called
87
+ // exactly as before.
88
+ let pointer = null;
89
+ const paint = () => {
90
+ if (frameTimer) clearTimeout(frameTimer);
91
+ frameTimer = null;
92
+ const buffer = term.buffer.active;
93
+ // cursorY counts from the buffer's base, so a scrolled-back viewport
94
+ // moves the cursor down the screen and eventually off it.
95
+ const row = buffer.baseY + buffer.cursorY - buffer.viewportY;
96
+ return renderer.render(term, {
97
+ cursor: {
98
+ x: buffer.cursorX,
99
+ y: row,
100
+ visible: blinkOn && !cursorHidden() && row >= 0 && row < renderer.rows,
101
+ },
102
+ images: images.visible(),
103
+ pointer,
104
+ });
105
+ };
106
+ const requestFrame = () => {
107
+ frameTimer ??= setTimeout(paint, 16);
108
+ };
109
+ const blinkTimer = cursorBlink
110
+ ? setInterval(() => { blinkOn = !blinkOn; requestFrame(); }, 500)
111
+ : null;
112
+ const resetBlink = () => {
113
+ blinkOn = true;
114
+ if (blinkTimer) blinkTimer.refresh();
115
+ };
116
+
117
+ // Output is processed in order: plain text is parsed before the kitty
118
+ // packet that follows it, so placements see the right cursor position.
119
+ let queue = Promise.resolve();
120
+ const pump = (chunk) => {
121
+ queue = queue.then(async () => {
122
+ const { events } = kitty.consume(chunk);
123
+ for (const event of events) {
124
+ if (event.kind === "plain") {
125
+ await writeTerminal(term, event.data);
126
+ continue;
127
+ }
128
+ const buffer = term.buffer.active;
129
+ const graphic = kitty.parsePacket(event.packet, { row: buffer.cursorY + 1, col: buffer.cursorX + 1 });
130
+ if (!graphic) continue;
131
+ const motion = await images.handle(graphic);
132
+ if (motion) await writeTerminal(term, motion);
133
+ const ack = kitty.acknowledge(graphic);
134
+ if (ack) backend.write(Buffer.from(ack, "utf8"));
135
+ }
136
+ requestFrame();
137
+ }).catch(onError);
138
+ };
139
+
140
+ let closed = false;
141
+ const exited = new Promise((resolve) => {
142
+ backend.onExit(() => { queue.then(() => resolve(backend.proc?.exitCode ?? 0)); });
143
+ });
144
+ backend.onData(pump);
145
+ term.onData((data) => backend.write(Buffer.from(data, "utf8")));
146
+
147
+ // Keyboard bytes from the console, rewritten for the program's key mode.
148
+ const input = (bytes) => {
149
+ const applicationCursorKeys = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
150
+ const text = translateKeys(bytes.toString("latin1"), { applicationCursorKeys });
151
+ backend.write(Buffer.from(text, "latin1"));
152
+ // Typing returns to the live screen, as every terminal does.
153
+ if (term.buffer.active.viewportY !== term.buffer.active.baseY) {
154
+ term.scrollToBottom();
155
+ renderer.invalidate();
156
+ }
157
+ resetBlink();
158
+ requestFrame();
159
+ };
160
+
161
+ // Turns a pointer report from mouse.js into the mouse sequences the
162
+ // program asked for. xterm.js's CoreMouseService owns the protocol: it
163
+ // knows which events the program enabled (DECSET 1000/1002/1003), encodes
164
+ // them (SGR 1006 or the default form) and sends them through onData, so
165
+ // nothing here builds an escape sequence. Reports arrive only while a
166
+ // pointer is open, and this is the only place the terminal sees one.
167
+ const CoreMouseButton = { LEFT: 0, MIDDLE: 1, RIGHT: 2, NONE: 3, WHEEL: 4 };
168
+ const CoreMouseAction = { UP: 0, DOWN: 1, LEFT: 2, RIGHT: 3, MOVE: 32 };
169
+ const heldButton = (buttons) => (buttons.left ? CoreMouseButton.LEFT
170
+ : buttons.middle ? CoreMouseButton.MIDDLE
171
+ : buttons.right ? CoreMouseButton.RIGHT
172
+ : CoreMouseButton.NONE);
173
+
174
+ // Scrolls the viewport when the program is not reading the mouse itself:
175
+ // through the scrollback on the normal screen, and as arrow keys on the
176
+ // alternate screen, which has no scrollback and is what a pager expects.
177
+ const wheelScroll = (notches) => {
178
+ const lines = notches * 3;
179
+ if (term.buffer.active.type === "alternate") {
180
+ const application = Boolean(term._core?.coreService?.decPrivateModes?.applicationCursorKeys);
181
+ const key = `\u001b${application ? "O" : "["}${lines > 0 ? "A" : "B"}`;
182
+ backend.write(Buffer.from(key.repeat(Math.abs(lines)), "latin1"));
183
+ return;
184
+ }
185
+ const before = term.buffer.active.viewportY;
186
+ term.scrollLines(-lines);
187
+ if (term.buffer.active.viewportY !== before) {
188
+ // Every row on screen is different now, and identical text at the same
189
+ // row would otherwise hash the same as before.
190
+ renderer.invalidate();
191
+ requestFrame();
192
+ }
193
+ };
194
+
195
+ const reportMouse = (report) => {
196
+ pointer = { x: report.x, y: report.y };
197
+ requestFrame();
198
+ const service = term._core?.coreMouseService;
199
+ if (!service?.areMouseEventsActive) {
200
+ if (report.wheel) wheelScroll(report.wheel);
201
+ return;
202
+ }
203
+ const column = Math.floor((report.x - renderer.offsetX) / renderer.cellWidth);
204
+ const row = Math.floor((report.y - renderer.offsetY) / renderer.cellHeight);
205
+ const send = (button, action) => {
206
+ try {
207
+ service.triggerMouseEvent({ col: column, row, button, action, ctrl: false, alt: false, shift: false });
208
+ } catch (error) {
209
+ onError(error);
210
+ }
211
+ };
212
+ for (const name of report.released) {
213
+ send(CoreMouseButton[name.toUpperCase()], CoreMouseAction.UP);
214
+ }
215
+ for (const name of report.pressed) {
216
+ send(CoreMouseButton[name.toUpperCase()], CoreMouseAction.DOWN);
217
+ }
218
+ if (report.moved) send(heldButton(report.buttons), CoreMouseAction.MOVE);
219
+ for (let notch = 0; notch < Math.abs(report.wheel); notch++) {
220
+ send(CoreMouseButton.WHEEL, report.wheel > 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN);
221
+ }
222
+ for (let notch = 0; notch < Math.abs(report.hwheel); notch++) {
223
+ send(CoreMouseButton.WHEEL, report.hwheel > 0 ? CoreMouseAction.RIGHT : CoreMouseAction.LEFT);
224
+ }
225
+ };
226
+
227
+ // The pointer is opened only when asked for, by a module that is imported
228
+ // only then, so a machine without one — or a broken input layer — cannot
229
+ // affect a plain session.
230
+ let pointerDevice = null;
231
+ const attachPointer = async () => {
232
+ if (!mouse) return null;
233
+ const { openPointer, findPointers } = await import("./mouse.js");
234
+ const devices = findPointers();
235
+ if (!devices.length) throw new Error("no pointing device in /dev/input (is the evdev module loaded?)");
236
+ pointerDevice = openPointer({
237
+ devices,
238
+ width: screen.width,
239
+ height: screen.height,
240
+ onSync: reportMouse,
241
+ onError,
242
+ });
243
+ pointer = { x: pointerDevice.state.x, y: pointerDevice.state.y };
244
+ paint();
245
+ return pointerDevice;
246
+ };
247
+
248
+ // Waits until every byte received so far has been parsed, then paints.
249
+ const settle = async () => {
250
+ await queue;
251
+ return paint();
252
+ };
253
+
254
+ const close = () => {
255
+ if (closed) return;
256
+ closed = true;
257
+ if (blinkTimer) clearInterval(blinkTimer);
258
+ if (frameTimer) clearTimeout(frameTimer);
259
+ frameTimer = null;
260
+ backend.close();
261
+ pointerDevice?.close();
262
+ pointerDevice = null;
263
+ images.delete();
264
+ renderer.delete();
265
+ term.dispose();
266
+ };
267
+
268
+ paint();
269
+ return {
270
+ term, backend, renderer, images, kitty,
271
+ paint, requestFrame, settle, input, exited, close, attachPointer, reportMouse, wheelScroll,
272
+ };
273
+ };
274
+
275
+ // Runs a session on the framebuffer with the console in graphics mode and
276
+ // the keyboard in raw mode, until the program exits. `console` names the
277
+ // virtual console to use (for example /dev/tty1); by default the process's
278
+ // own console and stdin are used.
279
+ export const runTerminal = async ({ console: consoleDevice = null, device = "/dev/fb0", ...options } = {}) => {
280
+ loadJsgotty();
281
+ const CanvasKit = await loadCanvasKit();
282
+ const fonts = loadFontSet(CanvasKit, "terminal");
283
+ const consolePaths = consoleDevice ? [consoleDevice] : undefined;
284
+
285
+ return runGraphics(async (display, { onRestore, onAcquire }) => {
286
+ const screen = openCanvas(CanvasKit, display);
287
+ const session = createSession({ CanvasKit, screen, fonts, ...options });
288
+ // Coming back from another console (Ctrl-Alt-Fn): fbcon has drawn over
289
+ // the framebuffer, so repaint everything.
290
+ onAcquire(() => {
291
+ session.renderer.invalidate();
292
+ session.paint();
293
+ });
294
+ let keyboard = null;
295
+ try {
296
+ keyboard = openKeyboard({
297
+ device: consoleDevice,
298
+ onData: session.input,
299
+ onError: (error) => {
300
+ console.error(`bunterm: keyboard: ${error.message}`);
301
+ session.backend.close();
302
+ },
303
+ });
304
+ onRestore(() => keyboard.close());
305
+ if (options.mouse !== false) {
306
+ // A pointer that cannot be opened is reported and the session
307
+ // continues as a keyboard-only terminal.
308
+ try {
309
+ const attached = await session.attachPointer();
310
+ if (attached?.devices.length) {
311
+ console.error(`bunterm: mouse: ${attached.devices.map((device) => device.name).join(", ")}`);
312
+ }
313
+ } catch (error) {
314
+ console.error(`bunterm: mouse: ${error.message}`);
315
+ }
316
+ }
317
+ return await session.exited;
318
+ } finally {
319
+ keyboard?.close();
320
+ session.close();
321
+ screen.close();
322
+ }
323
+ }, { device, console: consolePaths });
324
+ };
@@ -0,0 +1,181 @@
1
+ // /lib/canvas.js — CanvasKit (Skia in WebAssembly) on the Linux framebuffer.
2
+ //
3
+ // Skia draws into a buffer inside the wasm heap that already has the
4
+ // framebuffer's own pixel layout, so a frame reaches /dev/fb0 with a single
5
+ // copy: exactly what /lib/fbdev.js does with its JavaScript back buffer. The
6
+ // Framebuffer's `pixels` view is pointed at that heap buffer while a canvas is
7
+ // open, so the fbdev.js drawing calls and flush() keep working on the same
8
+ // frame and Skia and JavaScript drawing can be mixed freely.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import CanvasKitInit from "./canvaskit/canvaskit.js";
12
+ import { flush as flushFramebuffer } from "./fbdev.js";
13
+
14
+ export const canvasKitDirectory = `${import.meta.dir}/canvaskit`;
15
+ export const fontDirectory = `${import.meta.dir}/../usr/share/fonts`;
16
+
17
+ let canvasKitPromise = null;
18
+
19
+ // One CanvasKit instance per process; the wasm heap starts at 128 MiB.
20
+ export const loadCanvasKit = () => {
21
+ canvasKitPromise ??= CanvasKitInit({
22
+ wasmBinary: readFileSync(`${canvasKitDirectory}/canvaskit.wasm`),
23
+ });
24
+ return canvasKitPromise;
25
+ };
26
+
27
+ const fieldIs = (field, offset, length) =>
28
+ field.offset === offset && field.length === length;
29
+
30
+ // Maps the kernel's bitfield description to a Skia ColorType with the same
31
+ // byte order in memory. The fields are little-endian bit offsets, so 32-bit
32
+ // red@16 green@8 blue@0 is B,G,R,X in memory: Skia's BGRA_8888. Only formats
33
+ // Skia can rasterize into directly are accepted; others must go through
34
+ // blitImageData() in fbdev.js.
35
+ export const pickColorType = (CanvasKit, display) => {
36
+ const { red, green, blue } = display.fields;
37
+ if (display.bitsPerPixel === 32) {
38
+ if (fieldIs(red, 16, 8) && fieldIs(green, 8, 8) && fieldIs(blue, 0, 8)) {
39
+ return CanvasKit.ColorType.BGRA_8888;
40
+ }
41
+ if (fieldIs(red, 0, 8) && fieldIs(green, 8, 8) && fieldIs(blue, 16, 8)) {
42
+ return CanvasKit.ColorType.RGBA_8888;
43
+ }
44
+ }
45
+ if (display.bitsPerPixel === 16
46
+ && fieldIs(red, 11, 5) && fieldIs(green, 5, 6) && fieldIs(blue, 0, 5)) {
47
+ return CanvasKit.ColorType.RGB_565;
48
+ }
49
+ return null;
50
+ };
51
+
52
+ const describeFields = ({ red, green, blue }) =>
53
+ `red@${red.offset}/${red.length} green@${green.offset}/${green.length} blue@${blue.offset}/${blue.length}`;
54
+
55
+ // Binds a Skia raster surface to an open Framebuffer. `display` only needs
56
+ // the fields fbdev.js's Framebuffer exposes (width, height, stride,
57
+ // bitsPerPixel, fields, pixels), so a plain object backed by memory works for
58
+ // tests and off-screen rendering.
59
+ export const openCanvas = (CanvasKit, display) => {
60
+ const colorType = pickColorType(CanvasKit, display);
61
+ if (colorType === null) {
62
+ throw new Error(`no Skia color type for a ${display.bitsPerPixel} bpp framebuffer `
63
+ + `(${describeFields(display.fields)}); use blitImageData() instead`);
64
+ }
65
+ const length = display.stride * display.height;
66
+ const memory = CanvasKit.Malloc(Uint8Array, length);
67
+ const surface = CanvasKit.MakeRasterDirectSurface({
68
+ width: display.width,
69
+ height: display.height,
70
+ colorType,
71
+ alphaType: CanvasKit.AlphaType.Opaque,
72
+ colorSpace: CanvasKit.ColorSpace.SRGB,
73
+ }, memory, display.stride);
74
+ if (!surface) {
75
+ CanvasKit.Free(memory);
76
+ throw new Error("MakeRasterDirectSurface failed");
77
+ }
78
+ const previousPixels = display.pixels;
79
+ // Start from whatever the JavaScript back buffer held (usually zeros).
80
+ memory.toTypedArray().set(previousPixels.subarray(0, length));
81
+ display.pixels = memory.toTypedArray();
82
+ let open = true;
83
+ return {
84
+ CanvasKit,
85
+ display,
86
+ surface,
87
+ canvas: surface.getCanvas(),
88
+ colorType,
89
+ width: display.width,
90
+ height: display.height,
91
+ // Finishes pending Skia work and copies the frame to the framebuffer.
92
+ // toTypedArray() is re-read because wasm heap growth detaches old views.
93
+ flush() {
94
+ if (!open) throw new Error("canvas is closed");
95
+ surface.flush();
96
+ display.pixels = memory.toTypedArray();
97
+ flushFramebuffer(display);
98
+ },
99
+ // Releases the Skia surface and heap buffer; the Framebuffer stays open
100
+ // and gets its own back buffer back, holding the last frame.
101
+ close() {
102
+ if (!open) return;
103
+ open = false;
104
+ previousPixels.set(memory.toTypedArray().subarray(0, previousPixels.length));
105
+ display.pixels = previousPixels;
106
+ surface.delete();
107
+ CanvasKit.Free(memory);
108
+ },
109
+ };
110
+ };
111
+
112
+ // Font catalogue: /usr/share/fonts/fonts.json names every bundled file and
113
+ // the family sets built from them. Files load lazily, since the CJK
114
+ // collection alone is 32 MiB and is copied into the wasm heap.
115
+ let catalogue = null;
116
+
117
+ export const fontCatalogue = () => {
118
+ catalogue ??= JSON.parse(readFileSync(`${fontDirectory}/fonts.json`, "utf8"));
119
+ return catalogue;
120
+ };
121
+
122
+ export const fontPath = (name) => {
123
+ const entry = fontCatalogue().fonts[name];
124
+ if (!entry) throw new Error(`unknown font ${JSON.stringify(name)}`);
125
+ return `${fontDirectory}/${entry.file}`;
126
+ };
127
+
128
+ const fontData = new Map();
129
+
130
+ export const loadFontData = (name) => {
131
+ let data = fontData.get(name);
132
+ if (!data) {
133
+ const bytes = readFileSync(fontPath(name));
134
+ data = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
135
+ fontData.set(name, data);
136
+ }
137
+ return data;
138
+ };
139
+
140
+ // A named font set from fonts.json as CanvasKit objects:
141
+ // typefaces the "regular", "bold" and each fallback font as a Typeface
142
+ // (a collection such as Noto Sans CJK yields its preferred face)
143
+ // fontMgr every file of the set registered by family name, for the
144
+ // Paragraph API; a collection registers all of its families
145
+ // families family names for ParagraphStyle.textStyle.fontFamilies, in
146
+ // lookup order
147
+ export const loadFontSet = (CanvasKit, setName = "terminal") => {
148
+ const { sets, fonts } = fontCatalogue();
149
+ const set = sets[setName];
150
+ if (!set) throw new Error(`unknown font set ${JSON.stringify(setName)}`);
151
+ const names = [set.regular, set.bold, ...(set.fallback ?? [])].filter(Boolean);
152
+ const buffers = names.map(loadFontData);
153
+ const fontMgr = CanvasKit.FontMgr.FromData(...buffers);
154
+ if (!fontMgr) throw new Error("FontMgr.FromData failed");
155
+ const typefaces = {};
156
+ names.forEach((name, index) => {
157
+ // A collection's "preferred" family picks the face (Noto Sans CJK TC for
158
+ // Taiwanese glyph forms); a single-face file loads directly.
159
+ const preferred = fonts[name].preferred;
160
+ const typeface = preferred
161
+ ? fontMgr.matchFamilyStyle(preferred, {})
162
+ : CanvasKit.Typeface.MakeFreeTypeFaceFromData(buffers[index]);
163
+ if (!typeface) throw new Error(`cannot load font ${name} (${fonts[name].file})`);
164
+ typefaces[name] = typeface;
165
+ });
166
+ const families = names.flatMap((name) => fonts[name].families ?? [name]);
167
+ return {
168
+ set,
169
+ names,
170
+ typefaces,
171
+ regular: typefaces[set.regular],
172
+ bold: set.bold ? typefaces[set.bold] : null,
173
+ fallback: (set.fallback ?? []).map((name) => typefaces[name]),
174
+ fontMgr,
175
+ families,
176
+ delete() {
177
+ for (const typeface of Object.values(typefaces)) typeface.delete();
178
+ fontMgr.delete();
179
+ },
180
+ };
181
+ };
@@ -0,0 +1,29 @@
1
+ // Copyright (c) 2011 Google Inc. All rights reserved.
2
+ //
3
+ // Redistribution and use in source and binary forms, with or without
4
+ // modification, are permitted provided that the following conditions are
5
+ // met:
6
+ //
7
+ // * Redistributions of source code must retain the above copyright
8
+ // notice, this list of conditions and the following disclaimer.
9
+ // * Redistributions in binary form must reproduce the above
10
+ // copyright notice, this list of conditions and the following disclaimer
11
+ // in the documentation and/or other materials provided with the
12
+ // distribution.
13
+ // * Neither the name of Google Inc. nor the names of its
14
+ // contributors may be used to endorse or promote products derived from
15
+ // this software without specific prior written permission.
16
+ //
17
+ // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18
+ // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19
+ // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20
+ // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21
+ // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22
+ // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23
+ // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24
+ // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25
+ // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
+ // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
+
29
+ --------------------------------------------------------------------------------