You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
252 lines
8.3 KiB
252 lines
8.3 KiB
const { execSync } = require("child_process"); |
|
const fs = require("fs"); |
|
const path = require("path"); |
|
|
|
const { Font } = require("fonteditor-core"); |
|
const wawoff = require("wawoff2"); |
|
const which = require("which"); |
|
|
|
/** |
|
* 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 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 () => { |
|
const { outdir } = options; |
|
|
|
if (!outdir) { |
|
return; |
|
} |
|
const outputDir = path.resolve(outdir); |
|
|
|
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 liberationPath = path.resolve( |
|
__dirname, |
|
"./assets/LiberationSans-Regular.ttf", |
|
); |
|
|
|
// need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge) |
|
const liberationPath_2048 = path.resolve( |
|
__dirname, |
|
"./assets/LiberationSans-Regular-2048.ttf", |
|
); |
|
|
|
const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), { |
|
type: "ttf", |
|
}); |
|
const emojiFont = Font.create(fs.readFileSync(emojiPath), { |
|
type: "ttf", |
|
}); |
|
|
|
const liberationFont = Font.create(fs.readFileSync(liberationPath), { |
|
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 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`); |
|
|
|
const fallbackFontsPaths = []; |
|
const shouldIncludeXiaolaiFallback = family.includes("Excalifont"); |
|
|
|
if (shouldIncludeXiaolaiFallback) { |
|
fallbackFontsPaths.push(xiaolaiPath); |
|
} |
|
|
|
// add liberation as fallback to all fonts, so that unknown characters are rendered similarly to how browser renders them (Helvetica, Arial, etc.) |
|
if (baseFont.data.head.unitsPerEm === 2048) { |
|
fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048); |
|
} else { |
|
fallbackFontsPaths.push(emojiPath, liberationPath); |
|
} |
|
|
|
// 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]; |
|
const liberation = liberationFont.data.name[field]; |
|
// liberation font |
|
|
|
return shouldIncludeXiaolaiFallback |
|
? `${base} & ${xiaolai} & ${emoji} & ${liberation}` |
|
: `${base} & ${emoji} & ${liberation}`; |
|
}; |
|
|
|
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(``); |
|
} |
|
}); |
|
}, |
|
}; |
|
};
|
|
|