woff2-esbuild-plugins.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. const fs = require("fs");
  2. const path = require("path");
  3. const { execSync } = require("child_process");
  4. const which = require("which");
  5. const fetch = require("node-fetch");
  6. const wawoff = require("wawoff2");
  7. const { Font } = require("fonteditor-core");
  8. /**
  9. * Custom esbuild plugin to convert url woff2 imports into a text.
  10. * Other woff2 imports are handled by a "file" loader.
  11. *
  12. * @returns {import("esbuild").Plugin}
  13. */
  14. module.exports.woff2BrowserPlugin = () => {
  15. return {
  16. name: "woff2BrowserPlugin",
  17. setup(build) {
  18. build.initialOptions.loader = {
  19. ".woff2": "file",
  20. ...build.initialOptions.loader,
  21. };
  22. build.onResolve({ filter: /^https:\/\/.+?\.woff2$/ }, (args) => {
  23. return {
  24. path: args.path,
  25. namespace: "woff2BrowserPlugin",
  26. };
  27. });
  28. build.onLoad(
  29. { filter: /.*/, namespace: "woff2BrowserPlugin" },
  30. async (args) => {
  31. return {
  32. contents: args.path,
  33. loader: "text",
  34. };
  35. },
  36. );
  37. },
  38. };
  39. };
  40. /**
  41. * Custom esbuild plugin to:
  42. * 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)
  43. * 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)
  44. * - merging multiple woff2 into one ttf (for same families with different unicode ranges)
  45. * - deduplicating glyphs due to the merge process
  46. * - merging emoji font for each
  47. * - printing out font metrics
  48. *
  49. * @returns {import("esbuild").Plugin}
  50. */
  51. module.exports.woff2ServerPlugin = (options = {}) => {
  52. // google CDN fails time to time, so let's retry
  53. async function fetchRetry(url, options = {}, retries = 0, delay = 1000) {
  54. try {
  55. const response = await fetch(url, options);
  56. if (!response.ok) {
  57. throw new Error(`Status: ${response.status}, ${await response.json()}`);
  58. }
  59. return response;
  60. } catch (e) {
  61. if (retries > 0) {
  62. await new Promise((resolve) => setTimeout(resolve, delay));
  63. return fetchRetry(url, options, retries - 1, delay * 2);
  64. }
  65. console.error(`Couldn't fetch: ${url}, error: ${e.message}`);
  66. throw e;
  67. }
  68. }
  69. return {
  70. name: "woff2ServerPlugin",
  71. setup(build) {
  72. const { outdir, generateTtf } = options;
  73. const outputDir = path.resolve(outdir);
  74. const fonts = new Map();
  75. build.onResolve({ filter: /\.woff2$/ }, (args) => {
  76. const resolvedPath = args.path.startsWith("http")
  77. ? args.path // url
  78. : path.resolve(args.resolveDir, args.path); // absolute path
  79. return {
  80. path: resolvedPath,
  81. namespace: "woff2ServerPlugin",
  82. };
  83. });
  84. build.onLoad(
  85. { filter: /.*/, namespace: "woff2ServerPlugin" },
  86. async (args) => {
  87. let woff2Buffer;
  88. if (path.isAbsolute(args.path)) {
  89. // read local woff2 as a buffer (WARN: `readFileSync` does not work!)
  90. woff2Buffer = await fs.promises.readFile(args.path);
  91. } else {
  92. // fetch remote woff2 as a buffer (i.e. from a cdn)
  93. const response = await fetchRetry(args.path, {}, 3);
  94. woff2Buffer = await response.buffer();
  95. }
  96. // google's brotli decompression into snft
  97. const snftBuffer = new Uint8Array(
  98. await wawoff.decompress(woff2Buffer),
  99. ).buffer;
  100. // load font and store per fontfamily & subfamily cache
  101. let font;
  102. try {
  103. font = Font.create(snftBuffer, {
  104. type: "ttf",
  105. hinting: true,
  106. kerning: true,
  107. });
  108. } catch {
  109. // if loading as ttf fails, try to load as otf
  110. font = Font.create(snftBuffer, {
  111. type: "otf",
  112. hinting: true,
  113. kerning: true,
  114. });
  115. }
  116. const fontFamily = font.data.name.fontFamily;
  117. const subFamily = font.data.name.fontSubFamily;
  118. if (!fonts.get(fontFamily)) {
  119. fonts.set(fontFamily, {});
  120. }
  121. if (!fonts.get(fontFamily)[subFamily]) {
  122. fonts.get(fontFamily)[subFamily] = [];
  123. }
  124. // store the snftbuffer per subfamily
  125. fonts.get(fontFamily)[subFamily].push(font);
  126. // inline the woff2 as base64 for server-side use cases
  127. // NOTE: "file" loader is broken in commonjs and "dataurl" loader does not produce correct ur
  128. return {
  129. contents: `data:font/woff2;base64,${woff2Buffer.toString(
  130. "base64",
  131. )}`,
  132. loader: "text",
  133. };
  134. },
  135. );
  136. // TODO: strip away some unnecessary glyphs
  137. build.onEnd(async () => {
  138. if (!generateTtf) {
  139. return;
  140. }
  141. const isFontToolsInstalled = await which("fonttools", {
  142. nothrow: true,
  143. });
  144. if (!isFontToolsInstalled) {
  145. console.error(
  146. `Skipped TTF generation: install "fonttools" first in order to generate TTF fonts!\nhttps://github.com/fonttools/fonttools`,
  147. );
  148. return;
  149. }
  150. const sortedFonts = Array.from(fonts.entries()).sort(
  151. ([family1], [family2]) => (family1 > family2 ? 1 : -1),
  152. );
  153. // for now we are interested in the regular families only
  154. for (const [family, { Regular }] of sortedFonts) {
  155. const baseFont = Regular[0];
  156. const tempFilePaths = Regular.map((_, index) =>
  157. path.resolve(outputDir, `temp_${family}_${index}.ttf`),
  158. );
  159. for (const [index, font] of Regular.entries()) {
  160. // tempFileNames
  161. if (!fs.existsSync(outputDir)) {
  162. fs.mkdirSync(outputDir, { recursive: true });
  163. }
  164. // write down the buffer
  165. fs.writeFileSync(tempFilePaths[index], font.write({ type: "ttf" }));
  166. }
  167. const emojiFilePath = path.resolve(
  168. __dirname,
  169. "./assets/NotoEmoji-Regular.ttf",
  170. );
  171. const emojiBuffer = fs.readFileSync(emojiFilePath);
  172. const emojiFont = Font.create(emojiBuffer, { type: "ttf" });
  173. // hack so that:
  174. // - emoji font has same metrics as the base font, otherwise pyftmerge throws due to different unitsPerEm
  175. // - emoji font glyphs are adjusted based to the base font glyphs, otherwise the glyphs don't match
  176. const patchedEmojiFont = Font.create({
  177. ...baseFont.data,
  178. glyf: baseFont.find({ unicode: [65] }), // adjust based on the "A" glyph (does not have to be first)
  179. }).merge(emojiFont, { adjustGlyf: true });
  180. const emojiTempFilePath = path.resolve(
  181. outputDir,
  182. `temp_${family}_Emoji.ttf`,
  183. );
  184. fs.writeFileSync(
  185. emojiTempFilePath,
  186. patchedEmojiFont.write({ type: "ttf" }),
  187. );
  188. const mergedFontPath = path.resolve(outputDir, `${family}.ttf`);
  189. execSync(
  190. `pyftmerge --output-file="${mergedFontPath}" "${tempFilePaths.join(
  191. '" "',
  192. )}" "${emojiTempFilePath}"`,
  193. );
  194. // cleanup
  195. fs.rmSync(emojiTempFilePath);
  196. for (const path of tempFilePaths) {
  197. fs.rmSync(path);
  198. }
  199. // yeah, we need to read the font again (:
  200. const mergedFont = Font.create(fs.readFileSync(mergedFontPath), {
  201. type: "ttf",
  202. kerning: true,
  203. hinting: true,
  204. });
  205. // keep copyright & licence per both fonts, as per the OFL licence
  206. mergedFont.set({
  207. ...mergedFont.data,
  208. name: {
  209. ...mergedFont.data.name,
  210. copyright: `${baseFont.data.name.copyright} & ${emojiFont.data.name.copyright}`,
  211. licence: `${baseFont.data.name.licence} & ${emojiFont.data.name.licence}`,
  212. },
  213. });
  214. fs.rmSync(mergedFontPath);
  215. fs.writeFileSync(mergedFontPath, mergedFont.write({ type: "ttf" }));
  216. const { ascent, descent } = baseFont.data.hhea;
  217. console.info(`Generated "${family}"`);
  218. if (Regular.length > 1) {
  219. console.info(
  220. `- by merging ${Regular.length} woff2 files and 1 emoji ttf file`,
  221. );
  222. }
  223. console.info(
  224. `- with metrics ${baseFont.data.head.unitsPerEm}, ${ascent}, ${descent}`,
  225. );
  226. console.info(``);
  227. }
  228. });
  229. },
  230. };
  231. };