@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/CHANGELOG.md +33 -0
- package/README.md +18 -10
- package/dist/receipt-html-to-pdf.min.js +15 -14
- package/dist/receipt-html-to-pdf.min.js.map +4 -4
- package/examples/cdn.html +2 -2
- package/package.json +1 -1
- package/skills/receipt-html-to-pdf/SKILL.md +22 -5
- package/skills/receipt-html-to-pdf/references/receipt-format.md +3 -1
- package/src/font/gsub.js +229 -0
- package/src/index.js +22 -1
- package/src/pacer.js +50 -0
- package/src/page.js +101 -8
- package/src/paginate.js +19 -0
- package/src/pdf/content.js +44 -2
- package/src/pdf/shading.js +113 -0
- package/src/walker/gradient.js +193 -0
- package/src/walker/image.js +77 -0
- package/src/walker/svg-path.js +342 -0
- package/src/walker/text.js +29 -2
- package/src/walker/walk.js +273 -24
- package/types/font/gsub.d.ts +47 -0
- package/types/index.d.ts +28 -1
- package/types/pacer.d.ts +11 -0
- package/types/page.d.ts +4 -1
- package/types/paginate.d.ts +1 -0
- package/types/pdf/content.d.ts +18 -1
- package/types/pdf/shading.d.ts +44 -0
- package/types/walker/gradient.d.ts +25 -0
- package/types/walker/image.d.ts +36 -0
- package/types/walker/svg-path.d.ts +31 -0
- package/types/walker/text.d.ts +4 -0
- package/types/walker/walk.d.ts +50 -1
package/examples/cdn.html
CHANGED
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
|
|
62
62
|
<!--
|
|
63
63
|
ここが CDN からの読み込み。type="module" が要る。
|
|
64
|
-
バージョン(@0.
|
|
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.
|
|
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
|
@@ -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.
|
|
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()
|
|
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
|
-
|
|
103
|
+
GSUB の**単一置換**(`zero`・`jp90` などの異体字・`fwid` / `hwid`・`smcp`)は `font-variant-*` / `font-feature-settings` の指定どおりに再現する。**合字(`liga` / `dlig`)は未対応**。
|
|
103
104
|
|
|
104
|
-
|
|
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` /
|
|
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`
|
|
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>` に合計行を置くと、複数ページ時に各ページへ自動で繰り返される
|
package/src/font/gsub.js
ADDED
|
@@ -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.
|
|
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())
|
|
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:
|
|
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;
|