// 来源:https://snippets.exyone.vip/exyone/hanzi-split-optimizer import fs from 'fs-extra'; import path from 'path'; const FONTS_DIR = './fonts'; const TEMP_DIR = './temp'; const OUTPUT_DIR = './output'; // ==================== Splitter Functions ==================== async function splitFontFile(inputPath, outputDir, options = {}) { const { fontSplit } = await import('cn-font-split'); const inputBuffer = new Uint8Array( (await fs.readFile(inputPath)).buffer ); const defaultOptions = { previewImage: { name: 'preview', text: '中文网字计划\nThe Chinese Web Font Project' }, testHtml: true, reporter: true }; const finalOptions = { ...defaultOptions, ...options, input: inputBuffer, outDir: outputDir }; await fontSplit(finalOptions); return outputDir; } async function splitAllFonts(fontsDir, outputDir, options = {}) { const fontFiles = await fs.readdir(fontsDir); const validExtensions = ['.ttf', '.otf', '.woff', '.woff2']; const fontFilesToProcess = fontFiles.filter(file => validExtensions.includes(path.extname(file).toLowerCase()) ); if (fontFilesToProcess.length === 0) { console.log('没有找到可处理的字体文件'); return []; } const results = []; for (const fontFile of fontFilesToProcess) { const fontName = path.parse(fontFile).name; const inputPath = path.join(fontsDir, fontFile); const fontOutputDir = path.join(outputDir, fontName); console.log(`\n拆分字体: ${fontFile}`); console.time(` 耗时`); try { await splitFontFile(inputPath, fontOutputDir, options); results.push({ fontName, inputPath, outputDir: fontOutputDir, success: true }); console.timeEnd(` 耗时`); } catch (error) { console.error(` 错误: ${error.message}`); results.push({ fontName, inputPath, outputDir: fontOutputDir, success: false, error: error.message }); } } return results; } // ==================== Optimizer Functions ==================== function parseUnicodeRange(rangeStr) { const ranges = []; const parts = rangeStr.split(',').map(s => s.trim()); for (const part of parts) { const cleanPart = part.replace('U+', ''); if (cleanPart.includes('-')) { const [start, end] = cleanPart.split('-').map(h => parseInt(h, 16)); ranges.push({ start, end }); } else { const code = parseInt(cleanPart, 16); ranges.push({ start: code, end: code }); } } return ranges; } function mergeRanges(ranges) { if (ranges.length === 0) return []; const sorted = [...ranges].sort((a, b) => a.start - b.start); const merged = [sorted[0]]; for (let i = 1; i < sorted.length; i++) { const last = merged[merged.length - 1]; const current = sorted[i]; if (current.start <= last.end + 1) { last.end = Math.max(last.end, current.end); } else { merged.push({ ...current }); } } return merged; } function formatCodePoint(code) { return 'U+' + code.toString(16).toUpperCase().padStart(4, '0'); } function generateFileName(index, mergedRanges) { const indexStr = String(index).padStart(3, '0'); if (mergedRanges.length === 1) { const { start, end } = mergedRanges[0]; if (start === end) { return `${indexStr}-${formatCodePoint(start)}.woff2`; } return `${indexStr}-${formatCodePoint(start)}_${formatCodePoint(end)}.woff2`; } const minCode = Math.min(...mergedRanges.map(r => r.start)); const maxCode = Math.max(...mergedRanges.map(r => r.end)); return `${indexStr}-${formatCodePoint(minCode)}_${formatCodePoint(maxCode)}.woff2`; } function formatUnicodeRangeForManifest(mergedRanges) { return mergedRanges.map(r => r.start === r.end ? formatCodePoint(r.start) : `${formatCodePoint(r.start)}-${formatCodePoint(r.end)}` ).join(', '); } function parseFontFace(css) { const results = []; const regex = /@font-face\s*\{([^}]+)\}/g; let match; while ((match = regex.exec(css)) !== null) { const content = match[1]; const fontFamilyMatch = content.match(/font-family\s*:\s*["']?([^"';}]+)["']?/i); const fontFamily = fontFamilyMatch ? fontFamilyMatch[1].trim() : ''; const urlMatch = content.match(/url\(["']?\.\/([^"')]+\.woff2)["']?\)/i); const url = urlMatch ? urlMatch[1] : ''; const unicodeRangeMatch = content.match(/unicode-range\s*:\s*([^;]+);/i); const unicodeRange = unicodeRangeMatch ? unicodeRangeMatch[1].trim() : ''; if (url && unicodeRange) { results.push({ fontFamily, originalUrl: url, unicodeRange, fullMatch: match[0] }); } } return results; } function formatCss(css, fontFaces, processedFiles) { const lines = []; const commentMatch = css.match(/\/\*[\s\S]*?\*\//); if (commentMatch) { lines.push(commentMatch[0]); lines.push(''); } for (const face of fontFaces) { const newFileName = processedFiles.get(face.originalUrl); if (!newFileName) continue; lines.push('@font-face {'); lines.push(` font-family: "${face.fontFamily}";`); lines.push(` src: local("${face.fontFamily}"),`); lines.push(` url("./${newFileName}") format("woff2");`); lines.push(' font-style: normal;'); lines.push(' font-display: swap;'); lines.push(' font-weight: 400;'); lines.push(` unicode-range: ${face.unicodeRange};`); lines.push('}'); lines.push(''); } return lines.join('\n').trimEnd() + '\n'; } async function optimizeFontDirectory(inputDir, outputDir) { const resultCssPath = path.join(inputDir, 'result.css'); if (!await fs.pathExists(resultCssPath)) { console.log(` 跳过 - 没有 result.css`); return null; } const cssContent = await fs.readFile(resultCssPath, 'utf-8'); const fontFaces = parseFontFace(cssContent); if (fontFaces.length === 0) { console.log(` 警告: 没有找到 @font-face 规则`); return null; } await fs.ensureDir(outputDir); const manifest = { fontFamily: fontFaces[0].fontFamily, generated: new Date().toISOString(), chunks: [] }; const processedFiles = new Map(); for (let i = 0; i < fontFaces.length; i++) { const face = fontFaces[i]; const ranges = parseUnicodeRange(face.unicodeRange); const mergedRanges = mergeRanges(ranges); const newIndex = i + 1; const newFileName = generateFileName(newIndex, mergedRanges); const originalPath = path.join(inputDir, face.originalUrl); const newPath = path.join(outputDir, newFileName); if (await fs.pathExists(originalPath)) { await fs.copy(originalPath, newPath); processedFiles.set(face.originalUrl, newFileName); const rangeStr = formatUnicodeRangeForManifest(mergedRanges); manifest.chunks.push({ index: newIndex, file: newFileName, unicodeRange: rangeStr }); } else { console.log(` 警告: 文件不存在 ${face.originalUrl}`); } } const formattedCss = formatCss(cssContent, fontFaces, processedFiles); const newCssPath = path.join(outputDir, 'result.css'); await fs.writeFile(newCssPath, formattedCss); const manifestPath = path.join(outputDir, 'manifest.json'); await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); return manifest; } // ==================== Main Workflow ==================== async function processFont(fontFile, options = {}) { const fontName = path.parse(fontFile).name; const inputPath = path.join(FONTS_DIR, fontFile); const tempDir = path.join(TEMP_DIR, fontName); const outputDir = path.join(OUTPUT_DIR, fontName); console.log(`\n${'='.repeat(50)}`); console.log(`处理字体: ${fontFile}`); console.log('='.repeat(50)); // Check if the font has already been split (automatically reprocess) const hasSplitFont = await fs.pathExists(tempDir) && await fs.pathExists(path.join(tempDir, 'result.css')); if (hasSplitFont && !options.forceSplit) { console.log('\n检测到已拆分的字体,跳过拆分步骤'); console.log('使用 --force 参数可强制重新拆分'); } else { if (!await fs.pathExists(inputPath)) { console.error(`错误: 字体文件不存在 ${inputPath}`); return null; } console.log('\n[1/2] 拆分字体...'); console.time('拆分耗时'); try { await splitFontFile(inputPath, tempDir, options.splitOptions); console.timeEnd('拆分耗时'); } catch (error) { console.error(`拆分失败: ${error.message}`); return null; } } console.log('\n[2/2] 优化重命名...'); console.time('优化耗时'); try { const manifest = await optimizeFontDirectory(tempDir, outputDir); console.timeEnd('优化耗时'); if (manifest) { console.log(`\n完成! 输出目录: ${outputDir}`); console.log(` 字体名称: ${manifest.fontFamily}`); console.log(` 分块数量: ${manifest.chunks.length}`); } if (!options.keepTemp) { await fs.remove(tempDir); console.log(` 已清理临时文件`); } return manifest; } catch (error) { console.error(`优化失败: ${error.message}`); return null; } } async function processAllFonts(options = {}) { console.log('\n字体处理工具'); console.log('='.repeat(50)); const fontFiles = await fs.readdir(FONTS_DIR).catch(() => []); const validExtensions = ['.ttf', '.otf', '.woff', '.woff2']; const fontsToProcess = fontFiles.filter(file => validExtensions.includes(path.extname(file).toLowerCase()) ); if (fontsToProcess.length === 0) { console.log(`\n请在 ${FONTS_DIR} 目录中放入字体文件`); console.log(`支持的格式: ${validExtensions.join(', ')}`); return []; } console.log(`找到 ${fontsToProcess.length} 个字体文件\n`); const results = []; for (const fontFile of fontsToProcess) { const result = await processFont(fontFile, options); results.push({ file: fontFile, success: !!result }); } console.log('\n' + '='.repeat(50)); const successCount = results.filter(r => r.success).length; console.log(`处理完成: ${successCount}/${results.length} 成功`); return results; } async function reprocessSplitFonts(options = {}) { console.log('\n重新处理已拆分的字体'); console.log('='.repeat(50)); if (!await fs.pathExists(TEMP_DIR)) { console.log(`\n临时目录 ${TEMP_DIR} 不存在`); return []; } const subDirs = await fs.readdir(TEMP_DIR, { withFileTypes: true }); const fontDirs = subDirs .filter(d => d.isDirectory()) .filter(d => fs.pathExists(path.join(TEMP_DIR, d.name, 'result.css'))); if (fontDirs.length === 0) { console.log('\n没有找到已拆分的字体目录'); return []; } console.log(`找到 ${fontDirs.length} 个已拆分的字体\n`); const results = []; for (const dir of fontDirs) { const fontName = dir.name; const tempDir = path.join(TEMP_DIR, fontName); const outputDir = path.join(OUTPUT_DIR, fontName); console.log(`处理: ${fontName}`); console.time('优化耗时'); try { const manifest = await optimizeFontDirectory(tempDir, outputDir); console.timeEnd('优化耗时'); if (manifest) { console.log(` 完成: ${manifest.chunks.length} 个字体文件`); results.push({ fontName, success: true }); } else { results.push({ fontName, success: false }); } if (!options.keepTemp) { await fs.remove(tempDir); console.log(` 已清理临时文件`); } } catch (err) { console.error(` 错误: ${err.message}`); results.push({ fontName, success: false, error: err.message }); } } console.log('\n' + '='.repeat(50)); const successCount = results.filter(r => r.success).length; console.log(`处理完成: ${successCount}/${results.length} 成功`); return results; } async function optimizeExisting(inputDir, outputDir) { console.log('\n优化已拆分的字体'); console.log('='.repeat(50)); console.log(`输入目录: ${inputDir}`); console.log(`输出目录: ${outputDir}`); const subDirs = await fs.readdir(inputDir, { withFileTypes: true }); const fontDirs = subDirs .filter(d => d.isDirectory()) .map(d => path.join(inputDir, d.name)); console.log(`找到 ${fontDirs.length} 个字体目录\n`); let processed = 0; let failed = 0; for (const dir of fontDirs) { const fontName = path.basename(dir); const outputFontDir = path.join(outputDir, fontName); console.log(`处理: ${fontName}`); try { const result = await optimizeFontDirectory(dir, outputFontDir); if (result) { console.log(` 完成: ${result.chunks.length} 个字体文件`); processed++; } } catch (err) { console.error(` 错误: ${err.message}`); failed++; } } console.log('\n' + '='.repeat(50)); console.log(`优化完成: ${processed} 成功, ${failed} 失败`); } async function main() { const args = process.argv.slice(2); const command = args[0]; switch (command) { case 'split': await processAllFonts({ keepTemp: false }); break; case 'optimize': const inputDir = args[1] || './temp'; const outputDir = args[2] || './output'; await optimizeExisting(inputDir, outputDir); break; case 'reprocess': await reprocessSplitFonts({ keepTemp: false }); break; case 'process': const fontFile = args[1]; if (!fontFile) { console.log('用法: node index.js process <字体文件名>'); process.exit(1); } await processFont(fontFile); break; case 'all': default: await processAllFonts({ keepTemp: false }); break; } } main().catch(console.error); export { processFont, processAllFonts, reprocessSplitFonts, optimizeExisting };