const fs = require("fs"); const path = require("path"); const { execSync } = require("child_process"); const which = require("which"); const fetch = require("node-fetch"); const wawoff = require("wawoff2"); const { Font } = require("fonteditor-core"); /** * Custom esbuild plugin to convert url woff2 imports into a text. * Other woff2 imports are handled by a "file" loader. * * @returns {import("esbuild").Plugin} */ module.exports.woff2BrowserPlugin = () => { return { name: "woff2BrowserPlugin", setup(build) { build.initialOptions.loader = { ".woff2": "file", ...build.initialOptions.loader, }; build.onResolve({ filter: /^https:\/\/.+?\.woff2$/ }, (args) => { return { path: args.path, namespace: "woff2BrowserPlugin", }; }); build.onLoad( { filter: /.*/, namespace: "woff2BrowserPlugin" }, async (args) => { return { contents: args.path, loader: "text", }; }, ); }, }; }; /** * 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 emoji font for each * - printing out font metrics * * @returns {import("esbuild").Plugin} */ module.exports.woff2ServerPlugin = (options = {}) => { // google CDN fails time to time, so let's retry async function fetchRetry(url, options = {}, retries = 0, delay = 1000) { try { const response = await fetch(url, options); if (!response.ok) { throw new Error(`Status: ${response.status}, ${await response.json()}`); } return response; } catch (e) { if (retries > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); return fetchRetry(url, options, retries - 1, delay * 2); } console.error(`Couldn't fetch: ${url}, error: ${e.message}`); throw e; } } 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 = args.path.startsWith("http") ? args.path // url : path.resolve(args.resolveDir, args.path); // absolute 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 { // fetch remote woff2 as a buffer (i.e. from a cdn) const response = await fetchRetry(args.path, {}, 3); woff2Buffer = await response.buffer(); } // 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", }; }, ); // TODO: strip away some unnecessary glyphs 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 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) { const baseFont = Regular[0]; const tempFilePaths = 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(tempFilePaths[index], font.write({ type: "ttf" })); } const emojiFilePath = path.resolve( __dirname, "./assets/NotoEmoji-Regular.ttf", ); const emojiBuffer = fs.readFileSync(emojiFilePath); const emojiFont = Font.create(emojiBuffer, { type: "ttf" }); // hack so that: // - emoji font has same metrics as the base font, otherwise pyftmerge throws due to different unitsPerEm // - emoji font glyphs are adjusted based to the base font glyphs, otherwise the glyphs don't match const patchedEmojiFont = Font.create({ ...baseFont.data, glyf: baseFont.find({ unicode: [65] }), // adjust based on the "A" glyph (does not have to be first) }).merge(emojiFont, { adjustGlyf: true }); const emojiTempFilePath = path.resolve( outputDir, `temp_${family}_Emoji.ttf`, ); fs.writeFileSync( emojiTempFilePath, patchedEmojiFont.write({ type: "ttf" }), ); const mergedFontPath = path.resolve(outputDir, `${family}.ttf`); execSync( `pyftmerge --output-file="${mergedFontPath}" "${tempFilePaths.join( '" "', )}" "${emojiTempFilePath}"`, ); // cleanup fs.rmSync(emojiTempFilePath); for (const path of tempFilePaths) { fs.rmSync(path); } // yeah, we need to read the font again (: const mergedFont = Font.create(fs.readFileSync(mergedFontPath), { type: "ttf", kerning: true, hinting: true, }); // keep copyright & licence per both fonts, as per the OFL licence mergedFont.set({ ...mergedFont.data, name: { ...mergedFont.data.name, copyright: `${baseFont.data.name.copyright} & ${emojiFont.data.name.copyright}`, licence: `${baseFont.data.name.licence} & ${emojiFont.data.name.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 files and 1 emoji ttf file`, ); } console.info( `- with metrics ${baseFont.data.head.unitsPerEm}, ${ascent}, ${descent}`, ); console.info(``); } }); }, }; };