| 1 | export 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 | |
| 76 | async 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 | |
| 114 | export 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 | |
| 182 | function normalizeAxisTag(tag: string) { |
| 183 | const lower = tag.toLowerCase(); |
| 184 | return lower === "wght" || lower === "slnt" ? lower : tag; |
| 185 | } |
| 186 | |
| 187 | async 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 | |
| 199 | async 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 | |
| 207 | const subsetQueue = new queue.PriorityQueue(1); |
| 208 | const subsetFontBuffer = subsetQueue.wrap(subsetFontBufferUnqueued); |
| 209 | |
| 210 | async 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 | |
| 325 | type VariationAxisRange = { min: number; max: number; default?: number }; |
| 326 | type VariationAxes = Record<string, number | VariationAxisRange>; |
| 327 | |
| 328 | interface 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 | |
| 371 | const 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 | |
| 381 | function hbTag(str: string) { |
| 382 | return str.split("").reduce((a, ch) => (a << 8) + ch.charCodeAt(0), 0); |
| 383 | } |
| 384 | |
| 385 | import * as sg from "#sitegen"; |
| 386 | import * as fs from "#sitegen/fs"; |
| 387 | import { ASSERT } from "@clo/lib/assert"; |
| 388 | import * as async from "@clo/lib/async"; |
| 389 | import * as exception from "@clo/lib/exception"; |
| 390 | import * as progress from "@clo/lib/progress"; |
| 391 | import * as queue from "@clo/lib/queue"; |
| 392 | import { readFile } from "node:fs/promises"; |
| 393 | import * as os from "node:os"; |
| 394 | import * as path from "node:path"; |
| 395 | import * as wawoff2 from "wawoff2"; |
| 396 | import { ttf2eot } from "./font/ttf2eot.ts"; |
| 397 | import * as incr from "./incremental.ts"; |