| 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 | */ |
| 6 | export 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 | |
| 21 | function 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 | */ |
| 50 | export 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 | |
| 91 | function normalizeAxisTag(tag: string) { |
| 92 | const lower = tag.toLowerCase(); |
| 93 | return lower === "wght" || lower === "slnt" ? lower : tag; |
| 94 | } |
| 95 | |
| 96 | async 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 | |
| 108 | async function convertFromSfnt( |
| 109 | buffer: Uint8Array, |
| 110 | targetFormat: "sfnt" | "woff2", |
| 111 | ) { |
| 112 | if (targetFormat === "sfnt") return buffer; |
| 113 | return await compressWoff2(buffer); |
| 114 | } |
| 115 | |
| 116 | const 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 */ |
| 119 | async 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 | |
| 133 | const subsetQueue = new queue.PriorityQueue(1); |
| 134 | const subsetFontBuffer = subsetQueue.wrap(subsetFontBufferUnqueued); |
| 135 | |
| 136 | async 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 | |
| 249 | type VariationAxisRange = { min: number; max: number; default?: number }; |
| 250 | type VariationAxes = Record<string, number | VariationAxisRange>; |
| 251 | |
| 252 | interface 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 | |
| 295 | const 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 | |
| 305 | function hbTag(str: string) { |
| 306 | return str.split("").reduce((a, ch) => (a << 8) + ch.charCodeAt(0), 0); |
| 307 | } |
| 308 | |
| 309 | import * as sg from "#sitegen"; |
| 310 | import * as fs from "#sitegen/fs"; |
| 311 | import { ASSERT } from "@clo/lib/assert"; |
| 312 | import * as async from "@clo/lib/async"; |
| 313 | import * as exception from "@clo/lib/exception"; |
| 314 | import type * as incremental from "@clo/lib/incremental"; |
| 315 | import * as queue from "@clo/lib/queue"; |
| 316 | import type * as ts from "@clo/lib/ts"; |
| 317 | import { once } from "node:events"; |
| 318 | import { readFile } from "node:fs/promises"; |
| 319 | import { createRequire } from "node:module"; |
| 320 | import * as os from "node:os"; |
| 321 | import * as path from "node:path"; |
| 322 | import { Worker } from "node:worker_threads"; |
| 323 | import * as wawoff2 from "wawoff2"; |
| 324 | import { ttf2eot } from "./font/ttf2eot.ts"; |