diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-05-22 22:56:38 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-22 22:56:38 +0900 |
| commit | e6e8bfa591b28de29709139d4d238205d7a7e171 (patch) | |
| tree | 87091efbe1f4662171deabda8068183f85c1123c /packages/icons-subsetter/src | |
| parent | fix(deps): update [backend] update dependencies (#15911) (diff) | |
| download | misskey-e6e8bfa591b28de29709139d4d238205d7a7e171.tar.gz misskey-e6e8bfa591b28de29709139d4d238205d7a7e171.tar.bz2 misskey-e6e8bfa591b28de29709139d4d238205d7a7e171.zip | |
feat(frontend): tabler-iconsのサブセット化 (#15340)
* feat(frontend): tabler-iconsの使用されていないアイコンを削除するように
* fix
* fix
* fix
* fix
* fix
* Update Changelog
* enhance: tablerのCSSを使用されているクラスのみに限定
* 使用するアイコンパッケージをそろえる
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* spdx
* typo
* fix: サブセットから除外される書き方をしている部分を修正
* fix: 同じunicodeに複数のアイコンclassが割り当てられている場合に対応
* remove debug code
* Update CHANGELOG.md
* fix merge error
* setup renovate
* fix: woff2ではなくwoffに変換していたのを修正
* update deps
* update changelog
Diffstat (limited to 'packages/icons-subsetter/src')
| -rw-r--r-- | packages/icons-subsetter/src/generator.ts | 141 | ||||
| -rw-r--r-- | packages/icons-subsetter/src/subsetter.ts | 81 |
2 files changed, 222 insertions, 0 deletions
diff --git a/packages/icons-subsetter/src/generator.ts b/packages/icons-subsetter/src/generator.ts new file mode 100644 index 0000000000..1a9e3d8fd2 --- /dev/null +++ b/packages/icons-subsetter/src/generator.ts @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fsp, existsSync } from 'fs'; +import path from 'path'; +import glob from 'tiny-glob'; +import { generateSubsettedFont } from './subsetter.js'; + +const filesToScan = { + frontend: 'packages/frontend/src/**/*.{ts,vue}', + //frontendShared: 'packages/frontend-shared/js/**/*.{ts}', // 現時点では該当がないのでスキップ。ここをコメントアウトするときは、各フロントエンドにこのチャンクのCSSのimportを追加すること + frontendEmbed: 'packages/frontend-embed/src/**/*.{ts,vue}', +}; + +async function main() { + const start = performance.now(); + + // 1. ビルドディレクトリを削除 + if (existsSync('./built')) { + await fsp.rm('./built', { recursive: true }); + } + await fsp.mkdir('./built'); + + // 2. tabler-icons.min.cssから、class名とUnicodeのマッピングを抽出 + const css = await fsp.readFile('node_modules/@tabler/icons-webfont/dist/tabler-icons.min.css', 'utf-8'); + const cssRegex = /\.(ti-[a-z0-9-]+)::?before\s*{\n?\s*content:\s*["']\\([a-fA-F0-9]+)["'];?\n?\s*}/g; + const rgMap = new Map<string, string>(); + let matches: RegExpExecArray | null; + while ((matches = cssRegex.exec(css)) !== null) { + rgMap.set(matches[1], matches[2]); + } + + // 3. tabler-icons-classes.cssから、.tiのルールを抽出 + const classTiBaseRule = css.match(/\.ti\s*{[^}]*}/)![0]; + + // 4. フォールバック用のtabler-icons.woff2をコピー + const fontPath = 'node_modules/@tabler/icons-webfont/dist/fonts/'; + await fsp.copyFile(fontPath + 'tabler-icons.woff2', './built/tabler-icons.woff2'); + + // 5. 各チャンクごとにファイルをスキャンして、使用されているアイコンを抽出 + const unicodeRangeValues = new Map<string, number[]>(); + for (const [key, dir] of Object.entries(filesToScan)) { + console.log(`Scanning ${key}...`); + + const iconsToPack = new Set<string>(); + + const cwd = path.resolve(process.cwd(), '../../'); + const files = await glob(dir, { cwd }); + for (const file of files) { + //console.log(`Scanning ${file}`); + const content = await fsp.readFile(path.resolve(cwd, file), 'utf-8'); + const classRegex = /ti-[a-z0-9-]+/g; + let matches: RegExpExecArray | null; + while ((matches = classRegex.exec(content)) !== null) { + const icon = matches[0]; + if (rgMap.has(icon)) { + iconsToPack.add(icon); + } + } + } + + // 6. チャンク内で使用されているアイコンのUnicodeの配列を生成 + const unicodeValues = Array.from(iconsToPack).map((icon) => parseInt(rgMap.get(icon)!, 16)); + unicodeRangeValues.set(key, unicodeValues); + } + + // 7. Tabler Iconフォントをサブセット化 + const subsettedFonts = await generateSubsettedFont(fontPath + 'tabler-icons.ttf', unicodeRangeValues); + + // 8. サブセット化したフォント・CSSを書き出し + await Promise.allSettled(Array.from(subsettedFonts.entries()).map(async ([key, buffer]) => { + const cssRules = [`@font-face { + font-family: "tabler-icons"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("./tabler-icons.woff2") format("woff2"); +}`]; + + // サブセット化したフォントの中身がある(=unicodeRangeValuesの配列が空ではない)場合のみ、サブセットしたものに関する情報を追記 + if (unicodeRangeValues.get(key)!.length > 0) { + await fsp.writeFile(`./built/tabler-icons-${key}.woff2`, buffer); + + const unicodeRangeString = (() => { + const values = unicodeRangeValues.get(key)!.sort((a, b) => a - b); + const ranges = []; + + for (let i = 0; i < values.length; i++) { + const start = values[i]; + let end = values[i]; + while (values[i + 1] === end + 1) { + end = values[i + 1]; + i++; + } + if (start === end) { + ranges.push(`U+${start.toString(16)}`); + } else if (start + 1 === end) { + ranges.push(`U+${start.toString(16)}`, `U+${end.toString(16)}`); + } else { + ranges.push(`U+${start.toString(16)}-${end.toString(16)}`); + } + } + + return ranges.join(', '); + })(); + + cssRules.push(`@font-face { + font-family: "tabler-icons"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("./tabler-icons-${key}.woff2") format("woff2"); + unicode-range: ${unicodeRangeString}; +}`); + + cssRules.push(classTiBaseRule); + + // 使用されているアイコンのclassとの対応を追記 + for (const icon of unicodeRangeValues.get(key)!) { + const iconClasses = Array.from(rgMap.entries()).filter(([_, unicode]) => parseInt(unicode, 16) === icon); + if (iconClasses.length > 1) { + console.warn(`[WARN] Multiple classes for the same unicode: ${iconClasses.map(([cls]) => cls).join(', ')}. Maybe it's deprecated?`); + } + const iconSelector = iconClasses.map(([className]) => `.${className}::before`).join(', '); + cssRules.push(`${iconSelector} { content: "\\${icon.toString(16)}"; }`); + } + } + + await fsp.writeFile(`./built/tabler-icons-${key}.css`, cssRules.join('\n') + '\n'); + })); + + const end = performance.now(); + console.log(`Done in ${Math.round((end - start) * 100) / 100}ms`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/icons-subsetter/src/subsetter.ts b/packages/icons-subsetter/src/subsetter.ts new file mode 100644 index 0000000000..cd1aed2890 --- /dev/null +++ b/packages/icons-subsetter/src/subsetter.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fsp } from 'fs'; +import { compress } from 'wawoff2'; + +export async function generateSubsettedFont(ttfPath: string, unicodeRangeValues: Map<string, number[]>) { + const ttf = await fsp.readFile(ttfPath); + + const { + instance: { exports: harfbuzzWasm }, + }: any = await WebAssembly.instantiate(await fsp.readFile('./node_modules/harfbuzzjs/hb-subset.wasm')); + + const heapu8 = new Uint8Array(harfbuzzWasm.memory.buffer); + + const subsetFonts = new Map<string, Buffer>(); + + let i = 0; + for (const [key, unicodeValues] of unicodeRangeValues) { + i++; + console.log(`Generating subset ${i} of ${unicodeRangeValues.size}...`); + + // サブセット入力を作成 + const input = harfbuzzWasm.hb_subset_input_create_or_fail(); + if (input === 0) { + throw new Error('hb_subset_input_create_or_fail (harfbuzz) returned zero'); + } + + // フォントバッファにフォントデータをセット + const fontBuffer = harfbuzzWasm.malloc(ttf.byteLength); + heapu8.set(new Uint8Array(ttf), fontBuffer); + + // フォントフェイスを作成 + const blob = harfbuzzWasm.hb_blob_create(fontBuffer, ttf.byteLength, 2, 0, 0); + const face = harfbuzzWasm.hb_face_create(blob, 0); + harfbuzzWasm.hb_blob_destroy(blob); + + // Unicodeセットに指定されたUnicodeポイントを追加 + const inputUnicodes = harfbuzzWasm.hb_subset_input_unicode_set(input); + for (const unicode of unicodeValues) { + harfbuzzWasm.hb_set_add(inputUnicodes, unicode); + } + + // サブセットを作成 + let subset; + try { + subset = harfbuzzWasm.hb_subset_or_fail(face, input); + if (subset === 0) { + harfbuzzWasm.hb_face_destroy(face); + harfbuzzWasm.free(fontBuffer); + throw new Error('hb_subset_or_fail (harfbuzz) returned zero'); + } + } finally { + harfbuzzWasm.hb_subset_input_destroy(input); + } + + // サブセットフォントデータを取得 + const result = harfbuzzWasm.hb_face_reference_blob(subset); + const offset = harfbuzzWasm.hb_blob_get_data(result, 0); + const subsetByteLength = harfbuzzWasm.hb_blob_get_length(result); + if (subsetByteLength === 0) { + harfbuzzWasm.hb_face_destroy(face); + harfbuzzWasm.hb_blob_destroy(result); + harfbuzzWasm.free(fontBuffer); + throw new Error('hb_blob_get_length (harfbuzz) returned zero'); + } + + // サブセットフォントをバッファに格納 + subsetFonts.set(key, Buffer.from(await compress(heapu8.slice(offset, offset + subsetByteLength)))); + + // メモリを解放 + harfbuzzWasm.hb_blob_destroy(result); + harfbuzzWasm.hb_face_destroy(subset); + harfbuzzWasm.hb_face_destroy(face); + harfbuzzWasm.free(fontBuffer); + } + + return subsetFonts; +} |