Last active 4 days ago

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

exyone revised this gist 4 days ago. Go to revision

2 files changed, 560 insertions

index.js(file created)

@@ -0,0 +1,508 @@
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(file created)

@@ -0,0 +1,52 @@
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 + }
Newer Older