Utoljára aktív 4 days ago

把字体文件存放在`/fonts`文件夹,然后执行`node index.js`,会将字体自动子集化到`/output`文件夹

Revízió d629ae19fd58894c7adb214121f32f45a821ac76

index.js Eredeti
1// 来源:https://snippets.exyone.vip/exyone/hanzi-split-optimizer
2
3import fs from 'fs-extra';
4import path from 'path';
5
6const FONTS_DIR = './fonts';
7const TEMP_DIR = './temp';
8const OUTPUT_DIR = './output';
9
10// ==================== Splitter Functions ====================
11
12async 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
40async 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
89function 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
107function 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
127function formatCodePoint(code) {
128 return 'U+' + code.toString(16).toUpperCase().padStart(4, '0');
129}
130
131function 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
148function 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
156function 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
186function 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
214async 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
278async 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
338async 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
374async 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
433async 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
471async 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
506main().catch(console.error);
507
508export { processFont, processAllFonts, reprocessSplitFonts, optimizeExisting };
package.json Eredeti
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}