You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/scripts/woff2/woff2-esbuild-plugins.js

233 lines
7.5 KiB
JavaScript

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(``);
}
});
},
};
};