@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/CHANGELOG.md +45 -0
- package/README.md +20 -11
- package/dist/receipt-html-to-pdf.min.js +17 -16
- 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 +30 -5
- package/skills/receipt-html-to-pdf/references/receipt-format.md +3 -1
- package/src/font/gsub.js +229 -0
- package/src/index.js +26 -1
- package/src/pacer.js +50 -0
- package/src/page.js +195 -22
- package/src/paginate.js +19 -0
- package/src/pdf/content.js +44 -2
- package/src/pdf/outline.js +103 -0
- 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 +330 -24
- package/types/font/gsub.d.ts +47 -0
- package/types/index.d.ts +38 -1
- package/types/pacer.d.ts +11 -0
- package/types/page.d.ts +6 -1
- package/types/paginate.d.ts +1 -0
- package/types/pdf/content.d.ts +18 -1
- package/types/pdf/outline.d.ts +41 -0
- 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 +72 -1
package/examples/cdn.html
CHANGED
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
|
|
62
62
|
<!--
|
|
63
63
|
ここが CDN からの読み込み。type="module" が要る。
|
|
64
|
-
バージョン(@0.
|
|
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.
|
|
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
|
@@ -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.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()
|
|
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
|
-
|
|
105
|
+
GSUB の**単一置換**(`zero`・`jp90` などの異体字・`fwid` / `hwid`・`smcp`)は `font-variant-*` / `font-feature-settings` の指定どおりに再現する。**合字(`liga` / `dlig`)は未対応**。
|
|
103
106
|
|
|
104
|
-
|
|
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` /
|
|
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`
|
|
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,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.
|
|
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 () => {});
|