@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.
@@ -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 { loadImage, parseBackgroundUrl, fitImage, objectFitToSize } from './image.js';
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 {RectItem|LineItem|StrokeRRectItem|ImageItem|TextItem|GroupItem|ClipItem} DisplayItem
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 {{items: DisplayItem[], atoms: Atom[], breaks: number[], tables: TableInfo[], height: number}} WalkResult
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', 'SVG', 'OBJECT', 'EMBED']);
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 (only a single url() is); ignored`,
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
- if (style.backgroundRepeat !== 'no-repeat') {
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 "${style.backgroundRepeat}" is not supported; drawn once as no-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
- return { items: rootItems, atoms, breaks, tables, height };
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 === 'clip') {
677
- x1 = it.box.x; y1 = it.box.y; x2 = it.box.x + it.box.w; y2 = it.box.y + it.box.h;
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.2.1";
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 文字列。
@@ -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
  /**
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * 方針: 命令は動かさず、ページごとに「この y 範囲を描く」と決めるだけにする。
5
5
  * 境界は、アトム(テキスト行・表の行・画像・break-inside: avoid)を跨がない位置まで上へ戻す。
6
+ * `break-before/after: avoid` で結ばれた箱の間にも境界を置かず、置きそうなら前の箱の先頭まで戻す。
6
7
  * テーブルが次ページへ続くときは thead を各ページ先頭で繰り返し、その高さ分だけ本文を下げる。
7
8
  */
8
9
  /**
@@ -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
- fill(): this;
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 */