@hidemikimura/receipt-html-to-pdf 0.2.1 → 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.
package/examples/cdn.html CHANGED
@@ -61,11 +61,11 @@
61
61
 
62
62
  <!--
63
63
  ここが CDN からの読み込み。type="module" が要る。
64
- バージョン(@0.2.1)は必ず固定する。外すと最新版が読み込まれ、更新のたびに挙動が変わりうる。
64
+ バージョン(@0.3.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.3.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.3.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.3.0/dist/receipt-html-to-pdf.min.js';
49
49
  </script>
50
50
  ```
51
51
 
@@ -92,16 +92,21 @@ npm を使わない場合は CDN の URL をそのまま `import` できる(`d
92
92
  | `baseUrl` | 現在の文書 | 相対 URL(フォント・画像)の基準 |
93
93
  | `output` | `'blob'` | `'uint8array'` `'dataurl'` |
94
94
  | `onWarning` | — | 未対応 CSS・欠落グリフ・画像失敗の通知。**必ず配線する** |
95
+ | `onProgress` | — | `render` → `walk` → `layout`(`totalPages` 確定)→ `page`(1 ページずつ)→ `done`。長い文書の進捗表示に使う |
95
96
 
96
97
  ## 対応している CSS の要点
97
98
 
98
99
  レイアウト系(`display` 全般・Flexbox・Grid・テーブル・`position`・`margin`・`padding`・`white-space`・`word-break`・`text-align`・`line-height`・`letter-spacing`)は**ブラウザの計算結果をそのまま使うので全部そのとおりに出る**。追加実装は不要。
99
100
 
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` のみ)。
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` のみ)。
101
102
 
102
- **出力されないもの**(`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`)は未対応**。
103
104
 
104
- 代替の指針: ボーダーか薄い背景色。グラデーション 単色か画像。インライン 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">` はラスタライズされるので、ベクターにしたいならインラインで置く。
105
110
 
106
111
  全プロパティの詳細表は `docs/css-support.md`。
107
112
 
@@ -112,6 +117,8 @@ npm を使わない場合は CDN の URL をそのまま `import` できる(`d
112
117
  - テキストの行、`<tr>`、`<thead>`、`<tfoot>`、`<img>`
113
118
  - `break-inside: avoid`(`page-break-inside: avoid`)を指定した要素
114
119
 
120
+ `break-after: avoid` / `break-before: avoid` を書くと、隣の箱と同じページに保つ(見出しがページ末尾に取り残されるのを防ぐ)。兄弟が無ければ親をさかのぼるので、`<section>` の最後の見出しに書いても次の `<section>` と結びつく。結んだ範囲が 1 ページに収まらない場合は諦めて普通に分割する。
121
+
115
122
  強制改ページは `break-before: page` / `break-after: page`(`page-break-*: always` も可)。表が次ページへ続くときは **`<thead>` が各ページ先頭に、`<tfoot>` がそのページ最後の行の直下に**自動で繰り返される。
116
123
 
117
124
  ページ番号を入れるなら:
@@ -120,7 +127,17 @@ npm を使わない場合は CDN の URL をそのまま `import` できる(`d
120
127
  footer: '<div style="text-align:center;font-size:8pt">{{pageNumber}} / {{totalPages}}</div>'
121
128
  ```
122
129
 
123
- `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 へ埋め込んだ時点で解放される。同じ画像を次の変換でも使う場合は読み直しになる(速度よりメモリを優先している)。
124
141
 
125
142
  ## 変換対象の要素についての決まり
126
143
 
@@ -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,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.1';
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 () => {});
package/src/page.js CHANGED
@@ -9,6 +9,8 @@ import { EmbeddedFont, hex4 } from './font/cid.js';
9
9
  import { embedImage } from './pdf/image.js';
10
10
  import { PX_TO_PT, PAGE_SIZES, lengthToPt, num } from './units.js';
11
11
  import { paginate } from './paginate.js';
12
+ import { releasePixels, loadImage } from './walker/image.js';
13
+ import { buildAxialShading, uniformAlpha, buildAlphaMaskGState } from './pdf/shading.js';
12
14
 
13
15
  /**
14
16
  * @typedef {object} PageGeometry
@@ -56,7 +58,7 @@ export function resolvePage(page = {}) {
56
58
  /**
57
59
  * @param {import('./walker/walk.js').WalkResult} body
58
60
  * @param {PageGeometry} geo
59
- * @param {{compress: boolean, metadata?: import('./index.js').PdfMetadata, header?: PageDecoration|null, footer?: PageDecoration|null}} opts
61
+ * @param {{compress: boolean, metadata?: import('./index.js').PdfMetadata, header?: PageDecoration|null, footer?: PageDecoration|null, pacer?: import('./pacer.js').Pacer, progress?: (p: import('./index.js').ConversionProgress) => void, warn?: (w: import('./index.js').ConversionWarning) => void}} opts
60
62
  * @returns {Promise<Uint8Array>}
61
63
  */
