@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/src/walker/walk.js
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
import { parseColor, cssPx } from '../units.js';
|
|
7
7
|
import { splitFamilies, parseWeight } from '../font/registry.js';
|
|
8
8
|
import { measureText } from './text.js';
|
|
9
|
-
import {
|
|
9
|
+
import { featureTagsOf } from '../font/gsub.js';
|
|
10
|
+
import { loadImage, parseBackgroundUrl, fitImage, objectFitToSize, splitRepeat, tileAxis } from './image.js';
|
|
11
|
+
import { parseLinearGradient } from './gradient.js';
|
|
12
|
+
import { shapeToPath } from './svg-path.js';
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* @typedef {import('../units.js').Rgba} Rgba
|
|
@@ -21,11 +24,17 @@ import { loadImage, parseBackgroundUrl, fitImage, objectFitToSize } from './imag
|
|
|
21
24
|
* @typedef {{type: 'text', x: number, y: number, top: number, bottom: number, size: number, color: Rgba, font: import('../font/registry.js').RegisteredFont, glyphs: Glyph[], z: number, seq: number}} TextItem
|
|
22
25
|
* @typedef {{type: 'group', matrix: [number, number, number, number, number, number], origin: {x: number, y: number}, items: DisplayItem[], top: number, bottom: number, z: number, seq: number}} GroupItem
|
|
23
26
|
* @typedef {{type: 'clip', box: Box, items: DisplayItem[], top: number, bottom: number, z: number, seq: number}} ClipItem overflow: hidden
|
|
24
|
-
* @typedef {
|
|
27
|
+
* @typedef {{type: 'gradient', box: Box, clip: Box, gradient: import('./gradient.js').LinearGradient, alpha: number, z: number, seq: number}} GradientItem linear-gradient(box はグラデーションの基準領域、clip は描画範囲)
|
|
28
|
+
* @typedef {{color: Rgba, width: number, cap: 0|1|2, join: 0|1|2, miter: number, dash: number[]|null, dashOffset: number}} PathStroke
|
|
29
|
+
* @typedef {{type: 'path', segs: import('./svg-path.js').PathSeg[], matrix: [number, number, number, number, number, number], fill: Rgba|null, evenOdd: boolean, stroke: PathStroke|null, top: number, bottom: number, z: number, seq: number}} PathItem インライン SVG の図形(matrix はユーザー単位 → ドキュメント px)
|
|
30
|
+
* @typedef {RectItem|LineItem|StrokeRRectItem|ImageItem|TextItem|GroupItem|ClipItem|GradientItem|PathItem} DisplayItem
|
|
25
31
|
*
|
|
26
32
|
* @typedef {{top: number, bottom: number}} Atom ページ境界を跨いではいけない縦範囲(行・表の行・画像・break-inside: avoid)
|
|
27
33
|
* @typedef {{top: number, bottom: number, headTop: number, headBottom: number, headItems: DisplayItem[], footTop: number, footBottom: number, footItems: DisplayItem[]}} TableInfo
|
|
28
|
-
* @typedef {{
|
|
34
|
+
* @typedef {{start: number, end: number, pullTo: number}} Join break-before/after: avoid — [start, end] に境界を置かず、置きそうなら pullTo まで戻す
|
|
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
|
|
29
38
|
*/
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -34,9 +43,19 @@ import { loadImage, parseBackgroundUrl, fitImage, objectFitToSize } from './imag
|
|
|
34
43
|
* @property {string[]} fontFallback
|
|
35
44
|
* @property {(w: import('../index.js').ConversionWarning) => void} warn
|
|
36
45
|
* @property {'font'|'measure'|'auto'} textMeasure
|
|
46
|
+
* @property {import('../pacer.js').Pacer} [pacer] 長い走査で途中イベントループへ戻すための譲渡
|
|
37
47
|
*/
|
|
38
48
|
|
|
39
|
-
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BASE', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', '
|
|
49
|
+
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BASE', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', 'OBJECT', 'EMBED']);
|
|
50
|
+
|
|
51
|
+
/** background-repeat で並べるタイルの上限。これを超えたら 1 枚だけ描いて警告する。 */
|
|
52
|
+
const MAX_BG_TILES = 4000;
|
|
53
|
+
|
|
54
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
55
|
+
/** 描画されない SVG 要素(定義や説明)。黙って飛ばす。 */
|
|
56
|
+
const SVG_NON_RENDERED = new Set(['defs', 'symbol', 'marker', 'clipPath', 'mask', 'pattern', 'filter', 'linearGradient', 'radialGradient', 'style', 'title', 'desc', 'metadata', 'script']);
|
|
57
|
+
/** 子をたどるだけの SVG 要素 */
|
|
58
|
+
const SVG_CONTAINERS = new Set(['g', 'a', 'svg', 'switch']);
|
|
40
59
|
|
|
41
60
|
/**
|
|
42
61
|
* flat tree(シャドウ DOM を展開した木)での子ノードを返す。
|
|
@@ -88,6 +107,14 @@ export async function walk(root, ctx) {
|
|
|
88
107
|
const atoms = [];
|
|
89
108
|
/** @type {number[]} */
|
|
90
109
|
const breaks = [];
|
|
110
|
+
/** @type {Join[]} */
|
|
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 = [];
|
|
91
118
|
/** @type {TableInfo[]} */
|
|
92
119
|
const tables = [];
|
|
93
120
|
/** @type {TableInfo|null} 走査中のテーブル(thead の描画命令を記録する先) */
|
|
@@ -118,6 +145,7 @@ export async function walk(root, ctx) {
|
|
|
118
145
|
*/
|
|
119
146
|
async function visit(el, inherited) {
|
|
120
147
|
if (SKIP_TAGS.has(el.tagName)) return;
|
|
148
|
+
if (ctx.pacer) await ctx.pacer();
|
|
121
149
|
const style = win.getComputedStyle(el);
|
|
122
150
|
if (style.display === 'none') return;
|
|
123
151
|
|
|
@@ -189,11 +217,22 @@ export async function walk(root, ctx) {
|
|
|
189
217
|
if (/^(page|always|left|right|recto|verso)$/.test(ba)) breaks.push(bottom);
|
|
190
218
|
const bi = style.breakInside || style.pageBreakInside;
|
|
191
219
|
// 表の行・行グループ・画像・avoid 指定は分割しない
|
|
192
|
-
if (bi === 'avoid' || bi === 'avoid-page' || style.display === 'table-row' || style.display === 'table-header-group' || style.display === 'table-footer-group' || el.tagName === 'IMG') {
|
|
220
|
+
if (bi === 'avoid' || bi === 'avoid-page' || style.display === 'table-row' || style.display === 'table-header-group' || style.display === 'table-footer-group' || el.tagName === 'IMG' || el.tagName === 'svg') {
|
|
193
221
|
atoms.push({ top, bottom });
|
|
194
222
|
}
|
|
223
|
+
// break-before/after: avoid — 隣の箱との間にページ境界を置かせない
|
|
224
|
+
if (/^avoid(-page)?$/.test(ba)) {
|
|
225
|
+
const next = nextBoxAfter(el);
|
|
226
|
+
if (next) joins.push({ start: Math.min(bottom, next.top), end: Math.max(bottom, next.top), pullTo: top });
|
|
227
|
+
}
|
|
228
|
+
if (/^avoid(-page)?$/.test(bb)) {
|
|
229
|
+
const prev = prevBoxBefore(el);
|
|
230
|
+
if (prev) joins.push({ start: Math.min(prev.bottom, top), end: Math.max(prev.bottom, top), pullTo: prev.top });
|
|
231
|
+
}
|
|
195
232
|
}
|
|
196
233
|
}
|
|
234
|
+
collectLinkAndOutline(el, style);
|
|
235
|
+
|
|
197
236
|
/** @type {TableInfo|null} */
|
|
198
237
|
let openedTable = null;
|
|
199
238
|
if (style.display === 'table' || style.display === 'inline-table') {
|
|
@@ -247,6 +286,12 @@ export async function walk(root, ctx) {
|
|
|
247
286
|
out = [];
|
|
248
287
|
}
|
|
249
288
|
const next = { z, alpha, decorations };
|
|
289
|
+
// インライン SVG: 子は SVG の規則で走査してパスに変換する
|
|
290
|
+
if (el.tagName === 'svg' && el.namespaceURI === SVG_NS) {
|
|
291
|
+
paintSvgChildren(el, alpha, z);
|
|
292
|
+
if (clipBox && clipSaved) finishClip(clipBox, clipSaved, z);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
250
295
|
for (const node of flatChildNodes(el)) {
|
|
251
296
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
252
297
|
if (visible) paintText(/** @type {Text} */ (node), el, style, next);
|
|
@@ -254,14 +299,7 @@ export async function walk(root, ctx) {
|
|
|
254
299
|
await visit(/** @type {Element} */ (node), next);
|
|
255
300
|
}
|
|
256
301
|
}
|
|
257
|
-
if (clipBox && clipSaved)
|
|
258
|
-
// 完全に外にある命令は捨てる(クリップで消えた文字が抽出テキストに残らないように)
|
|
259
|
-
const inside = out.filter((it) => intersects(it, /** @type {Box} */ (clipBox)));
|
|
260
|
-
out = clipSaved;
|
|
261
|
-
if (inside.length) {
|
|
262
|
-
out.push({ type: 'clip', box: clipBox, items: inside, top: clipBox.y, bottom: clipBox.y + clipBox.h, z, seq: seq++ });
|
|
263
|
-
}
|
|
264
|
-
}
|
|
302
|
+
if (clipBox && clipSaved) finishClip(clipBox, clipSaved, z);
|
|
265
303
|
|
|
266
304
|
if ((isHead || isFoot) && currentTable && groupStart >= 0) {
|
|
267
305
|
const r = el.getBoundingClientRect();
|
|
@@ -323,11 +361,23 @@ export async function walk(root, ctx) {
|
|
|
323
361
|
*/
|
|
324
362
|
async function paintBackgroundImage(el, r, style, alpha, z, radius) {
|
|
325
363
|
if (style.backgroundImage === 'none') return;
|
|
364
|
+
|
|
365
|
+
// background-origin / clip(既定: padding-box / border-box)
|
|
366
|
+
const bgClip = boxFor(r, style, style.backgroundClip || 'border-box', radius);
|
|
367
|
+
const bgOrigin = boxFor(r, style, style.backgroundOrigin || 'padding-box', null);
|
|
368
|
+
|
|
369
|
+
// linear-gradient は PDF の軸シェーディングで描く
|
|
370
|
+
const gradient = parseLinearGradient(style.backgroundImage, bgOrigin.w, bgOrigin.h);
|
|
371
|
+
if (gradient) {
|
|
372
|
+
out.push({ type: 'gradient', box: bgOrigin, clip: bgClip, gradient, alpha, z, seq: seq++ });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
326
376
|
const url = parseBackgroundUrl(style.backgroundImage);
|
|
327
377
|
if (!url) {
|
|
328
378
|
warnOnce('css:backgroundImage', {
|
|
329
379
|
code: 'unsupported-css',
|
|
330
|
-
message: `background-image "${style.backgroundImage}" is not supported (
|
|
380
|
+
message: `background-image "${style.backgroundImage}" is not supported (a single url() or linear-gradient() is); ignored`,
|
|
331
381
|
element: el,
|
|
332
382
|
property: 'background-image',
|
|
333
383
|
});
|
|
@@ -335,19 +385,28 @@ export async function walk(root, ctx) {
|
|
|
335
385
|
}
|
|
336
386
|
const img = await loadImage(new URL(url, el.ownerDocument.baseURI).href, ctx.warn, el);
|
|
337
387
|
if (!img) return;
|
|
338
|
-
|
|
388
|
+
const fit = fitImage(bgOrigin, img.width, img.height, style.backgroundSize, style.backgroundPosition);
|
|
389
|
+
|
|
390
|
+
// background-repeat: 軸ごとにタイル位置を求め、描画領域(bgClip)を覆うまで並べる
|
|
391
|
+
const [rx, ry] = splitRepeat(style.backgroundRepeat);
|
|
392
|
+
const ax = tileAxis(rx, fit.x, fit.w, bgClip.x, bgClip.x + bgClip.w);
|
|
393
|
+
const ay = tileAxis(ry, fit.y, fit.h, bgClip.y, bgClip.y + bgClip.h);
|
|
394
|
+
const count = ax.positions.length * ay.positions.length;
|
|
395
|
+
if (count > MAX_BG_TILES) {
|
|
339
396
|
warnOnce('css:backgroundRepeat', {
|
|
340
397
|
code: 'unsupported-css',
|
|
341
|
-
message: `background-repeat
|
|
398
|
+
message: `background-repeat would need ${count} tiles (limit ${MAX_BG_TILES}); drawn once instead. Use a larger background-size or a pre-tiled image.`,
|
|
342
399
|
element: el,
|
|
343
400
|
property: 'background-repeat',
|
|
344
401
|
});
|
|
402
|
+
out.push({ type: 'image', ...fit, image: img, clip: bgClip, alpha, z, seq: seq++ });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
for (const y of ay.positions) {
|
|
406
|
+
for (const x of ax.positions) {
|
|
407
|
+
out.push({ type: 'image', x, y, w: ax.size, h: ay.size, image: img, clip: bgClip, alpha, z, seq: seq++ });
|
|
408
|
+
}
|
|
345
409
|
}
|
|
346
|
-
// background-origin / clip(既定: padding-box / border-box)
|
|
347
|
-
const clipBox = boxFor(r, style, style.backgroundClip || 'border-box', radius);
|
|
348
|
-
const originBox = boxFor(r, style, style.backgroundOrigin || 'padding-box', null);
|
|
349
|
-
const fit = fitImage(originBox, img.width, img.height, style.backgroundSize, style.backgroundPosition);
|
|
350
|
-
out.push({ type: 'image', ...fit, image: img, clip: clipBox, alpha, z, seq: seq++ });
|
|
351
410
|
}
|
|
352
411
|
|
|
353
412
|
/**
|
|
@@ -540,6 +599,7 @@ export async function walk(root, ctx) {
|
|
|
540
599
|
fstyle,
|
|
541
600
|
size,
|
|
542
601
|
textMeasure: ctx.textMeasure,
|
|
602
|
+
features: featureTagsOf(style),
|
|
543
603
|
warn: ctx.warn,
|
|
544
604
|
element: parent,
|
|
545
605
|
});
|
|
@@ -588,7 +648,250 @@ export async function walk(root, ctx) {
|
|
|
588
648
|
else if (it.type === 'line') height = Math.max(height, it.y1, it.y2);
|
|
589
649
|
else if (it.type === 'text' || it.type === 'group' || it.type === 'clip') height = Math.max(height, it.bottom);
|
|
590
650
|
}
|
|
591
|
-
|
|
651
|
+
/**
|
|
652
|
+
* インライン SVG の子要素を走査し、図形をパス命令に変換する。
|
|
653
|
+
* viewBox やプレゼンテーション属性の解決はブラウザに任せ、
|
|
654
|
+
* 変換行列は getScreenCTM()、塗りと線は getComputedStyle() から取る。
|
|
655
|
+
*
|
|
656
|
+
* @param {Element} container
|
|
657
|
+
* @param {number} alpha
|
|
658
|
+
* @param {number} z
|
|
659
|
+
*/
|
|
660
|
+
function paintSvgChildren(container, alpha, z) {
|
|
661
|
+
for (const child of container.children) {
|
|
662
|
+
if (child.namespaceURI !== SVG_NS) continue;
|
|
663
|
+
const tag = child.tagName;
|
|
664
|
+
if (SVG_NON_RENDERED.has(tag)) continue;
|
|
665
|
+
const style = win.getComputedStyle(child);
|
|
666
|
+
if (style.display === 'none') continue;
|
|
667
|
+
const op = parseFloat(style.opacity);
|
|
668
|
+
const a = alpha * (Number.isFinite(op) ? op : 1);
|
|
669
|
+
if (a <= 0) continue;
|
|
670
|
+
|
|
671
|
+
if (SVG_CONTAINERS.has(tag)) {
|
|
672
|
+
paintSvgChildren(child, a, z);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// 幾何プロパティは computed style を優先する(% 指定などをブラウザに解決させる)
|
|
677
|
+
const attr = (/** @type {string} */ name) => {
|
|
678
|
+
const v = style.getPropertyValue(name);
|
|
679
|
+
if (v && /^-?[\d.]+px$/.test(v)) return String(parseFloat(v));
|
|
680
|
+
return child.getAttribute(name) ?? '';
|
|
681
|
+
};
|
|
682
|
+
const segs = shapeToPath(child, attr);
|
|
683
|
+
if (segs === null) {
|
|
684
|
+
warnOnce(`svg:${tag}`, {
|
|
685
|
+
code: 'unsupported-css',
|
|
686
|
+
message: `<${tag}> inside an inline <svg> is not supported and was skipped (shapes are: path, rect, circle, ellipse, line, polyline, polygon)`,
|
|
687
|
+
element: child,
|
|
688
|
+
});
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
if (!segs.length || style.visibility !== 'visible') continue;
|
|
692
|
+
|
|
693
|
+
const ctm = /** @type {SVGGraphicsElement} */ (/** @type {unknown} */ (child)).getScreenCTM?.();
|
|
694
|
+
if (!ctm) continue;
|
|
695
|
+
const fill = svgPaint(child, style.fill, style.fillOpacity, a, 'fill');
|
|
696
|
+
const stroke = svgStroke(child, style, a);
|
|
697
|
+
if (!fill && !stroke) continue;
|
|
698
|
+
|
|
699
|
+
const r = child.getBoundingClientRect();
|
|
700
|
+
out.push({
|
|
701
|
+
type: 'path',
|
|
702
|
+
segs,
|
|
703
|
+
matrix: [ctm.a, ctm.b, ctm.c, ctm.d, ctm.e + sx, ctm.f + sy],
|
|
704
|
+
fill,
|
|
705
|
+
evenOdd: style.fillRule === 'evenodd',
|
|
706
|
+
stroke,
|
|
707
|
+
top: r.top + sy,
|
|
708
|
+
bottom: r.bottom + sy,
|
|
709
|
+
z,
|
|
710
|
+
seq: seq++,
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* SVG の paint 値(`none` / `rgb(...)` / `url(#id)`)を色にする。塗らないなら null。
|
|
717
|
+
* @param {Element} el
|
|
718
|
+
* @param {string} value
|
|
719
|
+
* @param {string} opacity
|
|
720
|
+
* @param {number} alpha
|
|
721
|
+
* @param {'fill'|'stroke'} kind
|
|
722
|
+
* @returns {Rgba|null}
|
|
723
|
+
*/
|
|
724
|
+
function svgPaint(el, value, opacity, alpha, kind) {
|
|
725
|
+
if (!value || value === 'none') return null;
|
|
726
|
+
if (value.startsWith('url(')) {
|
|
727
|
+
warnOnce(`svg:${kind}:url`, {
|
|
728
|
+
code: 'unsupported-css',
|
|
729
|
+
message: `${kind} with a paint server (${value}) inside an inline <svg> is not supported; the shape is skipped`,
|
|
730
|
+
element: el,
|
|
731
|
+
property: kind,
|
|
732
|
+
});
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
const c = parseColor(value);
|
|
736
|
+
if (!c) return null;
|
|
737
|
+
const o = parseFloat(opacity);
|
|
738
|
+
const f = alpha * (Number.isFinite(o) ? o : 1);
|
|
739
|
+
return f === 1 ? c : { ...c, a: c.a * f };
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* @param {Element} el
|
|
744
|
+
* @param {CSSStyleDeclaration} style
|
|
745
|
+
* @param {number} alpha
|
|
746
|
+
* @returns {PathStroke|null}
|
|
747
|
+
*/
|
|
748
|
+
function svgStroke(el, style, alpha) {
|
|
749
|
+
const color = svgPaint(el, style.stroke, style.strokeOpacity, alpha, 'stroke');
|
|
750
|
+
if (!color) return null;
|
|
751
|
+
const width = cssPx(style.strokeWidth);
|
|
752
|
+
if (!(width > 0)) return null;
|
|
753
|
+
const dashes = (style.strokeDasharray || 'none')
|
|
754
|
+
.split(/[\s,]+/)
|
|
755
|
+
.map((v) => cssPx(v))
|
|
756
|
+
.filter((v) => Number.isFinite(v) && v >= 0);
|
|
757
|
+
const cap = style.strokeLinecap === 'round' ? 1 : style.strokeLinecap === 'square' ? 2 : 0;
|
|
758
|
+
const join = style.strokeLinejoin === 'round' ? 1 : style.strokeLinejoin === 'bevel' ? 2 : 0;
|
|
759
|
+
const miter = parseFloat(style.strokeMiterlimit);
|
|
760
|
+
return {
|
|
761
|
+
color,
|
|
762
|
+
width,
|
|
763
|
+
cap: /** @type {0|1|2} */ (cap),
|
|
764
|
+
join: /** @type {0|1|2} */ (join),
|
|
765
|
+
miter: Number.isFinite(miter) && miter >= 1 ? miter : 4,
|
|
766
|
+
dash: dashes.length && dashes.some((v) => v > 0) ? dashes : null,
|
|
767
|
+
dashOffset: cssPx(style.strokeDashoffset) || 0,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
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
|
+
|
|
818
|
+
/**
|
|
819
|
+
* overflow クリップを閉じる。範囲外の命令は捨てる(クリップで消えた文字が抽出テキストに残らないように)。
|
|
820
|
+
* @param {Box} clipBox
|
|
821
|
+
* @param {DisplayItem[]} saved
|
|
822
|
+
* @param {number} z
|
|
823
|
+
*/
|
|
824
|
+
function finishClip(clipBox, saved, z) {
|
|
825
|
+
const inside = out.filter((it) => intersects(it, clipBox));
|
|
826
|
+
out = saved;
|
|
827
|
+
if (inside.length) {
|
|
828
|
+
out.push({ type: 'clip', box: clipBox, items: inside, top: clipBox.y, bottom: clipBox.y + clipBox.h, z, seq: seq++ });
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* el の子孫を飛ばして、文書順で次に現れる箱を返す。
|
|
834
|
+
* 兄弟が無ければ親をさかのぼるので、`<section>` の最後の見出しに break-after: avoid を書いても
|
|
835
|
+
* 次の `<section>` と結びつく。
|
|
836
|
+
* @param {Element} el
|
|
837
|
+
* @returns {{top: number, bottom: number}|null}
|
|
838
|
+
*/
|
|
839
|
+
function nextBoxAfter(el) {
|
|
840
|
+
/** @type {Element|null} */
|
|
841
|
+
let node = el;
|
|
842
|
+
while (node && node !== root) {
|
|
843
|
+
for (let sib = node.nextElementSibling; sib; sib = sib.nextElementSibling) {
|
|
844
|
+
const box = edgeBoxIn(sib, 'first');
|
|
845
|
+
if (box) return box;
|
|
846
|
+
}
|
|
847
|
+
node = node.parentElement;
|
|
848
|
+
}
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* el の子孫と祖先を飛ばして、文書順で直前に現れる箱を返す。
|
|
854
|
+
* @param {Element} el
|
|
855
|
+
* @returns {{top: number, bottom: number}|null}
|
|
856
|
+
*/
|
|
857
|
+
function prevBoxBefore(el) {
|
|
858
|
+
/** @type {Element|null} */
|
|
859
|
+
let node = el;
|
|
860
|
+
while (node && node !== root) {
|
|
861
|
+
for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
|
|
862
|
+
const box = edgeBoxIn(sib, 'last');
|
|
863
|
+
if (box) return box;
|
|
864
|
+
}
|
|
865
|
+
node = node.parentElement;
|
|
866
|
+
}
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* el 自身が箱ならそれを、そうでなければ(display: contents / inline、高さ 0)
|
|
872
|
+
* 子孫の最初/最後の箱を返す。
|
|
873
|
+
* @param {Element} el
|
|
874
|
+
* @param {'first'|'last'} side
|
|
875
|
+
* @returns {{top: number, bottom: number}|null}
|
|
876
|
+
*/
|
|
877
|
+
function edgeBoxIn(el, side) {
|
|
878
|
+
if (SKIP_TAGS.has(el.tagName)) return null;
|
|
879
|
+
const style = win.getComputedStyle(el);
|
|
880
|
+
if (style.display === 'none') return null;
|
|
881
|
+
if (style.display !== 'contents' && style.display !== 'inline') {
|
|
882
|
+
const r = el.getBoundingClientRect();
|
|
883
|
+
if (r.height > 0) return { top: r.top + sy, bottom: r.bottom + sy };
|
|
884
|
+
}
|
|
885
|
+
const children = [...el.children];
|
|
886
|
+
if (side === 'last') children.reverse();
|
|
887
|
+
for (const child of children) {
|
|
888
|
+
const box = edgeBoxIn(child, side);
|
|
889
|
+
if (box) return box;
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return { items: rootItems, atoms, breaks, joins, tables, links, anchors, headings, height };
|
|
592
895
|
}
|
|
593
896
|
|
|
594
897
|
/** @param {DisplayItem[]} items */
|
|
@@ -673,8 +976,11 @@ function intersects(it, b) {
|
|
|
673
976
|
} else if (it.type === 'text') {
|
|
674
977
|
const last = it.glyphs[it.glyphs.length - 1];
|
|
675
978
|
x1 = it.x; y1 = it.top; x2 = last ? last.x + last.advance : it.x; y2 = it.bottom;
|
|
676
|
-
} else if (it.type === '
|
|
677
|
-
|
|
979
|
+
} else if (it.type === 'path') {
|
|
980
|
+
return true; // 変換行列で回転しうるので常に残す
|
|
981
|
+
} else if (it.type === 'clip' || it.type === 'gradient') {
|
|
982
|
+
const b2 = it.type === 'clip' ? it.box : it.clip;
|
|
983
|
+
x1 = b2.x; y1 = b2.y; x2 = b2.x + b2.w; y2 = b2.y + b2.h;
|
|
678
984
|
} else {
|
|
679
985
|
return true; // group(transform)は境界が回転するので常に残す
|
|
680
986
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSUB(グリフ置換)の単一置換だけを読む。
|
|
3
|
+
*
|
|
4
|
+
* ブラウザが `font-variant-*` / `font-feature-settings` で有効にした機能を、
|
|
5
|
+
* 同じ結果になるように gid → gid の置換として再現する。
|
|
6
|
+
* 置換は埋め込み前に解決するので、PDF に GSUB テーブル自体は入らない。
|
|
7
|
+
*
|
|
8
|
+
* 対応するのは Lookup タイプ 1(単一置換、フォーマット 1 / 2)と、
|
|
9
|
+
* それを包むタイプ 7(拡張)だけ。タイプ 4(合字)はグリフ数が変わるため、
|
|
10
|
+
* 1 文字ずつ位置を実測する走査モデルでは扱えない。
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} GsubTable
|
|
14
|
+
* @property {Map<string, number[]>} features 機能タグ → Lookup 番号
|
|
15
|
+
* @property {(index: number) => Map<number, number>|null} lookup 単一置換の Lookup を読む(対応外なら null)
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* GSUB を読む。無い・壊れている場合は null。
|
|
19
|
+
* @param {import('./parse.js').ParsedFont} font
|
|
20
|
+
* @returns {GsubTable|null}
|
|
21
|
+
*/
|
|
22
|
+
export function parseGsub(font: import("./parse.js").ParsedFont): GsubTable | null;
|
|
23
|
+
/**
|
|
24
|
+
* 機能タグの集合から置換関数を作る。Lookup 番号の小さい順に適用する。
|
|
25
|
+
* @param {import('./parse.js').ParsedFont} font
|
|
26
|
+
* @param {string[]} tags
|
|
27
|
+
* @returns {((gid: number) => number)|null} 置換が 1 つも無ければ null
|
|
28
|
+
*/
|
|
29
|
+
export function buildSubstitution(font: import("./parse.js").ParsedFont, tags: string[]): ((gid: number) => number) | null;
|
|
30
|
+
/**
|
|
31
|
+
* computed style から、ブラウザが有効にしている機能タグを集める。
|
|
32
|
+
* 既定で有効な機能(ccmp / liga / calt)は含めない(合字は扱えないため)。
|
|
33
|
+
*
|
|
34
|
+
* @param {CSSStyleDeclaration} style
|
|
35
|
+
* @returns {string[]}
|
|
36
|
+
*/
|
|
37
|
+
export function featureTagsOf(style: CSSStyleDeclaration): string[];
|
|
38
|
+
export type GsubTable = {
|
|
39
|
+
/**
|
|
40
|
+
* 機能タグ → Lookup 番号
|
|
41
|
+
*/
|
|
42
|
+
features: Map<string, number[]>;
|
|
43
|
+
/**
|
|
44
|
+
* 単一置換の Lookup を読む(対応外なら null)
|
|
45
|
+
*/
|
|
46
|
+
lookup: (index: number) => Map<number, number> | null;
|
|
47
|
+
};
|
package/types/index.d.ts
CHANGED
|
@@ -73,6 +73,14 @@ export { expandPrintMediaCss } from "./renderer.js";
|
|
|
73
73
|
* @property {string} [property] unsupported-css のときの CSS プロパティ名
|
|
74
74
|
* @property {string} [text] missing-glyph のときの該当文字
|
|
75
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* 変換の進み具合。長い文書で進捗表示を出すために使う。
|
|
78
|
+
*
|
|
79
|
+
* @typedef {object} ConversionProgress
|
|
80
|
+
* @property {'render'|'walk'|'layout'|'page'|'done'} phase
|
|
81
|
+
* @property {number} [page] phase が 'page' のときの 1 始まりのページ番号
|
|
82
|
+
* @property {number} [totalPages] phase が 'layout' 以降で確定する総ページ数
|
|
83
|
+
*/
|
|
76
84
|
/**
|
|
77
85
|
* @typedef {object} ConvertOptions
|
|
78
86
|
* @property {PageOptions} [page]
|
|
@@ -87,13 +95,16 @@ export { expandPrintMediaCss } from "./renderer.js";
|
|
|
87
95
|
* @property {'blob'|'uint8array'|'dataurl'} [output='blob']
|
|
88
96
|
* @property {string} [baseUrl] 相対 URL(フォント・画像)の基準。既定は現在の文書
|
|
89
97
|
* @property {(warning: ConversionWarning) => void} [onWarning]
|
|
98
|
+
* @property {boolean} [links=true] `<a href>` を PDF のリンク注釈にする
|
|
99
|
+
* @property {boolean} [outline=false] 見出し(h1〜h6)からしおり(PDF の目次)を作る
|
|
100
|
+
* @property {(progress: ConversionProgress) => void} [onProgress] 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
|
|
90
101
|
*/
|
|
91
102
|
/**
|
|
92
103
|
* 変換の入力。DOM 要素、または HTML 文字列。
|
|
93
104
|
* @typedef {Element|string} ConvertInput
|
|
94
105
|
*/
|
|
95
106
|
/** ライブラリのバージョン(package.json と同期) */
|
|
96
|
-
export const version: "0.
|
|
107
|
+
export const version: "0.4.0";
|
|
97
108
|
/**
|
|
98
109
|
* 登録するフォントの定義。
|
|
99
110
|
* `src` は TrueType アウトライン(glyf)を持つ静的 TTF のみ対応。
|
|
@@ -154,6 +165,20 @@ export type ConversionWarning = {
|
|
|
154
165
|
*/
|
|
155
166
|
text?: string | undefined;
|
|
156
167
|
};
|
|
168
|
+
/**
|
|
169
|
+
* 変換の進み具合。長い文書で進捗表示を出すために使う。
|
|
170
|
+
*/
|
|
171
|
+
export type ConversionProgress = {
|
|
172
|
+
phase: "render" | "walk" | "layout" | "page" | "done";
|
|
173
|
+
/**
|
|
174
|
+
* phase が 'page' のときの 1 始まりのページ番号
|
|
175
|
+
*/
|
|
176
|
+
page?: number | undefined;
|
|
177
|
+
/**
|
|
178
|
+
* phase が 'layout' 以降で確定する総ページ数
|
|
179
|
+
*/
|
|
180
|
+
totalPages?: number | undefined;
|
|
181
|
+
};
|
|
157
182
|
export type ConvertOptions = {
|
|
158
183
|
page?: PageOptions | undefined;
|
|
159
184
|
/**
|
|
@@ -191,6 +216,18 @@ export type ConvertOptions = {
|
|
|
191
216
|
*/
|
|
192
217
|
baseUrl?: string | undefined;
|
|
193
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;
|
|
227
|
+
/**
|
|
228
|
+
* 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
|
|
229
|
+
*/
|
|
230
|
+
onProgress?: ((progress: ConversionProgress) => void) | undefined;
|
|
194
231
|
};
|
|
195
232
|
/**
|
|
196
233
|
* 変換の入力。DOM 要素、または HTML 文字列。
|
package/types/pacer.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {() => Promise<void>} Pacer
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* @param {number} [intervalMs] この時間を超えて動き続けていたら譲る
|
|
6
|
+
* @returns {Pacer}
|
|
7
|
+
*/
|
|
8
|
+
export function createPacer(intervalMs?: number): Pacer;
|
|
9
|
+
/** 何もしない Pacer(テストや同期実行したい場合に使う) */
|
|
10
|
+
export const noPacer: Pacer;
|
|
11
|
+
export type Pacer = () => Promise<void>;
|
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}} 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: {
|
|
@@ -28,6 +28,11 @@ export function buildPdf(body: import("./walker/walk.js").WalkResult, geo: PageG
|
|
|
28
28
|
metadata?: import("./index.js").PdfMetadata;
|
|
29
29
|
header?: PageDecoration | null;
|
|
30
30
|
footer?: PageDecoration | null;
|
|
31
|
+
pacer?: import("./pacer.js").Pacer;
|
|
32
|
+
progress?: (p: import("./index.js").ConversionProgress) => void;
|
|
33
|
+
warn?: (w: import("./index.js").ConversionWarning) => void;
|
|
34
|
+
links?: boolean;
|
|
35
|
+
outline?: boolean;
|
|
31
36
|
}): Promise<Uint8Array>;
|
|
32
37
|
export type PageGeometry = {
|
|
33
38
|
/**
|
package/types/paginate.d.ts
CHANGED
package/types/pdf/content.d.ts
CHANGED
|
@@ -41,8 +41,25 @@ export class ContentStream {
|
|
|
41
41
|
* @param {string} name @param {number} x @param {number} y @param {number} w @param {number} h
|
|
42
42
|
*/
|
|
43
43
|
image(name: string, x: number, y: number, w: number, h: number): this;
|
|
44
|
-
|
|
44
|
+
/** @param {0|1|2} join */
|
|
45
|
+
lineJoin(join: 0 | 1 | 2): this;
|
|
46
|
+
/** @param {number} limit */
|
|
47
|
+
miterLimit(limit: number): this;
|
|
48
|
+
/** @param {boolean} [evenOdd] */
|
|
49
|
+
fill(evenOdd?: boolean): this;
|
|
50
|
+
/** 塗りと線の両方(B / B*) @param {boolean} [evenOdd] */
|
|
51
|
+
fillAndStroke(evenOdd?: boolean): this;
|
|
52
|
+
/**
|
|
53
|
+
* 正規化済みのパス(M / L / C / Z)を出力する。
|
|
54
|
+
* @param {import('../walker/svg-path.js').PathSeg[]} segs
|
|
55
|
+
*/
|
|
56
|
+
path(segs: import("../walker/svg-path.js").PathSeg[]): this;
|
|
45
57
|
stroke(): this;
|
|
58
|
+
/**
|
|
59
|
+
* シェーディングを現在のクリップ範囲いっぱいに塗る。
|
|
60
|
+
* @param {string} name Shading リソース名
|
|
61
|
+
*/
|
|
62
|
+
shading(name: string): this;
|
|
46
63
|
/** 現在のパスでクリップして新しいパスを開始する */
|
|
47
64
|
clip(): this;
|
|
48
65
|
/** @param {number} x @param {number} y @param {number} w @param {number} h */
|