@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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/receipt-html-to-pdf.min.js +39 -0
- package/dist/receipt-html-to-pdf.min.js.map +7 -0
- package/examples/lit.js +52 -0
- package/examples/react.jsx +56 -0
- package/examples/vanilla.html +55 -0
- package/package.json +82 -0
- package/src/font/cid.js +187 -0
- package/src/font/parse.js +336 -0
- package/src/font/registry.js +168 -0
- package/src/font/subset.js +211 -0
- package/src/index.js +239 -0
- package/src/page.js +429 -0
- package/src/paginate.js +119 -0
- package/src/pdf/compress.js +30 -0
- package/src/pdf/content.js +192 -0
- package/src/pdf/image.js +33 -0
- package/src/pdf/writer.js +253 -0
- package/src/renderer.js +328 -0
- package/src/units.js +127 -0
- package/src/walker/image.js +264 -0
- package/src/walker/text.js +199 -0
- package/src/walker/walk.js +670 -0
- package/types/font/cid.d.ts +40 -0
- package/types/font/parse.d.ts +113 -0
- package/types/font/registry.d.ts +63 -0
- package/types/font/subset.d.ts +26 -0
- package/types/index.d.ts +198 -0
- package/types/page.d.ts +64 -0
- package/types/paginate.d.ts +59 -0
- package/types/pdf/compress.d.ts +13 -0
- package/types/pdf/content.d.ts +65 -0
- package/types/pdf/image.d.ts +9 -0
- package/types/pdf/writer.d.ts +125 -0
- package/types/renderer.d.ts +55 -0
- package/types/units.d.ts +53 -0
- package/types/walker/image.d.ts +72 -0
- package/types/walker/text.d.ts +38 -0
- package/types/walker/walk.d.ts +150 -0
|
@@ -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
|
+
}
|