2025-08-14 20:35:33 -07:00
|
|
|
export async function buildFonts(fonts: sg.Font[]) {
|
|
|
|
if (fonts.length === 0) return;
|
|
|
|
|
|
|
|
const temp = path.resolve(".clover/font");
|
|
|
|
const venv = path.join(temp, "venv");
|
|
|
|
const bin = path.join(venv, "bin");
|
|
|
|
|
|
|
|
if (!fs.existsSync(venv)) {
|
|
|
|
await subprocess.exec("python", ["-m", "venv", venv]);
|
|
|
|
}
|
|
|
|
if (!fs.existsSync(path.join(bin, "pyftsubset"))) {
|
|
|
|
await subprocess.exec(path.join(bin, "pip"), [
|
|
|
|
"install",
|
|
|
|
"fonttools==4.38.0",
|
|
|
|
"brotli==1.0.7",
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
const instances = new async.OnceMap<void>();
|
|
|
|
async function makeInstance(input: string, vars: sg.FontVars) {
|
|
|
|
const args = Object.entries(vars).map((x) => {
|
|
|
|
const lower = x[0].toLowerCase();
|
|
|
|
const k = lower === "wght" || lower === "slnt" ? lower : x[0];
|
|
|
|
const v = Array.isArray(x[1]) ? x[1].join(":") : x[1];
|
|
|
|
return `${k}=${v}`;
|
|
|
|
}).sort();
|
|
|
|
const hash = crypto
|
|
|
|
.createHash("sha256")
|
|
|
|
.update(`${input}${args.join(" ")}`)
|
|
|
|
.digest("hex")
|
|
|
|
.slice(0, 16)
|
|
|
|
.toLowerCase();
|
|
|
|
const outfile = path.join(temp, `${hash}${path.extname(input)}`);
|
|
|
|
await instances.get(outfile, async () => {
|
|
|
|
await fs.rm(outfile, { force: true });
|
|
|
|
await subprocess.exec(path.join(bin, "fonttools"), [
|
|
|
|
"varLib.instancer",
|
|
|
|
input,
|
|
|
|
...args,
|
|
|
|
`--output=${outfile}`,
|
|
|
|
]);
|
|
|
|
ASSERT(fs.existsSync(outfile));
|
|
|
|
});
|
|
|
|
return outfile;
|
|
|
|
}
|
|
|
|
|
|
|
|
await Promise.all(fonts.map((font) =>
|
|
|
|
incr.work(async (io, font) => {
|
|
|
|
const baseFile = await fetchFont(font.name, font.sources);
|
|
|
|
await Promise.all(font.subsets.map(async (subset) => {
|
|
|
|
let file = baseFile;
|
|
|
|
if (subset.vars) {
|
|
|
|
file = await makeInstance(baseFile, subset.vars);
|
|
|
|
}
|
|
|
|
const unicodes = fontRangesToString(
|
|
|
|
Array.isArray(subset.unicodes) ? subset.unicodes : [subset.unicodes],
|
|
|
|
);
|
|
|
|
const hash = crypto
|
|
|
|
.createHash("sha256")
|
|
|
|
.update(`${file}${unicodes}`)
|
|
|
|
.digest("hex")
|
|
|
|
.slice(0, 16)
|
|
|
|
.toLowerCase();
|
|
|
|
const woff = path.join(temp, hash + ".woff2");
|
|
|
|
await subprocess.exec(path.join(bin, "pyftsubset"), [
|
|
|
|
file,
|
|
|
|
"--flavor=woff2",
|
|
|
|
`--output-file=${woff}`,
|
|
|
|
...subset.layoutFeatures
|
|
|
|
? [`--layout-features=${subset.layoutFeatures.join(",")}`]
|
|
|
|
: [],
|
|
|
|
`--unicodes=${unicodes}`,
|
|
|
|
]);
|
|
|
|
await io.writeAsset({
|
|
|
|
pathname: subset.asset.replace("[ext]", "woff2"),
|
|
|
|
buffer: await fs.readFile(woff),
|
|
|
|
});
|
|
|
|
}));
|
|
|
|
}, font)
|
|
|
|
));
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function fetchFont(name: string, sources: string[]) {
|
|
|
|
const errs = [];
|
|
|
|
|
|
|
|
for (const source of sources) {
|
|
|
|
const cacheName = path.join("", name + path.extname(source));
|
|
|
|
if (fs.existsSync(cacheName)) return cacheName;
|
|
|
|
|
|
|
|
if (source.startsWith("https://")) {
|
2025-08-15 22:30:58 -07:00
|
|
|
try {
|
|
|
|
const response = await fetch(source);
|
|
|
|
if (response.ok) {
|
|
|
|
await fs.writeMkdir(
|
|
|
|
cacheName,
|
|
|
|
Buffer.from(await response.arrayBuffer()),
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
errs.push(
|
|
|
|
new Error(
|
|
|
|
`Fetching from ${source} failed: ${response.status} ${response.statusText}`,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
errs.push(`Fetching from ${source} failed: ${error.message(err)}`);
|
2025-08-14 20:35:33 -07:00
|
|
|
continue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (path.isAbsolute(source)) {
|
|
|
|
if (fs.existsSync(source)) return source;
|
|
|
|
errs.push(new Error(`Font not available at absolute path ${source}`));
|
|
|
|
} else if (!source.includes("/") && !source.includes("\\")) {
|
|
|
|
const home = os.homedir();
|
|
|
|
const bases = process.platform === "win32"
|
|
|
|
? [
|
|
|
|
"\\Windows\\Fonts",
|
|
|
|
path.win32.join(home, "AppData\\Local\\Microsoft\\Windows\\Fonts"),
|
|
|
|
]
|
|
|
|
: process.platform === "darwin"
|
|
|
|
? [
|
|
|
|
"/Library/Fonts",
|
|
|
|
path.posix.join(home, "Library/Fonts"),
|
|
|
|
]
|
|
|
|
: [
|
|
|
|
"/usr/share/fonts",
|
|
|
|
"/usr/local/share/fonts",
|
|
|
|
path.posix.join(home, ".local/share/fonts"),
|
|
|
|
];
|
|
|
|
for (const base of bases) {
|
|
|
|
const found = fs.readDirRecOptionalSync(base)
|
|
|
|
.find((file) => path.basename(file) === source);
|
|
|
|
if (found) {
|
|
|
|
return path.join(base, found);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
errs.push(
|
|
|
|
new Error(
|
|
|
|
`Font file ${source} not found in the following directories: ${
|
|
|
|
bases.join(", ")
|
|
|
|
}`,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
throw new AggregateError(errs, `Failed to fetch the ${name} font`);
|
|
|
|
}
|
|
|
|
|
|
|
|
function fontRangesToString(ranges: sg.FontRange[]): string {
|
|
|
|
const segments: Array<{ start: number; end: number }> = [];
|
|
|
|
|
|
|
|
ranges.forEach((range) => {
|
|
|
|
if (typeof range === "string") {
|
|
|
|
for (let i = 0; i < range.length; i++) {
|
|
|
|
const cp = range.codePointAt(i) || 0;
|
|
|
|
segments.push({ start: cp, end: cp });
|
|
|
|
}
|
|
|
|
} else if (typeof range === "number") {
|
|
|
|
segments.push({ start: range, end: range });
|
|
|
|
} else {
|
|
|
|
segments.push({ start: range.start, end: range.end });
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
segments.sort((a, b) => a.start - b.start);
|
|
|
|
|
|
|
|
const merged: Array<{ start: number; end: number }> = [];
|
|
|
|
for (const seg of segments) {
|
|
|
|
const last = merged[merged.length - 1];
|
|
|
|
if (last && seg.start <= last.end + 1) {
|
|
|
|
last.end = Math.max(last.end, seg.end);
|
|
|
|
} else {
|
|
|
|
merged.push({ ...seg });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return merged.map(({ start, end }) =>
|
|
|
|
start === end
|
|
|
|
? `U+${start.toString(16).toUpperCase().padStart(4, "0")}`
|
|
|
|
: `U+${start.toString(16).toUpperCase().padStart(4, "0")}-${
|
|
|
|
end.toString(16).toUpperCase().padStart(4, "0")
|
|
|
|
}`
|
|
|
|
).join(",");
|
|
|
|
}
|
|
|
|
|
|
|
|
import * as fs from "#sitegen/fs";
|
|
|
|
import * as os from "node:os";
|
|
|
|
import * as path from "node:path";
|
|
|
|
import * as sg from "#sitegen";
|
|
|
|
import * as subprocess from "#sitegen/subprocess";
|
|
|
|
import * as incr from "./incremental.ts";
|
|
|
|
import * as crypto from "node:crypto";
|
|
|
|
import * as async from "#sitegen/async";
|
2025-08-15 22:30:58 -07:00
|
|
|
import * as error from "#sitegen/error";
|