@hidemikimura/receipt-html-to-pdf 0.2.0 → 0.3.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,110 @@
1
+ <!DOCTYPE html>
2
+ <!--
3
+ CDN から読み込むだけの例。npm もバンドラーもビルドも要らない。
4
+ このファイルをブラウザで開けばそのまま動く(`npx serve` などローカルサーバー経由が確実)。
5
+
6
+ ライブラリ本体もフォントも CDN から読む。フォントは 4.6MB あるので、実運用では
7
+ 使う文字に絞ってサブセット化したものを自前で配信すること(pyftsubset など)。
8
+ -->
9
+ <html lang="ja">
10
+ <head>
11
+ <meta charset="utf-8">
12
+ <title>Receipt html to pdf — CDN から使う例</title>
13
+ <style>
14
+ /* PDF に埋め込むフォントと、ブラウザが計測に使うフォントは同じファイルにする */
15
+ @font-face {
16
+ font-family: "BIZ UDPGothic";
17
+ font-weight: 400;
18
+ /* registerFont に渡すファイルと必ず同じものにする。違うと文字幅がずれる */
19
+ src: url("https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/bizudpgothic/BIZUDPGothic-Regular.ttf") format("truetype");
20
+ }
21
+ body { font-family: system-ui, sans-serif; margin: 24px; line-height: 1.6; }
22
+ button { font-size: 15px; padding: 8px 16px; cursor: pointer; }
23
+ button[disabled] { cursor: default; opacity: .5; }
24
+ #status { margin-left: 12px; color: #555; font-size: 14px; }
25
+
26
+ /* 変換対象。用紙幅に合わせて組む */
27
+ /* 幅は A4(210mm)から左右の余白 15mm ずつを引いた 180mm ちょうど。
28
+ box-sizing: border-box を忘れると padding と border の分だけはみ出し、右端が切れる */
29
+ #receipt { font-family: "BIZ UDPGothic", sans-serif; font-size: 10pt; color: #222; width: 180mm; box-sizing: border-box; border: 1px solid #ccc; padding: 5mm; margin-top: 16px; }
30
+ #receipt h1 { text-align: center; font-size: 20pt; letter-spacing: .5em; margin: 0 0 6mm; padding-bottom: 2mm; border-bottom: 2px solid #222; }
31
+ #receipt .to { font-size: 13pt; font-weight: 700; border-bottom: 1px solid #222; padding: 0 2mm 1mm; min-width: 70mm; display: inline-block; }
32
+ #receipt .amount { font-size: 24pt; font-weight: 700; text-align: center; border: 1px solid #222; padding: 4mm; margin: 4mm 0; }
33
+ #receipt table { width: 100%; border-collapse: collapse; margin-top: 4mm; }
34
+ #receipt th, #receipt td { border: 1px solid #222; padding: 1.5mm 2mm; }
35
+ #receipt th { background: #f2f2f2; }
36
+ #receipt td.num { text-align: right; }
37
+ </style>
38
+ </head>
39
+ <body>
40
+
41
+ <button id="save" disabled>PDF をダウンロード</button>
42
+ <span id="status">読み込み中…</span>
43
+
44
+ <div id="receipt">
45
+ <h1>領 収 証</h1>
46
+ <p><span class="to">株式会社テスト商事 御中</span></p>
47
+ <p class="amount">¥33,000-(税込)</p>
48
+ <p>但し、下記の通り商品代として、上記金額を正に領収いたしました。</p>
49
+ <table>
50
+ <thead>
51
+ <tr><th>品名</th><th>数量</th><th>単価</th><th>金額</th><th>税率</th></tr>
52
+ </thead>
53
+ <tbody>
54
+ <tr><td>Web サイト制作サービス</td><td class="num">1</td><td class="num">30,000</td><td class="num">30,000</td><td class="num">10%</td></tr>
55
+ </tbody>
56
+ <tfoot>
57
+ <tr><td colspan="3">10% 対象</td><td class="num">30,000</td><td class="num">消費税 3,000</td></tr>
58
+ </tfoot>
59
+ </table>
60
+ </div>
61
+
62
+ <!--
63
+ ここが CDN からの読み込み。type="module" が要る。
64
+ バージョン(@0.3.0)は必ず固定する。外すと最新版が読み込まれ、更新のたびに挙動が変わりうる。
65
+ -->
66
+ <script type="module">
67
+ import * as ReceiptHtmlToPdf
68
+ from 'https://cdn.jsdelivr.net/npm/@hidemikimura/receipt-html-to-pdf@0.3.0/dist/receipt-html-to-pdf.min.js';
69
+
70
+ // type="module" でない普通のスクリプトからも使えるようにグローバルへ載せる
71
+ window.ReceiptHtmlToPdf = ReceiptHtmlToPdf;
72
+
73
+ const status = document.querySelector('#status');
74
+ const button = document.querySelector('#save');
75
+
76
+ try {
77
+ // 上の @font-face と同じファイルを渡す(TrueType の静的 TTF のみ。woff2 / otf は不可)
78
+ await ReceiptHtmlToPdf.registerFont({
79
+ family: 'BIZ UDPGothic',
80
+ weight: 400,
81
+ src: 'https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/bizudpgothic/BIZUDPGothic-Regular.ttf',
82
+ });
83
+ await document.fonts.ready;
84
+ status.textContent = `準備完了(v${ReceiptHtmlToPdf.version})`;
85
+ button.disabled = false;
86
+ } catch (e) {
87
+ status.textContent = `フォントの読み込みに失敗しました: ${e.message}`;
88
+ }
89
+
90
+ button.addEventListener('click', async () => {
91
+ button.disabled = true;
92
+ status.textContent = '生成中…';
93
+ try {
94
+ const pdf = await ReceiptHtmlToPdf.htmlToPdf(document.querySelector('#receipt'), {
95
+ page: { size: 'A4', margin: '15mm' },
96
+ metadata: { title: '領収証' },
97
+ onWarning: (w) => console.warn(w.code, w.message),
98
+ });
99
+ ReceiptHtmlToPdf.downloadPdf(pdf, 'receipt.pdf');
100
+ status.textContent = '生成しました';
101
+ } catch (e) {
102
+ status.textContent = `失敗: ${e.message}`;
103
+ } finally {
104
+ button.disabled = false;
105
+ }
106
+ });
107
+ </script>
108
+
109
+ </body>
110
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hidemikimura/receipt-html-to-pdf",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Receipt html to pdf — ブラウザ内で HTML/CSS をテキスト選択可能なベクター PDF に変換するライブラリ(日本の領収証・適格請求書向け)",
5
5
  "license": "MIT",
