woff2-esbuild-plugins.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. const fs = require("fs");
  2. const path = require("path");
  3. const { execSync } = require("child_process");
  4. const which = require("which");
  5. const wawoff = require("wawoff2");
  6. const { Font } = require("fonteditor-core");
  7. /**
  8. * Custom esbuild plugin to:
  9. * 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)
  10. * 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)
  11. * - merging multiple woff2 into one ttf (for same families with different unicode ranges)
  12. * - deduplicating glyphs due to the merge process
  13. * - merging fallback font for each
  14. * - printing out font metrics
  15. *
  16. * @returns {import("esbuild").Plugin}
  17. */
  18. module.exports.woff2ServerPlugin = (options = {}) => {
  19. return {
  20. name: "woff2ServerPlugin",
  21. setup(build) {
  22. const fonts = new Map();
  23. build.onResolve({ filter: /\.woff2$/ }, (args) => {
  24. const resolvedPath = path.resolve(args.resolveDir, args.path);
  25. return {
  26. path: resolvedPath,
  27. namespace: "woff2ServerPlugin",
  28. };
  29. });
  30. build.onLoad(
  31. { filter: /.*/, namespace: "woff2ServerPlugin" },
  32. async (args) => {
  33. let woff2Buffer;
  34. if (path.isAbsolute(args.path)) {
  35. // read local woff2 as a buffer (WARN: `readFileSync` does not work!)
  36. woff2Buffer = await fs.promises.readFile(args.path);
  37. } else {
  38. throw new Error(`Font path has to be absolute! "${args.path}"`);
  39. }
  40. // google's brotli decompression into snft
  41. const snftBuffer = new Uint8Array(
  42. await wawoff.decompress(woff2Buffer),
  43. ).buffer;
  44. // load font and store per fontfamily & subfamily cache
  45. let font;
  46. try {
  47. font = Font.create(snftBuffer, {
  48. type: "ttf",
  49. hinting: true,
  50. kerning: true,
  51. });
  52. } catch {
  53. // if loading as ttf fails, try to load as otf
  54. font = Font.create(snftBuffer, {
  55. type: "otf",
  56. hinting: true,
  57. kerning: true,
  58. });
  59. }
  60. const fontFamily = font.data.name.fontFamily;
  61. const subFamily = font.data.name.fontSubFamily;
  62. if (!fonts.get(fontFamily)) {
  63. fonts.set(fontFamily, {});
  64. }
  65. if (!fonts.get(fontFamily)[subFamily]) {
  66. fonts.get(fontFamily)[subFamily] = [];
  67. }
  68. // store the snftbuffer per subfamily
  69. fonts.get(fontFamily)[subFamily].push(font);
  70. // inline the woff2 as base64 for server-side use cases
  71. // NOTE: "file" loader is broken in commonjs and "dataurl" loader does not produce correct ur
  72. return {
  73. contents: `data:font/woff2;base64,${woff2Buffer.toString(
  74. "base64",
  75. )}`,
  76. loader: "text",
  77. };
  78. },
  79. );
  80. build.onEnd(async () => {
  81. const { outdir } = options;
  82. if (!outdir) {
  83. return;
  84. }
  85. const outputDir = path.resolve(outdir);
  86. const isFontToolsInstalled = await which("fonttools", {
  87. nothrow: true,
  88. });
  89. if (!isFontToolsInstalled) {
  90. console.error(
  91. `Skipped TTF generation: install "fonttools" first in order to generate TTF fonts!\nhttps://github.com/fonttools/fonttools`,
  92. );
  93. return;
  94. }
  95. const xiaolaiPath = path.resolve(
  96. __dirname,
  97. "./assets/Xiaolai-Regular.ttf",
  98. );
  99. const emojiPath = path.resolve(
  100. __dirname,
  101. "./assets/NotoEmoji-Regular.ttf",
  102. );
  103. // need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
  104. const emojiPath_2048 = path.resolve(
  105. __dirname,
  106. "./assets/NotoEmoji-Regular-2048.ttf",
  107. );
  108. const liberationPath = path.resolve(
  109. __dirname,
  110. "./assets/LiberationSans-Regular.ttf",
  111. );
  112. // need to use the same em size as built-in fonts, otherwise pyftmerge throws (modified manually with font forge)
  113. const liberationPath_2048 = path.resolve(
  114. __dirname,
  115. "./assets/LiberationSans-Regular-2048.ttf",
  116. );
  117. const xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
  118. type: "ttf",
  119. });
  120. const emojiFont = Font.create(fs.readFileSync(emojiPath), {
  121. type: "ttf",
  122. });
  123. const liberationFont = Font.create(fs.readFileSync(liberationPath), {
  124. type: "ttf",
  125. });
  126. const sortedFonts = Array.from(fonts.entries()).sort(
  127. ([family1], [family2]) => (family1 > family2 ? 1 : -1),
  128. );
  129. // for now we are interested in the regular families only
  130. for (const [family, { Regular }] of sortedFonts) {
  131. if (family.includes("Xiaolai")) {
  132. // don't generate ttf for Xiaolai, as we have it hardcoded as one ttf
  133. continue;
  134. }
  135. const baseFont = Regular[0];
  136. const tempPaths = Regular.map((_, index) =>
  137. path.resolve(outputDir, `temp_${family}_${index}.ttf`),
  138. );
  139. for (const [index, font] of Regular.entries()) {
  140. // tempFileNames
  141. if (!fs.existsSync(outputDir)) {
  142. fs.mkdirSync(outputDir, { recursive: true });
  143. }
  144. // write down the buffer
  145. fs.writeFileSync(tempPaths[index], font.write({ type: "ttf" }));
  146. }
  147. const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
  148. const fallbackFontsPaths = [];
  149. const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
  150. if (shouldIncludeXiaolaiFallback) {
  151. fallbackFontsPaths.push(xiaolaiPath);
  152. }
  153. // add liberation as fallback to all fonts, so that unknown characters are rendered similarly to how browser renders them (Helvetica, Arial, etc.)
  154. if (baseFont.data.head.unitsPerEm === 2048) {
  155. fallbackFontsPaths.push(emojiPath_2048, liberationPath_2048);
  156. } else {
  157. fallbackFontsPaths.push(emojiPath, liberationPath);
  158. }
  159. // drop Vertical related metrics, otherwise it does not allow us to merge the fonts
  160. // vhea (Vertical Header Table)
  161. // vmtx (Vertical Metrics Table)
  162. execSync(
  163. `pyftmerge --drop-tables=vhea,vmtx --output-file="${mergedFontPath}" "${tempPaths.join(
  164. '" "',
  165. )}" "${fallbackFontsPaths.join('" "')}"`,
  166. );
  167. // cleanup
  168. for (const path of tempPaths) {
  169. fs.rmSync(path);
  170. }
  171. // yeah, we need to read the font again (:
  172. const mergedFont = Font.create(fs.readFileSync(mergedFontPath), {
  173. type: "ttf",
  174. kerning: true,
  175. hinting: true,
  176. });
  177. const getNameField = (field) => {
  178. const base = baseFont.data.name[field];
  179. const xiaolai = xiaolaiFont.data.name[field];
  180. const emoji = emojiFont.data.name[field];
  181. const liberation = liberationFont.data.name[field];
  182. // liberation font
  183. return shouldIncludeXiaolaiFallback
  184. ? `${base} & ${xiaolai} & ${emoji} & ${liberation}`
  185. : `${base} & ${emoji} & ${liberation}`;
  186. };
  187. mergedFont.set({
  188. ...mergedFont.data,
  189. name: {
  190. ...mergedFont.data.name,
  191. copyright: getNameField("copyright"),
  192. licence: getNameField("licence"),
  193. },
  194. });
  195. fs.rmSync(mergedFontPath);
  196. fs.writeFileSync(mergedFontPath, mergedFont.write({ type: "ttf" }));
  197. const { ascent, descent } = baseFont.data.hhea;
  198. console.info(`Generated "${family}"`);
  199. if (Regular.length > 1) {
  200. console.info(
  201. `- by merging ${Regular.length} woff2 fonts and related fallback fonts`,
  202. );
  203. }
  204. console.info(
  205. `- with metrics ${baseFont.data.head.unitsPerEm}, ${ascent}, ${descent}`,
  206. );
  207. console.info(``);
  208. }
  209. });
  210. },
  211. };
  212. };