1/**
2 * the bytes of a font, from the first of `sources` that has it: the name of an
3 * installed font file, a path, or a url. `null` when this machine can reach
4 * none of them. the build keeps what was found, so nothing is fetched twice.
5 */
6export async function fetchFont(b: incremental.Build, name: string, sources: readonly string[]) {
7 using _ = b.start(`fetch ${name}`);
8 for (const source of sources) {
9 if (source.startsWith("https://")) {
10 const response = await fetch(source).catch(() => null);
11 if (response?.ok) return new Uint8Array(await response.arrayBuffer());
12 continue;
13 }
14 const file = path.isAbsolute(source) ? source : findInstalledFont(source);
15 const bytes = file && await b.readFile(file).bytes().catch(() => null);
16 if (bytes) return bytes;
17 }
18 return null;
19}
20
21function findInstalledFont(name: string) {
22 const home = os.homedir();
23 const bases = process.platform === "win32"
24 ? [
25 "\\Windows\\Fonts",
26 path.win32.join(home, "AppData\\Local\\Microsoft\\Windows\\Fonts"),
27 ]
28 : process.platform === "darwin"
29 ? [
30 "/Library/Fonts",
31 path.posix.join(home, "/Library/Fonts"),
32 ]
33 : [
34 "/usr/share/fonts",
35 "/usr/local/share/fonts",
36 path.posix.join(home, ".local/share/fonts"),
37 ];
38 for (const base of bases) {
39 const found = fs.readDirRecOptionalSync(base).find((file) => path.basename(file) === name);
40 if (found) return path.join(base, found);
41 }
42 return null;
43}
44
45/**
46 * one subset of a font in every format browsers ask for, as assets by
47 * pathname. without the font, the files production serves are used, so
48 * a machine that lacks a private font can still build the site.
49 */
50export async function subsetFont(
51 b: incremental.Build,
52 font: incremental.Step<Uint8Array | null>,
53 subset: ts.DeepReadonly<sg.Font["subsets"][number]>,
54 base: string,
55) {
56 const asset = (ext: string) => subset.asset.replace("[ext]", ext);
57 const source = await b.readStep(font);
58 if (!source) {
59 using _ = b.start(`fetch ${asset("*")}`);
60 const fetched = await Promise.all(["woff2", "ttf", "eot"].map(async (ext) => {
61 const url = new URL(asset(ext), base).href;
62 const response = await fetch(url).catch((err) => exception.message(err));
63 if (typeof response === "string" || !response.ok) {
64 const reason = typeof response === "string" ? response : `${response.status} ${response.statusText}`;
65 throw new Error(`Fetching fallback from ${url} failed: ${reason}`);
66 }
67 return [asset(ext), new Uint8Array(await response.arrayBuffer())] as const;
68 }));
69 return Object.fromEntries(fetched);
70 }
71
72 const unicodes = Array.isArray(subset.unicodes) ? subset.unicodes : [subset.unicodes];
73 const variationAxes = subset.vars && Object.fromEntries(
74 Object.entries(subset.vars).map(([key, value]) => [
75 normalizeAxisTag(key),
76 Array.isArray(value) ? { min: value[0], max: value[1] } : value,
77 ]),
78 ) as VariationAxes;
79 using _ = b.start(`subset ${asset("*")}`);
80 const [woff, ttf] = await Promise.all([
81 subsetFontBuffer(source as Uint8Array, unicodes, {
82 targetFormat: "woff2",
83 variationAxes,
84 assetName: asset("woff2"),
85 }),
86 subsetFontBuffer(source as Uint8Array, unicodes, { targetFormat: "sfnt", variationAxes, assetName: asset("ttf") }),
87 ]);
88 return { [asset("woff2")]: woff, [asset("ttf")]: ttf, [asset("eot")]: ttf2eot(ttf) };
89}
90
91function normalizeAxisTag(tag: string) {
92 const lower = tag.toLowerCase();
93 return lower === "wght" || lower === "slnt" ? lower : tag;
94}
95
96async function convertToSfnt(buffer: Uint8Array) {
97 const signature = String.fromCharCode(...buffer.subarray(0, 4));
98 if (
99 signature === "true"
100 || signature === "OTTO"
101 || signature === "\x00\x01\x00\x00"
102 ) {
103 return buffer;
104 }
105 return new Uint8Array(await wawoff2.decompress(buffer));
106}
107
108async function convertFromSfnt(
109 buffer: Uint8Array,
110 targetFormat: "sfnt" | "woff2",
111) {
112 if (targetFormat === "sfnt") return buffer;
113 return await compressWoff2(buffer);
114}
115
116const wawoff2Module = createRequire(import.meta.url).resolve("wawoff2");
117
118/** compressing is seconds of wasm, run in a thread of its own so the server and its debugger stay responsive */
119async function compressWoff2(buffer: Uint8Array): Promise<Uint8Array> {
120 const worker = new Worker(
121 "const { parentPort, workerData } = require('node:worker_threads');"
122 + "require(workerData.wawoff2).compress(workerData.buffer).then((woff2) => parentPort.postMessage(woff2));",
123 { eval: true, workerData: { wawoff2: wawoff2Module, buffer } },
124 );
125 try {
126 const [woff2] = await once(worker, "message") as [Uint8Array];
127 return new Uint8Array(woff2);
128 } finally {
129 void worker.terminate();
130 }
131}
132
133const subsetQueue = new queue.PriorityQueue(1);
134const subsetFontBuffer = subsetQueue.wrap(subsetFontBufferUnqueued);
135
136async function subsetFontBufferUnqueued(
137 originalFont: Uint8Array,
138 unicodes: sg.FontRange[],
139 opt: {
140 targetFormat: "sfnt" | "woff2";
141 variationAxes?: VariationAxes;
142 assetName?: string;
143 },
144) {
145 const charCount = unicodes.reduce<number>((count, range) => {
146 if (typeof range === "string") return count + [...range].length;
147 if (typeof range === "number") return count + 1;
148 return count + (range.end - range.start + 1);
149 }, 0);
150 const ctx = [
151 `${charCount} chars`,
152 opt.assetName ? `asset=${opt.assetName}` : "",
153 `target=${opt.targetFormat}`,
154 opt.variationAxes
155 ? `axes=${
156 Object.entries(opt.variationAxes).map(([key, value]) =>
157 typeof value === "number"
158 ? `${key}=${value}`
159 : `${key}=${value.min}:${value.max}${value.default == null ? "" : `:${value.default}`}`
160 ).join(",")
161 }`
162 : "",
163 ].filter(Boolean).join(" ");
164
165 const { harfbuzzJsWasm, getHeapU8 } = await loadAndInitializeHarfbuzz();
166 const sfntFont = await convertToSfnt(originalFont);
167
168 const input = harfbuzzJsWasm.hb_subset_input_create_or_fail();
169 if (input === 0) throw new Error(`Failed to create HarfBuzz subset input. ${ctx}`);
170
171 const fontBuffer = harfbuzzJsWasm.malloc(sfntFont.byteLength);
172 getHeapU8().set(new Uint8Array(sfntFont), fontBuffer);
173
174 const blob = harfbuzzJsWasm.hb_blob_create(
175 fontBuffer,
176 sfntFont.byteLength,
177 2,
178 0,
179 0,
180 );
181 const face = harfbuzzJsWasm.hb_face_create(blob, 0);
182 harfbuzzJsWasm.hb_blob_destroy(blob);
183
184 try {
185 const layoutFeatures = harfbuzzJsWasm.hb_subset_input_set(input, 6);
186 ASSERT(layoutFeatures, `Failed to set layout features. ${ctx}`);
187 harfbuzzJsWasm.hb_set_clear(layoutFeatures);
188 harfbuzzJsWasm.hb_set_invert(layoutFeatures);
189
190 const inputUnicodes = harfbuzzJsWasm.hb_subset_input_unicode_set(input);
191 for (const range of unicodes) {
192 if (typeof range === "string") {
193 for (const char of range) harfbuzzJsWasm.hb_set_add(inputUnicodes, char.codePointAt(0) || 0);
194 } else if (typeof range === "number") {
195 harfbuzzJsWasm.hb_set_add(inputUnicodes, range);
196 } else {
197 for (let cp = range.start; cp <= range.end; cp++) harfbuzzJsWasm.hb_set_add(inputUnicodes, cp);
198 }
199 }
200
201 if (opt.variationAxes) {
202 for (const [axisName, value] of Object.entries(opt.variationAxes)) {
203 if (typeof value === "number") {
204 ASSERT(harfbuzzJsWasm.hb_subset_input_pin_axis_location(
205 input,
206 face,
207 hbTag(axisName),
208 value,
209 ));
210 } else {
211 ASSERT(harfbuzzJsWasm.hb_subset_input_set_axis_range(
212 input,
213 face,
214 hbTag(axisName),
215 value.min,
216 value.max,
217 value.default ?? Number.NaN,
218 ));
219 }
220 }
221 }
222
223 const subset = harfbuzzJsWasm.hb_subset_or_fail(face, input);
224 ASSERT(subset, `HarfBuzz failed to subset font. ${ctx}`);
225
226 try {
227 const result = harfbuzzJsWasm.hb_face_reference_blob(subset);
228 try {
229 const offset = harfbuzzJsWasm.hb_blob_get_data(result, 0);
230 const subsetByteLength = harfbuzzJsWasm.hb_blob_get_length(result);
231 ASSERT(subsetByteLength > 0, `Subset font was empty. ${ctx}`);
232 const subsetFont = Uint8Array.from(
233 getHeapU8().subarray(offset, offset + subsetByteLength),
234 );
235 return await convertFromSfnt(subsetFont, opt.targetFormat);
236 } finally {
237 harfbuzzJsWasm.hb_blob_destroy(result);
238 }
239 } finally {
240 harfbuzzJsWasm.hb_face_destroy(subset);
241 }
242 } finally {
243 harfbuzzJsWasm.hb_subset_input_destroy(input);
244 harfbuzzJsWasm.hb_face_destroy(face);
245 harfbuzzJsWasm.free(fontBuffer);
246 }
247}
248
249type VariationAxisRange = { min: number; max: number; default?: number };
250type VariationAxes = Record<string, number | VariationAxisRange>;
251
252interface HarfbuzzSubset {
253 memory: WebAssembly.Memory;
254 malloc(size: number): number;
255 free(ptr: number): void;
256 hb_blob_create(
257 data: number,
258 length: number,
259 mode: number,
260 userData: number,
261 destroy: number,
262 ): number;
263 hb_blob_destroy(blob: number): void;
264 hb_blob_get_data(blob: number, length: number): number;
265 hb_blob_get_length(blob: number): number;
266 hb_face_create(blob: number, index: number): number;
267 hb_face_destroy(face: number): void;
268 hb_face_reference_blob(face: number): number;
269 hb_set_add(set: number, value: number): void;
270 hb_set_clear(set: number): void;
271 hb_set_invert(set: number): void;
272 hb_subset_input_create_or_fail(): number;
273 hb_subset_input_destroy(input: number): void;
274 hb_subset_input_get_flags(input: number): number;
275 hb_subset_input_set(input: number, setType: number): number;
276 hb_subset_input_set_flags(input: number, flags: number): void;
277 hb_subset_input_unicode_set(input: number): number;
278 hb_subset_input_pin_axis_location(
279 input: number,
280 face: number,
281 axisTag: number,
282 value: number,
283 ): number;
284 hb_subset_input_set_axis_range(
285 input: number,
286 face: number,
287 axisTag: number,
288 min: number,
289 max: number,
290 defaultValue: number,
291 ): number;
292 hb_subset_or_fail(face: number, input: number): number;
293}
294
295const loadAndInitializeHarfbuzz = async.once(async () => {
296 const wasm = await readFile(new URL(import.meta.resolve("harfbuzzjs/hb-subset.wasm")));
297 const { instance } = await WebAssembly.instantiate(wasm);
298 const harfbuzzJsWasm = instance.exports as unknown as HarfbuzzSubset;
299 return {
300 harfbuzzJsWasm,
301 getHeapU8: () => new Uint8Array(harfbuzzJsWasm.memory.buffer),
302 };
303});
304
305function hbTag(str: string) {
306 return str.split("").reduce((a, ch) => (a << 8) + ch.charCodeAt(0), 0);
307}
308
309import * as sg from "#sitegen";
310import * as fs from "#sitegen/fs";
311import { ASSERT } from "@clo/lib/assert";
312import * as async from "@clo/lib/async";
313import * as exception from "@clo/lib/exception";
314import type * as incremental from "@clo/lib/incremental";
315import * as queue from "@clo/lib/queue";
316import type * as ts from "@clo/lib/ts";
317import { once } from "node:events";
318import { readFile } from "node:fs/promises";
319import { createRequire } from "node:module";
320import * as os from "node:os";
321import * as path from "node:path";
322import { Worker } from "node:worker_threads";
323import * as wawoff2 from "wawoff2";
324import { ttf2eot } from "./font/ttf2eot.ts";