@sorane/font 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@sorane/font",
3
+ "version": "0.2.0",
4
+ "description": "Per-page WOFF2 font subsetting for sorane (bunsen WASM)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/masanork/sorane.git",
10
+ "directory": "packages/font"
11
+ },
12
+ "homepage": "https://sorane.dev",
13
+ "bugs": "https://github.com/masanork/sorane/issues",
14
+ "files": [
15
+ "src",
16
+ "wasm"
17
+ ],
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "engines": {
22
+ "node": ">=23.6"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }
@@ -0,0 +1,26 @@
1
+ function range(start: number, endInclusive: number): number[] {
2
+ const out: number[] = [];
3
+ for (let cp = start; cp <= endInclusive; cp++) out.push(cp);
4
+ return out;
5
+ }
6
+
7
+ const PUNCT_AND_SYMBOLS: readonly string[] = [
8
+ "、", "。", "〈", "〉", "《", "》", "「", "」", "『", "』",
9
+ "【", "】", "〒", "〓", "〔", "〕", "〜", "・", "…", "‥",
10
+ "―", "─",
11
+ "〇", "々", "〆", "ヶ", "※",
12
+ ":", ";", "!", "?", "(", ")", "{", "}",
13
+ "[", "]", "<", ">",
14
+ ];
15
+
16
+ const codepoints = new Set<number>();
17
+ for (const cp of range(0x0020, 0x007e)) codepoints.add(cp);
18
+ for (const cp of range(0x3041, 0x3096)) codepoints.add(cp);
19
+ for (const cp of range(0x30a1, 0x30f6)) codepoints.add(cp);
20
+ for (const cp of range(0xff10, 0xff19)) codepoints.add(cp);
21
+ for (const cp of range(0xff21, 0xff3a)) codepoints.add(cp);
22
+ for (const cp of range(0xff41, 0xff5a)) codepoints.add(cp);
23
+ for (const cp of range(0xff66, 0xff9d)) codepoints.add(cp);
24
+ for (const ch of PUNCT_AND_SYMBOLS) codepoints.add(ch.codePointAt(0)!);
25
+
26
+ export const BASELINE_CODEPOINTS: ReadonlySet<number> = codepoints;
@@ -0,0 +1,24 @@
1
+ import { BASELINE_CODEPOINTS } from "./baseline-charset.ts";
2
+
3
+ /** HTML からタグを除いた可視テキスト(フォントサブセット用) */
4
+ export function plainTextFromHtml(html: string): string {
5
+ return html
6
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
7
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
8
+ .replace(/<[^>]*>/g, "");
9
+ }
10
+
11
+ export function extractCharset(
12
+ body: string,
13
+ title = "",
14
+ baseline: ReadonlySet<number> = BASELINE_CODEPOINTS,
15
+ extra = "",
16
+ ): string {
17
+ const codepoints = new Set<number>(baseline);
18
+ for (const ch of title + body + extra) {
19
+ const cp = ch.codePointAt(0);
20
+ if (cp !== undefined) codepoints.add(cp);
21
+ }
22
+ const sorted = [...codepoints].sort((a, b) => a - b);
23
+ return String.fromCodePoint(...sorted);
24
+ }
@@ -0,0 +1,35 @@
1
+ export interface FontFaceEntry {
2
+ readonly family: string;
3
+ readonly url: string;
4
+ readonly weight: string;
5
+ readonly format?: "woff2" | "truetype" | "opentype";
6
+ }
7
+
8
+ function fontFaceRule(entry: FontFaceEntry): string {
9
+ const format = entry.format ?? "woff2";
10
+ return (
11
+ `@font-face {\n` +
12
+ ` font-family: '${entry.family}';\n` +
13
+ ` src: url('${entry.url}') format('${format}');\n` +
14
+ ` font-weight: ${entry.weight};\n` +
15
+ ` font-style: normal;\n` +
16
+ ` font-display: swap;\n` +
17
+ `}`
18
+ );
19
+ }
20
+
21
+ /** 単一フォント(後方互換)。 */
22
+ export function buildFontFaceCss(family: string, woff2Url: string, weight = "450"): string {
23
+ return (
24
+ `<style>\n` +
25
+ `${fontFaceRule({ family, url: woff2Url, weight })}\n` +
26
+ `body { font-family: '${family}', system-ui, sans-serif; font-weight: ${weight}; }\n` +
27
+ `</style>`
28
+ );
29
+ }
30
+
31
+ /** 複数フォントスタック。本文の font-family は main.css 側で定義する。 */
32
+ export function buildFontStackCss(faces: readonly FontFaceEntry[]): string {
33
+ if (faces.length === 0) return "";
34
+ return `<style>\n${faces.map(fontFaceRule).join("\n")}\n</style>`;
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { BASELINE_CODEPOINTS } from "./baseline-charset.ts";
2
+ export { extractCharset, plainTextFromHtml } from "./extract-charset.ts";
3
+ export { buildFontFaceCss, buildFontStackCss, type FontFaceEntry } from "./font-face.ts";
4
+ export {
5
+ createFontProcessor,
6
+ type FontConfig,
7
+ type FontProcessor,
8
+ type FontRoles,
9
+ type FontSourceSpec,
10
+ } from "./processor.ts";
@@ -0,0 +1,226 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ copyFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { basename, join, resolve } from "node:path";
10
+ import { extractCharset } from "./extract-charset.ts";
11
+ import { buildFontFaceCss, buildFontStackCss } from "./font-face.ts";
12
+ import { subsetWoff2 } from "./wasm-subsetter.ts";
13
+
14
+ export interface FontSourceSpec {
15
+ readonly source: string;
16
+ readonly weight?: string;
17
+ /** subset(既定)または dist へそのままコピーする static */
18
+ readonly embed?: "subset" | "static";
19
+ }
20
+
21
+ export interface FontRoles {
22
+ readonly body: readonly string[];
23
+ readonly heading?: readonly string[];
24
+ readonly code?: readonly string[];
25
+ }
26
+
27
+ export interface FontConfig {
28
+ readonly enabled: boolean;
29
+ readonly cache_dir: string;
30
+ readonly skip_key: string;
31
+ /** 単一フォント(後方互換) */
32
+ readonly family?: string;
33
+ readonly source?: string;
34
+ readonly weight?: string;
35
+ /** 複数フォントスタック */
36
+ readonly roles?: FontRoles;
37
+ readonly sources?: Readonly<Record<string, FontSourceSpec>>;
38
+ }
39
+
40
+ export interface FontProcessor {
41
+ fontCssForPage(opts: {
42
+ body: string;
43
+ title: string;
44
+ /** レンダリング済み HTML 由来の追加文字(index 等) */
45
+ extraText?: string;
46
+ frontmatter: Record<string, unknown>;
47
+ rootPrefix: string;
48
+ }): Promise<string | undefined>;
49
+ }
50
+
51
+ interface LoadedFont {
52
+ readonly family: string;
53
+ readonly bytes: Uint8Array;
54
+ readonly weight: string;
55
+ readonly embed: "subset" | "static";
56
+ readonly sourcePath: string;
57
+ }
58
+
59
+ function sha256Hex(bytes: Uint8Array): string {
60
+ return createHash("sha256").update(bytes).digest("hex");
61
+ }
62
+
63
+ function textHash(text: string): string {
64
+ return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
65
+ }
66
+
67
+ function familySlug(family: string): string {
68
+ return family.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
69
+ }
70
+
71
+ function collectFamilies(config: FontConfig): readonly string[] {
72
+ if (!config.roles) return [];
73
+ const out = new Set<string>();
74
+ for (const list of [config.roles.body, config.roles.heading, config.roles.code]) {
75
+ if (list) for (const family of list) out.add(family);
76
+ }
77
+ return [...out];
78
+ }
79
+
80
+ function loadFonts(cwd: string, config: FontConfig): LoadedFont[] | null {
81
+ if (config.sources && config.roles) {
82
+ const families = collectFamilies(config);
83
+ const loaded: LoadedFont[] = [];
84
+ for (const family of families) {
85
+ const spec = config.sources[family];
86
+ if (!spec) {
87
+ process.stderr.write(`[sorane] font source spec missing for family: ${family}\n`);
88
+ continue;
89
+ }
90
+ const sourcePath = resolve(cwd, spec.source);
91
+ if (!existsSync(sourcePath)) {
92
+ process.stderr.write(`[sorane] font source not found: ${sourcePath} (skipping ${family})\n`);
93
+ continue;
94
+ }
95
+ loaded.push({
96
+ family,
97
+ bytes: new Uint8Array(readFileSync(sourcePath)),
98
+ weight: spec.weight ?? "400",
99
+ embed: spec.embed ?? "subset",
100
+ sourcePath,
101
+ });
102
+ }
103
+ return loaded.length > 0 ? loaded : null;
104
+ }
105
+
106
+ if (!config.family || !config.source) return null;
107
+ const sourcePath = resolve(cwd, config.source);
108
+ if (!existsSync(sourcePath)) {
109
+ process.stderr.write(`[sorane] font source not found: ${sourcePath} (skipping subset)\n`);
110
+ return null;
111
+ }
112
+ return [{
113
+ family: config.family,
114
+ bytes: new Uint8Array(readFileSync(sourcePath)),
115
+ weight: config.weight ?? "450",
116
+ embed: "subset",
117
+ sourcePath,
118
+ }];
119
+ }
120
+
121
+ export async function createFontProcessor(
122
+ cwd: string,
123
+ config: FontConfig,
124
+ outDir: string,
125
+ ): Promise<FontProcessor | null> {
126
+ if (!config.enabled) return null;
127
+
128
+ const fonts = loadFonts(cwd, config);
129
+ if (!fonts || fonts.length === 0) return null;
130
+
131
+ const stackMode = Boolean(config.sources && config.roles);
132
+ const cacheDir = resolve(cwd, config.cache_dir);
133
+ const distFontDir = join(outDir, "assets", "fonts");
134
+ mkdirSync(cacheDir, { recursive: true });
135
+ mkdirSync(distFontDir, { recursive: true });
136
+
137
+ const subsetFonts = fonts.filter((f) => f.embed === "subset");
138
+ if (subsetFonts.length > 0) {
139
+ try {
140
+ await subsetWoff2(subsetFonts[0]!.bytes, "Aa"); // warm WASM
141
+ } catch {
142
+ // ウォームアップ失敗は本番サブセット処理に委ねる
143
+ }
144
+ }
145
+
146
+ const written = new Map<string, string>();
147
+ const staticFaces: Array<{ family: string; url: string; weight: string; format: "woff2" | "truetype" | "opentype" }> = [];
148
+
149
+ for (const font of fonts) {
150
+ if (font.embed !== "static") continue;
151
+ const distName = basename(font.sourcePath);
152
+ const distPath = join(distFontDir, distName);
153
+ if (!existsSync(distPath)) {
154
+ copyFileSync(font.sourcePath, distPath);
155
+ }
156
+ const ext = distName.split(".").pop()?.toLowerCase();
157
+ const format = ext === "otf" ? "opentype" : "truetype";
158
+ staticFaces.push({
159
+ family: font.family,
160
+ url: `assets/fonts/${distName}`,
161
+ weight: font.weight,
162
+ format,
163
+ });
164
+ }
165
+
166
+ return {
167
+ async fontCssForPage(opts) {
168
+ if (opts.frontmatter[config.skip_key] === true) {
169
+ return undefined;
170
+ }
171
+
172
+ const text = extractCharset(opts.body, opts.title, undefined, opts.extraText ?? "");
173
+ const key = textHash(text);
174
+ const faces: Array<{ family: string; url: string; weight: string; format?: "woff2" | "truetype" | "opentype" }> = [
175
+ ...staticFaces.map((f) => ({
176
+ ...f,
177
+ url: `${opts.rootPrefix}${f.url}`,
178
+ })),
179
+ ];
180
+
181
+ for (const font of fonts) {
182
+ if (font.embed === "static") continue;
183
+
184
+ const cacheKey = `${familySlug(font.family)}:${key}`;
185
+ let distName = written.get(cacheKey);
186
+
187
+ if (!distName) {
188
+ const cachePath = join(cacheDir, `${familySlug(font.family)}-${key}.woff2`);
189
+ let woff2: Uint8Array;
190
+ if (existsSync(cachePath)) {
191
+ woff2 = new Uint8Array(readFileSync(cachePath));
192
+ } else {
193
+ try {
194
+ woff2 = await subsetWoff2(font.bytes, text);
195
+ } catch {
196
+ // Ext/PUP などページ内に該当グリフが無い書体はスキップ
197
+ continue;
198
+ }
199
+ writeFileSync(cachePath, woff2);
200
+ }
201
+ const hash = sha256Hex(woff2).slice(0, 12);
202
+ distName = `${familySlug(font.family)}-${hash}.woff2`;
203
+ const distPath = join(distFontDir, distName);
204
+ if (!existsSync(distPath)) {
205
+ writeFileSync(distPath, woff2);
206
+ }
207
+ written.set(cacheKey, distName);
208
+ }
209
+
210
+ faces.push({
211
+ family: font.family,
212
+ url: `${opts.rootPrefix}assets/fonts/${distName}`,
213
+ weight: font.weight,
214
+ format: "woff2",
215
+ });
216
+ }
217
+
218
+ if (stackMode) {
219
+ return buildFontStackCss(faces);
220
+ }
221
+
222
+ const primary = faces[0]!;
223
+ return buildFontFaceCss(primary.family, primary.url, primary.weight);
224
+ },
225
+ };
226
+ }
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from "node:fs";
2
+ // @ts-expect-error wasm-bindgen glue
3
+ import * as bg from "../wasm/bunsen_font_subsetter_bg.js";
4
+
5
+ let initPromise: Promise<void> | null = null;
6
+
7
+ async function ensureInit(): Promise<void> {
8
+ if (initPromise) return initPromise;
9
+ initPromise = (async () => {
10
+ const url = new URL("../wasm/bunsen_font_subsetter_bg.wasm", import.meta.url);
11
+ const bytes = readFileSync(url);
12
+ const imports = {
13
+ "./bunsen_font_subsetter_bg.js": bg as unknown as Record<string, WebAssembly.ImportValue>,
14
+ };
15
+ const result = await WebAssembly.instantiate(bytes, imports);
16
+ const instance = "instance" in result ? result.instance : result;
17
+ bg.__wbg_set_wasm(instance.exports);
18
+ const exports = instance.exports as Record<string, unknown>;
19
+ if (typeof exports.__wbindgen_start === "function") {
20
+ (exports.__wbindgen_start as () => void)();
21
+ }
22
+ })();
23
+ return initPromise;
24
+ }
25
+
26
+ export async function subsetWoff2(fontBytes: Uint8Array, text: string): Promise<Uint8Array> {
27
+ await ensureInit();
28
+ const out = bg.subset_font(fontBytes, text) as Uint8Array;
29
+ if (out.length === 0) {
30
+ throw new Error("font subsetter returned empty bytes");
31
+ }
32
+ return out;
33
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Encode raw font data to WOFF2 format.
3
+ * @param {Uint8Array} font_data
4
+ * @returns {Uint8Array}
5
+ */
6
+ export function compress_woff2(font_data) {
7
+ const ptr0 = passArray8ToWasm0(font_data, wasm.__wbindgen_malloc);
8
+ const len0 = WASM_VECTOR_LEN;
9
+ const ret = wasm.compress_woff2(ptr0, len0);
10
+ var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
11
+ wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
12
+ return v2;
13
+ }
14
+
15
+ /**
16
+ * Check if a font has glyphs for all base characters in the text.
17
+ * @param {Uint8Array} font_data
18
+ * @param {string} text
19
+ * @returns {boolean}
20
+ */
21
+ export function font_has_glyphs(font_data, text) {
22
+ const ptr0 = passArray8ToWasm0(font_data, wasm.__wbindgen_malloc);
23
+ const len0 = WASM_VECTOR_LEN;
24
+ const ptr1 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
25
+ const len1 = WASM_VECTOR_LEN;
26
+ const ret = wasm.font_has_glyphs(ptr0, len0, ptr1, len1);
27
+ return ret !== 0;
28
+ }
29
+
30
+ /**
31
+ * Subset a font to only include glyphs needed for the given text,
32
+ * with IVS (cmap format 14) support, then compress to WOFF2 format.
33
+ * @param {Uint8Array} font_data
34
+ * @param {string} text
35
+ * @returns {Uint8Array}
36
+ */
37
+ export function subset_font(font_data, text) {
38
+ const ptr0 = passArray8ToWasm0(font_data, wasm.__wbindgen_malloc);
39
+ const len0 = WASM_VECTOR_LEN;
40
+ const ptr1 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
41
+ const len1 = WASM_VECTOR_LEN;
42
+ const ret = wasm.subset_font(ptr0, len0, ptr1, len1);
43
+ var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
44
+ wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
45
+ return v3;
46
+ }
47
+
48
+ /**
49
+ * Subset a font without WOFF2 compression (returns raw OTF).
50
+ * @param {Uint8Array} font_data
51
+ * @param {string} text
52
+ * @returns {Uint8Array}
53
+ */
54
+ export function subset_font_raw(font_data, text) {
55
+ const ptr0 = passArray8ToWasm0(font_data, wasm.__wbindgen_malloc);
56
+ const len0 = WASM_VECTOR_LEN;
57
+ const ptr1 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
58
+ const len1 = WASM_VECTOR_LEN;
59
+ const ret = wasm.subset_font_raw(ptr0, len0, ptr1, len1);
60
+ var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
61
+ wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
62
+ return v3;
63
+ }
64
+ export function __wbindgen_init_externref_table() {
65
+ const table = wasm.__wbindgen_externrefs;
66
+ const offset = table.grow(4);
67
+ table.set(0, undefined);
68
+ table.set(offset + 0, undefined);
69
+ table.set(offset + 1, null);
70
+ table.set(offset + 2, true);
71
+ table.set(offset + 3, false);
72
+ }
73
+ function getArrayU8FromWasm0(ptr, len) {
74
+ ptr = ptr >>> 0;
75
+ return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
76
+ }
77
+
78
+ let cachedUint8ArrayMemory0 = null;
79
+ function getUint8ArrayMemory0() {
80
+ if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
81
+ cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
82
+ }
83
+ return cachedUint8ArrayMemory0;
84
+ }
85
+
86
+ function passArray8ToWasm0(arg, malloc) {
87
+ const ptr = malloc(arg.length * 1, 1) >>> 0;
88
+ getUint8ArrayMemory0().set(arg, ptr / 1);
89
+ WASM_VECTOR_LEN = arg.length;
90
+ return ptr;
91
+ }
92
+
93
+ function passStringToWasm0(arg, malloc, realloc) {
94
+ if (realloc === undefined) {
95
+ const buf = cachedTextEncoder.encode(arg);
96
+ const ptr = malloc(buf.length, 1) >>> 0;
97
+ getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
98
+ WASM_VECTOR_LEN = buf.length;
99
+ return ptr;
100
+ }
101
+
102
+ let len = arg.length;
103
+ let ptr = malloc(len, 1) >>> 0;
104
+
105
+ const mem = getUint8ArrayMemory0();
106
+
107
+ let offset = 0;
108
+
109
+ for (; offset < len; offset++) {
110
+ const code = arg.charCodeAt(offset);
111
+ if (code > 0x7F) break;
112
+ mem[ptr + offset] = code;
113
+ }
114
+ if (offset !== len) {
115
+ if (offset !== 0) {
116
+ arg = arg.slice(offset);
117
+ }
118
+ ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
119
+ const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
120
+ const ret = cachedTextEncoder.encodeInto(arg, view);
121
+
122
+ offset += ret.written;
123
+ ptr = realloc(ptr, len, offset, 1) >>> 0;
124
+ }
125
+
126
+ WASM_VECTOR_LEN = offset;
127
+ return ptr;
128
+ }
129
+
130
+ const cachedTextEncoder = new TextEncoder();
131
+
132
+ if (!('encodeInto' in cachedTextEncoder)) {
133
+ cachedTextEncoder.encodeInto = function (arg, view) {
134
+ const buf = cachedTextEncoder.encode(arg);
135
+ view.set(buf);
136
+ return {
137
+ read: arg.length,
138
+ written: buf.length
139
+ };
140
+ };
141
+ }
142
+
143
+ let WASM_VECTOR_LEN = 0;
144
+
145
+
146
+ let wasm;
147
+ export function __wbg_set_wasm(val) {
148
+ wasm = val;
149
+ }