@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,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
|
+
}
|