@hidemikimura/receipt-html-to-pdf 0.2.1 → 0.4.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/examples/cdn.html CHANGED
@@ -61,11 +61,11 @@
61
61
 
62
62
  <!--
63
63
  ここが CDN からの読み込み。type="module" が要る。
64
- バージョン(@0.2.1)は必ず固定する。外すと最新版が読み込まれ、更新のたびに挙動が変わりうる。
64
+ バージョン(@0.4.0)は必ず固定する。外すと最新版が読み込まれ、更新のたびに挙動が変わりうる。
65
65
  -->
66
66
  <script type="module">
67
67
  import * as ReceiptHtmlToPdf
68
- from 'https://cdn.jsdelivr.net/npm/@hidemikimura/receipt-html-to-pdf@0.2.1/dist/receipt-html-to-pdf.min.js';
68
+ from 'https://cdn.jsdelivr.net/npm/@hidemikimura/receipt-html-to-pdf@0.4.0/dist/receipt-html-to-pdf.min.js';
69
69
 
70
70
  // type="module" でない普通のスクリプトからも使えるようにグローバルへ載せる
71
71
  window.ReceiptHtmlToPdf = ReceiptHtmlToPdf;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidemikimura/receipt-html-to-pdf",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Receipt html to pdf — ブラウザ内で HTML/CSS をテキスト選択可能なベクター PDF に変換するライブラリ(日本の領収証・適格請求書向け)",
5
5
  "license": "MIT",
6
6
  "author": "Hidemi Kimura",
