index.js
· 14 KiB · JavaScript
原始文件
// 来源: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 };
| 1 | // 来源:https://snippets.exyone.vip/exyone/hanzi-split-optimizer |
| 2 | |
| 3 | import fs from 'fs-extra'; |
| 4 | import path from 'path'; |
| 5 | |
| 6 | const FONTS_DIR = './fonts'; |
| 7 | const TEMP_DIR = './temp'; |
| 8 | const OUTPUT_DIR = './output'; |
| 9 | |
| 10 | // ==================== Splitter Functions ==================== |
| 11 | |
| 12 | async function splitFontFile(inputPath, outputDir, options = {}) { |
| 13 | const { fontSplit } = await import('cn-font-split'); |
| 14 | |
| 15 | const inputBuffer = new Uint8Array( |
| 16 | (await fs.readFile(inputPath)).buffer |
| 17 | ); |
| 18 | |
| 19 | const defaultOptions = { |
| 20 | previewImage: { |
| 21 | name: 'preview', |
| 22 | text: '中文网字计划\nThe Chinese Web Font Project' |
| 23 | }, |
| 24 | testHtml: true, |
| 25 | reporter: true |
| 26 | }; |
| 27 | |
| 28 | const finalOptions = { |
| 29 | ...defaultOptions, |
| 30 | ...options, |
| 31 | input: inputBuffer, |
| 32 | outDir: outputDir |
| 33 | }; |
| 34 | |
| 35 | await fontSplit(finalOptions); |
| 36 | |
| 37 | return outputDir; |
| 38 | } |
| 39 | |
| 40 | async function splitAllFonts(fontsDir, outputDir, options = {}) { |
| 41 | const fontFiles = await fs.readdir(fontsDir); |
| 42 | const validExtensions = ['.ttf', '.otf', '.woff', '.woff2']; |
| 43 | |
| 44 | const fontFilesToProcess = fontFiles.filter(file => |
| 45 | validExtensions.includes(path.extname(file).toLowerCase()) |
| 46 | ); |
| 47 | |
| 48 | if (fontFilesToProcess.length === 0) { |
| 49 | console.log('没有找到可处理的字体文件'); |
| 50 | return []; |
| 51 | } |
| 52 | |
| 53 | const results = []; |
| 54 | |
| 55 | for (const fontFile of fontFilesToProcess) { |
| 56 | const fontName = path.parse(fontFile).name; |
| 57 | const inputPath = path.join(fontsDir, fontFile); |
| 58 | const fontOutputDir = path.join(outputDir, fontName); |
| 59 | |
| 60 | console.log(`\n拆分字体: ${fontFile}`); |
| 61 | console.time(` 耗时`); |
| 62 | |
| 63 | try { |
| 64 | await splitFontFile(inputPath, fontOutputDir, options); |
| 65 | results.push({ |
| 66 | fontName, |
| 67 | inputPath, |
| 68 | outputDir: fontOutputDir, |
| 69 | success: true |
| 70 | }); |
| 71 | console.timeEnd(` 耗时`); |
| 72 | } catch (error) { |
| 73 | console.error(` 错误: ${error.message}`); |
| 74 | results.push({ |
| 75 | fontName, |
| 76 | inputPath, |
| 77 | outputDir: fontOutputDir, |
| 78 | success: false, |
| 79 | error: error.message |
| 80 | }); |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | return results; |
| 85 | } |
| 86 | |
| 87 | // ==================== Optimizer Functions ==================== |
| 88 | |
| 89 | function parseUnicodeRange(rangeStr) { |
| 90 | const ranges = []; |
| 91 | const parts = rangeStr.split(',').map(s => s.trim()); |
| 92 | |
| 93 | for (const part of parts) { |
| 94 | const cleanPart = part.replace('U+', ''); |
| 95 | if (cleanPart.includes('-')) { |
| 96 | const [start, end] = cleanPart.split('-').map(h => parseInt(h, 16)); |
| 97 | ranges.push({ start, end }); |
| 98 | } else { |
| 99 | const code = parseInt(cleanPart, 16); |
| 100 | ranges.push({ start: code, end: code }); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | return ranges; |
| 105 | } |
| 106 | |
| 107 | function mergeRanges(ranges) { |
| 108 | if (ranges.length === 0) return []; |
| 109 | |
| 110 | const sorted = [...ranges].sort((a, b) => a.start - b.start); |
| 111 | const merged = [sorted[0]]; |
| 112 | |
| 113 | for (let i = 1; i < sorted.length; i++) { |
| 114 | const last = merged[merged.length - 1]; |
| 115 | const current = sorted[i]; |
| 116 | |
| 117 | if (current.start <= last.end + 1) { |
| 118 | last.end = Math.max(last.end, current.end); |
| 119 | } else { |
| 120 | merged.push({ ...current }); |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | return merged; |
| 125 | } |
| 126 | |
| 127 | function formatCodePoint(code) { |
| 128 | return 'U+' + code.toString(16).toUpperCase().padStart(4, '0'); |
| 129 | } |
| 130 | |
| 131 | function generateFileName(index, mergedRanges) { |
| 132 | const indexStr = String(index).padStart(3, '0'); |
| 133 | |
| 134 | if (mergedRanges.length === 1) { |
| 135 | const { start, end } = mergedRanges[0]; |
| 136 | if (start === end) { |
| 137 | return `${indexStr}-${formatCodePoint(start)}.woff2`; |
| 138 | } |
| 139 | return `${indexStr}-${formatCodePoint(start)}_${formatCodePoint(end)}.woff2`; |
| 140 | } |
| 141 | |
| 142 | const minCode = Math.min(...mergedRanges.map(r => r.start)); |
| 143 | const maxCode = Math.max(...mergedRanges.map(r => r.end)); |
| 144 | |
| 145 | return `${indexStr}-${formatCodePoint(minCode)}_${formatCodePoint(maxCode)}.woff2`; |
| 146 | } |
| 147 | |
| 148 | function formatUnicodeRangeForManifest(mergedRanges) { |
| 149 | return mergedRanges.map(r => |
| 150 | r.start === r.end |
| 151 | ? formatCodePoint(r.start) |
| 152 | : `${formatCodePoint(r.start)}-${formatCodePoint(r.end)}` |
| 153 | ).join(', '); |
| 154 | } |
| 155 | |
| 156 | function parseFontFace(css) { |
| 157 | const results = []; |
| 158 | const regex = /@font-face\s*\{([^}]+)\}/g; |
| 159 | let match; |
| 160 | |
| 161 | while ((match = regex.exec(css)) !== null) { |
| 162 | const content = match[1]; |
| 163 | |
| 164 | const fontFamilyMatch = content.match(/font-family\s*:\s*["']?([^"';}]+)["']?/i); |
| 165 | const fontFamily = fontFamilyMatch ? fontFamilyMatch[1].trim() : ''; |
| 166 | |
| 167 | const urlMatch = content.match(/url\(["']?\.\/([^"')]+\.woff2)["']?\)/i); |
| 168 | const url = urlMatch ? urlMatch[1] : ''; |
| 169 | |
| 170 | const unicodeRangeMatch = content.match(/unicode-range\s*:\s*([^;]+);/i); |
| 171 | const unicodeRange = unicodeRangeMatch ? unicodeRangeMatch[1].trim() : ''; |
| 172 | |
| 173 | if (url && unicodeRange) { |
| 174 | results.push({ |
| 175 | fontFamily, |
| 176 | originalUrl: url, |
| 177 | unicodeRange, |
| 178 | fullMatch: match[0] |
| 179 | }); |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | return results; |
| 184 | } |
| 185 | |
| 186 | function formatCss(css, fontFaces, processedFiles) { |
| 187 | const lines = []; |
| 188 | |
| 189 | const commentMatch = css.match(/\/\*[\s\S]*?\*\//); |
| 190 | if (commentMatch) { |
| 191 | lines.push(commentMatch[0]); |
| 192 | lines.push(''); |
| 193 | } |
| 194 | |
| 195 | for (const face of fontFaces) { |
| 196 | const newFileName = processedFiles.get(face.originalUrl); |
| 197 | if (!newFileName) continue; |
| 198 | |
| 199 | lines.push('@font-face {'); |
| 200 | lines.push(` font-family: "${face.fontFamily}";`); |
| 201 | lines.push(` src: local("${face.fontFamily}"),`); |
| 202 | lines.push(` url("./${newFileName}") format("woff2");`); |
| 203 | lines.push(' font-style: normal;'); |
| 204 | lines.push(' font-display: swap;'); |
| 205 | lines.push(' font-weight: 400;'); |
| 206 | lines.push(` unicode-range: ${face.unicodeRange};`); |
| 207 | lines.push('}'); |
| 208 | lines.push(''); |
| 209 | } |
| 210 | |
| 211 | return lines.join('\n').trimEnd() + '\n'; |
| 212 | } |
| 213 | |
| 214 | async function optimizeFontDirectory(inputDir, outputDir) { |
| 215 | const resultCssPath = path.join(inputDir, 'result.css'); |
| 216 | |
| 217 | if (!await fs.pathExists(resultCssPath)) { |
| 218 | console.log(` 跳过 - 没有 result.css`); |
| 219 | return null; |
| 220 | } |
| 221 | |
| 222 | const cssContent = await fs.readFile(resultCssPath, 'utf-8'); |
| 223 | const fontFaces = parseFontFace(cssContent); |
| 224 | |
| 225 | if (fontFaces.length === 0) { |
| 226 | console.log(` 警告: 没有找到 @font-face 规则`); |
| 227 | return null; |
| 228 | } |
| 229 | |
| 230 | await fs.ensureDir(outputDir); |
| 231 | |
| 232 | const manifest = { |
| 233 | fontFamily: fontFaces[0].fontFamily, |
| 234 | generated: new Date().toISOString(), |
| 235 | chunks: [] |
| 236 | }; |
| 237 | |
| 238 | const processedFiles = new Map(); |
| 239 | |
| 240 | for (let i = 0; i < fontFaces.length; i++) { |
| 241 | const face = fontFaces[i]; |
| 242 | const ranges = parseUnicodeRange(face.unicodeRange); |
| 243 | const mergedRanges = mergeRanges(ranges); |
| 244 | |
| 245 | const newIndex = i + 1; |
| 246 | const newFileName = generateFileName(newIndex, mergedRanges); |
| 247 | const originalPath = path.join(inputDir, face.originalUrl); |
| 248 | const newPath = path.join(outputDir, newFileName); |
| 249 | |
| 250 | if (await fs.pathExists(originalPath)) { |
| 251 | await fs.copy(originalPath, newPath); |
| 252 | processedFiles.set(face.originalUrl, newFileName); |
| 253 | |
| 254 | const rangeStr = formatUnicodeRangeForManifest(mergedRanges); |
| 255 | |
| 256 | manifest.chunks.push({ |
| 257 | index: newIndex, |
| 258 | file: newFileName, |
| 259 | unicodeRange: rangeStr |
| 260 | }); |
| 261 | } else { |
| 262 | console.log(` 警告: 文件不存在 ${face.originalUrl}`); |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | const formattedCss = formatCss(cssContent, fontFaces, processedFiles); |
| 267 | const newCssPath = path.join(outputDir, 'result.css'); |
| 268 | await fs.writeFile(newCssPath, formattedCss); |
| 269 | |
| 270 | const manifestPath = path.join(outputDir, 'manifest.json'); |
| 271 | await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)); |
| 272 | |
| 273 | return manifest; |
| 274 | } |
| 275 | |
| 276 | // ==================== Main Workflow ==================== |
| 277 | |
| 278 | async function processFont(fontFile, options = {}) { |
| 279 | const fontName = path.parse(fontFile).name; |
| 280 | const inputPath = path.join(FONTS_DIR, fontFile); |
| 281 | const tempDir = path.join(TEMP_DIR, fontName); |
| 282 | const outputDir = path.join(OUTPUT_DIR, fontName); |
| 283 | |
| 284 | console.log(`\n${'='.repeat(50)}`); |
| 285 | console.log(`处理字体: ${fontFile}`); |
| 286 | console.log('='.repeat(50)); |
| 287 | |
| 288 | // Check if the font has already been split (automatically reprocess) |
| 289 | const hasSplitFont = await fs.pathExists(tempDir) && |
| 290 | await fs.pathExists(path.join(tempDir, 'result.css')); |
| 291 | |
| 292 | if (hasSplitFont && !options.forceSplit) { |
| 293 | console.log('\n检测到已拆分的字体,跳过拆分步骤'); |
| 294 | console.log('使用 --force 参数可强制重新拆分'); |
| 295 | } else { |
| 296 | if (!await fs.pathExists(inputPath)) { |
| 297 | console.error(`错误: 字体文件不存在 ${inputPath}`); |
| 298 | return null; |
| 299 | } |
| 300 | |
| 301 | console.log('\n[1/2] 拆分字体...'); |
| 302 | console.time('拆分耗时'); |
| 303 | |
| 304 | try { |
| 305 | await splitFontFile(inputPath, tempDir, options.splitOptions); |
| 306 | console.timeEnd('拆分耗时'); |
| 307 | } catch (error) { |
| 308 | console.error(`拆分失败: ${error.message}`); |
| 309 | return null; |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | console.log('\n[2/2] 优化重命名...'); |
| 314 | console.time('优化耗时'); |
| 315 | |
| 316 | try { |
| 317 | const manifest = await optimizeFontDirectory(tempDir, outputDir); |
| 318 | console.timeEnd('优化耗时'); |
| 319 | |
| 320 | if (manifest) { |
| 321 | console.log(`\n完成! 输出目录: ${outputDir}`); |
| 322 | console.log(` 字体名称: ${manifest.fontFamily}`); |
| 323 | console.log(` 分块数量: ${manifest.chunks.length}`); |
| 324 | } |
| 325 | |
| 326 | if (!options.keepTemp) { |
| 327 | await fs.remove(tempDir); |
| 328 | console.log(` 已清理临时文件`); |
| 329 | } |
| 330 | |
| 331 | return manifest; |
| 332 | } catch (error) { |
| 333 | console.error(`优化失败: ${error.message}`); |
| 334 | return null; |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | async function processAllFonts(options = {}) { |
| 339 | console.log('\n字体处理工具'); |
| 340 | console.log('='.repeat(50)); |
| 341 | |
| 342 | const fontFiles = await fs.readdir(FONTS_DIR).catch(() => []); |
| 343 | const validExtensions = ['.ttf', '.otf', '.woff', '.woff2']; |
| 344 | |
| 345 | const fontsToProcess = fontFiles.filter(file => |
| 346 | validExtensions.includes(path.extname(file).toLowerCase()) |
| 347 | ); |
| 348 | |
| 349 | if (fontsToProcess.length === 0) { |
| 350 | console.log(`\n请在 ${FONTS_DIR} 目录中放入字体文件`); |
| 351 | console.log(`支持的格式: ${validExtensions.join(', ')}`); |
| 352 | return []; |
| 353 | } |
| 354 | |
| 355 | console.log(`找到 ${fontsToProcess.length} 个字体文件\n`); |
| 356 | |
| 357 | const results = []; |
| 358 | |
| 359 | for (const fontFile of fontsToProcess) { |
| 360 | const result = await processFont(fontFile, options); |
| 361 | results.push({ |
| 362 | file: fontFile, |
| 363 | success: !!result |
| 364 | }); |
| 365 | } |
| 366 | |
| 367 | console.log('\n' + '='.repeat(50)); |
| 368 | const successCount = results.filter(r => r.success).length; |
| 369 | console.log(`处理完成: ${successCount}/${results.length} 成功`); |
| 370 | |
| 371 | return results; |
| 372 | } |
| 373 | |
| 374 | async function reprocessSplitFonts(options = {}) { |
| 375 | console.log('\n重新处理已拆分的字体'); |
| 376 | console.log('='.repeat(50)); |
| 377 | |
| 378 | if (!await fs.pathExists(TEMP_DIR)) { |
| 379 | console.log(`\n临时目录 ${TEMP_DIR} 不存在`); |
| 380 | return []; |
| 381 | } |
| 382 | |
| 383 | const subDirs = await fs.readdir(TEMP_DIR, { withFileTypes: true }); |
| 384 | const fontDirs = subDirs |
| 385 | .filter(d => d.isDirectory()) |
| 386 | .filter(d => fs.pathExists(path.join(TEMP_DIR, d.name, 'result.css'))); |
| 387 | |
| 388 | if (fontDirs.length === 0) { |
| 389 | console.log('\n没有找到已拆分的字体目录'); |
| 390 | return []; |
| 391 | } |
| 392 | |
| 393 | console.log(`找到 ${fontDirs.length} 个已拆分的字体\n`); |
| 394 | |
| 395 | const results = []; |
| 396 | |
| 397 | for (const dir of fontDirs) { |
| 398 | const fontName = dir.name; |
| 399 | const tempDir = path.join(TEMP_DIR, fontName); |
| 400 | const outputDir = path.join(OUTPUT_DIR, fontName); |
| 401 | |
| 402 | console.log(`处理: ${fontName}`); |
| 403 | console.time('优化耗时'); |
| 404 | |
| 405 | try { |
| 406 | const manifest = await optimizeFontDirectory(tempDir, outputDir); |
| 407 | console.timeEnd('优化耗时'); |
| 408 | |
| 409 | if (manifest) { |
| 410 | console.log(` 完成: ${manifest.chunks.length} 个字体文件`); |
| 411 | results.push({ fontName, success: true }); |
| 412 | } else { |
| 413 | results.push({ fontName, success: false }); |
| 414 | } |
| 415 | |
| 416 | if (!options.keepTemp) { |
| 417 | await fs.remove(tempDir); |
| 418 | console.log(` 已清理临时文件`); |
| 419 | } |
| 420 | } catch (err) { |
| 421 | console.error(` 错误: ${err.message}`); |
| 422 | results.push({ fontName, success: false, error: err.message }); |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | console.log('\n' + '='.repeat(50)); |
| 427 | const successCount = results.filter(r => r.success).length; |
| 428 | console.log(`处理完成: ${successCount}/${results.length} 成功`); |
| 429 | |
| 430 | return results; |
| 431 | } |
| 432 | |
| 433 | async function optimizeExisting(inputDir, outputDir) { |
| 434 | console.log('\n优化已拆分的字体'); |
| 435 | console.log('='.repeat(50)); |
| 436 | console.log(`输入目录: ${inputDir}`); |
| 437 | console.log(`输出目录: ${outputDir}`); |
| 438 | |
| 439 | const subDirs = await fs.readdir(inputDir, { withFileTypes: true }); |
| 440 | const fontDirs = subDirs |
| 441 | .filter(d => d.isDirectory()) |
| 442 | .map(d => path.join(inputDir, d.name)); |
| 443 | |
| 444 | console.log(`找到 ${fontDirs.length} 个字体目录\n`); |
| 445 | |
| 446 | let processed = 0; |
| 447 | let failed = 0; |
| 448 | |
| 449 | for (const dir of fontDirs) { |
| 450 | const fontName = path.basename(dir); |
| 451 | const outputFontDir = path.join(outputDir, fontName); |
| 452 | |
| 453 | console.log(`处理: ${fontName}`); |
| 454 | |
| 455 | try { |
| 456 | const result = await optimizeFontDirectory(dir, outputFontDir); |
| 457 | if (result) { |
| 458 | console.log(` 完成: ${result.chunks.length} 个字体文件`); |
| 459 | processed++; |
| 460 | } |
| 461 | } catch (err) { |
| 462 | console.error(` 错误: ${err.message}`); |
| 463 | failed++; |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | console.log('\n' + '='.repeat(50)); |
| 468 | console.log(`优化完成: ${processed} 成功, ${failed} 失败`); |
| 469 | } |
| 470 | |
| 471 | async function main() { |
| 472 | const args = process.argv.slice(2); |
| 473 | const command = args[0]; |
| 474 | |
| 475 | switch (command) { |
| 476 | case 'split': |
| 477 | await processAllFonts({ keepTemp: false }); |
| 478 | break; |
| 479 | |
| 480 | case 'optimize': |
| 481 | const inputDir = args[1] || './temp'; |
| 482 | const outputDir = args[2] || './output'; |
| 483 | await optimizeExisting(inputDir, outputDir); |
| 484 | break; |
| 485 | |
| 486 | case 'reprocess': |
| 487 | await reprocessSplitFonts({ keepTemp: false }); |
| 488 | break; |
| 489 | |
| 490 | case 'process': |
| 491 | const fontFile = args[1]; |
| 492 | if (!fontFile) { |
| 493 | console.log('用法: node index.js process <字体文件名>'); |
| 494 | process.exit(1); |
| 495 | } |
| 496 | await processFont(fontFile); |
| 497 | break; |
| 498 | |
| 499 | case 'all': |
| 500 | default: |
| 501 | await processAllFonts({ keepTemp: false }); |
| 502 | break; |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | main().catch(console.error); |
| 507 | |
| 508 | export { processFont, processAllFonts, reprocessSplitFonts, optimizeExisting }; |
package.json
· 948 B · JSON
原始文件
{
"name": "hanzi-split-optimizer",
"version": "2.0.0",
"description": "千字网 - 字体子集化工具",
"main": "index.js",
"type": "module",
"bin": {
"hanzi-split": "./index.js"
},
"scripts": {
"all": "node index.js all",
"split": "node index.js split",
"optimize": "node index.js optimize",
"reprocess": "node index.js reprocess",
"process": "node index.js process"
},
"keywords": [
"font",
"font-subset",
"font-split",
"font-optimizer",
"chinese-font",
"cjk",
"hanzi",
"woff2",
"ttf",
"otf",
"unicode-range",
"web-font"
],
"author": "",
"license": "BSD-2-Clause",
"repository": {
"type": "git",
"url": ""
},
"bugs": {
"url": ""
},
"homepage": "",
"engines": {
"node": ">=18.0.0"
},
"files": [
"index.js",
"README.md"
],
"dependencies": {
"cn-font-split": "^7.4.1",
"fs-extra": "^11.2.0"
}
}
| 1 | { |
| 2 | "name": "hanzi-split-optimizer", |
| 3 | "version": "2.0.0", |
| 4 | "description": "千字网 - 字体子集化工具", |
| 5 | "main": "index.js", |
| 6 | "type": "module", |
| 7 | "bin": { |
| 8 | "hanzi-split": "./index.js" |
| 9 | }, |
| 10 | "scripts": { |
| 11 | "all": "node index.js all", |
| 12 | "split": "node index.js split", |
| 13 | "optimize": "node index.js optimize", |
| 14 | "reprocess": "node index.js reprocess", |
| 15 | "process": "node index.js process" |
| 16 | }, |
| 17 | "keywords": [ |
| 18 | "font", |
| 19 | "font-subset", |
| 20 | "font-split", |
| 21 | "font-optimizer", |
| 22 | "chinese-font", |
| 23 | "cjk", |
| 24 | "hanzi", |
| 25 | "woff2", |
| 26 | "ttf", |
| 27 | "otf", |
| 28 | "unicode-range", |
| 29 | "web-font" |
| 30 | ], |
| 31 | "author": "", |
| 32 | "license": "BSD-2-Clause", |
| 33 | "repository": { |
| 34 | "type": "git", |
| 35 | "url": "" |
| 36 | }, |
| 37 | "bugs": { |
| 38 | "url": "" |
| 39 | }, |
| 40 | "homepage": "", |
| 41 | "engines": { |
| 42 | "node": ">=18.0.0" |
| 43 | }, |
| 44 | "files": [ |
| 45 | "index.js", |
| 46 | "README.md" |
| 47 | ], |
| 48 | "dependencies": { |
| 49 | "cn-font-split": "^7.4.1", |
| 50 | "fs-extra": "^11.2.0" |
| 51 | } |
| 52 | } |