62
64
  export async function buildPdf(body, geo, opts) {
@@ -113,33 +115,77 @@ export async function buildPdf(body, geo, opts) {
113
115
  for (const ef of fonts.values()) fontDict[ef.resourceName] = await ef.embed(writer);
114
116
  /** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
115
117
  const xobjDict = {};
116
- for (const im of images.values()) xobjDict[im.name] = await embedImage(writer, im.image);
118
+ for (const im of images.values()) {
119
+ let img = im.image;
120
+ // 並行して走る別の変換が先に解放していた場合は読み直す
121
+ if (!img.jpeg && !img.rgb) {
122
+ const again = await loadImage(img.key, opts.warn ?? (() => {}));
123
+ if (!again) continue;
124
+ img = again;
125
+ }
126
+ xobjDict[im.name] = await embedImage(writer, img);
127
+ // 埋め込みが済めばピクセルデータは不要。大きな文書のピーク使用量を抑える
128
+ releasePixels(img);
129
+ if (opts.pacer) await opts.pacer();
130
+ }
117
131
 
118
132
  // ExtGState(透明度)
119
133
  /** @type {Map<string, string>} */
120
134
  const gstates = new Map();
121
135
  /** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
122
136
  const gstateDict = {};
123
- const gsName = (/** @type {number} */ a) => {
124
- const key = num(a);
137
+ const gsName = (/** @type {number} */ a, /** @type {number} */ strokeA = a) => {
138
+ const key = `${num(a)}/${num(strokeA)}`;
125
139
  let name = gstates.get(key);
126
140
  if (!name) {
127
141
  name = `GS${gstates.size + 1}`;
128
142
  gstates.set(key, name);
129
- gstateDict[name] = writer.add({ Type: 'ExtGState', ca: a, CA: a });
143
+ gstateDict[name] = writer.add({ Type: 'ExtGState', ca: a, CA: strokeA });
130
144
  }
131
145
  return name;
132
146
  };
133
147
 
148
+ // Shading(linear-gradient)。座標は箱ローカルの CSS px で持ち、描画時に cm で用紙座標へ写す。
149
+ // こうするとページごとに作り直さずに済む。
150
+ /** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
151
+ const shadingDict = {};
152
+ /** @type {Map<import('./walker/walk.js').DisplayItem, {sh: string, gs: string|null}>} */
153
+ const gradients = new Map();
154
+ /** @param {import('./walker/walk.js').DisplayItem[]} list */
155
+ const prepareGradients = async (list) => {
156
+ for (const it of list) {
157
+ if (it.type === 'gradient') {
158
+ const name = `Sh${Object.keys(shadingDict).length + 1}`;
159
+ shadingDict[name] = buildAxialShading(writer, it.gradient, it.gradient.stops, 'rgb');
160
+ const alpha = uniformAlpha(it.gradient.stops);
161
+ let gs = null;
162
+ if (alpha === null) {
163
+ // 色止めごとにアルファが変わる → 輝度ソフトマスクで再現する
164
+ const gsRef = await buildAlphaMaskGState(writer, it.gradient, it.gradient.stops, { x: 0, y: 0, w: it.box.w, h: it.box.h }, it.alpha);
165
+ gs = `GM${gradients.size + 1}`;
166
+ gstateDict[gs] = gsRef;
167
+ }
168
+ gradients.set(it, { sh: name, gs });
169
+ } else if (it.type === 'group' || it.type === 'clip') {
170
+ await prepareGradients(it.items);
171
+ }
172
+ }
173
+ };
174
+ await prepareGradients(body.items);
175
+ for (const w of [...headers, ...footers]) if (w) await prepareGradients(w.items);
176
+
134
177
  const pagesRef = writer.reserve();
135
178
  /** @type {import('./pdf/writer.js').Ref[]} */
136
179
  const pageRefs = [];
137
180
 
138
181
  // 4. ページごとに描画
182
+ opts.progress?.({ phase: 'layout', totalPages });
139
183
  for (let p = 0; p < totalPages; p++) {
184
+ if (opts.pacer) await opts.pacer();
185
+ opts.progress?.({ phase: 'page', page: p + 1, totalPages });
140
186
  const range = /** @type {import('./paginate.js').PageRange} */ (ranges[p]);
141
187
  const cs = new ContentStream();
142
- const painter = new Painter(cs, geo, fonts, images, gsName);
188
+ const painter = new Painter(cs, geo, fonts, images, gsName, gradients);
143
189
 
144
190
  // 本文: ドキュメント y = range.start が本文領域の上端 + 繰り返し thead の高さ に来る
145
191
  const bodyShiftPt = range.headShift * PX_TO_PT;
@@ -200,6 +246,7 @@ export async function buildPdf(body, geo, opts) {
200
246
  Font: fontDict,
201
247
  XObject: xobjDict,
202
248
  ExtGState: gstateDict,
249
+ Shading: shadingDict,
203
250
  ProcSet: [new Name('PDF'), new Name('Text'), new Name('ImageC')],
204
251
  },
205
252
  Contents: contentRef,
@@ -252,14 +299,16 @@ class Painter {
252
299
  * @param {PageGeometry} geo
253
300
  * @param {Map<import('./font/registry.js').RegisteredFont, EmbeddedFont>} fonts
254
301
  * @param {Map<string, {name: string, image: import('./walker/image.js').DecodedImage}>} images
255
- * @param {(alpha: number) => string} gsName
302
+ * @param {(alpha: number, strokeAlpha?: number) => string} gsName
303
+ * @param {Map<import('./walker/walk.js').DisplayItem, {sh: string, gs: string|null}>} gradients
256
304
  */
257
- constructor(cs, geo, fonts, images, gsName) {
305
+ constructor(cs, geo, fonts, images, gsName, gradients) {
258
306
  this.cs = cs;
259
307
  this.geo = geo;
260
308
  this.fonts = fonts;
261
309
  this.images = images;
262
310
  this.gsName = gsName;
311
+ this.gradients = gradients;
263
312
  this.pdfTop = geo.height - geo.top;
264
313
  this.docTop = 0;
265
314
  this.curAlpha = 1;
@@ -339,6 +388,46 @@ class Painter {
339
388
  cs.roundedRect(this.X(it.x), this.Y(it.y + it.h), it.w * PX_TO_PT, it.h * PX_TO_PT, this.radiusPt(it.radius)).stroke();
340
389
  cs.restore();
341
390
  this.curAlpha = 1;
391
+ } else if (it.type === 'path') {
392
+ if (!it.segs.length) continue;
393
+ const [a, b2, c, d, e, f] = it.matrix;
394
+ // ユーザー単位 → ドキュメント px → PDF pt(y 反転)を 1 つの行列にまとめる
395
+ const S = PX_TO_PT;
396
+ const tx = this.geo.left;
397
+ const ty = this.pdfTop + this.docTop * S;
398
+ cs.save();
399
+ cs.transform(S * a, -S * b2, S * c, -S * d, S * e + tx, -S * f + ty);
400
+ if (it.fill) cs.fillColor(it.fill.r, it.fill.g, it.fill.b);
401
+ if (it.stroke) {
402
+ cs.strokeColor(it.stroke.color.r, it.stroke.color.g, it.stroke.color.b);
403
+ cs.lineWidth(it.stroke.width);
404
+ cs.lineCap(it.stroke.cap);
405
+ cs.lineJoin(it.stroke.join);
406
+ if (it.stroke.join === 0) cs.miterLimit(it.stroke.miter);
407
+ if (it.stroke.dash) cs.dash(it.stroke.dash, it.stroke.dashOffset);
408
+ }
409
+ // 塗りと線でアルファが違うことがあるので ExtGState には両方を渡す
410
+ const fa = it.fill ? it.fill.a : 1;
411
+ const sa = it.stroke ? it.stroke.color.a : 1;
412
+ if (fa !== 1 || sa !== 1) cs.setGState(this.gsName(fa, sa));
413
+ cs.path(it.segs);
414
+ if (it.fill && it.stroke) cs.fillAndStroke(it.evenOdd);
415
+ else if (it.fill) cs.fill(it.evenOdd);
416
+ else cs.stroke();
417
+ cs.restore();
418
+ this.curAlpha = 1;
419
+ } else if (it.type === 'gradient') {
420
+ const g = this.gradients.get(it);
421
+ if (!g || it.box.w <= 0 || it.box.h <= 0) continue;
422
+ cs.save();
423
+ this.clipBox(it.clip);
424
+ // 箱ローカルの CSS px 空間(左上原点・y 下向き)へ写す。シェーディングの座標系もこれ。
425
+ cs.transform(PX_TO_PT, 0, 0, -PX_TO_PT, this.X(it.box.x), this.Y(it.box.y));
426
+ if (g.gs) cs.setGState(g.gs);
427
+ else this.setAlpha(it.alpha);
428
+ cs.shading(g.sh);
429
+ cs.restore();
430
+ this.curAlpha = 1;
342
431
  } else if (it.type === 'image') {
343
432
  const im = this.images.get(it.image.key);
344
433
  if (!im || it.w <= 0 || it.h <= 0) continue;
@@ -415,6 +504,8 @@ function buildTJ(it, ef) {
415
504
  /** @param {import('./walker/walk.js').DisplayItem} it */
416
505
  function itemTop(it) {
417
506
  if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y;
507
+ if (it.type === 'gradient') return it.clip.y;
508
+ if (it.type === 'path') return it.top;
418
509
  if (it.type === 'line') return Math.min(it.y1, it.y2) - it.width / 2;
419
510
  if (it.type === 'group' || it.type === 'clip') return it.top;
420
511
  return it.top;
@@ -423,6 +514,8 @@ function itemTop(it) {
423
514
  /** @param {import('./walker/walk.js').DisplayItem} it */
424
515
  function itemBottom(it) {
425
516
  if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y + it.h;
517
+ if (it.type === 'gradient') return it.clip.y + it.clip.h;
518
+ if (it.type === 'path') return it.bottom;
426
519
  if (it.type === 'line') return Math.max(it.y1, it.y2) + it.width / 2;
427
520
  if (it.type === 'group' || it.type === 'clip') return it.bottom;
428
521
  return it.bottom;