setup-fonts.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. #!/usr/bin/env node
  2. /**
  3. * 一次性下载字体到 reports/assets/fonts/,让报告完全离线可用
  4. *
  5. * 来源:jsdelivr + @fontsource npm 包(字体的 CDN 包装,稳定)
  6. * - Inter: 英文正文 + 标题(400/700/800/900)
  7. * - Noto Sans SC: 中文正文 + 标题(400/500/700/900)— chinese-simplified subset
  8. * - JetBrains Mono: 代码字体(400/700)
  9. *
  10. * 所有字体都是 woff2 格式,浏览器原生支持,体积最小。
  11. * 下载后生成 fonts.css,HTML 用相对路径 `./assets/fonts/fonts.css` 引用。
  12. */
  13. const fs = require('fs');
  14. const path = require('path');
  15. const https = require('https');
  16. const FONTS_DIR = path.join('reports', 'assets', 'fonts');
  17. fs.mkdirSync(FONTS_DIR, { recursive: true });
  18. // ============================================================
  19. // 字体清单(family + weight + subset)
  20. // ============================================================
  21. const FONTS = [
  22. // Inter 英文
  23. { family: 'Inter', pkg: '@fontsource/inter', weight: 400, subset: 'latin', localName: 'inter-400.woff2' },
  24. { family: 'Inter', pkg: '@fontsource/inter', weight: 500, subset: 'latin', localName: 'inter-500.woff2' },
  25. { family: 'Inter', pkg: '@fontsource/inter', weight: 600, subset: 'latin', localName: 'inter-600.woff2' },
  26. { family: 'Inter', pkg: '@fontsource/inter', weight: 700, subset: 'latin', localName: 'inter-700.woff2' },
  27. { family: 'Inter', pkg: '@fontsource/inter', weight: 800, subset: 'latin', localName: 'inter-800.woff2' },
  28. { family: 'Inter', pkg: '@fontsource/inter', weight: 900, subset: 'latin', localName: 'inter-900.woff2' },
  29. // Noto Sans SC 中文(核心字重:400/500/700/900)
  30. // chinese-simplified subset 包含 ~7000 常用汉字
  31. { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 400, subset: 'chinese-simplified', localName: 'noto-sc-400.woff2' },
  32. { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 500, subset: 'chinese-simplified', localName: 'noto-sc-500.woff2' },
  33. { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 700, subset: 'chinese-simplified', localName: 'noto-sc-700.woff2' },
  34. { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 900, subset: 'chinese-simplified', localName: 'noto-sc-900.woff2' },
  35. // latin fallback for numbers/English in Chinese font family
  36. { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 400, subset: 'latin', localName: 'noto-sc-latin-400.woff2' },
  37. { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 700, subset: 'latin', localName: 'noto-sc-latin-700.woff2' },
  38. { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 900, subset: 'latin', localName: 'noto-sc-latin-900.woff2' },
  39. // JetBrains Mono 代码
  40. { family: 'JetBrains Mono', pkg: '@fontsource/jetbrains-mono', weight: 400, subset: 'latin', localName: 'jetbrains-400.woff2' },
  41. { family: 'JetBrains Mono', pkg: '@fontsource/jetbrains-mono', weight: 500, subset: 'latin', localName: 'jetbrains-500.woff2' },
  42. { family: 'JetBrains Mono', pkg: '@fontsource/jetbrains-mono', weight: 700, subset: 'latin', localName: 'jetbrains-700.woff2' },
  43. ];
  44. function buildUrl(font) {
  45. // jsdelivr 对 @fontsource npm 包的路径格式:
  46. // https://cdn.jsdelivr.net/npm/@fontsource/<name>/files/<name>-<subset>-<weight>-normal.woff2
  47. const pkgName = font.pkg.replace('@fontsource/', '');
  48. return `https://cdn.jsdelivr.net/npm/${font.pkg}/files/${pkgName}-${font.subset}-${font.weight}-normal.woff2`;
  49. }
  50. function download(url, dest) {
  51. return new Promise((resolve, reject) => {
  52. const doDownload = (u, redirects = 0) => {
  53. if (redirects > 3) return reject(new Error('too many redirects'));
  54. https.get(u, (res) => {
  55. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
  56. res.resume();
  57. return doDownload(res.headers.location, redirects + 1);
  58. }
  59. if (res.statusCode !== 200) {
  60. res.resume();
  61. return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
  62. }
  63. const chunks = [];
  64. res.on('data', (c) => chunks.push(c));
  65. res.on('end', () => {
  66. fs.writeFileSync(dest, Buffer.concat(chunks));
  67. resolve(Buffer.concat(chunks).length);
  68. });
  69. res.on('error', reject);
  70. }).on('error', reject);
  71. };
  72. doDownload(url);
  73. });
  74. }
  75. async function main() {
  76. console.log(`╔═══ 字体离线化:下载 ${FONTS.length} 个 woff2 → ${FONTS_DIR} ═══╗\n`);
  77. const results = [];
  78. for (const font of FONTS) {
  79. const url = buildUrl(font);
  80. const dest = path.join(FONTS_DIR, font.localName);
  81. if (fs.existsSync(dest) && fs.statSync(dest).size > 1000) {
  82. const kb = (fs.statSync(dest).size / 1024).toFixed(0);
  83. console.log(` ⏭ ${font.localName.padEnd(26)} 已存在 ${kb}KB`);
  84. results.push({ ...font, size: fs.statSync(dest).size, ok: true });
  85. continue;
  86. }
  87. try {
  88. const size = await download(url, dest);
  89. const kb = (size / 1024).toFixed(0);
  90. console.log(` ✅ ${font.localName.padEnd(26)} ${kb}KB`);
  91. results.push({ ...font, size, ok: true });
  92. } catch (e) {
  93. console.log(` ❌ ${font.localName.padEnd(26)} ${e.message}`);
  94. results.push({ ...font, ok: false, err: e.message });
  95. }
  96. }
  97. // ============================================================
  98. // 生成 fonts.css
  99. // ============================================================
  100. const okFonts = results.filter((r) => r.ok);
  101. const cssParts = [
  102. '/* 本地离线字体 · 由 scripts/tools/setup-fonts.js 自动生成 */',
  103. '/* 相对路径引用:HTML 文件应放在 reports/ 下,字体在 reports/assets/fonts/ */',
  104. '',
  105. ];
  106. // Inter 的 @font-face(英文基本拉丁)
  107. const interLatinRange = 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD';
  108. for (const f of okFonts.filter((x) => x.family === 'Inter')) {
  109. cssParts.push(`@font-face {
  110. font-family: 'Inter';
  111. font-style: normal;
  112. font-weight: ${f.weight};
  113. font-display: swap;
  114. src: url('./${f.localName}') format('woff2');
  115. unicode-range: ${interLatinRange};
  116. }`);
  117. }
  118. // Noto Sans SC 的 @font-face(中文简体 + 西文拉丁各自 unicode-range)
  119. const nsLatinRange = 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD';
  120. // 中文 + 中日韩符号常用范围
  121. const nsCnRange = 'U+4E00-9FFF, U+3000-303F, U+FF00-FFEF, U+2E80-2EFF, U+31C0-31EF, U+3200-33FF, U+3400-4DBF';
  122. for (const f of okFonts.filter((x) => x.family === 'Noto Sans SC')) {
  123. const range = f.subset === 'latin' ? nsLatinRange : nsCnRange;
  124. cssParts.push(`@font-face {
  125. font-family: 'Noto Sans SC';
  126. font-style: normal;
  127. font-weight: ${f.weight};
  128. font-display: swap;
  129. src: url('./${f.localName}') format('woff2');
  130. unicode-range: ${range};
  131. }`);
  132. }
  133. // JetBrains Mono
  134. for (const f of okFonts.filter((x) => x.family === 'JetBrains Mono')) {
  135. cssParts.push(`@font-face {
  136. font-family: 'JetBrains Mono';
  137. font-style: normal;
  138. font-weight: ${f.weight};
  139. font-display: swap;
  140. src: url('./${f.localName}') format('woff2');
  141. unicode-range: ${interLatinRange};
  142. }`);
  143. }
  144. const cssPath = path.join(FONTS_DIR, 'fonts.css');
  145. fs.writeFileSync(cssPath, cssParts.join('\n\n') + '\n', 'utf-8');
  146. const totalKB = (okFonts.reduce((s, f) => s + f.size, 0) / 1024).toFixed(0);
  147. console.log(`\n✅ fonts.css → ${cssPath}`);
  148. console.log(` 字体总量: ${okFonts.length} 个 woff2 · ${totalKB} KB`);
  149. const failed = results.filter((r) => !r.ok);
  150. if (failed.length) {
  151. console.log(`\n⚠ 失败 ${failed.length} 个:`);
  152. failed.forEach((f) => console.log(` · ${f.localName}: ${f.err}`));
  153. }
  154. console.log(`\n╚═══ 完成。现在可在 HTML 里用 <link href="./assets/fonts/fonts.css"> 引用 ═══╝`);
  155. }
  156. main().catch((e) => { console.error(e); process.exit(1); });