woff2-esbuild-plugins.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  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 { outdir, generateTtf } = options;
  23. const outputDir = path.resolve(outdir);
  24. const fonts = new Map();
  25. build.onResolve({ filter: /\.woff2$/ }, (args) => {
  26. const resolvedPath = path.resolve(args.resolveDir, args.path);
  27. return {
  28. path: resolvedPath,
  29. namespace: "woff2ServerPlugin",
  30. };
  31. });
  32. build.onLoad(
  33. { filter: /.*/, namespace: "woff2ServerPlugin" },
  34. async (args) => {
  35. let woff2Buffer;
  36. if (path.isAbsolute(args.path)) {
  37. // read local woff2 as a buffer (WARN: `readFileSync` does not work!)
  38. woff2Buffer = await fs.promises.readFile(args.path);
  39. } else {
  40. throw new Error(`Font path has to be absolute! "${args.path}"`);
  41. }
  42. // google's brotli decompression into snft
  43. const snftBuffer = new Uint8Array(
  44. await wawoff.decompress(woff2Buffer),
  45. ).buffer;
  46. // load font and store per fontfamily & subfamily cache
  47. let font;
  48. try {
  49. font = Font.create(snftBuffer, {
  50. type: "ttf",
  51. hinting: true,
  52. kerning: true,
  53. });
  54. } catch {
  55. // if loading as ttf fails, try to load as otf
  56. font = Font.create(snftBuffer, {
  57. type: "otf",
  58. hinting: true,
  59. kerning: true,
  60. });
  61. }
  62. const fontFamily = font.data.name.fontFamily;
  63. const subFamily = font.data.name.fontSubFamily;
  64. if (!fonts.get(fontFamily)) {
  65. fonts.set(fontFamily, {});
  66. }
  67. if (!fonts.get(fontFamily)[subFamily]) {
  68. fonts.get(fontFamily)[subFamily] = [];
  69. }
  70. // store the snftbuffer per subfamily
  71. fonts.get(fontFamily)[subFamily].push(font);
  72. // inline the woff2 as base64 for server-side use cases
  73. // NOTE: "file" loader is broken in commonjs and "dataurl" loader does not produce correct ur
  74. return {
  75. contents: `data:font/woff2;base64,${woff2Buffer.toString(
  76. "base64",
  77. )}`,
  78. loader: "text",
  79. };
  80. },
  81. );
  82. build.onEnd(async () => {
  83. if (!generateTtf) {
  84. return;
  85. }
  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 xiaolaiFont = Font.create(fs.readFileSync(xiaolaiPath), {
  109. type: "ttf",
  110. });
  111. const emojiFont = Font.create(fs.readFileSync(emojiPath), {
  112. type: "ttf",
  113. });
  114. const sortedFonts = Array.from(fonts.entries()).sort(
  115. ([family1], [family2]) => (family1 > family2 ? 1 : -1),
  116. );
  117. // for now we are interested in the regular families only
  118. for (const [family, { Regular }] of sortedFonts) {
  119. if (family.includes("Xiaolai")) {
  120. // don't generate ttf for Xiaolai, as we have it hardcoded as one ttf
  121. continue;
  122. }
  123. const fallbackFontsPaths = [];
  124. const shouldIncludeXiaolaiFallback = family.includes("Excalifont");
  125. if (shouldIncludeXiaolaiFallback) {
  126. fallbackFontsPaths.push(xiaolaiPath);
  127. }
  128. const baseFont = Regular[0];
  129. const tempPaths = Regular.map((_, index) =>
  130. path.resolve(outputDir, `temp_${family}_${index}.ttf`),
  131. );
  132. for (const [index, font] of Regular.entries()) {
  133. // tempFileNames
  134. if (!fs.existsSync(outputDir)) {
  135. fs.mkdirSync(outputDir, { recursive: true });
  136. }
  137. // write down the buffer
  138. fs.writeFileSync(tempPaths[index], font.write({ type: "ttf" }));
  139. }
  140. const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
  141. if (baseFont.data.head.unitsPerEm === 2048) {
  142. fallbackFontsPaths.push(emojiPath_2048);
  143. } else {
  144. fallbackFontsPaths.push(emojiPath);
  145. }
  146. // drop Vertical related metrics, otherwise it does not allow us to merge the fonts
  147. // vhea (Vertical Header Table)
  148. // vmtx (Vertical Metrics Table)
  149. execSync(
  150. `pyftmerge --drop-tables=vhea,vmtx --output-file="${mergedFontPath}" "${tempPaths.join(
  151. '" "',
  152. )}" "${fallbackFontsPaths.join('" "')}"`,
  153. );
  154. // cleanup
  155. for (const path of tempPaths) {
  156. fs.rmSync(path);
  157. }
  158. // yeah, we need to read the font again (:
  159. const mergedFont = Font.create(fs.readFileSync(mergedFontPath), {
  160. type: "ttf",
  161. kerning: true,
  162. hinting: true,
  163. });
  164. const getNameField = (field) => {
  165. const base = baseFont.data.name[field];
  166. const xiaolai = xiaolaiFont.data.name[field];
  167. const emoji = emojiFont.data.name[field];
  168. return shouldIncludeXiaolaiFallback
  169. ? `${base} & ${xiaolai} & ${emoji}`
  170. : `${base} & ${emoji}`;
  171. };
  172. mergedFont.set({
  173. ...mergedFont.data,
  174. name: {
  175. ...mergedFont.data.name,
  176. copyright: getNameField("copyright"),
  177. licence: getNameField("licence"),
  178. },
  179. });
  180. fs.rmSync(mergedFontPath);
  181. fs.writeFileSync(mergedFontPath, mergedFont.write({ type: "ttf" }));
  182. const { ascent, descent } = baseFont.data.hhea;
  183. console.info(`Generated "${family}"`);
  184. if (Regular.length > 1) {
  185. console.info(
  186. `- by merging ${Regular.length} woff2 fonts and related fallback fonts`,
  187. );
  188. }
  189. console.info(
  190. `- with metrics ${baseFont.data.head.unitsPerEm}, ${ascent}, ${descent}`,
  191. );
  192. console.info(``);
  193. }
  194. });
  195. },
  196. };
  197. };