@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,211 @@
1
+ // @ts-check
2
+ /**
3
+ * TrueType サブセッター。
4
+ * 使用グリフ(と複合グリフが参照するコンポーネント)だけを含む新しい sfnt を組み立てる。
5
+ * GID は 0 から詰め直され、旧 GID → 新 GID の対応表を返す。
6
+ * cmap は出力しない(PDF 側では CIDToGIDMap /Identity で CID = 新 GID として扱う)。
7
+ */
8
+ import { glyphData, componentGids } from './parse.js';
9
+ import { concat } from '../pdf/writer.js';
10
+
11
+ /**
12
+ * @typedef {object} SubsetResult
13
+ * @property {Uint8Array} data サブセット化されたフォントファイル
14
+ * @property {Map<number, number>} gidMap 旧 GID → 新 GID
15
+ * @property {number[]} oldGids 新 GID 順に並んだ旧 GID
16
+ */
17
+
18
+ /**
19
+ * @param {import('./parse.js').ParsedFont} font
20
+ * @param {Iterable<number>} usedGids
21
+ * @returns {SubsetResult}
22
+ */
23
+ export function subsetFont(font, usedGids) {
24
+ // 1. グリフ集合を閉包する(複合グリフのコンポーネントを再帰的に追加)
25
+ /** @type {Set<number>} */
26
+ const set = new Set([0]);
27
+ const stack = [...usedGids];
28
+ while (stack.length) {
29
+ const gid = /** @type {number} */ (stack.pop());
30
+ if (gid < 0 || gid >= font.numGlyphs || set.has(gid)) continue;
31
+ set.add(gid);
32
+ for (const c of componentGids(glyphData(font, gid))) if (!set.has(c)) stack.push(c);
33
+ }
34
+ const oldGids = [...set].sort((a, b) => a - b);
35
+ /** @type {Map<number, number>} */
36
+ const gidMap = new Map();
37
+ oldGids.forEach((g, i) => gidMap.set(g, i));
38
+ const n = oldGids.length;
39
+
40
+ // 2. glyf / loca
41
+ /** @type {Uint8Array[]} */
42
+ const glyphChunks = [];
43
+ const loca = new Uint32Array(n + 1);
44
+ let glyfLen = 0;
45
+ for (let i = 0; i < n; i++) {
46
+ let g = glyphData(font, /** @type {number} */ (oldGids[i]));
47
+ if (g.length && new DataView(g.buffer, g.byteOffset, g.byteLength).getInt16(0) < 0) {
48
+ g = remapComposite(g, gidMap);
49
+ }
50
+ loca[i] = glyfLen;
51
+ glyphChunks.push(g);
52
+ glyfLen += g.length;
53
+ const pad = (4 - (glyfLen % 4)) % 4;
54
+ if (pad) {
55
+ glyphChunks.push(new Uint8Array(pad));
56
+ glyfLen += pad;
57
+ }
58
+ }
59
+ loca[n] = glyfLen;
60
+ const glyf = concat(glyphChunks);
61
+ const locaBytes = new Uint8Array(loca.length * 4);
62
+ const ldv = new DataView(locaBytes.buffer);
63
+ loca.forEach((v, i) => ldv.setUint32(i * 4, v));
64
+
65
+ // 3. hmtx(全グリフ分の advance + lsb を書く)
66
+ const origHmtx = /** @type {{offset: number, length: number}} */ (font.tables.get('hmtx'));
67
+ const origHhea = /** @type {{offset: number, length: number}} */ (font.tables.get('hhea'));
68
+ const fdv = new DataView(font.data.buffer, font.data.byteOffset, font.data.byteLength);
69
+ const numberOfHMetrics = fdv.getUint16(origHhea.offset + 34);
70
+ const hmtx = new Uint8Array(n * 4);
71
+ const hdv = new DataView(hmtx.buffer);
72
+ for (let i = 0; i < n; i++) {
73
+ const old = /** @type {number} */ (oldGids[i]);
74
+ const lsbOffset =
75
+ old < numberOfHMetrics
76
+ ? origHmtx.offset + old * 4 + 2
77
+ : origHmtx.offset + numberOfHMetrics * 4 + (old - numberOfHMetrics) * 2;
78
+ hdv.setUint16(i * 4, font.advances[old] ?? 0);
79
+ hdv.setInt16(i * 4 + 2, lsbOffset + 2 <= font.data.byteLength ? fdv.getInt16(lsbOffset) : 0);
80
+ }
81
+
82
+ // 4. head / hhea / maxp をコピーして書き換え
83
+ const head = copyTable(font, 'head');
84
+ const headDv = new DataView(head.buffer);
85
+ headDv.setUint32(8, 0); // checkSumAdjustment(後で計算)
86
+ headDv.setInt16(50, 1); // indexToLocFormat = long
87
+
88
+ const hhea = copyTable(font, 'hhea');
89
+ new DataView(hhea.buffer).setUint16(34, n);
90
+
91
+ const maxp = copyTable(font, 'maxp');
92
+ new DataView(maxp.buffer).setUint16(4, n);
93
+
94
+ // 5. テーブル群を組み立てる(タグ順にソート)
95
+ /** @type {[string, Uint8Array][]} */
96
+ const tables = [
97
+ ['glyf', glyf],
98
+ ['head', head],
99
+ ['hhea', hhea],
100
+ ['hmtx', hmtx],
101
+ ['loca', locaBytes],
102
+ ['maxp', maxp],
103
+ ];
104
+ for (const opt of ['cvt ', 'fpgm', 'prep']) {
105
+ if (font.tables.has(opt)) tables.push([opt, copyTable(font, opt)]);
106
+ }
107
+ tables.sort((a, b) => (a[0] < b[0] ? -1 : 1));
108
+
109
+ const data = buildSfnt(tables);
110
+ return { data, gidMap, oldGids };
111
+ }
112
+
113
+ /**
114
+ * @param {import('./parse.js').ParsedFont} font
115
+ * @param {string} tag
116
+ * @returns {Uint8Array}
117
+ */
118
+ function copyTable(font, tag) {
119
+ const t = font.tables.get(tag);
120
+ if (!t) throw new Error(`missing table ${tag}`);
121
+ // Node の Buffer は slice がビューを返すため、必ずコピーを作る
122
+ return new Uint8Array(font.data.subarray(t.offset, t.offset + t.length));
123
+ }
124
+
125
+ /**
126
+ * 複合グリフ内のコンポーネント GID を新 GID に書き換える。
127
+ * @param {Uint8Array} g
128
+ * @param {Map<number, number>} gidMap
129
+ * @returns {Uint8Array}
130
+ */
131
+ function remapComposite(g, gidMap) {
132
+ const out = new Uint8Array(g);
133
+ const dv = new DataView(out.buffer);
134
+ let p = 10;
135
+ for (;;) {
136
+ const flags = dv.getUint16(p);
137
+ const oldGid = dv.getUint16(p + 2);
138
+ dv.setUint16(p + 2, gidMap.get(oldGid) ?? 0);
139
+ p += 4;
140
+ p += flags & 0x0001 ? 4 : 2;
141
+ if (flags & 0x0008) p += 2;
142
+ else if (flags & 0x0040) p += 4;
143
+ else if (flags & 0x0080) p += 8;
144
+ if (!(flags & 0x0020)) break;
145
+ if (p >= out.length) break;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * テーブル群から sfnt ファイルを組み立てる。
152
+ * @param {[string, Uint8Array][]} tables タグ順にソート済み
153
+ * @returns {Uint8Array}
154
+ */
155
+ function buildSfnt(tables) {
156
+ const numTables = tables.length;
157
+ let entrySelector = 0;
158
+ while (1 << (entrySelector + 1) <= numTables) entrySelector++;
159
+ const searchRange = (1 << entrySelector) * 16;
160
+ const rangeShift = numTables * 16 - searchRange;
161
+
162
+ const dirLen = 12 + numTables * 16;
163
+ let offset = dirLen;
164
+ /** @type {{tag: string, data: Uint8Array, offset: number, checksum: number}[]} */
165
+ const entries = [];
166
+ for (const [tag, data] of tables) {
167
+ entries.push({ tag, data, offset, checksum: checksum(data) });
168
+ offset += (data.length + 3) & ~3;
169
+ }
170
+
171
+ const out = new Uint8Array(offset);
172
+ const dv = new DataView(out.buffer);
173
+ dv.setUint32(0, 0x00010000);
174
+ dv.setUint16(4, numTables);
175
+ dv.setUint16(6, searchRange);
176
+ dv.setUint16(8, entrySelector);
177
+ dv.setUint16(10, rangeShift);
178
+ entries.forEach((e, i) => {
179
+ const p = 12 + i * 16;
180
+ for (let k = 0; k < 4; k++) out[p + k] = e.tag.charCodeAt(k);
181
+ dv.setUint32(p + 4, e.checksum);
182
+ dv.setUint32(p + 8, e.offset);
183
+ dv.setUint32(p + 12, e.data.length);
184
+ out.set(e.data, e.offset);
185
+ });
186
+
187
+ // head.checkSumAdjustment = 0xB1B0AFBA - checksum(whole font)
188
+ const headEntry = entries.find((e) => e.tag === 'head');
189
+ if (headEntry) {
190
+ const total = checksum(out);
191
+ dv.setUint32(headEntry.offset + 8, (0xb1b0afba - total) >>> 0);
192
+ }
193
+ return out;
194
+ }
195
+
196
+ /**
197
+ * @param {Uint8Array} data
198
+ * @returns {number}
199
+ */
200
+ function checksum(data) {
201
+ let sum = 0;
202
+ const n = data.length;
203
+ for (let i = 0; i < n; i += 4) {
204
+ const b0 = data[i] ?? 0;
205
+ const b1 = data[i + 1] ?? 0;
206
+ const b2 = data[i + 2] ?? 0;
207
+ const b3 = data[i + 3] ?? 0;
208
+ sum = (sum + (((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0)) >>> 0;
209
+ }
210
+ return sum;
211
+ }
package/src/index.js ADDED
@@ -0,0 +1,239 @@
1
+ // @ts-check
2
+ /**
3
+ * Receipt html to pdf — 公開 API
4
+ *
5
+ * ブラウザ内で HTML/CSS をテキスト選択可能なベクター PDF に変換する。
6
+ * 設計書: docs/design.md
7
+ */
8
+ import { FontRegistry } from './font/registry.js';
9
+ import { renderDocument } from './renderer.js';
10
+ import { walk } from './walker/walk.js';
11
+ import { resolvePage, buildPdf } from './page.js';
12
+ import { PX_TO_PT } from './units.js';
13
+
14
+ export { expandPrintMediaCss } from './renderer.js';
15
+
16
+ /**
17
+ * 登録するフォントの定義。
18
+ * `src` は TrueType アウトライン(glyf)を持つ静的 TTF のみ対応。
19
+ *
20
+ * @typedef {object} FontSource
21
+ * @property {string} family CSS の font-family と一致させる名前
22
+ * @property {number} [weight=400] 100〜900
23
+ * @property {'normal'|'italic'} [style='normal']
24
+ * @property {string|ArrayBuffer|Uint8Array} src URL または フォントファイルのバイト列
25
+ */
26
+
27
+ /**
28
+ * 用紙サイズ。既定名か、幅・高さを CSS 長さ('80mm' など)で指定する。
29
+ *
30
+ * @typedef {'A3'|'A4'|'A5'|'B4'|'B5'|'Letter'|'Legal'|{width: string, height: string}} PageSize
31
+ */
32
+
33
+ /**
34
+ * @typedef {object} PageOptions
35
+ * @property {PageSize} [size='A4']
36
+ * @property {'portrait'|'landscape'} [orientation='portrait']
37
+ * @property {string|{top: string, right: string, bottom: string, left: string}} [margin='15mm']
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} PdfMetadata
42
+ * @property {string} [title]
43
+ * @property {string} [author]
44
+ * @property {string} [subject]
45
+ * @property {string} [keywords]
46
+ * @property {string} [creator]
47
+ * @property {Date} [creationDate]
48
+ */
49
+
50
+ /**
51
+ * 変換中に発生した非致命的な問題。例外にはせず onWarning に流す。
52
+ *
53
+ * @typedef {object} ConversionWarning
54
+ * @property {'unsupported-css'|'missing-font'|'missing-glyph'|'image-failed'|'other'} code
55
+ * @property {string} message
56
+ * @property {Element} [element]
57
+ * @property {string} [property] unsupported-css のときの CSS プロパティ名
58
+ * @property {string} [text] missing-glyph のときの該当文字
59
+ */
60
+
61
+ /**
62
+ * @typedef {object} ConvertOptions
63
+ * @property {PageOptions} [page]
64
+ * @property {string[]} [fontFallback] 未登録ファミリーが要求されたときに試す family の順序
65
+ * @property {'inherit'|'none'|string[]} [stylesheets='inherit'] 親文書のスタイルを継承するか、URL/CSS テキストを明示するか
66
+ * @property {boolean} [mediaPrint=false] `@media print` ルールを通常ルールとして適用する
67
+ * @property {boolean} [compress=true] CompressionStream が使えれば FlateDecode を適用する
68
+ * @property {PdfMetadata} [metadata]
69
+ * @property {string|null} [header=null] 各ページ上部の HTML テンプレート。{{pageNumber}} {{totalPages}} を置換する
70
+ * @property {string|null} [footer=null] 各ページ下部の HTML テンプレート。同上
71
+ * @property {'font'|'measure'|'auto'} [textMeasure='auto'] グリフ位置の決め方(現在は常に実測)
72
+ * @property {'blob'|'uint8array'|'dataurl'} [output='blob']
73
+ * @property {string} [baseUrl] 相対 URL(フォント・画像)の基準。既定は現在の文書
74
+ * @property {(warning: ConversionWarning) => void} [onWarning]
75
+ */
76
+
77
+ /**
78
+ * 変換の入力。DOM 要素、または HTML 文字列。
79
+ * @typedef {Element|string} ConvertInput
80
+ */
81
+
82
+ /** ライブラリのバージョン(package.json と同期) */
83
+ export const version = '0.1.0';
84
+
85
+ /** モジュール共有のフォントレジストリ */
86
+ const registry = new FontRegistry();
87
+
88
+ /**
89
+ * フォントを登録する。同じ family/weight/style を再登録した場合は上書きする。
90
+ * パース結果はモジュール内にキャッシュされ、以降の htmlToPdf 呼び出しで再利用される。
91
+ *
92
+ * @param {FontSource} font
93
+ * @returns {Promise<void>}
94
+ */
95
+ export async function registerFont(font) {
96
+ if (!font || !font.family || !font.src) throw new TypeError('registerFont: { family, src } are required');
97
+ const entry = await registry.register(font);
98
+ if (entry.parsed.variable) {
99
+ console.warn(
100
+ `[receipt-html-to-pdf] "${font.family}" is a variable font; only the default instance outlines are embedded. ` +
101
+ 'Use static TTF instances for other weights.',
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 登録済みフォントの一覧(デバッグ用)。
108
+ * @returns {{family: string, weight: number, style: string, glyphs: number}[]}
109
+ */
110
+ export function listFonts() {
111
+ return registry.fonts.map((f) => ({ family: f.displayFamily, weight: f.weight, style: f.style, glyphs: f.parsed.numGlyphs }));
112
+ }
113
+
114
+ /**
115
+ * HTML を PDF に変換する。
116
+ *
117
+ * @param {ConvertInput} input
118
+ * @param {ConvertOptions} [options]
119
+ * @returns {Promise<Blob|Uint8Array|string>} options.output に応じた PDF
120
+ */
121
+ export async function htmlToPdf(input, options = {}) {
122
+ if (typeof document === 'undefined') throw new Error('htmlToPdf must run in a browser (needs DOM layout)');
123
+ if (registry.fonts.length === 0) {
124
+ throw new Error('htmlToPdf: no fonts registered. Call registerFont() with at least one TrueType font first.');
125
+ }
126
+ const warn = options.onWarning ?? (() => {});
127
+ const geo = resolvePage(options.page);
128
+ const widthPx = (geo.width - geo.left - geo.right) / PX_TO_PT;
129
+
130
+ const rendered = await renderDocument(input, {
131
+ widthPx,
132
+ stylesheets: options.stylesheets ?? 'inherit',
133
+ mediaPrint: options.mediaPrint ?? false,
134
+ baseUrl: options.baseUrl,
135
+ warn,
136
+ });
137
+
138
+ /** @type {import('./walker/walk.js').WalkContext} */
139
+ const walkCtx = {
140
+ registry,
141
+ fontFallback: options.fontFallback ?? [],
142
+ warn,
143
+ textMeasure: options.textMeasure ?? 'auto',
144
+ };
145
+ const renderOpts = {
146
+ widthPx,
147
+ stylesheets: options.stylesheets ?? 'inherit',
148
+ mediaPrint: options.mediaPrint ?? false,
149
+ baseUrl: options.baseUrl,
150
+ warn,
151
+ };
152
+
153
+ try {
154
+ const body = await walk(rendered.root, walkCtx);
155
+ const header = options.header ? await makeDecoration(options.header, renderOpts, walkCtx) : null;
156
+ const footer = options.footer ? await makeDecoration(options.footer, renderOpts, walkCtx) : null;
157
+ const bytes = await buildPdf(body, geo, {
158
+ compress: options.compress ?? true,
159
+ metadata: options.metadata,
160
+ header,
161
+ footer,
162
+ });
163
+ return toOutput(bytes, options.output ?? 'blob');
164
+ } finally {
165
+ rendered.destroy();
166
+ }
167
+ }
168
+
169
+ /**
170
+ * ヘッダー/フッターのテンプレートを描画する準備をする。
171
+ * 高さは 1 ページ目相当({{pageNumber}} = {{totalPages}} = 1)で測り、全ページ同じとみなす。
172
+ * @param {string} template
173
+ * @param {Parameters<typeof renderDocument>[1]} renderOpts
174
+ * @param {import('./walker/walk.js').WalkContext} walkCtx
175
+ * @returns {Promise<import('./page.js').PageDecoration>}
176
+ */
177
+ async function makeDecoration(template, renderOpts, walkCtx) {
178
+ const renderOnce = async (/** @type {number} */ page, /** @type {number} */ total) => {
179
+ const html = template.replace(/\{\{\s*pageNumber\s*\}\}/g, String(page)).replace(/\{\{\s*totalPages\s*\}\}/g, String(total));
180
+ const rendered = await renderDocument(html, renderOpts);
181
+ try {
182
+ return await walk(rendered.root, walkCtx);
183
+ } finally {
184
+ rendered.destroy();
185
+ }
186
+ };
187
+ const probe = await renderOnce(1, 1);
188
+ /** @type {Map<string, import('./walker/walk.js').WalkResult>} */
189
+ const cache = new Map([['1/1', probe]]);
190
+ return {
191
+ heightPx: probe.height,
192
+ render: async (page, total) => {
193
+ const key = `${page}/${total}`;
194
+ let r = cache.get(key);
195
+ if (!r) {
196
+ r = await renderOnce(page, total);
197
+ cache.set(key, r);
198
+ }
199
+ return r;
200
+ },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * @param {Uint8Array} bytes
206
+ * @param {'blob'|'uint8array'|'dataurl'} output
207
+ * @returns {Blob|Uint8Array|string}
208
+ */
209
+ function toOutput(bytes, output) {
210
+ if (output === 'uint8array') return bytes;
211
+ if (output === 'dataurl') {
212
+ let bin = '';
213
+ for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
214
+ return 'data:application/pdf;base64,' + btoa(bin);
215
+ }
216
+ return new Blob([/** @type {Uint8Array<ArrayBuffer>} */ (bytes)], { type: 'application/pdf' });
217
+ }
218
+
219
+ /**
220
+ * 生成した PDF をブラウザでダウンロードさせる補助関数。
221
+ *
222
+ * @param {Blob|Uint8Array} pdf
223
+ * @param {string} filename
224
+ * @returns {void}
225
+ */
226
+ export function downloadPdf(pdf, filename) {
227
+ const blob =
228
+ pdf instanceof Blob
229
+ ? pdf
230
+ : new Blob([/** @type {Uint8Array<ArrayBuffer>} */ (pdf)], { type: 'application/pdf' });
231
+ const url = URL.createObjectURL(blob);
232
+ const a = document.createElement('a');
233
+ a.href = url;
234
+ a.download = filename;
235
+ document.body.appendChild(a);
236
+ a.click();
237
+ a.remove();
238
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
239
+ }