@hidemikimura/receipt-html-to-pdf 0.2.0 → 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 +50 -0
- package/README.md +33 -9
- 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 +110 -0
- package/package.json +1 -1
- package/skills/receipt-html-to-pdf/SKILL.md +34 -4
- package/skills/receipt-html-to-pdf/references/receipt-format.md +3 -1
- package/skills/receipt-html-to-pdf/references/troubleshooting.md +1 -0
- 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/renderer.js +27 -1
- 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/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,15 @@ 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 {{items: DisplayItem[], atoms: Atom[], breaks: number[], joins: Join[], tables: TableInfo[], height: number}} WalkResult
|
|
29
36
|
*/
|
|
30
37
|
|
|
31
38
|
/**
|
|
@@ -34,9 +41,19 @@ import { loadImage, parseBackgroundUrl, fitImage, objectFitToSize } from './imag
|
|
|
34
41
|
* @property {string[]} fontFallback
|
|
35
42
|
* @property {(w: import('../index.js').ConversionWarning) => void} warn
|
|
36
43
|
* @property {'font'|'measure'|'auto'} textMeasure
|
|
44
|
+
* @property {import('../pacer.js').Pacer} [pacer] 長い走査で途中イベントループへ戻すための譲渡
|
|
37
45
|
*/
|
|
38
46
|
|
|
39
|
-
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BASE', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', '
|
|
47
|
+
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BASE', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', 'OBJECT', 'EMBED']);
|
|
48
|
+
|
|
49
|
+
/** background-repeat で並べるタイルの上限。これを超えたら 1 枚だけ描いて警告する。 */
|
|
50
|
+
const MAX_BG_TILES = 4000;
|
|
51
|
+
|
|
52
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
53
|
+
/** 描画されない SVG 要素(定義や説明)。黙って飛ばす。 */
|
|
54
|
+
const SVG_NON_RENDERED = new Set(['defs', 'symbol', 'marker', 'clipPath', 'mask', 'pattern', 'filter', 'linearGradient', 'radialGradient', 'style', 'title', 'desc', 'metadata', 'script']);
|
|
55
|
+
/** 子をたどるだけの SVG 要素 */
|
|
56
|
+
const SVG_CONTAINERS = new Set(['g', 'a', 'svg', 'switch']);
|
|
40
57
|
|
|
41
58
|
/**
|
|
42
59
|
* flat tree(シャドウ DOM を展開した木)での子ノードを返す。
|
|
@@ -88,6 +105,8 @@ export async function walk(root, ctx) {
|
|
|
88
105
|
const atoms = [];
|
|
89
106
|
/** @type {number[]} */
|
|
90
107
|
const breaks = [];
|
|
108
|
+
/** @type {Join[]} */
|
|
109
|
+
const joins = [];
|
|
91
110
|
/** @type {TableInfo[]} */
|
|
92
111
|
const tables = [];
|
|
93
112
|
/** @type {TableInfo|null} 走査中のテーブル(thead の描画命令を記録する先) */
|
|
@@ -118,6 +137,7 @@ export async function walk(root, ctx) {
|
|
|
118
137
|
*/
|
|
119
138
|
async function visit(el, inherited) {
|
|
120
139
|
if (SKIP_TAGS.has(el.tagName)) return;
|
|
140
|
+
if (ctx.pacer) await ctx.pacer();
|
|
121
141
|
const style = win.getComputedStyle(el);
|
|
122
142
|
if (style.display === 'none') return;
|
|
123
143
|
|
|
@@ -189,9 +209,18 @@ export async function walk(root, ctx) {
|
|
|
189
209
|
if (/^(page|always|left|right|recto|verso)$/.test(ba)) breaks.push(bottom);
|
|
190
210
|
const bi = style.breakInside || style.pageBreakInside;
|
|
191
211
|
// 表の行・行グループ・画像・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') {
|
|
212
|
+
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
213
|
atoms.push({ top, bottom });
|
|
194
214
|
}
|
|
215
|
+
// break-before/after: avoid — 隣の箱との間にページ境界を置かせない
|
|
216
|
+
if (/^avoid(-page)?$/.test(ba)) {
|
|
217
|
+
const next = nextBoxAfter(el);
|
|
218
|
+
if (next) joins.push({ start: Math.min(bottom, next.top), end: Math.max(bottom, next.top), pullTo: top });
|
|
219
|
+
}
|
|
220
|
+
if (/^avoid(-page)?$/.test(bb)) {
|
|
221
|
+
const prev = prevBoxBefore(el);
|
|
222
|
+
if (prev) joins.push({ start: Math.min(prev.bottom, top), end: Math.max(prev.bottom, top), pullTo: prev.top });
|
|
223
|
+
}
|
|
195
224
|
}
|
|
196
225
|
}
|
|
197
226
|
/** @type {TableInfo|null} */
|
|
@@ -247,6 +276,12 @@ export async function walk(root, ctx) {
|
|
|
247
276
|
out = [];
|
|
248
277
|
}
|
|
249
278
|
const next = { z, alpha, decorations };
|
|
279
|
+
// インライン SVG: 子は SVG の規則で走査してパスに変換する
|
|
280
|
+
if (el.tagName === 'svg' && el.namespaceURI === SVG_NS) {
|
|
281
|
+
paintSvgChildren(el, alpha, z);
|
|
282
|
+
if (clipBox && clipSaved) finishClip(clipBox, clipSaved, z);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
250
285
|
for (const node of flatChildNodes(el)) {
|
|
251
286
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
252
287
|
if (visible) paintText(/** @type {Text} */ (node), el, style, next);
|
|
@@ -254,14 +289,7 @@ export async function walk(root, ctx) {
|
|
|
254
289
|
await visit(/** @type {Element} */ (node), next);
|
|
255
290
|
}
|
|
256
291
|
}
|
|
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
|
-
}
|
|
292
|
+
if (clipBox && clipSaved) finishClip(clipBox, clipSaved, z);
|
|
265
293
|
|
|
266
294
|
if ((isHead || isFoot) && currentTable && groupStart >= 0) {
|
|
267
295
|
const r = el.getBoundingClientRect();
|
|
@@ -323,11 +351,23 @@ export async function walk(root, ctx) {
|
|
|
323
351
|
*/
|
|
324
352
|
async function paintBackgroundImage(el, r, style, alpha, z, radius) {
|
|
325
353
|
if (style.backgroundImage === 'none') return;
|
|
354
|
+
|
|
355
|
+
// background-origin / clip(既定: padding-box / border-box)
|
|
356
|
+
const bgClip = boxFor(r, style, style.backgroundClip || 'border-box', radius);
|
|
357
|
+
const bgOrigin = boxFor(r, style, style.backgroundOrigin || 'padding-box', null);
|
|
358
|
+
|
|
359
|
+
// linear-gradient は PDF の軸シェーディングで描く
|
|
360
|
+
const gradient = parseLinearGradient(style.backgroundImage, bgOrigin.w, bgOrigin.h);
|
|
361
|
+
if (gradient) {
|
|
362
|
+
out.push({ type: 'gradient', box: bgOrigin, clip: bgClip, gradient, alpha, z, seq: seq++ });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
326
366
|
const url = parseBackgroundUrl(style.backgroundImage);
|
|
327
367
|
if (!url) {
|
|
328
368
|
warnOnce('css:backgroundImage', {
|
|
329
369
|
code: 'unsupported-css',
|
|
330
|
-
message: `background-image "${style.backgroundImage}" is not supported (
|
|
370
|
+
message: `background-image "${style.backgroundImage}" is not supported (a single url() or linear-gradient() is); ignored`,
|
|
331
371
|
element: el,
|
|
332
372
|
property: 'background-image',
|
|
333
373
|
});
|
|
@@ -335,19 +375,28 @@ export async function walk(root, ctx) {
|
|
|
335
375
|
}
|
|
336
376
|
const img = await loadImage(new URL(url, el.ownerDocument.baseURI).href, ctx.warn, el);
|
|
337
377
|
if (!img) return;
|
|
338
|
-
|
|
378
|
+
const fit = fitImage(bgOrigin, img.width, img.height, style.backgroundSize, style.backgroundPosition);
|
|
379
|
+
|
|
380
|
+
// background-repeat: 軸ごとにタイル位置を求め、描画領域(bgClip)を覆うまで並べる
|
|
381
|
+
const [rx, ry] = splitRepeat(style.backgroundRepeat);
|
|
382
|
+
const ax = tileAxis(rx, fit.x, fit.w, bgClip.x, bgClip.x + bgClip.w);
|
|
383
|
+
const ay = tileAxis(ry, fit.y, fit.h, bgClip.y, bgClip.y + bgClip.h);
|
|
384
|
+
const count = ax.positions.length * ay.positions.length;
|
|
385
|
+
if (count > MAX_BG_TILES) {
|
|
339
386
|
warnOnce('css:backgroundRepeat', {
|
|
340
387
|
code: 'unsupported-css',
|
|
341
|
-
message: `background-repeat
|
|
388
|
+
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
389
|
element: el,
|
|
343
390
|
property: 'background-repeat',
|
|
344
391
|
});
|
|
392
|
+
out.push({ type: 'image', ...fit, image: img, clip: bgClip, alpha, z, seq: seq++ });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
for (const y of ay.positions) {
|
|
396
|
+
for (const x of ax.positions) {
|
|
397
|
+
out.push({ type: 'image', x, y, w: ax.size, h: ay.size, image: img, clip: bgClip, alpha, z, seq: seq++ });
|
|
398
|
+
}
|
|
345
399
|
}
|
|
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
400
|
}
|
|
352
401
|
|
|
353
402
|
/**
|
|
@@ -540,6 +589,7 @@ export async function walk(root, ctx) {
|
|
|
540
589
|
fstyle,
|
|
541
590
|
size,
|
|
542
591
|
textMeasure: ctx.textMeasure,
|
|
592
|
+
features: featureTagsOf(style),
|
|
543
593
|
warn: ctx.warn,
|
|
544
594
|
element: parent,
|
|
545
595
|
});
|
|
@@ -588,7 +638,203 @@ export async function walk(root, ctx) {
|
|
|
588
638
|
else if (it.type === 'line') height = Math.max(height, it.y1, it.y2);
|
|
589
639
|
else if (it.type === 'text' || it.type === 'group' || it.type === 'clip') height = Math.max(height, it.bottom);
|
|
590
640
|
}
|
|
591
|
-
|
|
641
|
+
/**
|
|
642
|
+
* インライン SVG の子要素を走査し、図形をパス命令に変換する。
|
|
643
|
+
* viewBox やプレゼンテーション属性の解決はブラウザに任せ、
|
|
644
|
+
* 変換行列は getScreenCTM()、塗りと線は getComputedStyle() から取る。
|
|
645
|
+
*
|
|
646
|
+
* @param {Element} container
|
|
647
|
+
* @param {number} alpha
|
|
648
|
+
* @param {number} z
|
|
649
|
+
*/
|
|
650
|
+
function paintSvgChildren(container, alpha, z) {
|
|
651
|
+
for (const child of container.children) {
|
|
652
|
+
if (child.namespaceURI !== SVG_NS) continue;
|
|
653
|
+
const tag = child.tagName;
|
|
654
|
+
if (SVG_NON_RENDERED.has(tag)) continue;
|
|
655
|
+
const style = win.getComputedStyle(child);
|
|
656
|
+
if (style.display === 'none') continue;
|
|
657
|
+
const op = parseFloat(style.opacity);
|
|
658
|
+
const a = alpha * (Number.isFinite(op) ? op : 1);
|
|
659
|
+
if (a <= 0) continue;
|
|
660
|
+
|
|
661
|
+
if (SVG_CONTAINERS.has(tag)) {
|
|
662
|
+
paintSvgChildren(child, a, z);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// 幾何プロパティは computed style を優先する(% 指定などをブラウザに解決させる)
|
|
667
|
+
const attr = (/** @type {string} */ name) => {
|
|
668
|
+
const v = style.getPropertyValue(name);
|
|
669
|
+
if (v && /^-?[\d.]+px$/.test(v)) return String(parseFloat(v));
|
|
670
|
+
return child.getAttribute(name) ?? '';
|
|
671
|
+
};
|
|
672
|
+
const segs = shapeToPath(child, attr);
|
|
673
|
+
if (segs === null) {
|
|
674
|
+
warnOnce(`svg:${tag}`, {
|
|
675
|
+
code: 'unsupported-css',
|
|
676
|
+
message: `<${tag}> inside an inline <svg> is not supported and was skipped (shapes are: path, rect, circle, ellipse, line, polyline, polygon)`,
|
|
677
|
+
element: child,
|
|
678
|
+
});
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (!segs.length || style.visibility !== 'visible') continue;
|
|
682
|
+
|
|
683
|
+
const ctm = /** @type {SVGGraphicsElement} */ (/** @type {unknown} */ (child)).getScreenCTM?.();
|
|
684
|
+
if (!ctm) continue;
|
|
685
|
+
const fill = svgPaint(child, style.fill, style.fillOpacity, a, 'fill');
|
|
686
|
+
const stroke = svgStroke(child, style, a);
|
|
687
|
+
if (!fill && !stroke) continue;
|
|
688
|
+
|
|
689
|
+
const r = child.getBoundingClientRect();
|
|
690
|
+
out.push({
|
|
691
|
+
type: 'path',
|
|
692
|
+
segs,
|
|
693
|
+
matrix: [ctm.a, ctm.b, ctm.c, ctm.d, ctm.e + sx, ctm.f + sy],
|
|
694
|
+
fill,
|
|
695
|
+
evenOdd: style.fillRule === 'evenodd',
|
|
696
|
+
stroke,
|
|
697
|
+
top: r.top + sy,
|
|
698
|
+
bottom: r.bottom + sy,
|
|
699
|
+
z,
|
|
700
|
+
seq: seq++,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* SVG の paint 値(`none` / `rgb(...)` / `url(#id)`)を色にする。塗らないなら null。
|
|
707
|
+
* @param {Element} el
|
|
708
|
+
* @param {string} value
|
|
709
|
+
* @param {string} opacity
|
|
710
|
+
* @param {number} alpha
|
|
711
|
+
* @param {'fill'|'stroke'} kind
|
|
712
|
+
* @returns {Rgba|null}
|
|
713
|
+
*/
|
|
714
|
+
function svgPaint(el, value, opacity, alpha, kind) {
|
|
715
|
+
if (!value || value === 'none') return null;
|
|
716
|
+
if (value.startsWith('url(')) {
|
|
717
|
+
warnOnce(`svg:${kind}:url`, {
|
|
718
|
+
code: 'unsupported-css',
|
|
719
|
+
message: `${kind} with a paint server (${value}) inside an inline <svg> is not supported; the shape is skipped`,
|
|
720
|
+
element: el,
|
|
721
|
+
property: kind,
|
|
722
|
+
});
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
const c = parseColor(value);
|
|
726
|
+
if (!c) return null;
|
|
727
|
+
const o = parseFloat(opacity);
|
|
728
|
+
const f = alpha * (Number.isFinite(o) ? o : 1);
|
|
729
|
+
return f === 1 ? c : { ...c, a: c.a * f };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* @param {Element} el
|
|
734
|
+
* @param {CSSStyleDeclaration} style
|
|
735
|
+
* @param {number} alpha
|
|
736
|
+
* @returns {PathStroke|null}
|
|
737
|
+
*/
|
|
738
|
+
function svgStroke(el, style, alpha) {
|
|
739
|
+
const color = svgPaint(el, style.stroke, style.strokeOpacity, alpha, 'stroke');
|
|
740
|
+
if (!color) return null;
|
|
741
|
+
const width = cssPx(style.strokeWidth);
|
|
742
|
+
if (!(width > 0)) return null;
|
|
743
|
+
const dashes = (style.strokeDasharray || 'none')
|
|
744
|
+
.split(/[\s,]+/)
|
|
745
|
+
.map((v) => cssPx(v))
|
|
746
|
+
.filter((v) => Number.isFinite(v) && v >= 0);
|
|
747
|
+
const cap = style.strokeLinecap === 'round' ? 1 : style.strokeLinecap === 'square' ? 2 : 0;
|
|
748
|
+
const join = style.strokeLinejoin === 'round' ? 1 : style.strokeLinejoin === 'bevel' ? 2 : 0;
|
|
749
|
+
const miter = parseFloat(style.strokeMiterlimit);
|
|
750
|
+
return {
|
|
751
|
+
color,
|
|
752
|
+
width,
|
|
753
|
+
cap: /** @type {0|1|2} */ (cap),
|
|
754
|
+
join: /** @type {0|1|2} */ (join),
|
|
755
|
+
miter: Number.isFinite(miter) && miter >= 1 ? miter : 4,
|
|
756
|
+
dash: dashes.length && dashes.some((v) => v > 0) ? dashes : null,
|
|
757
|
+
dashOffset: cssPx(style.strokeDashoffset) || 0,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* overflow クリップを閉じる。範囲外の命令は捨てる(クリップで消えた文字が抽出テキストに残らないように)。
|
|
763
|
+
* @param {Box} clipBox
|
|
764
|
+
* @param {DisplayItem[]} saved
|
|
765
|
+
* @param {number} z
|
|
766
|
+
*/
|
|
767
|
+
function finishClip(clipBox, saved, z) {
|
|
768
|
+
const inside = out.filter((it) => intersects(it, clipBox));
|
|
769
|
+
out = saved;
|
|
770
|
+
if (inside.length) {
|
|
771
|
+
out.push({ type: 'clip', box: clipBox, items: inside, top: clipBox.y, bottom: clipBox.y + clipBox.h, z, seq: seq++ });
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* el の子孫を飛ばして、文書順で次に現れる箱を返す。
|
|
777
|
+
* 兄弟が無ければ親をさかのぼるので、`<section>` の最後の見出しに break-after: avoid を書いても
|
|
778
|
+
* 次の `<section>` と結びつく。
|
|
779
|
+
* @param {Element} el
|
|
780
|
+
* @returns {{top: number, bottom: number}|null}
|
|
781
|
+
*/
|
|
782
|
+
function nextBoxAfter(el) {
|
|
783
|
+
/** @type {Element|null} */
|
|
784
|
+
let node = el;
|
|
785
|
+
while (node && node !== root) {
|
|
786
|
+
for (let sib = node.nextElementSibling; sib; sib = sib.nextElementSibling) {
|
|
787
|
+
const box = edgeBoxIn(sib, 'first');
|
|
788
|
+
if (box) return box;
|
|
789
|
+
}
|
|
790
|
+
node = node.parentElement;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* el の子孫と祖先を飛ばして、文書順で直前に現れる箱を返す。
|
|
797
|
+
* @param {Element} el
|
|
798
|
+
* @returns {{top: number, bottom: number}|null}
|
|
799
|
+
*/
|
|
800
|
+
function prevBoxBefore(el) {
|
|
801
|
+
/** @type {Element|null} */
|
|
802
|
+
let node = el;
|
|
803
|
+
while (node && node !== root) {
|
|
804
|
+
for (let sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
|
|
805
|
+
const box = edgeBoxIn(sib, 'last');
|
|
806
|
+
if (box) return box;
|
|
807
|
+
}
|
|
808
|
+
node = node.parentElement;
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* el 自身が箱ならそれを、そうでなければ(display: contents / inline、高さ 0)
|
|
815
|
+
* 子孫の最初/最後の箱を返す。
|
|
816
|
+
* @param {Element} el
|
|
817
|
+
* @param {'first'|'last'} side
|
|
818
|
+
* @returns {{top: number, bottom: number}|null}
|
|
819
|
+
*/
|
|
820
|
+
function edgeBoxIn(el, side) {
|
|
821
|
+
if (SKIP_TAGS.has(el.tagName)) return null;
|
|
822
|
+
const style = win.getComputedStyle(el);
|
|
823
|
+
if (style.display === 'none') return null;
|
|
824
|
+
if (style.display !== 'contents' && style.display !== 'inline') {
|
|
825
|
+
const r = el.getBoundingClientRect();
|
|
826
|
+
if (r.height > 0) return { top: r.top + sy, bottom: r.bottom + sy };
|
|
827
|
+
}
|
|
828
|
+
const children = [...el.children];
|
|
829
|
+
if (side === 'last') children.reverse();
|
|
830
|
+
for (const child of children) {
|
|
831
|
+
const box = edgeBoxIn(child, side);
|
|
832
|
+
if (box) return box;
|
|
833
|
+
}
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return { items: rootItems, atoms, breaks, joins, tables, height };
|
|
592
838
|
}
|
|
593
839
|
|
|
594
840
|
/** @param {DisplayItem[]} items */
|
|
@@ -673,8 +919,11 @@ function intersects(it, b) {
|
|
|
673
919
|
} else if (it.type === 'text') {
|
|
674
920
|
const last = it.glyphs[it.glyphs.length - 1];
|
|
675
921
|
x1 = it.x; y1 = it.top; x2 = last ? last.x + last.advance : it.x; y2 = it.bottom;
|
|
676
|
-
} else if (it.type === '
|
|
677
|
-
|
|
922
|
+
} else if (it.type === 'path') {
|
|
923
|
+
return true; // 変換行列で回転しうるので常に残す
|
|
924
|
+
} else if (it.type === 'clip' || it.type === 'gradient') {
|
|
925
|
+
const b2 = it.type === 'clip' ? it.box : it.clip;
|
|
926
|
+
x1 = b2.x; y1 = b2.y; x2 = b2.x + b2.w; y2 = b2.y + b2.h;
|
|
678
927
|
} else {
|
|
679
928
|
return true; // group(transform)は境界が回転するので常に残す
|
|
680
929
|
}
|
|
@@ -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,14 @@ 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 {(progress: ConversionProgress) => void} [onProgress] 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
|
|
90
99
|
*/
|
|
91
100
|
/**
|
|
92
101
|
* 変換の入力。DOM 要素、または HTML 文字列。
|
|
93
102
|
* @typedef {Element|string} ConvertInput
|
|
94
103
|
*/
|
|
95
104
|
/** ライブラリのバージョン(package.json と同期) */
|
|
96
|
-
export const version: "0.
|
|
105
|
+
export const version: "0.3.0";
|
|
97
106
|
/**
|
|
98
107
|
* 登録するフォントの定義。
|
|
99
108
|
* `src` は TrueType アウトライン(glyf)を持つ静的 TTF のみ対応。
|
|
@@ -154,6 +163,20 @@ export type ConversionWarning = {
|
|
|
154
163
|
*/
|
|
155
164
|
text?: string | undefined;
|
|
156
165
|
};
|
|
166
|
+
/**
|
|
167
|
+
* 変換の進み具合。長い文書で進捗表示を出すために使う。
|
|
168
|
+
*/
|
|
169
|
+
export type ConversionProgress = {
|
|
170
|
+
phase: "render" | "walk" | "layout" | "page" | "done";
|
|
171
|
+
/**
|
|
172
|
+
* phase が 'page' のときの 1 始まりのページ番号
|
|
173
|
+
*/
|
|
174
|
+
page?: number | undefined;
|
|
175
|
+
/**
|
|
176
|
+
* phase が 'layout' 以降で確定する総ページ数
|
|
177
|
+
*/
|
|
178
|
+
totalPages?: number | undefined;
|
|
179
|
+
};
|
|
157
180
|
export type ConvertOptions = {
|
|
158
181
|
page?: PageOptions | undefined;
|
|
159
182
|
/**
|
|
@@ -191,6 +214,10 @@ export type ConvertOptions = {
|
|
|
191
214
|
*/
|
|
192
215
|
baseUrl?: string | undefined;
|
|
193
216
|
onWarning?: ((warning: ConversionWarning) => void) | undefined;
|
|
217
|
+
/**
|
|
218
|
+
* 進捗通知。長い文書では途中でイベントループへ戻すので、UI を更新できる
|
|
219
|
+
*/
|
|
220
|
+
onProgress?: ((progress: ConversionProgress) => void) | undefined;
|
|
194
221
|
};
|
|
195
222
|
/**
|
|
196
223
|
* 変換の入力。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}} 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,9 @@ 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;
|
|
31
34
|
}): Promise<Uint8Array>;
|
|
32
35
|
export type PageGeometry = {
|
|
33
36
|
/**
|
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 */
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 軸シェーディングの辞書を作る。座標は PDF 座標(pt)。
|
|
3
|
+
*
|
|
4
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
5
|
+
* @param {{x0: number, y0: number, x1: number, y1: number}} coords
|
|
6
|
+
* @param {GradientStop[]} stops
|
|
7
|
+
* @param {'rgb'|'gray'} space
|
|
8
|
+
* @returns {import('./writer.js').Ref}
|
|
9
|
+
*/
|
|
10
|
+
export function buildAxialShading(writer: import("./writer.js").PdfWriter, coords: {
|
|
11
|
+
x0: number;
|
|
12
|
+
y0: number;
|
|
13
|
+
x1: number;
|
|
14
|
+
y1: number;
|
|
15
|
+
}, stops: GradientStop[], space: "rgb" | "gray"): import("./writer.js").Ref;
|
|
16
|
+
/**
|
|
17
|
+
* 色止めのアルファが一定ならその値、そうでなければ null。
|
|
18
|
+
* @param {GradientStop[]} stops
|
|
19
|
+
* @returns {number|null}
|
|
20
|
+
*/
|
|
21
|
+
export function uniformAlpha(stops: GradientStop[]): number | null;
|
|
22
|
+
/**
|
|
23
|
+
* アルファが変化するグラデーション用の輝度ソフトマスクを作る。
|
|
24
|
+
* グレースケールのシェーディングを描くフォーム XObject を /SMask に入れた ExtGState を返す。
|
|
25
|
+
*
|
|
26
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
27
|
+
* @param {{x0: number, y0: number, x1: number, y1: number}} coords PDF 座標
|
|
28
|
+
* @param {GradientStop[]} stops
|
|
29
|
+
* @param {{x: number, y: number, w: number, h: number}} bbox マスクを塗る範囲(PDF 座標)
|
|
30
|
+
* @param {number} groupAlpha 要素から継承した不透明度(グラデーション全体に掛かる)
|
|
31
|
+
* @returns {Promise<import('./writer.js').Ref>} ExtGState の参照
|
|
32
|
+
*/
|
|
33
|
+
export function buildAlphaMaskGState(writer: import("./writer.js").PdfWriter, coords: {
|
|
34
|
+
x0: number;
|
|
35
|
+
y0: number;
|
|
36
|
+
x1: number;
|
|
37
|
+
y1: number;
|
|
38
|
+
}, stops: GradientStop[], bbox: {
|
|
39
|
+
x: number;
|
|
40
|
+
y: number;
|
|
41
|
+
w: number;
|
|
42
|
+
h: number;
|
|
43
|
+
}, groupAlpha: number): Promise<import("./writer.js").Ref>;
|
|
44
|
+
export type GradientStop = import("../walker/gradient.js").GradientStop;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{t: number, color: import('../units.js').Rgba}} GradientStop
|
|
3
|
+
* @typedef {{x0: number, y0: number, x1: number, y1: number, stops: GradientStop[]}} LinearGradient
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* computed の background-image が単一の linear-gradient ならそれを解析する。
|
|
7
|
+
* 対応しない書式(repeating / radial / conic / 複数レイヤー)は null。
|
|
8
|
+
*
|
|
9
|
+
* @param {string} value computed の background-image
|
|
10
|
+
* @param {number} width 箱の幅(px)
|
|
11
|
+
* @param {number} height 箱の高さ(px)
|
|
12
|
+
* @returns {LinearGradient|null}
|
|
13
|
+
*/
|
|
14
|
+
export function parseLinearGradient(value: string, width: number, height: number): LinearGradient | null;
|
|
15
|
+
export type GradientStop = {
|
|
16
|
+
t: number;
|
|
17
|
+
color: import("../units.js").Rgba;
|
|
18
|
+
};
|
|
19
|
+
export type LinearGradient = {
|
|
20
|
+
x0: number;
|
|
21
|
+
y0: number;
|
|
22
|
+
x1: number;
|
|
23
|
+
y1: number;
|
|
24
|
+
stops: GradientStop[];
|
|
25
|
+
};
|