@hidemikimura/receipt-html-to-pdf 0.3.0 → 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 +12 -0
- package/README.md +8 -7
- package/dist/receipt-html-to-pdf.min.js +16 -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 +9 -1
- package/src/index.js +5 -1
- package/src/page.js +96 -16
- package/src/pdf/outline.js +103 -0
- package/src/walker/walk.js +59 -2
- package/types/index.d.ts +11 -1
- package/types/page.d.ts +3 -1
- package/types/pdf/outline.d.ts +41 -0
- package/types/walker/walk.d.ts +22 -0
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,6 +92,8 @@ 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 の目次)を作る |
|
|
95
97
|
| `onProgress` | — | `render` → `walk` → `layout`(`totalPages` 確定)→ `page`(1 ページずつ)→ `done`。長い文書の進捗表示に使う |
|
|
96
98
|
|
|
97
99
|
## 対応している CSS の要点
|
|
@@ -129,6 +131,12 @@ footer: '<div style="text-align:center;font-size:8pt">{{pageNumber}} / {{totalPa
|
|
|
129
131
|
|
|
130
132
|
`orphans` / `widows` / `@page` は未対応。用紙サイズと余白は `options.page` で指定する。
|
|
131
133
|
|
|
134
|
+
## リンクとしおり
|
|
135
|
+
|
|
136
|
+
`<a href>` は既定で PDF のリンク注釈になる(外部 URL・`mailto:` / `tel:`・文書内 `#id`)。折り返した行やページを跨ぐリンクは分割して注釈を作り、ヘッダー/フッターの中のリンクも各ページに出る。`javascript:` と、飛び先の無い `#id` は注釈にしない。止めるなら `links: false`。
|
|
137
|
+
|
|
138
|
+
`outline: true` を渡すと `h1`〜`h6` の入れ子からしおり(ビューアのサイドバーに出る目次)を作る。長い明細書で便利。既定では作らない。
|
|
139
|
+
|
|
132
140
|
## 長い文書
|
|
133
141
|
|
|
134
142
|
変換は途中でイベントループへ戻すので、数百ページでも画面は固まらない(所要時間は 1 割ほど増える)。進捗表示を出すなら `onProgress` を配線する。
|
package/src/index.js
CHANGED
|
@@ -82,6 +82,8 @@ export { expandPrintMediaCss } from './renderer.js';
|
|
|
82
82
|
* @property {'blob'|'uint8array'|'dataurl'} [output='blob']
|
|
83
83
|
* @property {string} [baseUrl] 相対 URL(フォント・画像)の基準。既定は現在の文書
|
|
84
84
|
* @property {(warning: ConversionWarning) => void} [onWarning]
|
|
85
|
+
* @property {boolean} [links=true] `<a href>` を PDF のリンク注釈にする
|
|
86
|
+
* @property {boolean} [outline=false] 見出し(h1〜h6)からしおり(PDF の目次)を作る
|
|
85
87
|
* @property {(progress: ConversionProgress) => void} [onProgress] 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
|
|
86
88
|
*/
|
|
87
89
|
|
|
@@ -91,7 +93,7 @@ export { expandPrintMediaCss } from './renderer.js';
|
|
|
91
93
|
*/
|
|
92
94
|
|
|
93
95
|
/** ライブラリのバージョン(package.json と同期) */
|
|
94
|
-
export const version = '0.
|
|
96
|
+
export const version = '0.4.0';
|
|
95
97
|
|
|
96
98
|
/** モジュール共有のフォントレジストリ */
|
|
97
99
|
const registry = new FontRegistry();
|
|
@@ -176,6 +178,8 @@ export async function htmlToPdf(input, options = {}) {
|
|
|
176
178
|
metadata: options.metadata,
|
|
177
179
|
header,
|
|
178
180
|
footer,
|
|
181
|
+
links: options.links ?? true,
|
|
182
|
+
outline: options.outline ?? false,
|
|
179
183
|
pacer,
|
|
180
184
|
progress,
|
|
181
185
|
warn,
|
package/src/page.js
CHANGED
|
@@ -11,6 +11,7 @@ import { PX_TO_PT, PAGE_SIZES, lengthToPt, num } from './units.js';
|
|
|
11
11
|
import { paginate } from './paginate.js';
|
|
12
12
|
import { releasePixels, loadImage } from './walker/image.js';
|
|
13
13
|
import { buildAxialShading, uniformAlpha, buildAlphaMaskGState } from './pdf/shading.js';
|
|
14
|
+
import { buildLinkAnnot, buildOutline } from './pdf/outline.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* @typedef {object} PageGeometry
|
|
@@ -58,7 +59,7 @@ export function resolvePage(page = {}) {
|
|
|
58
59
|
/**
|
|
59
60
|
* @param {import('./walker/walk.js').WalkResult} body
|
|
60
61
|
* @param {PageGeometry} geo
|
|
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
|
|
62
|
+
* @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, links?: boolean, outline?: boolean}} opts
|
|
62
63
|
* @returns {Promise<Uint8Array>}
|
|
63
64
|
*/
|
|
64
65
|
export async function buildPdf(body, geo, opts) {
|
|
@@ -177,6 +178,26 @@ export async function buildPdf(body, geo, opts) {
|
|
|
177
178
|
const pagesRef = writer.reserve();
|
|
178
179
|
/** @type {import('./pdf/writer.js').Ref[]} */
|
|
179
180
|
const pageRefs = [];
|
|
181
|
+
// リンクの飛び先は前後どちらのページにもなりうるので、ページ参照を先に確保しておく
|
|
182
|
+
const pageSlots = Array.from({ length: totalPages }, () => writer.reserve());
|
|
183
|
+
/** @param {number} i @returns {import('./pdf/writer.js').Ref} */
|
|
184
|
+
const slotOf = (i) => /** @type {import('./pdf/writer.js').Ref} */ (pageSlots[i]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* ドキュメント y が載るページと、そのページでの PDF 座標を求める。
|
|
188
|
+
* @param {number} docY
|
|
189
|
+
* @returns {{page: number, x: number, y: number}|null}
|
|
190
|
+
*/
|
|
191
|
+
const locate = (docY) => {
|
|
192
|
+
for (let i = 0; i < totalPages; i++) {
|
|
193
|
+
const r = /** @type {import('./paginate.js').PageRange} */ (ranges[i]);
|
|
194
|
+
if (docY >= r.start - 0.01 && (docY < r.end - 0.01 || i === totalPages - 1)) {
|
|
195
|
+
const top = contentTop - r.headShift * PX_TO_PT;
|
|
196
|
+
return { page: i, x: geo.left, y: top - (docY - r.start) * PX_TO_PT };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
180
201
|
|
|
181
202
|
// 4. ページごとに描画
|
|
182
203
|
opts.progress?.({ phase: 'layout', totalPages });
|
|
@@ -236,26 +257,85 @@ export async function buildPdf(body, geo, opts) {
|
|
|
236
257
|
cs.restore();
|
|
237
258
|
}
|
|
238
259
|
|
|
260
|
+
// リンク注釈。ページ範囲で切り取ってから用紙座標へ写す
|
|
261
|
+
/** @type {import('./pdf/writer.js').PdfValue[]} */
|
|
262
|
+
const annots = [];
|
|
263
|
+
if (opts.links !== false) {
|
|
264
|
+
/**
|
|
265
|
+
* @param {import('./walker/walk.js').LinkRect[]} list
|
|
266
|
+
* @param {number} pdfTop この帯の上端(PDF 座標)
|
|
267
|
+
* @param {number} docTop その位置に対応するドキュメント y
|
|
268
|
+
* @param {number} docEnd この帯に出せるドキュメント y の終わり
|
|
269
|
+
*/
|
|
270
|
+
const addLinks = (list, pdfTop, docTop, docEnd) => {
|
|
271
|
+
for (const link of list) {
|
|
272
|
+
const y0 = Math.max(link.y, docTop);
|
|
273
|
+
const y1 = Math.min(link.y + link.h, docEnd);
|
|
274
|
+
if (y1 - y0 <= 0.01) continue;
|
|
275
|
+
const rect = {
|
|
276
|
+
x: geo.left + link.x * PX_TO_PT,
|
|
277
|
+
y: pdfTop - (y1 - docTop) * PX_TO_PT,
|
|
278
|
+
w: link.w * PX_TO_PT,
|
|
279
|
+
h: (y1 - y0) * PX_TO_PT,
|
|
280
|
+
};
|
|
281
|
+
if (link.fragment === null) {
|
|
282
|
+
annots.push(buildLinkAnnot(writer, rect, { uri: link.href }));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const targetY = body.anchors?.get(link.fragment);
|
|
286
|
+
if (targetY === undefined) continue; // 飛び先が無いリンクは注釈にしない
|
|
287
|
+
const at = locate(targetY);
|
|
288
|
+
if (!at) continue;
|
|
289
|
+
annots.push(buildLinkAnnot(writer, rect, { dest: [slotOf(at.page), new Name('XYZ'), at.x, at.y, null] }));
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
addLinks(body.links ?? [], contentTop - bodyShiftPt, range.start, range.end);
|
|
293
|
+
if (header) addLinks(header.links ?? [], geo.height - geo.top, 0, headerPt / PX_TO_PT);
|
|
294
|
+
if (footer) addLinks(footer.links ?? [], geo.bottom + footerPt, 0, footerPt / PX_TO_PT);
|
|
295
|
+
}
|
|
296
|
+
|
|
239
297
|
const contentRef = await writer.addStream({}, cs.toBytes());
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
298
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
299
|
+
const pageDict = {
|
|
300
|
+
Type: 'Page',
|
|
301
|
+
Parent: pagesRef,
|
|
302
|
+
MediaBox: [0, 0, geo.width, geo.height],
|
|
303
|
+
Resources: {
|
|
304
|
+
Font: fontDict,
|
|
305
|
+
XObject: xobjDict,
|
|
306
|
+
ExtGState: gstateDict,
|
|
307
|
+
Shading: shadingDict,
|
|
308
|
+
ProcSet: [new Name('PDF'), new Name('Text'), new Name('ImageC')],
|
|
309
|
+
},
|
|
310
|
+
Contents: contentRef,
|
|
311
|
+
};
|
|
312
|
+
if (annots.length) pageDict.Annots = annots;
|
|
313
|
+
const slot = slotOf(p);
|
|
314
|
+
writer.set(slot, pageDict);
|
|
315
|
+
pageRefs.push(slot);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
writer.set(pagesRef, { Type: 'Pages', Kids: pageRefs, Count: pageRefs.length });
|
|
319
|
+
|
|
320
|
+
// しおり: 見出しから木を作る
|
|
321
|
+
let outlineRef = null;
|
|
322
|
+
if (opts.outline) {
|
|
323
|
+
outlineRef = buildOutline(
|
|
324
|
+
writer,
|
|
325
|
+
(body.headings ?? []).map((h) => {
|
|
326
|
+
const at = locate(h.y);
|
|
327
|
+
return { level: h.level, text: h.text, dest: at ? [slotOf(at.page), new Name('XYZ'), at.x, at.y, null] : null };
|
|
253
328
|
}),
|
|
254
329
|
);
|
|
255
330
|
}
|
|
256
331
|
|
|
257
|
-
|
|
258
|
-
const
|
|
332
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
333
|
+
const catalogDict = { Type: 'Catalog', Pages: pagesRef };
|
|
334
|
+
if (outlineRef) {
|
|
335
|
+
catalogDict.Outlines = outlineRef;
|
|
336
|
+
catalogDict.PageMode = new Name('UseOutlines');
|
|
337
|
+
}
|
|
338
|
+
const catalog = writer.add(catalogDict);
|
|
259
339
|
|
|
260
340
|
const md = opts.metadata ?? {};
|
|
261
341
|
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* しおり(/Outlines)とリンク注釈(/Annots)の組み立て。
|
|
4
|
+
*/
|
|
5
|
+
import { Name, pdfString } from './writer.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{page: number, x: number, y: number}} DestPoint 飛び先(0 始まりのページ番号と、そのページの PDF 座標)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* リンク注釈を作る。
|
|
13
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
14
|
+
* @param {{x: number, y: number, w: number, h: number}} rect PDF 座標(左下原点)
|
|
15
|
+
* @param {{uri: string}|{dest: import('./writer.js').PdfValue}} action
|
|
16
|
+
* @returns {import('./writer.js').Ref}
|
|
17
|
+
*/
|
|
18
|
+
export function buildLinkAnnot(writer, rect, action) {
|
|
19
|
+
/** @type {{[key: string]: import('./writer.js').PdfValue}} */
|
|
20
|
+
const annot = {
|
|
21
|
+
Type: new Name('Annot'),
|
|
22
|
+
Subtype: new Name('Link'),
|
|
23
|
+
Rect: [rect.x, rect.y, rect.x + rect.w, rect.y + rect.h],
|
|
24
|
+
// 既定の枠線を消す(多くのビューアは描かないが、仕様上は残る)
|
|
25
|
+
Border: [0, 0, 0],
|
|
26
|
+
F: 4, // Print
|
|
27
|
+
};
|
|
28
|
+
if ('uri' in action) annot.A = { S: new Name('URI'), URI: pdfString(action.uri) };
|
|
29
|
+
else annot.Dest = action.dest;
|
|
30
|
+
return writer.add(annot);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 見出しの並びから、レベルに応じた木構造のしおりを作り、カタログに入れる参照を返す。
|
|
35
|
+
* 飛び先が解決できない見出しは飛ばす。
|
|
36
|
+
*
|
|
37
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
38
|
+
* @param {{level: number, text: string, dest: import('./writer.js').PdfValue|null}[]} entries
|
|
39
|
+
* @returns {import('./writer.js').Ref|null}
|
|
40
|
+
*/
|
|
41
|
+
export function buildOutline(writer, entries) {
|
|
42
|
+
const usable = entries.filter((e) => e.dest !== null);
|
|
43
|
+
if (!usable.length) return null;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {{level: number, text: string, dest: import('./writer.js').PdfValue, children: Node[], ref: import('./writer.js').Ref}} Node
|
|
47
|
+
*/
|
|
48
|
+
/** @type {Node[]} */
|
|
49
|
+
const roots = [];
|
|
50
|
+
/** @type {Node[]} 現在の祖先チェーン */
|
|
51
|
+
const stack = [];
|
|
52
|
+
for (const e of usable) {
|
|
53
|
+
/** @type {Node} */
|
|
54
|
+
const node = { level: e.level, text: e.text, dest: /** @type {import('./writer.js').PdfValue} */ (e.dest), children: [], ref: writer.reserve() };
|
|
55
|
+
while (stack.length && /** @type {Node} */ (stack[stack.length - 1]).level >= node.level) stack.pop();
|
|
56
|
+
const parent = stack[stack.length - 1];
|
|
57
|
+
if (parent) parent.children.push(node);
|
|
58
|
+
else roots.push(node);
|
|
59
|
+
stack.push(node);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const outlineRef = writer.reserve();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 兄弟の並びを書き出し、開いている項目数(Count 用)を返す。
|
|
66
|
+
* @param {Node[]} nodes
|
|
67
|
+
* @param {import('./writer.js').Ref} parentRef
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
const emit = (nodes, parentRef) => {
|
|
71
|
+
let total = 0;
|
|
72
|
+
nodes.forEach((node, i) => {
|
|
73
|
+
const childCount = emit(node.children, node.ref);
|
|
74
|
+
/** @type {{[key: string]: import('./writer.js').PdfValue}} */
|
|
75
|
+
const dict = {
|
|
76
|
+
Title: pdfString(node.text),
|
|
77
|
+
Parent: parentRef,
|
|
78
|
+
Dest: node.dest,
|
|
79
|
+
};
|
|
80
|
+
const prev = nodes[i - 1];
|
|
81
|
+
const next = nodes[i + 1];
|
|
82
|
+
if (prev) dict.Prev = prev.ref;
|
|
83
|
+
if (next) dict.Next = next.ref;
|
|
84
|
+
if (node.children.length) {
|
|
85
|
+
dict.First = /** @type {Node} */ (node.children[0]).ref;
|
|
86
|
+
dict.Last = /** @type {Node} */ (node.children[node.children.length - 1]).ref;
|
|
87
|
+
dict.Count = childCount; // 正の値 = 既定で開いた状態
|
|
88
|
+
}
|
|
89
|
+
writer.set(node.ref, dict);
|
|
90
|
+
total += 1 + childCount;
|
|
91
|
+
});
|
|
92
|
+
return total;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const count = emit(roots, outlineRef);
|
|
96
|
+
writer.set(outlineRef, {
|
|
97
|
+
Type: new Name('Outlines'),
|
|
98
|
+
First: /** @type {Node} */ (roots[0]).ref,
|
|
99
|
+
Last: /** @type {Node} */ (roots[roots.length - 1]).ref,
|
|
100
|
+
Count: count,
|
|
101
|
+
});
|
|
102
|
+
return outlineRef;
|
|
103
|
+
}
|
package/src/walker/walk.js
CHANGED
|
@@ -32,7 +32,9 @@ import { shapeToPath } from './svg-path.js';
|
|
|
32
32
|
* @typedef {{top: number, bottom: number}} Atom ページ境界を跨いではいけない縦範囲(行・表の行・画像・break-inside: avoid)
|
|
33
33
|
* @typedef {{top: number, bottom: number, headTop: number, headBottom: number, headItems: DisplayItem[], footTop: number, footBottom: number, footItems: DisplayItem[]}} TableInfo
|
|
34
34
|
* @typedef {{start: number, end: number, pullTo: number}} Join break-before/after: avoid — [start, end] に境界を置かず、置きそうなら pullTo まで戻す
|
|
35
|
-
* @typedef {{
|
|
35
|
+
* @typedef {{x: number, y: number, w: number, h: number, href: string, fragment: string|null}} LinkRect <a href> の 1 行ぶんの当たり判定(ドキュメント px)
|
|
36
|
+
* @typedef {{level: number, text: string, y: number}} Heading しおり用の見出し
|
|
37
|
+
* @typedef {{items: DisplayItem[], atoms: Atom[], breaks: number[], joins: Join[], tables: TableInfo[], links: LinkRect[], anchors: Map<string, number>, headings: Heading[], height: number}} WalkResult
|
|
36
38
|
*/
|
|
37
39
|
|
|
38
40
|
/**
|
|
@@ -107,6 +109,12 @@ export async function walk(root, ctx) {
|
|
|
107
109
|
const breaks = [];
|
|
108
110
|
/** @type {Join[]} */
|
|
109
111
|
const joins = [];
|
|
112
|
+
/** @type {LinkRect[]} */
|
|
113
|
+
const links = [];
|
|
114
|
+
/** @type {Map<string, number>} 文書内リンクの飛び先: id → ドキュメント y */
|
|
115
|
+
const anchors = new Map();
|
|
116
|
+
/** @type {Heading[]} */
|
|
117
|
+
const headings = [];
|
|
110
118
|
/** @type {TableInfo[]} */
|
|
111
119
|
const tables = [];
|
|
112
120
|
/** @type {TableInfo|null} 走査中のテーブル(thead の描画命令を記録する先) */
|
|
@@ -223,6 +231,8 @@ export async function walk(root, ctx) {
|
|
|
223
231
|
}
|
|
224
232
|
}
|
|
225
233
|
}
|
|
234
|
+
collectLinkAndOutline(el, style);
|
|
235
|
+
|
|
226
236
|
/** @type {TableInfo|null} */
|
|
227
237
|
let openedTable = null;
|
|
228
238
|
if (style.display === 'table' || style.display === 'inline-table') {
|
|
@@ -758,6 +768,53 @@ export async function walk(root, ctx) {
|
|
|
758
768
|
};
|
|
759
769
|
}
|
|
760
770
|
|
|
771
|
+
/**
|
|
772
|
+
* リンク注釈としおりの材料を集める。
|
|
773
|
+
*
|
|
774
|
+
* 描画命令ではないので DisplayList には入れず、WalkResult に別で持つ。
|
|
775
|
+
* `getClientRects()` は transform 適用後の矩形を返すので、変形の中のリンクも
|
|
776
|
+
* そのまま外接矩形として扱える(PDF の注釈は軸並行の矩形しか持てない)。
|
|
777
|
+
*
|
|
778
|
+
* @param {Element} el
|
|
779
|
+
* @param {CSSStyleDeclaration} style
|
|
780
|
+
*/
|
|
781
|
+
function collectLinkAndOutline(el, style) {
|
|
782
|
+
// 飛び先になりうる id を記録する(<a name> も含む)
|
|
783
|
+
const id = el.id || (el.tagName === 'A' ? el.getAttribute('name') : null);
|
|
784
|
+
if (id && !anchors.has(id)) {
|
|
785
|
+
const r = el.getBoundingClientRect();
|
|
786
|
+
anchors.set(id, r.top + sy);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (/^H[1-6]$/.test(el.tagName)) {
|
|
790
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ');
|
|
791
|
+
if (text) headings.push({ level: Number(el.tagName[1]), text, y: el.getBoundingClientRect().top + sy });
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (el.tagName !== 'A') return;
|
|
795
|
+
const href = el.getAttribute('href');
|
|
796
|
+
if (!href) return;
|
|
797
|
+
if (style.visibility !== 'visible') return;
|
|
798
|
+
// 同じ文書内へのリンクは飛び先の id を覚えておき、ページが決まってから解決する
|
|
799
|
+
const fragment = href.startsWith('#') ? decodeURIComponent(href.slice(1)) : null;
|
|
800
|
+
/** @type {string} */
|
|
801
|
+
let uri = href;
|
|
802
|
+
if (!fragment) {
|
|
803
|
+
try {
|
|
804
|
+
uri = new URL(href, el.ownerDocument.baseURI).href;
|
|
805
|
+
} catch {
|
|
806
|
+
return; // 解決できない href は注釈にしない
|
|
807
|
+
}
|
|
808
|
+
// javascript: などは注釈にしない
|
|
809
|
+
if (!/^(https?|mailto|tel|ftp|file):/i.test(uri)) return;
|
|
810
|
+
}
|
|
811
|
+
// インラインで折り返していると行ごとに矩形が返る
|
|
812
|
+
for (const r of el.getClientRects()) {
|
|
813
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
814
|
+
links.push({ x: r.left + sx, y: r.top + sy, w: r.width, h: r.height, href: uri, fragment });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
761
818
|
/**
|
|
762
819
|
* overflow クリップを閉じる。範囲外の命令は捨てる(クリップで消えた文字が抽出テキストに残らないように)。
|
|
763
820
|
* @param {Box} clipBox
|
|
@@ -834,7 +891,7 @@ export async function walk(root, ctx) {
|
|
|
834
891
|
return null;
|
|
835
892
|
}
|
|
836
893
|
|
|
837
|
-
return { items: rootItems, atoms, breaks, joins, tables, height };
|
|
894
|
+
return { items: rootItems, atoms, breaks, joins, tables, links, anchors, headings, height };
|
|
838
895
|
}
|
|
839
896
|
|
|
840
897
|
/** @param {DisplayItem[]} items */
|
package/types/index.d.ts
CHANGED
|
@@ -95,6 +95,8 @@ export { expandPrintMediaCss } from "./renderer.js";
|
|
|
95
95
|
* @property {'blob'|'uint8array'|'dataurl'} [output='blob']
|
|
96
96
|
* @property {string} [baseUrl] 相対 URL(フォント・画像)の基準。既定は現在の文書
|
|
97
97
|
* @property {(warning: ConversionWarning) => void} [onWarning]
|
|
98
|
+
* @property {boolean} [links=true] `<a href>` を PDF のリンク注釈にする
|
|
99
|
+
* @property {boolean} [outline=false] 見出し(h1〜h6)からしおり(PDF の目次)を作る
|
|
98
100
|
* @property {(progress: ConversionProgress) => void} [onProgress] 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
|
|
99
101
|
*/
|
|
100
102
|
/**
|
|
@@ -102,7 +104,7 @@ export { expandPrintMediaCss } from "./renderer.js";
|
|
|
102
104
|
* @typedef {Element|string} ConvertInput
|
|
103
105
|
*/
|
|
104
106
|
/** ライブラリのバージョン(package.json と同期) */
|
|
105
|
-
export const version: "0.
|
|
107
|
+
export const version: "0.4.0";
|
|
106
108
|
/**
|
|
107
109
|
* 登録するフォントの定義。
|
|
108
110
|
* `src` は TrueType アウトライン(glyf)を持つ静的 TTF のみ対応。
|
|
@@ -214,6 +216,14 @@ export type ConvertOptions = {
|
|
|
214
216
|
*/
|
|
215
217
|
baseUrl?: string | undefined;
|
|
216
218
|
onWarning?: ((warning: ConversionWarning) => void) | undefined;
|
|
219
|
+
/**
|
|
220
|
+
* `<a href>` を PDF のリンク注釈にする
|
|
221
|
+
*/
|
|
222
|
+
links?: boolean | undefined;
|
|
223
|
+
/**
|
|
224
|
+
* 見出し(h1〜h6)からしおり(PDF の目次)を作る
|
|
225
|
+
*/
|
|
226
|
+
outline?: boolean | undefined;
|
|
217
227
|
/**
|
|
218
228
|
* 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
|
|
219
229
|
*/
|
package/types/page.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export function resolvePage(page?: import("./index.js").PageOptions | undefined)
|
|
|
20
20
|
/**
|
|
21
21
|
* @param {import('./walker/walk.js').WalkResult} body
|
|
22
22
|
* @param {PageGeometry} geo
|
|
23
|
-
* @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
|
|
23
|
+
* @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, links?: boolean, outline?: boolean}} opts
|
|
24
24
|
* @returns {Promise<Uint8Array>}
|
|
25
25
|
*/
|
|
26
26
|
export function buildPdf(body: import("./walker/walk.js").WalkResult, geo: PageGeometry, opts: {
|
|
@@ -31,6 +31,8 @@ export function buildPdf(body: import("./walker/walk.js").WalkResult, geo: PageG
|
|
|
31
31
|
pacer?: import("./pacer.js").Pacer;
|
|
32
32
|
progress?: (p: import("./index.js").ConversionProgress) => void;
|
|
33
33
|
warn?: (w: import("./index.js").ConversionWarning) => void;
|
|
34
|
+
links?: boolean;
|
|
35
|
+
outline?: boolean;
|
|
34
36
|
}): Promise<Uint8Array>;
|
|
35
37
|
export type PageGeometry = {
|
|
36
38
|
/**
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{page: number, x: number, y: number}} DestPoint 飛び先(0 始まりのページ番号と、そのページの PDF 座標)
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* リンク注釈を作る。
|
|
6
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
7
|
+
* @param {{x: number, y: number, w: number, h: number}} rect PDF 座標(左下原点)
|
|
8
|
+
* @param {{uri: string}|{dest: import('./writer.js').PdfValue}} action
|
|
9
|
+
* @returns {import('./writer.js').Ref}
|
|
10
|
+
*/
|
|
11
|
+
export function buildLinkAnnot(writer: import("./writer.js").PdfWriter, rect: {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
w: number;
|
|
15
|
+
h: number;
|
|
16
|
+
}, action: {
|
|
17
|
+
uri: string;
|
|
18
|
+
} | {
|
|
19
|
+
dest: import("./writer.js").PdfValue;
|
|
20
|
+
}): import("./writer.js").Ref;
|
|
21
|
+
/**
|
|
22
|
+
* 見出しの並びから、レベルに応じた木構造のしおりを作り、カタログに入れる参照を返す。
|
|
23
|
+
* 飛び先が解決できない見出しは飛ばす。
|
|
24
|
+
*
|
|
25
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
26
|
+
* @param {{level: number, text: string, dest: import('./writer.js').PdfValue|null}[]} entries
|
|
27
|
+
* @returns {import('./writer.js').Ref|null}
|
|
28
|
+
*/
|
|
29
|
+
export function buildOutline(writer: import("./writer.js").PdfWriter, entries: {
|
|
30
|
+
level: number;
|
|
31
|
+
text: string;
|
|
32
|
+
dest: import("./writer.js").PdfValue | null;
|
|
33
|
+
}[]): import("./writer.js").Ref | null;
|
|
34
|
+
/**
|
|
35
|
+
* 飛び先(0 始まりのページ番号と、そのページの PDF 座標)
|
|
36
|
+
*/
|
|
37
|
+
export type DestPoint = {
|
|
38
|
+
page: number;
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
};
|
package/types/walker/walk.d.ts
CHANGED
|
@@ -179,12 +179,34 @@ export type Join = {
|
|
|
179
179
|
end: number;
|
|
180
180
|
pullTo: number;
|
|
181
181
|
};
|
|
182
|
+
/**
|
|
183
|
+
* <a href> の 1 行ぶんの当たり判定(ドキュメント px)
|
|
184
|
+
*/
|
|
185
|
+
export type LinkRect = {
|
|
186
|
+
x: number;
|
|
187
|
+
y: number;
|
|
188
|
+
w: number;
|
|
189
|
+
h: number;
|
|
190
|
+
href: string;
|
|
191
|
+
fragment: string | null;
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* しおり用の見出し
|
|
195
|
+
*/
|
|
196
|
+
export type Heading = {
|
|
197
|
+
level: number;
|
|
198
|
+
text: string;
|
|
199
|
+
y: number;
|
|
200
|
+
};
|
|
182
201
|
export type WalkResult = {
|
|
183
202
|
items: DisplayItem[];
|
|
184
203
|
atoms: Atom[];
|
|
185
204
|
breaks: number[];
|
|
186
205
|
joins: Join[];
|
|
187
206
|
tables: TableInfo[];
|
|
207
|
+
links: LinkRect[];
|
|
208
|
+
anchors: Map<string, number>;
|
|
209
|
+
headings: Heading[];
|
|
188
210
|
height: number;
|
|
189
211
|
};
|
|
190
212
|
export type WalkContext = {
|