#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import { TextDecoder } from 'node:util'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DEFAULT_APP_ROOT = path.resolve(__dirname, '..'); const SOURCE_FILES = [ 'index.tsx', 'style.css', 'index.html', 'README.md', ]; const EXTRA_CODE_POINTS = [ 0x00a0, 0x2013, 0x2014, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2026, 0x3000, 0x3001, 0x3002, 0x300a, 0x300b, 0x300c, 0x300d, 0xff01, 0xff08, 0xff09, 0xff0c, 0xff1a, 0xff1b, 0xff1f, ]; const ASCII_FALLBACK = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + ' ~`!@#$%^&*()-_=+[]{}\\|;:\'",.<>/?'; function uniqueCharacters(input) { const seen = new Set(); const output = []; for (const character of input) { if (!seen.has(character)) { seen.add(character); output.push(character); } } return output; } export function parseSubsetArgs(argv = process.argv) { const args = { appRoot: DEFAULT_APP_ROOT, dryRun: false, sourceFontDir: process.env.AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR || '', }; const values = argv.slice(2); for (let index = 0; index < values.length; index += 1) { const value = values[index]; if (value === '--dry-run') { args.dryRun = true; continue; } if (value === '--app-root') { args.appRoot = path.resolve(values[index + 1] || ''); index += 1; continue; } if (value === '--source-font-dir') { args.sourceFontDir = path.resolve(values[index + 1] || ''); index += 1; continue; } if (value === '--help' || value === '-h') { args.help = true; continue; } throw new Error(`Unknown option: ${value}`); } return args; } export function resolveBeginnerGuidePaths(options = {}) { const appRoot = path.resolve(options.appRoot || DEFAULT_APP_ROOT); const prototypeRoot = path.join(appRoot, 'src/prototypes/beginner-guide'); const localSourceFontDir = path.join(appRoot, '.local/font-sources/beginner-guide'); const sourceFontDir = options.sourceFontDir ? path.resolve(options.sourceFontDir) : fs.existsSync(localSourceFontDir) ? localSourceFontDir : prototypeRoot; return { appRoot, prototypeRoot, sourceFontDir, }; } export function collectCharactersFromFiles(filePaths) { const content = filePaths .filter((filePath) => fs.existsSync(filePath)) .map((filePath) => fs.readFileSync(filePath, 'utf8')) .join('\n'); return uniqueCharacters(content); } export function getGb2312LevelOneCharacters() { const decoder = new TextDecoder('gb18030'); const characters = []; for (let high = 0xb0; high <= 0xd7; high += 1) { for (let low = 0xa1; low <= 0xfe; low += 1) { characters.push(decoder.decode(Buffer.from([high, low]))); } } return uniqueCharacters(characters).join(''); } export function collectSubsetCharacters(options = {}) { const pageCharacters = Array.isArray(options.pageCharacters) ? options.pageCharacters : [...String(options.pageCharacters || '')]; const commonCharacters = String( options.commonCharacters ?? getGb2312LevelOneCharacters(), ); const extraCharacters = String( options.extraCharacters ?? `${ASCII_FALLBACK}${String.fromCodePoint(...EXTRA_CODE_POINTS)}`, ); return uniqueCharacters(`${commonCharacters}${extraCharacters}${pageCharacters.join('')}`); } export function getFontJobs(paths) { return [ { weight: 400, inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W04.ttf'), outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W04.subset.woff2'), }, { weight: 500, inputPath: path.join(paths.sourceFontDir, 'TsangerJinKai02-W05.ttf'), outputPath: path.join(paths.prototypeRoot, 'TsangerJinKai02-W05.subset.woff2'), }, ]; } function getSourceFilePaths(paths) { return SOURCE_FILES.map((fileName) => path.join(paths.prototypeRoot, fileName)); } function formatSize(byteLength) { if (byteLength < 1024) return `${byteLength} B`; if (byteLength < 1024 * 1024) return `${(byteLength / 1024).toFixed(1)} KB`; return `${(byteLength / 1024 / 1024).toFixed(1)} MB`; } function printHelp() { console.log(`Usage: node scripts/subset-beginner-guide-fonts.mjs [options] Options: --dry-run Print the plan without writing fonts. --app-root Client app root. Defaults to the current client. --source-font-dir Directory containing TsangerJinKai02-W04.ttf and W05.ttf. Environment: AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR can provide the source font directory. `); } export async function subsetBeginnerGuideFonts(options = {}) { const paths = resolveBeginnerGuidePaths(options); const sourceFilePaths = getSourceFilePaths(paths); const pageCharacters = collectCharactersFromFiles(sourceFilePaths); const subsetCharacters = collectSubsetCharacters({ pageCharacters }); const jobs = getFontJobs(paths); if (options.dryRun) { return { paths, jobs, characterCount: subsetCharacters.length, written: [], }; } const missingInputs = jobs .map((job) => job.inputPath) .filter((inputPath) => !fs.existsSync(inputPath)); if (missingInputs.length > 0) { throw new Error( [ 'Missing source font file(s):', ...missingInputs.map((inputPath) => ` - ${inputPath}`), 'Pass --source-font-dir or set AXHUB_BEGINNER_GUIDE_FONT_SOURCE_DIR.', ].join('\n'), ); } const { default: subsetFont } = await import('subset-font'); fs.mkdirSync(paths.prototypeRoot, { recursive: true }); const written = []; for (const job of jobs) { const inputBuffer = fs.readFileSync(job.inputPath); const outputBuffer = await subsetFont(inputBuffer, subsetCharacters.join(''), { targetFormat: 'woff2', preserveNameIds: [1, 2, 4, 6], }); fs.writeFileSync(job.outputPath, outputBuffer); written.push({ ...job, inputSize: inputBuffer.byteLength, outputSize: outputBuffer.byteLength, }); } return { paths, jobs, characterCount: subsetCharacters.length, written, }; } async function runCli() { const args = parseSubsetArgs(process.argv); if (args.help) { printHelp(); return; } const result = await subsetBeginnerGuideFonts(args); console.log(`Beginner guide subset characters: ${result.characterCount}`); for (const job of result.jobs) { if (args.dryRun) { console.log(`dry-run ${job.weight}: ${job.inputPath} -> ${job.outputPath}`); continue; } const written = result.written.find((item) => item.weight === job.weight); console.log( `${job.weight}: ${formatSize(written.inputSize)} -> ${formatSize(written.outputSize)} ${written.outputPath}`, ); } } if (process.argv[1] && path.resolve(process.argv[1]) === __filename) { runCli().catch((error) => { console.error(error.message || error); process.exit(1); }); }