@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.
@@ -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,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 {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 {{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', 'SVG', 'OBJECT', 'EMBED']);
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 (only a single url() is); ignored`,
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
- if (style.backgroundRepeat !== 'no-repeat') {
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 "${style.backgroundRepeat}" is not supported; drawn once as no-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
- return { items: rootItems, atoms, breaks, tables, height };
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 === 'clip') {
677
- x1 = it.box.x; y1 = it.box.y; x2 = it.box.x + it.box.w; y2 = it.box.y + it.box.h;
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.2.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 文字列。
@@ -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
  /**
@@ -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 */
@@ -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
+ };