#!/usr/bin/env node /** * 一次性下载字体到 reports/assets/fonts/,让报告完全离线可用 * * 来源:jsdelivr + @fontsource npm 包(字体的 CDN 包装,稳定) * - Inter: 英文正文 + 标题(400/700/800/900) * - Noto Sans SC: 中文正文 + 标题(400/500/700/900)— chinese-simplified subset * - JetBrains Mono: 代码字体(400/700) * * 所有字体都是 woff2 格式,浏览器原生支持,体积最小。 * 下载后生成 fonts.css,HTML 用相对路径 `./assets/fonts/fonts.css` 引用。 */ const fs = require('fs'); const path = require('path'); const https = require('https'); const FONTS_DIR = path.join('reports', 'assets', 'fonts'); fs.mkdirSync(FONTS_DIR, { recursive: true }); // ============================================================ // 字体清单(family + weight + subset) // ============================================================ const FONTS = [ // Inter 英文 { family: 'Inter', pkg: '@fontsource/inter', weight: 400, subset: 'latin', localName: 'inter-400.woff2' }, { family: 'Inter', pkg: '@fontsource/inter', weight: 500, subset: 'latin', localName: 'inter-500.woff2' }, { family: 'Inter', pkg: '@fontsource/inter', weight: 600, subset: 'latin', localName: 'inter-600.woff2' }, { family: 'Inter', pkg: '@fontsource/inter', weight: 700, subset: 'latin', localName: 'inter-700.woff2' }, { family: 'Inter', pkg: '@fontsource/inter', weight: 800, subset: 'latin', localName: 'inter-800.woff2' }, { family: 'Inter', pkg: '@fontsource/inter', weight: 900, subset: 'latin', localName: 'inter-900.woff2' }, // Noto Sans SC 中文(核心字重:400/500/700/900) // chinese-simplified subset 包含 ~7000 常用汉字 { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 400, subset: 'chinese-simplified', localName: 'noto-sc-400.woff2' }, { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 500, subset: 'chinese-simplified', localName: 'noto-sc-500.woff2' }, { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 700, subset: 'chinese-simplified', localName: 'noto-sc-700.woff2' }, { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 900, subset: 'chinese-simplified', localName: 'noto-sc-900.woff2' }, // latin fallback for numbers/English in Chinese font family { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 400, subset: 'latin', localName: 'noto-sc-latin-400.woff2' }, { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 700, subset: 'latin', localName: 'noto-sc-latin-700.woff2' }, { family: 'Noto Sans SC', pkg: '@fontsource/noto-sans-sc', weight: 900, subset: 'latin', localName: 'noto-sc-latin-900.woff2' }, // JetBrains Mono 代码 { family: 'JetBrains Mono', pkg: '@fontsource/jetbrains-mono', weight: 400, subset: 'latin', localName: 'jetbrains-400.woff2' }, { family: 'JetBrains Mono', pkg: '@fontsource/jetbrains-mono', weight: 500, subset: 'latin', localName: 'jetbrains-500.woff2' }, { family: 'JetBrains Mono', pkg: '@fontsource/jetbrains-mono', weight: 700, subset: 'latin', localName: 'jetbrains-700.woff2' }, ]; function buildUrl(font) { // jsdelivr 对 @fontsource npm 包的路径格式: // https://cdn.jsdelivr.net/npm/@fontsource//files/---normal.woff2 const pkgName = font.pkg.replace('@fontsource/', ''); return `https://cdn.jsdelivr.net/npm/${font.pkg}/files/${pkgName}-${font.subset}-${font.weight}-normal.woff2`; } function download(url, dest) { return new Promise((resolve, reject) => { const doDownload = (u, redirects = 0) => { if (redirects > 3) return reject(new Error('too many redirects')); https.get(u, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); return doDownload(res.headers.location, redirects + 1); } if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} for ${u}`)); } const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { fs.writeFileSync(dest, Buffer.concat(chunks)); resolve(Buffer.concat(chunks).length); }); res.on('error', reject); }).on('error', reject); }; doDownload(url); }); } async function main() { console.log(`╔═══ 字体离线化:下载 ${FONTS.length} 个 woff2 → ${FONTS_DIR} ═══╗\n`); const results = []; for (const font of FONTS) { const url = buildUrl(font); const dest = path.join(FONTS_DIR, font.localName); if (fs.existsSync(dest) && fs.statSync(dest).size > 1000) { const kb = (fs.statSync(dest).size / 1024).toFixed(0); console.log(` ⏭ ${font.localName.padEnd(26)} 已存在 ${kb}KB`); results.push({ ...font, size: fs.statSync(dest).size, ok: true }); continue; } try { const size = await download(url, dest); const kb = (size / 1024).toFixed(0); console.log(` ✅ ${font.localName.padEnd(26)} ${kb}KB`); results.push({ ...font, size, ok: true }); } catch (e) { console.log(` ❌ ${font.localName.padEnd(26)} ${e.message}`); results.push({ ...font, ok: false, err: e.message }); } } // ============================================================ // 生成 fonts.css // ============================================================ const okFonts = results.filter((r) => r.ok); const cssParts = [ '/* 本地离线字体 · 由 scripts/tools/setup-fonts.js 自动生成 */', '/* 相对路径引用:HTML 文件应放在 reports/ 下,字体在 reports/assets/fonts/ */', '', ]; // Inter 的 @font-face(英文基本拉丁) 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'; for (const f of okFonts.filter((x) => x.family === 'Inter')) { cssParts.push(`@font-face { font-family: 'Inter'; font-style: normal; font-weight: ${f.weight}; font-display: swap; src: url('./${f.localName}') format('woff2'); unicode-range: ${interLatinRange}; }`); } // Noto Sans SC 的 @font-face(中文简体 + 西文拉丁各自 unicode-range) 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'; // 中文 + 中日韩符号常用范围 const nsCnRange = 'U+4E00-9FFF, U+3000-303F, U+FF00-FFEF, U+2E80-2EFF, U+31C0-31EF, U+3200-33FF, U+3400-4DBF'; for (const f of okFonts.filter((x) => x.family === 'Noto Sans SC')) { const range = f.subset === 'latin' ? nsLatinRange : nsCnRange; cssParts.push(`@font-face { font-family: 'Noto Sans SC'; font-style: normal; font-weight: ${f.weight}; font-display: swap; src: url('./${f.localName}') format('woff2'); unicode-range: ${range}; }`); } // JetBrains Mono for (const f of okFonts.filter((x) => x.family === 'JetBrains Mono')) { cssParts.push(`@font-face { font-family: 'JetBrains Mono'; font-style: normal; font-weight: ${f.weight}; font-display: swap; src: url('./${f.localName}') format('woff2'); unicode-range: ${interLatinRange}; }`); } const cssPath = path.join(FONTS_DIR, 'fonts.css'); fs.writeFileSync(cssPath, cssParts.join('\n\n') + '\n', 'utf-8'); const totalKB = (okFonts.reduce((s, f) => s + f.size, 0) / 1024).toFixed(0); console.log(`\n✅ fonts.css → ${cssPath}`); console.log(` 字体总量: ${okFonts.length} 个 woff2 · ${totalKB} KB`); const failed = results.filter((r) => !r.ok); if (failed.length) { console.log(`\n⚠ 失败 ${failed.length} 个:`); failed.forEach((f) => console.log(` · ${f.localName}: ${f.err}`)); } console.log(`\n╚═══ 完成。现在可在 HTML 里用 引用 ═══╝`); } main().catch((e) => { console.error(e); process.exit(1); });