123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- const fs = require("fs");
- const path = require("path");
- const { execSync } = require("child_process");
- const which = require("which");
- const wawoff = require("wawoff2");
- const { Font } = require("fonteditor-core");
- /**
- * Custom esbuild plugin to:
- * 1. inline all woff2 (url and relative imports) as base64 for server-side use cases (no need for additional font fetch; works in both esm and commonjs)
- * 2. convert all the imported fonts (including those from cdn) at build time into .ttf (since Resvg does not support woff2, neither inlined dataurls - https://github.com/RazrFalcon/resvg/issues/541)
- * - merging multiple woff2 into one ttf (for same families with different unicode ranges)
- * - deduplicating glyphs due to the merge process
- * - merging fallback font for each
- * - printing out font metrics
- *
- * @returns {import("esbuild").Plugin}
- */
- module.exports.woff2ServerPlugin = (options = {}) => {
- return {
- name: "woff2ServerPlugin",
- setup(build) {
- const { outdir, generateTtf } = options;
- const outputDir = path.resolve(outdir);
- const fonts = new Map();
- build.onResolve({ filter: /\.woff2$/ }, (args) => {
- const resolvedPath = path.resolve(args.resolveDir, args.path);
- return {
- path: resolvedPath,
- namespace: "woff2ServerPlugin",
- };
- });
- build.onLoad(
- { filter: /.*/, namespace: "woff2ServerPlugin" },
- async (args) => {
- let woff2Buffer;
- if (path.isAbsolute(args.path)) {
- // read local woff2 as a buffer (WARN: `readFileSync` does not work!)
- woff2Buffer = await fs.promises.readFile(args.path);
- } else {
- throw new Error(`Font path has to be absolute! "${args.path}"`);
- }
- // google's brotli decompression into snft
- const snftBuffer = new Uint8Array(
- await wawoff.decompress(woff2Buffer),
- ).buffer;
- // load font and store per fontfamily & subfamily cache
- let font;
- try {
- font = Font.create(snftBuffer, {
- type: "ttf",
- hinting: true,
- kerning: true,
- });
- } catch {
- // if loading as ttf fails, try to load as otf
- font = Font.create(snftBuffer, {
- type: "otf",
- hinting: true,
- kerning: true,
- });
- }
- const fontFamily = font.data.name.fontFamily;
- const subFamily = font.data.name.fontSubFamily;
- if (!fonts.get(fontFamily)) {
- fonts.set(fontFamily, {});
- }
- if (!fonts.get(fontFamily)[subFamily]) {
- fonts.get(fontFamily)[subFamily] = [];
- }
- // store the snftbuffer per subfamily
- fonts.get(fontFamily)[subFamily].push(font);
- // inline the woff2 as base64 for server-side use cases
- // NOTE: "file" loader is broken in commonjs and "dataurl" loader does not produce correct ur
- return {
- contents: `data:font/woff2;base64,${woff2Buffer.toString(
- "base64",
- )}`,
- loader: "text",
- };
- },
- );
- build.onEnd(async () => {
- if (!generateTtf) {
- return;
- }
- const isFontToolsInstalled = await which("fonttools", {
- nothrow: true,
- });
- if (!isFontToolsInstalled) {
- console.error(
- `Skipped TTF generation: install "fonttools" first in order to generate TTF fonts!\nhttps://github.com/fonttools/fonttools`,
- );
- return;
- }
- const xiaolaiPath = path.resolve(
- __dirname,
- "./assets/Xiaolai-Regular.ttf",
- );
- const emojiPath = path.resolve(
- __dirname,
- "./assets/NotoEmoji-Regular.ttf",
- );
- // need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
- const emojiPath_2048 = path.resolve(
- __dirname,
- "./assets/NotoEmoji-Regular-2048.ttf",
- );
- const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
- type: "ttf",
- });
- const emojiFont = Font.create(fs.readFileSync(emojiPath), {
- type: "ttf",
- });
- const sortedFonts = Array.from(fonts.entries()).sort(
- ([family1], [family2]) => (family1 > family2 ? 1 : -1),
- );
- // for now we are interested in the regular families only
- for (const [family, { Regular }] of sortedFonts) {
- if (family.includes("Xiaolai")) {
- // don't generate ttf for Xiaolai, as we have it hardcoded as one ttf
- continue;
- }
- const fallbackFontsPaths = [];
- const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
- if (shouldIncludeXiaolaiFallback) {
- fallbackFontsPaths.push(xiaolaiPath);
- }
- const baseFont = Regular[0];
- const tempPaths = Regular.map((_, index) =>
- path.resolve(outputDir, `temp_${family}_${index}.ttf`),
- );
- for (const [index, font] of Regular.entries()) {
- // tempFileNames
- if (!fs.existsSync(outputDir)) {
- fs.mkdirSync(outputDir, { recursive: true });
- }
- // write down the buffer
- fs.writeFileSync(tempPaths[index], font.write({ type: "ttf" }));
- }
- const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
- if (baseFont.data.head.unitsPerEm === 2048) {
- fallbackFontsPaths.push(emojiPath_2048);
- } else {
- fallbackFontsPaths.push(emojiPath);
- }
- // drop Vertical related metrics, otherwise it does not allow us to merge the fonts
- // vhea (Vertical Header Table)
- // vmtx (Vertical Metrics Table)
- execSync(
- `pyftmerge --drop-tables=vhea,vmtx --output-file="${mergedFontPath}" "${tempPaths.join(
- '" "',
- )}" "${fallbackFontsPaths.join('" "')}"`,
- );
- // cleanup
- for (const path of tempPaths) {
- fs.rmSync(path);
- }
- // yeah, we need to read the font again (:
- const mergedFont = Font.create(fs.readFileSync(mergedFontPath), {
- type: "ttf",
- kerning: true,
- hinting: true,
- });
- const getNameField = (field) => {
- const base = baseFont.data.name[field];
- const xiaolai = xiaolaiFont.data.name[field];
- const emoji = emojiFont.data.name[field];
- return shouldIncludeXiaolaiFallback
- ? `${base} & ${xiaolai} & ${emoji}`
- : `${base} & ${emoji}`;
- };
- mergedFont.set({
- ...mergedFont.data,
- name: {
- ...mergedFont.data.name,
- copyright: getNameField("copyright"),
- licence: getNameField("licence"),
- },
- });
- fs.rmSync(mergedFontPath);
- fs.writeFileSync(mergedFontPath, mergedFont.write({ type: "ttf" }));
- const { ascent, descent } = baseFont.data.hhea;
- console.info(`Generated "${family}"`);
- if (Regular.length > 1) {
- console.info(
- `- by merging ${Regular.length} woff2 fonts and related fallback fonts`,
- );
- }
- console.info(
- `- with metrics ${baseFont.data.head.unitsPerEm}, ${ascent}, ${descent}`,
- );
- console.info(``);
- }
- });
- },
- };
- };
|