1export async function buildFonts(p: progress.Ref, fonts: sg.Font[], base: string) {
2 if (fonts.length === 0) return;
3 using node = p.start("build fonts");
4 function getArgs(vars: sg.FontVars) {
5 return Object.entries(vars).sort((x) => x[0] === "wght" || x[0] === "slnt" ? 1 : 0).map((x) => {
6 const lower = x[0].toLowerCase();
7 const k = lower === "wght" || lower === "slnt" ? lower : x[0];
8 const v = Array.isArray(x[1]) ? x[1].join(":") : x[1];
9 return `${k}=${v}`;
10 });
11 }
12
13 function getVariationAxes(vars: sg.FontVars) {
14 return Object.fromEntries(
15 Object.entries(vars).map(([key, value]) => [
16 normalizeAxisTag(key),
17 Array.isArray(value) ? { min: value[0], max: value[1] } : value,
18 ]),
19 ) as VariationAxes;
20 }
21
22 await Promise.all(fonts.map((font) =>
23 incr.work(node, async (io, font) => {
24 using node = io.start(font.name);
25 let baseFile: string;
26 try {
27 baseFile = await fetchFont(node, font.name, font.sources);
28 } catch (err) {
29 using _ = node.start("fetch fallback");
30 await fetchProductionFontFiles(node, io, font, base);
31 return;
32 }
33 await using baseBuffer = async.deferred(fs.readFile(baseFile));
34 await Promise.all(font.subsets.map(async (subset) => {
35 const args = getArgs(subset.vars ?? {});
36 const unicodes = Array.isArray(subset.unicodes) ? subset.unicodes : [subset.unicodes];
37 const charCount = unicodes.reduce<number>((count, range) => {
38 if (typeof range === "string") return count + [...range].length;
39 if (typeof range === "number") return count + 1;
40 return count + (range.end - range.start + 1);
41 }, 0);
42 const variationAxes = subset.vars ? getVariationAxes(subset.vars) : undefined;
43 using _ = node.start("subset: " + [...args, `chars=${charCount}`].join(" "));
44 const [woff, ttf] = await Promise.all([
45 subsetFontBuffer(await baseBuffer, unicodes, {
46 targetFormat: "woff2",
47 variationAxes,
48 sourceName: baseFile,
49 assetName: subset.asset.replace("[ext]", "woff2"),
50 }),
51 subsetFontBuffer(await baseBuffer, unicodes, {
52 targetFormat: "sfnt",
53 variationAxes,
54 sourceName: baseFile,
55 assetName: subset.asset.replace("[ext]", "ttf"),
56 }),
57 ]);
58 const eot = ttf2eot(ttf);
59 await io.writeAsset({
60 pathname: subset.asset.replace("[ext]", "woff2"),
61 buffer: woff,
62 });
63 await io.writeAsset({
64 pathname: subset.asset.replace("[ext]", "ttf"),
65 buffer: ttf,
66 });
67 await io.writeAsset({
68 pathname: subset.asset.replace("[ext]", "eot"),
69 buffer: eot,
70 });
71 }));
72 }, font)
73 ));
74}
75
76async function fetchProductionFontFiles(
77 ref: progress.Ref,
78 io: incr.Io,
79 font: sg.Font,
80 base: string,
81) {
82 const fetchPromises = font.subsets.flatMap((subset) => {
83 return ["woff2", "ttf", "eot"].map(async (ext) => {
84 const assetPath = subset.asset.replace("[ext]", ext);
85 const cacheDir = path.join(os.tmpdir(), "sitegen-fonts", font.name);
86 fs.mkdirSync(cacheDir);
87 const cacheName = path.join(cacheDir, assetPath.replace(/[\/\\]/g, "_"));
88
89 if (!fs.existsSync(cacheName)) {
90 const url = new URL(assetPath, base).href;
91 try {
92 using _ = ref.start("fetch: " + assetPath);
93 const response = await fetch(url);
94 if (response.ok) {
95 await fs.writeMkdir(cacheName, new Uint8Array(await response.arrayBuffer()));
96 } else {
97 throw new Error(`Fetching fallback from ${url} failed: ${response.status} ${response.statusText}`);
98 }
99 } catch (err) {
100 throw new Error(`Fetching fallback from ${url} failed: ${exception.message(err)}`);
101 }
102 }
103
104 const buffer = await fs.readFile(cacheName);
105 await io.writeAsset({
106 pathname: assetPath,
107 buffer: buffer,
108 });
109 });
110 });
111 await Promise.all(fetchPromises);
112}
113
114export async function fetchFont(ref: progress.Ref, name: string, sources: string[]) {
115 using _ = ref.start("fetch");
116 const errs = [];
117
118 for (const source of sources) {
119 const cacheName = name + path.extname(source);
120 if (fs.existsSync(cacheName)) return cacheName;
121
122 if (source.startsWith("https://")) {
123 try {
124 const response = await fetch(source);
125 if (response.ok) {
126 await fs.writeMkdir(
127 cacheName,
128 new Uint8Array(await response.arrayBuffer()),
129 );
130 return cacheName;
131 } else {
132 errs.push(
133 new Error(
134 `Fetching from ${source} failed: ${response.status} ${response.statusText}`,
135 ),
136 );
137 continue;
138 }
139 } catch (err) {
140 errs.push(`Fetching from ${source} failed: ${exception.message(err)}`);
141 continue;
142 }
143 }
144
145 if (path.isAbsolute(source)) {
146 if (fs.existsSync(source)) return source;
147 errs.push(new Error(`Font not available at absolute path ${source}`));
148 } else if (!source.includes("/") && !source.includes("\\")) {
149 const home = os.homedir();
150 const bases = process.platform === "win32"
151 ? [
152 "\\Windows\\Fonts",
153 path.win32.join(home, "AppData\\Local\\Microsoft\\Windows\\Fonts"),
154 ]
155 : process.platform === "darwin"
156 ? [
157 "/Library/Fonts",
158 path.posix.join(home, "/Library/Fonts"),
159 ]
160 : [
161 "/usr/share/fonts",
162 "/usr/local/share/fonts",
163 path.posix.join(home, ".local/share/fonts"),
164 ];
165 for (const base of bases) {
166 const files = fs.readDirRecOptionalSync(base);
167 const found = files.find((file) => path.basename(file) === source);
168 if (found) return path.join(base, found);
169 }
170 errs.push(
171 new Error(
172 `Font file ${source} not found in the following directories: ${bases.join(", ")}`,
173 ),
174 );
175 }
176 }
177
178 ASSERT(errs.length > 0);
179 throw new AggregateError(errs, `Failed to fetch the ${name} font`);
180}
181
182function normalizeAxisTag(tag: string) {
183 const lower = tag.toLowerCase();
184 return lower === "wght" || lower === "slnt" ? lower : tag;
185}
186
187async function convertToSfnt(buffer: Uint8Array) {
188 const signature = String.fromCharCode(...buffer.subarray(0, 4));
189 if (
190 signature === "true"
191 || signature === "OTTO"
192 || signature === "\x00\x01\x00\x00"
193 ) {
194 return buffer;
195 }
196 return new Uint8Array(await wawoff2.decompress(buffer));
197}
198
199async function convertFromSfnt(
200 buffer: Uint8Array,
201 targetFormat: "sfnt" | "woff2",
202) {
203 if (targetFormat === "sfnt") return buffer;
204 return new Uint8Array(await wawoff2.compress(buffer));
205}
206
207const subsetQueue = new queue.PriorityQueue(1);
208const subsetFontBuffer = subsetQueue.wrap(subsetFontBufferUnqueued);
209
210async function subsetFontBufferUnqueued(
211 originalFont: Uint8Array,
212 unicodes: sg.FontRange[],
213 opt: {
214 targetFormat: "sfnt" | "woff2";
215 variationAxes?: VariationAxes;
216 sourceName?: string;
217 assetName?: string;
218 },
219) {
220 const charCount = unicodes.reduce<number>((count, range) => {
221 if (typeof range === "string") return count + [...range].length;
222 if (typeof range === "number") return count + 1;
223 return count + (range.end - range.start + 1);
224 }, 0);
225 const ctx = [
226 `${charCount} chars`,
227 opt.sourceName ? `source=${opt.sourceName}` : "",
228 opt.assetName ? `asset=${opt.assetName}` : "",
229 `target=${opt.targetFormat}`,
230 opt.variationAxes
231 ? `axes=${
232 Object.entries(opt.variationAxes).map(([key, value]) =>
233 typeof value === "number"
234 ? `${key}=${value}`
235 : `${key}=${value.min}:${value.max}${value.default == null ? "" : `:${value.default}`}`
236 ).join(",")
237 }`
238 : "",
239 ].filter(Boolean).join(" ");
240
241 const { harfbuzzJsWasm, getHeapU8 } = await loadAndInitializeHarfbuzz();
242 const sfntFont = await convertToSfnt(originalFont);
243
244 const input = harfbuzzJsWasm.hb_subset_input_create_or_fail();
245 if (input === 0) throw new Error(`Failed to create HarfBuzz subset input. ${ctx}`);
246
247 const fontBuffer = harfbuzzJsWasm.malloc(sfntFont.byteLength);
248 getHeapU8().set(new Uint8Array(sfntFont), fontBuffer);
249
250 const blob = harfbuzzJsWasm.hb_blob_create(
251 fontBuffer,
252 sfntFont.byteLength,
253 2,
254 0,
255 0,
256 );
257 const face = harfbuzzJsWasm.hb_face_create(blob, 0);
258 harfbuzzJsWasm.hb_blob_destroy(blob);
259
260 try {
261 const layoutFeatures = harfbuzzJsWasm.hb_subset_input_set(input, 6);
262 ASSERT(layoutFeatures, `Failed to set layout features. ${ctx}`);
263 harfbuzzJsWasm.hb_set_clear(layoutFeatures);
264 harfbuzzJsWasm.hb_set_invert(layoutFeatures);
265
266 const inputUnicodes = harfbuzzJsWasm.hb_subset_input_unicode_set(input);
267 for (const range of unicodes) {
268 if (typeof range === "string") {
269 for (const char of range) harfbuzzJsWasm.hb_set_add(inputUnicodes, char.codePointAt(0) || 0);
270 } else if (typeof range === "number") {
271 harfbuzzJsWasm.hb_set_add(inputUnicodes, range);
272 } else {
273 for (let cp = range.start; cp <= range.end; cp++) harfbuzzJsWasm.hb_set_add(inputUnicodes, cp);
274 }
275 }
276
277 if (opt.variationAxes) {
278 for (const [axisName, value] of Object.entries(opt.variationAxes)) {
279 if (typeof value === "number") {
280 ASSERT(harfbuzzJsWasm.hb_subset_input_pin_axis_location(
281 input,
282 face,
283 hbTag(axisName),
284 value,
285 ));
286 } else {
287 ASSERT(harfbuzzJsWasm.hb_subset_input_set_axis_range(
288 input,
289 face,
290 hbTag(axisName),
291 value.min,
292 value.max,
293 value.default ?? Number.NaN,
294 ));
295 }
296 }
297 }
298
299 const subset = harfbuzzJsWasm.hb_subset_or_fail(face, input);
300 ASSERT(subset, `HarfBuzz failed to subset font. ${ctx}`);
301
302 try {
303 const result = harfbuzzJsWasm.hb_face_reference_blob(subset);
304 try {
305 const offset = harfbuzzJsWasm.hb_blob_get_data(result, 0);
306 const subsetByteLength = harfbuzzJsWasm.hb_blob_get_length(result);
307 ASSERT(subsetByteLength > 0, `Subset font was empty. ${ctx}`);
308 const subsetFont = Uint8Array.from(
309 getHeapU8().subarray(offset, offset + subsetByteLength),
310 );
311 return await convertFromSfnt(subsetFont, opt.targetFormat);
312 } finally {
313 harfbuzzJsWasm.hb_blob_destroy(result);
314 }
315 } finally {
316 harfbuzzJsWasm.hb_face_destroy(subset);
317 }
318 } finally {
319 harfbuzzJsWasm.hb_subset_input_destroy(input);
320 harfbuzzJsWasm.hb_face_destroy(face);
321 harfbuzzJsWasm.free(fontBuffer);
322 }
323}
324
325type VariationAxisRange = { min: number; max: number; default?: number };
326type VariationAxes = Record<string, number | VariationAxisRange>;
327
328interface HarfbuzzSubset {
329 memory: WebAssembly.Memory;
330 malloc(size: number): number;
331 free(ptr: number): void;
332 hb_blob_create(
333 data: number,
334 length: number,
335 mode: number,
336 userData: number,
337 destroy: number,
338 ): number;
339 hb_blob_destroy(blob: number): void;
340 hb_blob_get_data(blob: number, length: number): number;
341 hb_blob_get_length(blob: number): number;
342 hb_face_create(blob: number, index: number): number;
343 hb_face_destroy(face: number): void;
344 hb_face_reference_blob(face: number): number;
345 hb_set_add(set: number, value: number): void;
346 hb_set_clear(set: number): void;
347 hb_set_invert(set: number): void;
348 hb_subset_input_create_or_fail(): number;
349 hb_subset_input_destroy(input: number): void;
350 hb_subset_input_get_flags(input: number): number;
351 hb_subset_input_set(input: number, setType: number): number;
352 hb_subset_input_set_flags(input: number, flags: number): void;
353 hb_subset_input_unicode_set(input: number): number;
354 hb_subset_input_pin_axis_location(
355 input: number,
356 face: number,
357 axisTag: number,
358 value: number,
359 ): number;
360 hb_subset_input_set_axis_range(
361 input: number,
362 face: number,
363 axisTag: number,
364 min: number,
365 max: number,
366 defaultValue: number,
367 ): number;
368 hb_subset_or_fail(face: number, input: number): number;
369}
370
371const loadAndInitializeHarfbuzz = async.once(async () => {
372 const wasm = await readFile(new URL(import.meta.resolve("harfbuzzjs/hb-subset.wasm")));
373 const { instance } = await WebAssembly.instantiate(wasm);
374 const harfbuzzJsWasm = instance.exports as unknown as HarfbuzzSubset;
375 return {
376 harfbuzzJsWasm,
377 getHeapU8: () => new Uint8Array(harfbuzzJsWasm.memory.buffer),
378 };
379});
380
381function hbTag(str: string) {
382 return str.split("").reduce((a, ch) => (a << 8) + ch.charCodeAt(0), 0);
383}
384
385import * as sg from "#sitegen";
386import * as fs from "#sitegen/fs";
387import { ASSERT } from "@clo/lib/assert";
388import * as async from "@clo/lib/async";
389import * as exception from "@clo/lib/exception";
390import * as progress from "@clo/lib/progress";
391import * as queue from "@clo/lib/queue";
392import { readFile } from "node:fs/promises";
393import * as os from "node:os";
394import * as path from "node:path";
395import * as wawoff2 from "wawoff2";
396import { ttf2eot } from "./font/ttf2eot.ts";
397import * as incr from "./incremental.ts";