@hidemikimura/receipt-html-to-pdf 0.1.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.
@@ -0,0 +1,336 @@
1
+ // @ts-check
2
+ /**
3
+ * TrueType (sfnt / glyf アウトライン) フォントの最小パーサー。
4
+ * サブセット化と PDF 埋め込みに必要なテーブルだけを読む。
5
+ */
6
+
7
+ /**
8
+ * @typedef {object} ParsedFont
9
+ * @property {Uint8Array} data
10
+ * @property {Map<string, {offset: number, length: number}>} tables
11
+ * @property {number} unitsPerEm
12
+ * @property {number} indexToLocFormat
13
+ * @property {[number, number, number, number]} bbox xMin yMin xMax yMax (font units)
14
+ * @property {number} ascender hhea
15
+ * @property {number} descender hhea(負値)
16
+ * @property {number} lineGap
17
+ * @property {number} numGlyphs
18
+ * @property {Uint16Array} advances グリフごとの advance width (font units)
19
+ * @property {Uint32Array} loca numGlyphs + 1 要素
20
+ * @property {Map<number, number>} cmap コードポイント → GID
21
+ * @property {number} capHeight OS/2 sCapHeight(なければ ascender * 0.7)
22
+ * @property {number} italicAngle
23
+ * @property {boolean} useTypoMetrics OS/2 fsSelection bit 7
24
+ * @property {number} typoAscender
25
+ * @property {number} typoDescender
26
+ * @property {number} winAscent
27
+ * @property {number} winDescent
28
+ * @property {string} postScriptName
29
+ * @property {boolean} bold
30
+ * @property {boolean} italic
31
+ * @property {boolean} variable fvar テーブルを持つ可変フォントか
32
+ */
33
+
34
+ /**
35
+ * @param {ArrayBuffer|Uint8Array} buffer
36
+ * @returns {ParsedFont}
37
+ */
38
+ export function parseFont(buffer) {
39
+ const data = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
40
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
41
+
42
+ const tag = dv.getUint32(0);
43
+ if (tag === 0x4f54544f /* 'OTTO' */) {
44
+ throw new Error('CFF outlines (OpenType/CFF) are not supported. Use a TrueType (glyf) font.');
45
+ }
46
+ if (tag === 0x774f4646 /* 'wOFF' */ || tag === 0x774f4632 /* 'wOF2' */) {
47
+ throw new Error('WOFF/WOFF2 are not supported. Use a raw .ttf file.');
48
+ }
49
+ if (tag === 0x74746366 /* 'ttcf' */) {
50
+ throw new Error('TrueType collections (.ttc) are not supported. Extract a single face first.');
51
+ }
52
+ if (tag !== 0x00010000 && tag !== 0x74727565 /* 'true' */) {
53
+ throw new Error('Not a TrueType font (bad sfnt version)');
54
+ }
55
+
56
+ const numTables = dv.getUint16(4);
57
+ /** @type {Map<string, {offset: number, length: number}>} */
58
+ const tables = new Map();
59
+ for (let i = 0; i < numTables; i++) {
60
+ const p = 12 + i * 16;
61
+ const name = String.fromCharCode(data[p] ?? 0, data[p + 1] ?? 0, data[p + 2] ?? 0, data[p + 3] ?? 0);
62
+ tables.set(name, { offset: dv.getUint32(p + 8), length: dv.getUint32(p + 12) });
63
+ }
64
+ if (tables.has('CFF ')) throw new Error('CFF outlines are not supported. Use a TrueType (glyf) font.');
65
+ // 可変フォント: glyf にはデフォルトインスタンスのみ入っている。呼び出し側が警告できるようフラグを立てる。
66
+ const variable = tables.has('fvar');
67
+ for (const req of ['head', 'hhea', 'maxp', 'hmtx', 'loca', 'glyf']) {
68
+ if (!tables.has(req)) throw new Error(`Font is missing required table: ${req}`);
69
+ }
70
+
71
+ const t = (/** @type {string} */ name) => /** @type {{offset: number, length: number}} */ (tables.get(name));
72
+
73
+ // head
74
+ const head = t('head');
75
+ const unitsPerEm = dv.getUint16(head.offset + 18);
76
+ const bbox = /** @type {[number, number, number, number]} */ ([
77
+ dv.getInt16(head.offset + 36),
78
+ dv.getInt16(head.offset + 38),
79
+ dv.getInt16(head.offset + 40),
80
+ dv.getInt16(head.offset + 42),
81
+ ]);
82
+ const macStyle = dv.getUint16(head.offset + 44);
83
+ const indexToLocFormat = dv.getInt16(head.offset + 50);
84
+
85
+ // hhea
86
+ const hhea = t('hhea');
87
+ const ascender = dv.getInt16(hhea.offset + 4);
88
+ const descender = dv.getInt16(hhea.offset + 6);
89
+ const lineGap = dv.getInt16(hhea.offset + 8);
90
+ const numberOfHMetrics = dv.getUint16(hhea.offset + 34);
91
+
92
+ // maxp
93
+ const numGlyphs = dv.getUint16(t('maxp').offset + 4);
94
+
95
+ // hmtx
96
+ const hmtx = t('hmtx');
97
+ const advances = new Uint16Array(numGlyphs);
98
+ let lastAdvance = 0;
99
+ for (let g = 0; g < numGlyphs; g++) {
100
+ if (g < numberOfHMetrics) lastAdvance = dv.getUint16(hmtx.offset + g * 4);
101
+ advances[g] = lastAdvance;
102
+ }
103
+
104
+ // loca
105
+ const locaT = t('loca');
106
+ const loca = new Uint32Array(numGlyphs + 1);
107
+ for (let g = 0; g <= numGlyphs; g++) {
108
+ loca[g] = indexToLocFormat === 0 ? dv.getUint16(locaT.offset + g * 2) * 2 : dv.getUint32(locaT.offset + g * 4);
109
+ }
110
+
111
+ // cmap(サブセット出力には含まれないため省略可。無ければ空)
112
+ const cmapT = tables.get('cmap');
113
+ const cmap = cmapT ? parseCmap(dv, cmapT.offset) : new Map();
114
+
115
+ // OS/2 (optional)
116
+ let capHeight = Math.round(ascender * 0.7);
117
+ let useTypoMetrics = false;
118
+ let typoAscender = ascender;
119
+ let typoDescender = descender;
120
+ let winAscent = ascender;
121
+ let winDescent = -descender;
122
+ let bold = (macStyle & 1) !== 0;
123
+ let italic = (macStyle & 2) !== 0;
124
+ const os2 = tables.get('OS/2');
125
+ if (os2) {
126
+ const version = dv.getUint16(os2.offset);
127
+ const fsSelection = dv.getUint16(os2.offset + 62);
128
+ useTypoMetrics = (fsSelection & (1 << 7)) !== 0;
129
+ bold = bold || (fsSelection & (1 << 5)) !== 0;
130
+ italic = italic || (fsSelection & 1) !== 0;
131
+ typoAscender = dv.getInt16(os2.offset + 68);
132
+ typoDescender = dv.getInt16(os2.offset + 70);
133
+ winAscent = dv.getUint16(os2.offset + 74);
134
+ winDescent = dv.getUint16(os2.offset + 76);
135
+ if (version >= 2 && os2.length >= 90) {
136
+ const ch = dv.getInt16(os2.offset + 88);
137
+ if (ch > 0) capHeight = ch;
138
+ }
139
+ }
140
+
141
+ // post (optional) — italicAngle
142
+ let italicAngle = 0;
143
+ const post = tables.get('post');
144
+ if (post) italicAngle = dv.getInt32(post.offset + 4) / 65536;
145
+
146
+ const postScriptName = parsePostScriptName(dv, data, tables.get('name')) ?? 'Embedded';
147
+
148
+ return {
149
+ data,
150
+ tables,
151
+ unitsPerEm,
152
+ indexToLocFormat,
153
+ bbox,
154
+ ascender,
155
+ descender,
156
+ lineGap,
157
+ numGlyphs,
158
+ advances,
159
+ loca,
160
+ cmap,
161
+ capHeight,
162
+ italicAngle,
163
+ useTypoMetrics,
164
+ typoAscender,
165
+ typoDescender,
166
+ winAscent,
167
+ winDescent,
168
+ postScriptName,
169
+ bold,
170
+ italic,
171
+ variable,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * cmap テーブルから Unicode サブテーブル(format 12 優先、なければ 4)を読む。
177
+ * @param {DataView} dv
178
+ * @param {number} base
179
+ * @returns {Map<number, number>}
180
+ */
181
+ function parseCmap(dv, base) {
182
+ const n = dv.getUint16(base + 2);
183
+ let best = -1;
184
+ let bestScore = -1;
185
+ for (let i = 0; i < n; i++) {
186
+ const p = base + 4 + i * 8;
187
+ const platform = dv.getUint16(p);
188
+ const encoding = dv.getUint16(p + 2);
189
+ const offset = dv.getUint32(p + 4);
190
+ const format = dv.getUint16(base + offset);
191
+ let score = -1;
192
+ if (platform === 3 && encoding === 10 && format === 12) score = 4;
193
+ else if (platform === 0 && (encoding === 4 || encoding === 6) && format === 12) score = 3;
194
+ else if (platform === 3 && encoding === 1 && format === 4) score = 2;
195
+ else if (platform === 0 && format === 4) score = 1;
196
+ if (score > bestScore) {
197
+ bestScore = score;
198
+ best = base + offset;
199
+ }
200
+ }
201
+ if (best < 0) throw new Error('Font has no usable Unicode cmap subtable (format 4 or 12)');
202
+
203
+ /** @type {Map<number, number>} */
204
+ const map = new Map();
205
+ const format = dv.getUint16(best);
206
+ if (format === 4) {
207
+ const segCountX2 = dv.getUint16(best + 6);
208
+ const segCount = segCountX2 / 2;
209
+ const endP = best + 14;
210
+ const startP = endP + segCountX2 + 2;
211
+ const deltaP = startP + segCountX2;
212
+ const rangeP = deltaP + segCountX2;
213
+ for (let s = 0; s < segCount; s++) {
214
+ const end = dv.getUint16(endP + s * 2);
215
+ const start = dv.getUint16(startP + s * 2);
216
+ const delta = dv.getInt16(deltaP + s * 2);
217
+ const rangeOffset = dv.getUint16(rangeP + s * 2);
218
+ if (start === 0xffff) continue;
219
+ for (let c = start; c <= end; c++) {
220
+ let gid;
221
+ if (rangeOffset === 0) {
222
+ gid = (c + delta) & 0xffff;
223
+ } else {
224
+ const gp = rangeP + s * 2 + rangeOffset + (c - start) * 2;
225
+ if (gp + 2 > dv.byteLength) continue;
226
+ gid = dv.getUint16(gp);
227
+ if (gid !== 0) gid = (gid + delta) & 0xffff;
228
+ }
229
+ if (gid !== 0) map.set(c, gid);
230
+ }
231
+ }
232
+ } else if (format === 12) {
233
+ const nGroups = dv.getUint32(best + 12);
234
+ let p = best + 16;
235
+ for (let i = 0; i < nGroups; i++, p += 12) {
236
+ const start = dv.getUint32(p);
237
+ const end = dv.getUint32(p + 4);
238
+ const startGid = dv.getUint32(p + 8);
239
+ for (let c = start; c <= end && c - start < 0x10000; c++) {
240
+ const gid = startGid + (c - start);
241
+ if (gid !== 0) map.set(c, gid);
242
+ }
243
+ }
244
+ }
245
+ return map;
246
+ }
247
+
248
+ /**
249
+ * name テーブルから PostScript 名 (nameID 6) を取り出す。
250
+ * @param {DataView} dv
251
+ * @param {Uint8Array} data
252
+ * @param {{offset: number, length: number}|undefined} nameT
253
+ * @returns {string|null}
254
+ */
255
+ function parsePostScriptName(dv, data, nameT) {
256
+ if (!nameT) return null;
257
+ const count = dv.getUint16(nameT.offset + 2);
258
+ const stringOffset = dv.getUint16(nameT.offset + 4);
259
+ let fallback = null;
260
+ for (let i = 0; i < count; i++) {
261
+ const p = nameT.offset + 6 + i * 12;
262
+ const platform = dv.getUint16(p);
263
+ const nameId = dv.getUint16(p + 6);
264
+ const length = dv.getUint16(p + 8);
265
+ const offset = dv.getUint16(p + 10);
266
+ if (nameId !== 6) continue;
267
+ const start = nameT.offset + stringOffset + offset;
268
+ if (platform === 1) {
269
+ // Macintosh Roman: ASCII とみなす
270
+ return sanitizePsName(String.fromCharCode(...data.subarray(start, start + length)));
271
+ }
272
+ if (platform === 3 || platform === 0) {
273
+ let s = '';
274
+ for (let j = 0; j + 1 < length; j += 2) s += String.fromCharCode(dv.getUint16(start + j));
275
+ fallback = sanitizePsName(s);
276
+ }
277
+ }
278
+ return fallback;
279
+ }
280
+
281
+ /** @param {string} s */
282
+ function sanitizePsName(s) {
283
+ return s.replace(/[^\x21-\x7e]/g, '').replace(/[\[\]\(\)\{\}<>\/%#]/g, '') || 'Embedded';
284
+ }
285
+
286
+ /**
287
+ * グリフの生バイト列を返す(空グリフは長さ 0)。
288
+ * @param {ParsedFont} font
289
+ * @param {number} gid
290
+ * @returns {Uint8Array}
291
+ */
292
+ export function glyphData(font, gid) {
293
+ const glyf = /** @type {{offset: number, length: number}} */ (font.tables.get('glyf'));
294
+ const start = font.loca[gid] ?? 0;
295
+ const end = font.loca[gid + 1] ?? start;
296
+ return font.data.subarray(glyf.offset + start, glyf.offset + end);
297
+ }
298
+
299
+ /**
300
+ * 複合グリフが参照するコンポーネント GID を返す(単純グリフなら空)。
301
+ * @param {Uint8Array} g
302
+ * @returns {number[]}
303
+ */
304
+ export function componentGids(g) {
305
+ if (g.length < 10) return [];
306
+ const dv = new DataView(g.buffer, g.byteOffset, g.byteLength);
307
+ const numberOfContours = dv.getInt16(0);
308
+ if (numberOfContours >= 0) return [];
309
+ /** @type {number[]} */
310
+ const out = [];
311
+ let p = 10;
312
+ for (;;) {
313
+ const flags = dv.getUint16(p);
314
+ const glyphIndex = dv.getUint16(p + 2);
315
+ out.push(glyphIndex);
316
+ p += 4;
317
+ p += flags & 0x0001 /* ARG_1_AND_2_ARE_WORDS */ ? 4 : 2;
318
+ if (flags & 0x0008 /* WE_HAVE_A_SCALE */) p += 2;
319
+ else if (flags & 0x0040 /* WE_HAVE_AN_X_AND_Y_SCALE */) p += 4;
320
+ else if (flags & 0x0080 /* WE_HAVE_A_TWO_BY_TWO */) p += 8;
321
+ if (!(flags & 0x0020 /* MORE_COMPONENTS */)) break;
322
+ if (p >= g.length) break;
323
+ }
324
+ return out;
325
+ }
326
+
327
+ /**
328
+ * ブラウザが行ボックス計算に使うアセント/ディセントに近い値(font units)を返す。
329
+ * Chrome/Firefox は OS/2 USE_TYPO_METRICS が立っていれば typo、そうでなければ hhea を使う。
330
+ * @param {ParsedFont} font
331
+ * @returns {{ascent: number, descent: number}} descent は正値
332
+ */
333
+ export function browserMetrics(font) {
334
+ if (font.useTypoMetrics) return { ascent: font.typoAscender, descent: -font.typoDescender };
335
+ return { ascent: font.ascender, descent: -font.descender };
336
+ }
@@ -0,0 +1,168 @@
1
+ // @ts-check
2
+ /**
3
+ * 登録フォントの管理と CSS font-family / font-weight / font-style からのフォント選択。
4
+ */
5
+ import { parseFont } from './parse.js';
6
+
7
+ /**
8
+ * @typedef {object} RegisteredFont
9
+ * @property {string} family 正規化済み(小文字・引用符なし)
10
+ * @property {string} displayFamily
11
+ * @property {number} weight
12
+ * @property {'normal'|'italic'} style
13
+ * @property {import('./parse.js').ParsedFont} parsed
14
+ */
15
+
16
+ export class FontRegistry {
17
+ constructor() {
18
+ /** @type {RegisteredFont[]} */
19
+ this.fonts = [];
20
+ }
21
+
22
+ /**
23
+ * @param {import('../index.js').FontSource} source
24
+ * @returns {Promise<RegisteredFont>}
25
+ */
26
+ async register(source) {
27
+ const bytes = await loadBytes(source.src);
28
+ const parsed = parseFont(bytes);
29
+ const entry = {
30
+ family: normalizeFamily(source.family),
31
+ displayFamily: source.family,
32
+ weight: source.weight ?? 400,
33
+ style: source.style ?? 'normal',
34
+ parsed,
35
+ };
36
+ const idx = this.fonts.findIndex(
37
+ (f) => f.family === entry.family && f.weight === entry.weight && f.style === entry.style,
38
+ );
39
+ if (idx >= 0) this.fonts[idx] = entry;
40
+ else this.fonts.push(entry);
41
+ return entry;
42
+ }
43
+
44
+ /** @param {string} family */
45
+ hasFamily(family) {
46
+ const n = normalizeFamily(family);
47
+ return this.fonts.some((f) => f.family === n);
48
+ }
49
+
50
+ /**
51
+ * CSS の指定に最も近い登録フォントを返す。
52
+ * @param {string[]} families font-family リスト(優先順)
53
+ * @param {number} weight
54
+ * @param {'normal'|'italic'} style
55
+ * @param {number} [codePoint] 指定するとこの文字のグリフを持つフォントに限定する
56
+ * @returns {RegisteredFont|null}
57
+ */
58
+ match(families, weight, style, codePoint) {
59
+ for (const fam of families) {
60
+ const n = normalizeFamily(fam);
61
+ let candidates = this.fonts.filter((f) => f.family === n);
62
+ if (codePoint !== undefined) candidates = candidates.filter((f) => f.parsed.cmap.has(codePoint));
63
+ if (!candidates.length) continue;
64
+ return pickFace(candidates, weight, style);
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * 登録済みの全ファミリーの中から、指定文字のグリフを持つものを探す(最終フォールバック)。
71
+ * @param {number} codePoint
72
+ * @param {number} weight
73
+ * @param {'normal'|'italic'} style
74
+ * @returns {RegisteredFont|null}
75
+ */
76
+ anyWithGlyph(codePoint, weight, style) {
77
+ const candidates = this.fonts.filter((f) => f.parsed.cmap.has(codePoint));
78
+ return candidates.length ? pickFace(candidates, weight, style) : null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * CSS Fonts Level 4 のフォントマッチングを簡略化したもの。
84
+ * 1. style が一致するものを優先(なければ無視)
85
+ * 2. weight: 400 は 500 を先に見る。400〜500 は上→下、<400 は下→上、>500 は上→下。
86
+ * @param {RegisteredFont[]} candidates
87
+ * @param {number} weight
88
+ * @param {'normal'|'italic'} style
89
+ * @returns {RegisteredFont}
90
+ */
91
+ function pickFace(candidates, weight, style) {
92
+ const styled = candidates.filter((f) => f.style === style);
93
+ const pool = styled.length ? styled : candidates;
94
+ const exact = pool.find((f) => f.weight === weight);
95
+ if (exact) return exact;
96
+
97
+ const sorted = [...pool].sort((a, b) => a.weight - b.weight);
98
+ const heavier = sorted.filter((f) => f.weight > weight);
99
+ const lighter = sorted.filter((f) => f.weight < weight).reverse();
100
+
101
+ if (weight >= 400 && weight <= 500) {
102
+ const mid = heavier.find((f) => f.weight <= 500);
103
+ if (mid) return mid;
104
+ if (lighter.length) return /** @type {RegisteredFont} */ (lighter[0]);
105
+ return /** @type {RegisteredFont} */ (heavier[0]);
106
+ }
107
+ if (weight < 400) {
108
+ return /** @type {RegisteredFont} */ (lighter[0] ?? heavier[0]);
109
+ }
110
+ return /** @type {RegisteredFont} */ (heavier[0] ?? lighter[0]);
111
+ }
112
+
113
+ /**
114
+ * @param {string} family
115
+ * @returns {string}
116
+ */
117
+ export function normalizeFamily(family) {
118
+ return family.trim().replace(/^["']|["']$/g, '').trim().toLowerCase();
119
+ }
120
+
121
+ /**
122
+ * getComputedStyle().fontFamily('"BIZ UDPGothic", sans-serif')をリストに分割する。
123
+ * @param {string} value
124
+ * @returns {string[]}
125
+ */
126
+ export function splitFamilies(value) {
127
+ /** @type {string[]} */
128
+ const out = [];
129
+ let cur = '';
130
+ let quote = '';
131
+ for (const ch of value) {
132
+ if (quote) {
133
+ if (ch === quote) quote = '';
134
+ else cur += ch;
135
+ } else if (ch === '"' || ch === "'") {
136
+ quote = ch;
137
+ } else if (ch === ',') {
138
+ if (cur.trim()) out.push(cur.trim());
139
+ cur = '';
140
+ } else cur += ch;
141
+ }
142
+ if (cur.trim()) out.push(cur.trim());
143
+ return out;
144
+ }
145
+
146
+ /**
147
+ * getComputedStyle().fontWeight('400' / '700' / 'bold')を数値にする。
148
+ * @param {string} value
149
+ * @returns {number}
150
+ */
151
+ export function parseWeight(value) {
152
+ if (value === 'bold') return 700;
153
+ if (value === 'normal') return 400;
154
+ const n = parseInt(value, 10);
155
+ return Number.isFinite(n) ? n : 400;
156
+ }
157
+
158
+ /**
159
+ * @param {string|ArrayBuffer|Uint8Array} src
160
+ * @returns {Promise<Uint8Array>}
161
+ */
162
+ async function loadBytes(src) {
163
+ if (src instanceof Uint8Array) return src;
164
+ if (src instanceof ArrayBuffer) return new Uint8Array(src);
165
+ const res = await fetch(src);
166
+ if (!res.ok) throw new Error(`Failed to fetch font ${src}: ${res.status} ${res.statusText}`);
167
+ return new Uint8Array(await res.arrayBuffer());
168
+ }