6
6
  "author": "Hidemi Kimura",
@@ -40,6 +40,17 @@ const pdf = await htmlToPdf(document.getElementById('receipt'), {
40
40
  downloadPdf(pdf, 'receipt.pdf');
41
41
  ```
42
42
 
43
+ npm を使わない場合は CDN の URL をそのまま `import` できる(`dist/` は依存ゼロの単一 ESM)。バージョンは必ず固定する。
44
+
45
+ ```html
46
+ <script type="module">
47
+ import { registerFont, htmlToPdf, downloadPdf }
48
+ from 'https://cdn.jsdelivr.net/npm/@hidemikimura/receipt-html-to-pdf@0.3.0/dist/receipt-html-to-pdf.min.js';
49
+ </script>
50
+ ```
51
+
52
+ グローバル変数を配るビルド(IIFE / UMD)は無い。`type="module"` でない普通のスクリプトから使いたいときは、`import * as ReceiptHtmlToPdf` して `window` に載せる。モジュールスクリプトは `defer` 相当で後から実行されるので、読み込み完了をイベントで知らせるかボタンを `disabled` にしておくこと。動く一式は `examples/cdn.html`。
53
+
43
54
  `htmlToPdf` は既定で `Blob` を返す。`output: 'uint8array'` でバイト列、`'dataurl'` で data URL。サーバーへ送るなら `Blob` のまま `FormData` に入れればよい。
44
55
 
45
56
  ## フォント: ここが一番詰まる
@@ -81,16 +92,21 @@ downloadPdf(pdf, 'receipt.pdf');
81
92
  | `baseUrl` | 現在の文書 | 相対 URL(フォント・画像)の基準 |
82
93
  | `output` | `'blob'` | `'uint8array'` `'dataurl'` |
83
94
  | `onWarning` | — | 未対応 CSS・欠落グリフ・画像失敗の通知。**必ず配線する** |
95
+ | `onProgress` | — | `render` → `walk` → `layout`(`totalPages` 確定)→ `page`(1 ページずつ)→ `done`。長い文書の進捗表示に使う |
84
96
 
85
97
  ## 対応している CSS の要点
86
98
 
87
99
  レイアウト系(`display` 全般・Flexbox・Grid・テーブル・`position`・`margin`・`padding`・`white-space`・`word-break`・`text-align`・`line-height`・`letter-spacing`)は**ブラウザの計算結果をそのまま使うので全部そのとおりに出る**。追加実装は不要。
88
100
 
89
- 描画系で対応しているもの: `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` のみ)。
101
+ 描画系で対応しているもの: `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` のみ)。
90
102
 
91
- **出力されないもの**(`onWarning` `unsupported-css` が届く): `box-shadow`、`text-shadow`、グラデーション、`filter`、`clip-path`、`outline`、縦書き、3D transform、`counter()` の content、インライン `<svg>` / `<canvas>` / `<video>`。
103
+ GSUB の**単一置換**(`zero`・`jp90` などの異体字・`fwid` / `hwid`・`smcp`)は `font-variant-*` / `font-feature-settings` の指定どおりに再現する。**合字(`liga` / `dlig`)は未対応**。
92
104
 
93
- 代替の指針: ボーダーか薄い背景色。グラデーション 単色か画像。インライン SVG `<img src="x.svg">`(ラスタライズされて埋め込まれる)。
105
+ **数字の桁を揃えたいとき**: `tabular-nums` は日本語フォントでは効かないことが多い(BIZ UDPGothic・Noto Sans JP などに `tnum` 機能が無く、ブラウザ側でも何も起きない)。数字の送り幅がもともと揃っているフォント(BIZ UD**G**othic など)を選ぶか、表のセルを右揃え + 列幅固定にする。
106
+
107
+ **出力されないもの**(`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)"`)。
108
+
109
+ 代替の指針: 影 → ボーダーか薄い背景色。放射・円錐グラデーション → `linear-gradient` か単色、画像。SVG の文字 → 事前にパス化しておく(`<text>` は飛ばされる)。`<img src="x.svg">` はラスタライズされるので、ベクターにしたいならインラインで置く。
94
110
 
95
111
  全プロパティの詳細表は `docs/css-support.md`。
96
112
 
@@ -101,6 +117,8 @@ downloadPdf(pdf, 'receipt.pdf');
101
117
  - テキストの行、`<tr>`、`<thead>`、`<tfoot>`、`<img>`
102
118
  - `break-inside: avoid`(`page-break-inside: avoid`)を指定した要素
103
119
 
120
+ `break-after: avoid` / `break-before: avoid` を書くと、隣の箱と同じページに保つ(見出しがページ末尾に取り残されるのを防ぐ)。兄弟が無ければ親をさかのぼるので、`<section>` の最後の見出しに書いても次の `<section>` と結びつく。結んだ範囲が 1 ページに収まらない場合は諦めて普通に分割する。
121
+
104
122
  強制改ページは `break-before: page` / `break-after: page`(`page-break-*: always` も可)。表が次ページへ続くときは **`<thead>` が各ページ先頭に、`<tfoot>` がそのページ最後の行の直下に**自動で繰り返される。
105
123
 
106
124
  ページ番号を入れるなら:
@@ -109,7 +127,17 @@ downloadPdf(pdf, 'receipt.pdf');
109
127
  footer: '<div style="text-align:center;font-size:8pt">{{pageNumber}} / {{totalPages}}</div>'
110
128
  ```
111
129
 
112
- `orphans` / `widows` / `break-*: avoid` / `@page` は未対応。用紙サイズと余白は `options.page` で指定する。
130
+ `orphans` / `widows` / `@page` は未対応。用紙サイズと余白は `options.page` で指定する。
131
+
132
+ ## 長い文書
133
+
134
+ 変換は途中でイベントループへ戻すので、数百ページでも画面は固まらない(所要時間は 1 割ほど増える)。進捗表示を出すなら `onProgress` を配線する。
135
+
136
+ ```js
137
+ onProgress: (p) => { if (p.phase === 'page') bar.value = p.page / p.totalPages; }
138
+ ```
139
+
140
+ デコード済みの画像データは PDF へ埋め込んだ時点で解放される。同じ画像を次の変換でも使う場合は読み直しになる(速度よりメモリを優先している)。
113
141
 
114
142
  ## 変換対象の要素についての決まり
115
143
 
@@ -118,6 +146,7 @@ footer: '<div style="text-align:center;font-size:8pt">{{pageNumber}} / {{totalPa
118
146
  - **Web Components**: シャドウ DOM に対応している。ホスト要素をそのまま渡してよく、`<slot>` の割り当て・`:host` / `::slotted()`・`adoptedStyleSheets`(Lit の `static styles`)・`:defined` はすべて引き継がれる。シャドウルート内の要素(`renderRoot.querySelector()`)を渡した場合も、そのツリーのスタイルは既定の `stylesheets: 'inherit'` で拾われる。
119
147
  - ただし **`closed` なシャドウルートは中身が出ない**(外から参照できないため)。`Element.getHTML()` が無い古いブラウザでも同様で、その場合は警告が出る。
120
148
  - iframe 側ではカスタム要素はアップグレードされない。`connectedCallback` で DOM を組む要素は、`customElements.whenDefined()` と `document.fonts.ready` を待ってから変換する。
149
+ - **幅は本文領域に合わせる**。A4・余白 15mm なら 180mm ちょうど。固定幅に `padding` / `border` を足すときは `box-sizing: border-box` を付けないと右端が切れる(`other` の警告が出る)。
121
150
  - HTML 文字列も渡せる。その場合 `stylesheets` を明示するのが確実。
122
151
  - `display: none` の要素は子孫ごと出力されない(場所も取らない)。渡したルート要素自身が `display: none` だと空の PDF になる。`visibility: hidden` は描かれないが場所は残るので、PDF 上は空白になる。
123
152
  - **画面に出さずに PDF にだけ載せたい**ときは `display: none` ではなく、画面外へ逃がす(`position: absolute; left: -10000px`)か、`@media print` に書いて `mediaPrint: true` で変換する。
@@ -135,4 +164,5 @@ footer: '<div style="text-align:center;font-size:8pt">{{pageNumber}} / {{totalPa
135
164
  - `CFF outlines are not supported` → OTF ではなく静的 TTF を渡す
136
165
  - 文字が □ になる → そのフォントにグリフが無い(`missing-glyph` 警告に該当文字が出る)
137
166
  - 文字がずれる → `@font-face` と `registerFont` のファイルが違う
167
+ - **右端が切れる** → 内容が本文領域より横に広い(`other` の警告が出る)。A4・余白 15mm なら本文領域は 180mm ちょうど。`width: 180mm` に `padding` / `border` を足すなら `box-sizing: border-box` を付ける
138
168
  - 何も描かれない → 要素が `display:none`、または Shadow DOM でスタイルが届いていない
@@ -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>` に合計行を置くと、複数ページ時に各ページへ自動で繰り返される
@@ -23,6 +23,7 @@
23
23
  | カスタム要素の中身が出ない(枠だけになる) | `closed` なシャドウルート、または `Element.getHTML()` が無いブラウザ | `mode: 'open'` にする。警告(`unsupported-css`)にブラウザ側の理由が出ている |
24
24
  | カスタム要素の中身が古い | `connectedCallback` の描画が終わる前に変換した | `customElements.whenDefined()` と `document.fonts.ready` を待ってから呼ぶ |
25
25
  | シャドウ外の CSS が当たらない | 文書のスタイルシートはシャドウツリーに届かない(ブラウザの仕様どおり) | 必要な CSS をシャドウルート内に置くか、`stylesheets` に文字列で渡す |
26
+ | 右端が切れる | 内容が本文領域より横に広い。`other` の警告にはみ出し量が出る(0.2.1 以降) | A4・余白 15mm なら本文領域は 180mm。固定幅 + `padding` / `border` には `box-sizing: border-box` を付ける。縮まない表なら列幅を見直す |
26
27
  | 画像が出ない | クロスオリジンで CORS ヘッダーが無い | `crossorigin="anonymous"` と `Access-Control-Allow-Origin` を設定する。`image-failed` 警告が出ている |
27
28
  | 影や角丸グラデーションが消える | 未対応 CSS | `onWarning` の `unsupported-css` を見る。ボーダーや単色で代替する |
28
29
  | テーブルの罫線が二重になる | `border-collapse: separate` のまま隣接セルに罫線を引いた | `border-collapse: collapse` を使う |
@@ -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,7 @@ 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 {(progress: ConversionProgress) => void} [onProgress] 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
75
86
  */
76
87
 
77
88
  /**
@@ -80,7 +91,7 @@ export { expandPrintMediaCss } from './renderer.js';
80
91
  */
81
92
 
82
93
  /** ライブラリのバージョン(package.json と同期) */
83
- export const version = '0.2.0';
94
+ export const version = '0.3.0';
84
95
 
85
96
  /** モジュール共有のフォントレジストリ */
86
97
  const registry = new FontRegistry();
@@ -124,9 +135,13 @@ export async function htmlToPdf(input, options = {}) {
124
135
  throw new Error('htmlToPdf: no fonts registered. Call registerFont() with at least one TrueType font first.');
125
136
  }
126
137
  const warn = options.onWarning ?? (() => {});
138
+ const progress = options.onProgress ?? (() => {});
139
+ // 長い変換でメインスレッドを占有しないよう、一定時間ごとにイベントループへ戻す
140
+ const pacer = createPacer();
127
141
  const geo = resolvePage(options.page);
128
142
  const widthPx = (geo.width - geo.left - geo.right) / PX_TO_PT;
129
143
 
144
+ progress({ phase: 'render' });
130
145
  const rendered = await renderDocument(input, {
131
146
  widthPx,
132
147
  stylesheets: options.stylesheets ?? 'inherit',
@@ -141,6 +156,7 @@ export async function htmlToPdf(input, options = {}) {
141
156
  fontFallback: options.fontFallback ?? [],
142
157
  warn,
143
158
  textMeasure: options.textMeasure ?? 'auto',
159
+ pacer,
144
160
  };
145
161
  const renderOpts = {
146
162
  widthPx,
@@ -151,6 +167,7 @@ export async function htmlToPdf(input, options = {}) {
151
167
  };
152
168
 
153
169
  try {
170
+ progress({ phase: 'walk' });
154
171
  const body = await walk(rendered.root, walkCtx);
155
172
  const header = options.header ? await makeDecoration(options.header, renderOpts, walkCtx) : null;
156
173
  const footer = options.footer ? await makeDecoration(options.footer, renderOpts, walkCtx) : null;
@@ -159,7 +176,11 @@ export async function htmlToPdf(input, options = {}) {
159
176
  metadata: options.metadata,
160
177
  header,
161
178
  footer,
179
+ pacer,
180
+ progress,
181
+ warn,
162
182
  });
183
+ progress({ phase: 'done' });
163
184
  return toOutput(bytes, options.output ?? 'blob');
164
185
  } finally {
165
186
  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 () => {});