@@ -45,7 +45,7 @@ npm を使わない場合は CDN の URL をそのまま `import` できる(`d
45
45
  ```html
46
46
  <script type="module">
47
47
  import { registerFont, htmlToPdf, downloadPdf }
48
- from 'https://cdn.jsdelivr.net/npm/@hidemikimura/receipt-html-to-pdf@0.2.1/dist/receipt-html-to-pdf.min.js';
48
+ from 'https://cdn.jsdelivr.net/npm/@hidemikimura/receipt-html-to-pdf@0.4.0/dist/receipt-html-to-pdf.min.js';
49
49
  </script>
50
50
  ```
51
51
 
@@ -92,16 +92,23 @@ npm を使わない場合は CDN の URL をそのまま `import` できる(`d
92
92
  | `baseUrl` | 現在の文書 | 相対 URL(フォント・画像)の基準 |
93
93
  | `output` | `'blob'` | `'uint8array'` `'dataurl'` |
94
94
  | `onWarning` | — | 未対応 CSS・欠落グリフ・画像失敗の通知。**必ず配線する** |
95
+ | `links` | `true` | `<a href>` を PDF のリンク注釈にする(外部 URL・`mailto:`・文書内 `#id`) |
96
+ | `outline` | `false` | `h1`〜`h6` からしおり(PDF の目次)を作る |
97
+ | `onProgress` | — | `render` → `walk` → `layout`(`totalPages` 確定)→ `page`(1 ページずつ)→ `done`。長い文書の進捗表示に使う |
95
98
 
96
99
  ## 対応している CSS の要点
97
100
 
98
101
  レイアウト系(`display` 全般・Flexbox・Grid・テーブル・`position`・`margin`・`padding`・`white-space`・`word-break`・`text-align`・`line-height`・`letter-spacing`)は**ブラウザの計算結果をそのまま使うので全部そのとおりに出る**。追加実装は不要。
99
102
 
100
- 描画系で対応しているもの: `color`、`background-color`、`background-image: url()`(単一・`no-repeat`)、`border-*`(辺ごと、solid / dashed / dotted)、`border-collapse: collapse`、`border-radius`、`opacity`、`text-decoration`(underline / line-through)、`overflow: hidden` のクリップ、`<img>`(PNG 透過・JPEG・`object-fit`)、2D `transform`、`::before` / `::after`(引用文字列の `content` のみ)。
103
+ 描画系で対応しているもの: `color`、`background-color`、`background-image: url()`(単一。`background-repeat` は `repeat` / `-x` / `-y` / `space` / `round` に対応)、`background-image: linear-gradient()`(ベクター。色止めごとのアルファも可)、`border-*`(辺ごと、solid / dashed / dotted)、`border-collapse: collapse`、`border-radius`、`opacity`、`text-decoration`(underline / line-through)、`overflow: hidden` のクリップ、`<img>`(PNG 透過・JPEG・`object-fit`)、インライン `<svg>`(パス・基本図形をベクター変換)、2D `transform`、`::before` / `::after`(引用文字列の `content` のみ)。
101
104
 
102
- **出力されないもの**(`onWarning` `unsupported-css` が届く): `box-shadow`、`text-shadow`、グラデーション、`filter`、`clip-path`、`outline`、縦書き、3D transform、`counter()` の content、インライン `<svg>` / `<canvas>` / `<video>`。
105
+ GSUB の**単一置換**(`zero`・`jp90` などの異体字・`fwid` / `hwid`・`smcp`)は `font-variant-*` / `font-feature-settings` の指定どおりに再現する。**合字(`liga` / `dlig`)は未対応**。
103
106
 
104
- 代替の指針: ボーダーか薄い背景色。グラデーション 単色か画像。インライン SVG `<img src="x.svg">`(ラスタライズされて埋め込まれる)。
107
+ **数字の桁を揃えたいとき**: `tabular-nums` は日本語フォントでは効かないことが多い(BIZ UDPGothic・Noto Sans JP などに `tnum` 機能が無く、ブラウザ側でも何も起きない)。数字の送り幅がもともと揃っているフォント(BIZ UD**G**othic など)を選ぶか、表のセルを右揃え + 列幅固定にする。
108
+
109
+ **出力されないもの**(`onWarning` に `unsupported-css` が届く): `box-shadow`、`text-shadow`、`repeating-linear-gradient` / `radial-gradient` / `conic-gradient`、`filter`、`clip-path`、`outline`、縦書き、3D transform、`counter()` の content、`<canvas>` / `<video>`、SVG の `<text>` / `<use>` / paint server(`fill="url(#id)"`)。
110
+
111
+ 代替の指針: 影 → ボーダーか薄い背景色。放射・円錐グラデーション → `linear-gradient` か単色、画像。SVG の文字 → 事前にパス化しておく(`<text>` は飛ばされる)。`<img src="x.svg">` はラスタライズされるので、ベクターにしたいならインラインで置く。
105
112
 
106
113
  全プロパティの詳細表は `docs/css-support.md`。
107
114
 
@@ -112,6 +119,8 @@ npm を使わない場合は CDN の URL をそのまま `import` できる(`d
112
119
  - テキストの行、`<tr>`、`<thead>`、`<tfoot>`、`<img>`
113
120
  - `break-inside: avoid`(`page-break-inside: avoid`)を指定した要素
114
121
 
122
+ `break-after: avoid` / `break-before: avoid` を書くと、隣の箱と同じページに保つ(見出しがページ末尾に取り残されるのを防ぐ)。兄弟が無ければ親をさかのぼるので、`<section>` の最後の見出しに書いても次の `<section>` と結びつく。結んだ範囲が 1 ページに収まらない場合は諦めて普通に分割する。
123
+
115
124
  強制改ページは `break-before: page` / `break-after: page`(`page-break-*: always` も可)。表が次ページへ続くときは **`<thead>` が各ページ先頭に、`<tfoot>` がそのページ最後の行の直下に**自動で繰り返される。
116
125
 
117
126
  ページ番号を入れるなら:
@@ -120,7 +129,23 @@ npm を使わない場合は CDN の URL をそのまま `import` できる(`d
120
129
  footer: '<div style="text-align:center;font-size:8pt">{{pageNumber}} / {{totalPages}}</div>'
121
130
  ```
122
131
 
123
- `orphans` / `widows` / `break-*: avoid` / `@page` は未対応。用紙サイズと余白は `options.page` で指定する。
132
+ `orphans` / `widows` / `@page` は未対応。用紙サイズと余白は `options.page` で指定する。
133
+
134
+ ## リンクとしおり
135
+
136
+ `<a href>` は既定で PDF のリンク注釈になる(外部 URL・`mailto:` / `tel:`・文書内 `#id`)。折り返した行やページを跨ぐリンクは分割して注釈を作り、ヘッダー/フッターの中のリンクも各ページに出る。`javascript:` と、飛び先の無い `#id` は注釈にしない。止めるなら `links: false`。
137
+
138
+ `outline: true` を渡すと `h1`〜`h6` の入れ子からしおり(ビューアのサイドバーに出る目次)を作る。長い明細書で便利。既定では作らない。
139
+
140
+ ## 長い文書
141
+
142
+ 変換は途中でイベントループへ戻すので、数百ページでも画面は固まらない(所要時間は 1 割ほど増える)。進捗表示を出すなら `onProgress` を配線する。
143
+
144
+ ```js
145
+ onProgress: (p) => { if (p.phase === 'page') bar.value = p.page / p.totalPages; }
146
+ ```
147
+
148
+ デコード済みの画像データは PDF へ埋め込んだ時点で解放される。同じ画像を次の変換でも使う場合は読み直しになる(速度よりメモリを優先している)。
124
149
 
125
150
  ## 変換対象の要素についての決まり
126
151
 
@@ -51,6 +51,8 @@ taxable8 = 4,200 → tax8 = floor(4200 * 0.08) = 336
51
51
  ## このライブラリで組むときの注意
52
52
 
53
53
  - 金額欄の右寄せ(`text-align: right`)はブラウザの計算どおりに出るので追加実装は不要
54
- - `font-variant-numeric: tabular-nums` GSUB 依存で**効かない**。数字が元から等幅のフォント(BIZ UDPGothic は等幅)を使う
54
+ - **金額の桁揃え**: `font-variant-numeric: tabular-nums` は日本語フォントでは効かないことが多い。BIZ UDPGothic・Noto Sans JP・M PLUS 1p・IBM Plex Sans JP・Zen Kaku Gothic New のいずれにも `tnum` 機能が無く、ブラウザ側でも何も起きない。しかも **BIZ UDPGothic はプロポーショナル体で、数字の `1` だけ幅が狭い**(送り幅 1290、他は 1556 / unitsPerEm 2048)ため、そのままでは縦に揃わない
55
+ - 対策 1: 数字の送り幅がもともと揃っているフォントを使う(BIZ UD**G**othic は全数字 1024、Noto Sans JP も一定)
56
+ - 対策 2: 金額は表のセルに入れて右揃え + 列幅固定にする(この方法は今のままで正しく出る)
55
57
  - 明細が長くなる帳票では、集計ブロックと発行者ブロックに `break-inside: avoid` を付けるとページ境界で割れない
56
58
  - `<thead>` に見出し行、`<tfoot>` に合計行を置くと、複数ページ時に各ページへ自動で繰り返される
@@ -0,0 +1,229 @@
1
+ // @ts-check
2
+ /**
3
+ * GSUB(グリフ置換)の単一置換だけを読む。
4
+ *
5
+ * ブラウザが `font-variant-*` / `font-feature-settings` で有効にした機能を、
6
+ * 同じ結果になるように gid → gid の置換として再現する。
7
+ * 置換は埋め込み前に解決するので、PDF に GSUB テーブル自体は入らない。
8
+ *
9
+ * 対応するのは Lookup タイプ 1(単一置換、フォーマット 1 / 2)と、
10
+ * それを包むタイプ 7(拡張)だけ。タイプ 4(合字)はグリフ数が変わるため、
11
+ * 1 文字ずつ位置を実測する走査モデルでは扱えない。
12
+ */
13
+
14
+ /**
15
+ * @typedef {object} GsubTable
16
+ * @property {Map<string, number[]>} features 機能タグ → Lookup 番号
17
+ * @property {(index: number) => Map<number, number>|null} lookup 単一置換の Lookup を読む(対応外なら null)
18
+ */
19
+
20
+ /**
21
+ * GSUB を読む。無い・壊れている場合は null。
22
+ * @param {import('./parse.js').ParsedFont} font
23
+ * @returns {GsubTable|null}
24
+ */
25
+ export function parseGsub(font) {
26
+ const t = font.tables.get('GSUB');
27
+ if (!t || t.length < 10) return null;
28
+ const dv = new DataView(font.data.buffer, font.data.byteOffset, font.data.byteLength);
29
+ const base = t.offset;
30
+ try {
31
+ const featureListOff = base + dv.getUint16(base + 6);
32
+ const lookupListOff = base + dv.getUint16(base + 8);
33
+
34
+ /** @type {Map<string, number[]>} */
35
+ const features = new Map();
36
+ const featureCount = dv.getUint16(featureListOff);
37
+ for (let i = 0; i < featureCount; i++) {
38
+ const rec = featureListOff + 2 + i * 6;
39
+ const tag = String.fromCharCode(dv.getUint8(rec), dv.getUint8(rec + 1), dv.getUint8(rec + 2), dv.getUint8(rec + 3));
40
+ const featureOff = featureListOff + dv.getUint16(rec + 4);
41
+ const count = dv.getUint16(featureOff + 2);
42
+ const list = features.get(tag) ?? [];
43
+ for (let j = 0; j < count; j++) list.push(dv.getUint16(featureOff + 4 + j * 2));
44
+ features.set(tag, list);
45
+ }
46
+
47
+ const lookupCount = dv.getUint16(lookupListOff);
48
+ /** @type {Map<number, Map<number, number>|null>} */
49
+ const cache = new Map();
50
+ /** @param {number} index */
51
+ const lookup = (index) => {
52
+ if (cache.has(index)) return /** @type {Map<number, number>|null} */ (cache.get(index) ?? null);
53
+ let result = null;
54
+ if (index >= 0 && index < lookupCount) {
55
+ const off = lookupListOff + dv.getUint16(lookupListOff + 2 + index * 2);
56
+ result = readLookup(dv, off, dv.getUint16(off), dv.getUint16(off + 4), off + 6);
57
+ }
58
+ cache.set(index, result);
59
+ return result;
60
+ };
61
+
62
+ return { features, lookup };
63
+ } catch {
64
+ return null; // 壊れた GSUB は無いものとして扱う
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @param {DataView} dv
70
+ * @param {number} lookupOff
71
+ * @param {number} type
72
+ * @param {number} subTableCount
73
+ * @param {number} offsetsAt サブテーブルオフセット配列の位置
74
+ * @returns {Map<number, number>|null}
75
+ */
76
+ function readLookup(dv, lookupOff, type, subTableCount, offsetsAt) {
77
+ if (type !== 1 && type !== 7) return null;
78
+ /** @type {Map<number, number>} */
79
+ const map = new Map();
80
+ for (let i = 0; i < subTableCount; i++) {
81
+ let sub = lookupOff + dv.getUint16(offsetsAt + i * 2);
82
+ if (type === 7) {
83
+ // 拡張: 実体の型とオフセットを読み直す
84
+ if (dv.getUint16(sub) !== 1) continue;
85
+ if (dv.getUint16(sub + 2) !== 1) continue; // 単一置換以外は対象外
86
+ sub += dv.getUint32(sub + 4);
87
+ }
88
+ readSingleSubst(dv, sub, map);
89
+ }
90
+ return map.size ? map : null;
91
+ }
92
+
93
+ /**
94
+ * SingleSubst(フォーマット 1 / 2)を map に読み込む。
95
+ * @param {DataView} dv
96
+ * @param {number} off
97
+ * @param {Map<number, number>} map
98
+ */
99
+ function readSingleSubst(dv, off, map) {
100
+ const format = dv.getUint16(off);
101
+ const coverage = readCoverage(dv, off + dv.getUint16(off + 2));
102
+ if (format === 1) {
103
+ const delta = dv.getInt16(off + 4);
104
+ for (const gid of coverage) map.set(gid, (gid + delta) & 0xffff);
105
+ } else if (format === 2) {
106
+ const count = dv.getUint16(off + 4);
107
+ for (let i = 0; i < coverage.length && i < count; i++) {
108
+ map.set(/** @type {number} */ (coverage[i]), dv.getUint16(off + 6 + i * 2));
109
+ }
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Coverage テーブルを、カバレッジ番号順のグリフ配列として読む。
115
+ * @param {DataView} dv
116
+ * @param {number} off
117
+ * @returns {number[]}
118
+ */
119
+ function readCoverage(dv, off) {
120
+ const format = dv.getUint16(off);
121
+ /** @type {number[]} */
122
+ const out = [];
123
+ if (format === 1) {
124
+ const count = dv.getUint16(off + 2);
125
+ for (let i = 0; i < count; i++) out.push(dv.getUint16(off + 4 + i * 2));
126
+ } else if (format === 2) {
127
+ const count = dv.getUint16(off + 2);
128
+ for (let i = 0; i < count; i++) {
129
+ const rec = off + 4 + i * 6;
130
+ const start = dv.getUint16(rec);
131
+ const end = dv.getUint16(rec + 2);
132
+ const startIndex = dv.getUint16(rec + 4);
133
+ for (let g = start; g <= end; g++) out[startIndex + (g - start)] = g;
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * 機能タグの集合から置換関数を作る。Lookup 番号の小さい順に適用する。
141
+ * @param {import('./parse.js').ParsedFont} font
142
+ * @param {string[]} tags
143
+ * @returns {((gid: number) => number)|null} 置換が 1 つも無ければ null
144
+ */
145
+ export function buildSubstitution(font, tags) {
146
+ const gsub = parseGsub(font);
147
+ if (!gsub) return null;
148
+ /** @type {number[]} */
149
+ const indices = [];
150
+ for (const tag of tags) for (const i of gsub.features.get(tag) ?? []) if (!indices.includes(i)) indices.push(i);
151
+ if (!indices.length) return null;
152
+ indices.sort((a, b) => a - b);
153
+ /** @type {Map<number, number>[]} */
154
+ const maps = [];
155
+ for (const i of indices) {
156
+ const m = gsub.lookup(i);
157
+ if (m) maps.push(m);
158
+ }
159
+ if (!maps.length) return null;
160
+ /** @type {Map<number, number>} */
161
+ const memo = new Map();
162
+ return (gid) => {
163
+ const hit = memo.get(gid);
164
+ if (hit !== undefined) return hit;
165
+ let g = gid;
166
+ for (const m of maps) g = m.get(g) ?? g;
167
+ memo.set(gid, g);
168
+ return g;
169
+ };
170
+ }
171
+
172
+ /** CSS のキーワード → OpenType の機能タグ */
173
+ const VARIANT_TAGS = /** @type {Record<string, string>} */ ({
174
+ // font-variant-numeric
175
+ 'lining-nums': 'lnum',
176
+ 'oldstyle-nums': 'onum',
177
+ 'proportional-nums': 'pnum',
178
+ 'tabular-nums': 'tnum',
179
+ 'diagonal-fractions': 'frac',
180
+ 'stacked-fractions': 'afrc',
181
+ ordinal: 'ordn',
182
+ 'slashed-zero': 'zero',
183
+ // font-variant-caps
184
+ 'small-caps': 'smcp',
185
+ 'all-small-caps': 'c2sc',
186
+ 'petite-caps': 'pcap',
187
+ 'all-petite-caps': 'c2pc',
188
+ unicase: 'unic',
189
+ 'titling-caps': 'titl',
190
+ // font-variant-east-asian
191
+ jis78: 'jp78',
192
+ jis83: 'jp83',
193
+ jis90: 'jp90',
194
+ jis04: 'jp04',
195
+ simplified: 'smpl',
196
+ traditional: 'trad',
197
+ 'full-width': 'fwid',
198
+ 'proportional-width': 'pwid',
199
+ ruby: 'ruby',
200
+ });
201
+
202
+ /**
203
+ * computed style から、ブラウザが有効にしている機能タグを集める。
204
+ * 既定で有効な機能(ccmp / liga / calt)は含めない(合字は扱えないため)。
205
+ *
206
+ * @param {CSSStyleDeclaration} style
207
+ * @returns {string[]}
208
+ */
209
+ export function featureTagsOf(style) {
210
+ /** @type {Set<string>} */
211
+ const tags = new Set();
212
+ const words = `${style.fontVariantNumeric ?? ''} ${style.fontVariantCaps ?? ''} ${style.fontVariantEastAsian ?? ''}`.split(/\s+/);
213
+ for (const w of words) {
214
+ const tag = VARIANT_TAGS[w];
215
+ if (tag) tags.add(tag);
216
+ }
217
+ // font-feature-settings: "zero" 1, "jp90"
218
+ const ffs = style.fontFeatureSettings ?? '';
219
+ if (ffs && ffs !== 'normal') {
220
+ for (const part of ffs.split(',')) {
221
+ const m = /^\s*["']([A-Za-z0-9]{4})["']\s*(.*)$/.exec(part);
222
+ if (!m) continue;
223
+ const value = /** @type {string} */ (m[2]).trim();
224
+ if (value === '0' || value === 'off') continue;
225
+ tags.add(/** @type {string} */ (m[1]));
226
+ }
227
+ }
228
+ return [...tags];
229
+ }
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import { renderDocument } from './renderer.js';
10
10
  import { walk } from './walker/walk.js';
11
11
  import { resolvePage, buildPdf } from './page.js';
12
12
  import { PX_TO_PT } from './units.js';
13
+ import { createPacer } from './pacer.js';
13
14
 
14
15
  export { expandPrintMediaCss } from './renderer.js';
15
16
 
@@ -58,6 +59,15 @@ export { expandPrintMediaCss } from './renderer.js';
58
59
  * @property {string} [text] missing-glyph のときの該当文字
59
60
  */
60
61
 
62
+ /**
63
+ * 変換の進み具合。長い文書で進捗表示を出すために使う。
64
+ *
65
+ * @typedef {object} ConversionProgress
66
+ * @property {'render'|'walk'|'layout'|'page'|'done'} phase
67
+ * @property {number} [page] phase が 'page' のときの 1 始まりのページ番号
68
+ * @property {number} [totalPages] phase が 'layout' 以降で確定する総ページ数
69
+ */
70
+
61
71
  /**
62
72
  * @typedef {object} ConvertOptions
63
73
  * @property {PageOptions} [page]
@@ -72,6 +82,9 @@ export { expandPrintMediaCss } from './renderer.js';
72
82
  * @property {'blob'|'uint8array'|'dataurl'} [output='blob']
73
83
  * @property {string} [baseUrl] 相対 URL(フォント・画像)の基準。既定は現在の文書
74
84
  * @property {(warning: ConversionWarning) => void} [onWarning]
85
+ * @property {boolean} [links=true] `<a href>` を PDF のリンク注釈にする
86
+ * @property {boolean} [outline=false] 見出し(h1〜h6)からしおり(PDF の目次)を作る
87
+ * @property {(progress: ConversionProgress) => void} [onProgress] 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
75
88
  */
76
89
 
77
90
  /**
@@ -80,7 +93,7 @@ export { expandPrintMediaCss } from './renderer.js';
80
93
  */
81
94
 
82
95
  /** ライブラリのバージョン(package.json と同期) */
83
- export const version = '0.2.1';
96
+ export const version = '0.4.0';
84
97
 
85
98
  /** モジュール共有のフォントレジストリ */
86
99
  const registry = new FontRegistry();
@@ -124,9 +137,13 @@ export async function htmlToPdf(input, options = {}) {
124
137
  throw new Error('htmlToPdf: no fonts registered. Call registerFont() with at least one TrueType font first.');
125
138
  }
126
139
  const warn = options.onWarning ?? (() => {});
140
+ const progress = options.onProgress ?? (() => {});
141
+ // 長い変換でメインスレッドを占有しないよう、一定時間ごとにイベントループへ戻す
142
+ const pacer = createPacer();
127
143
  const geo = resolvePage(options.page);
128
144
  const widthPx = (geo.width - geo.left - geo.right) / PX_TO_PT;
129
145
 
146
+ progress({ phase: 'render' });
130
147
  const rendered = await renderDocument(input, {
131
148
  widthPx,
132
149
  stylesheets: options.stylesheets ?? 'inherit',
@@ -141,6 +158,7 @@ export async function htmlToPdf(input, options = {}) {
141
158
  fontFallback: options.fontFallback ?? [],
142
159
  warn,
143
160
  textMeasure: options.textMeasure ?? 'auto',
161
+ pacer,
144
162
  };
145
163
  const renderOpts = {
146
164
  widthPx,
@@ -151,6 +169,7 @@ export async function htmlToPdf(input, options = {}) {
151
169
  };
152
170
 
153
171
  try {
172
+ progress({ phase: 'walk' });
154
173
  const body = await walk(rendered.root, walkCtx);
155
174
  const header = options.header ? await makeDecoration(options.header, renderOpts, walkCtx) : null;
156
175
  const footer = options.footer ? await makeDecoration(options.footer, renderOpts, walkCtx) : null;
@@ -159,7 +178,13 @@ export async function htmlToPdf(input, options = {}) {
159
178
  metadata: options.metadata,
160
179
  header,
161
180
  footer,
181
+ links: options.links ?? true,
182
+ outline: options.outline ?? false,
183
+ pacer,
184
+ progress,
185
+ warn,
162
186
  });
187
+ progress({ phase: 'done' });
163
188
  return toOutput(bytes, options.output ?? 'blob');
164
189
  } finally {
165
190
  rendered.destroy();
package/src/pacer.js ADDED
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+ /**
3
+ * 長い変換でメインスレッドを占有しないための、時間ベースの譲渡。
4
+ *
5
+ * 一定時間(既定 12ms)を超えて動き続けていたら 1 回だけイベントループへ戻す。
6
+ * 小さな文書ではほとんど発火せず、大きな文書では画面が固まらなくなる。
7
+ */
8
+
9
+ /**
10
+ * マクロタスクへ譲る。`setTimeout(0)` はネストすると 4ms にクランプされるので、
11
+ * `scheduler.yield()` か MessageChannel を使う。
12
+ * @returns {Promise<void>}
13
+ */
14
+ function yieldToEventLoop() {
15
+ const sched = /** @type {{yield?: () => Promise<void>}|undefined} */ (/** @type {any} */ (globalThis).scheduler);
16
+ if (sched && typeof sched.yield === 'function') return sched.yield();
17
+ return new Promise((resolve) => {
18
+ if (typeof MessageChannel === 'function') {
19
+ const ch = new MessageChannel();
20
+ ch.port1.onmessage = () => {
21
+ ch.port1.close();
22
+ resolve();
23
+ };
24
+ ch.port2.postMessage(0);
25
+ } else {
26
+ setTimeout(resolve, 0);
27
+ }
28
+ });
29
+ }
30
+
31
+ /**
32
+ * @typedef {() => Promise<void>} Pacer
33
+ */
34
+
35
+ /**
36
+ * @param {number} [intervalMs] この時間を超えて動き続けていたら譲る
37
+ * @returns {Pacer}
38
+ */
39
+ export function createPacer(intervalMs = 12) {
40
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
41
+ let last = now();
42
+ return async () => {
43
+ if (now() - last < intervalMs) return;
44
+ await yieldToEventLoop();
45
+ last = now();
46
+ };
47
+ }
48
+
49
+ /** 何もしない Pacer(テストや同期実行したい場合に使う) */
50
+ export const noPacer = /** @type {Pacer} */ (async () => {});