@hidemikimura/receipt-html-to-pdf 0.1.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.
@@ -0,0 +1,670 @@
1
+ // @ts-check
2
+ /**
3
+ * DOM Walker — ブラウザがレイアウトした Document を走査し、DisplayList(描画命令の配列)を作る。
4
+ * 座標はすべて CSS px、iframe ドキュメント座標(左上原点、y 下向き)。
5
+ */
6
+ import { parseColor, cssPx } from '../units.js';
7
+ import { splitFamilies, parseWeight } from '../font/registry.js';
8
+ import { measureText } from './text.js';
9
+ import { loadImage, parseBackgroundUrl, fitImage, objectFitToSize } from './image.js';
10
+
11
+ /**
12
+ * @typedef {import('../units.js').Rgba} Rgba
13
+ * @typedef {[number, number, number, number]} Radius [左上, 右上, 右下, 左下] px
14
+ * @typedef {{x: number, y: number, w: number, h: number, radius?: Radius}} Box
15
+ *
16
+ * @typedef {{type: 'rect', x: number, y: number, w: number, h: number, color: Rgba, radius?: Radius, z: number, seq: number}} RectItem
17
+ * @typedef {{type: 'line', x1: number, y1: number, x2: number, y2: number, width: number, color: Rgba, dash: number[]|null, z: number, seq: number}} LineItem
18
+ * @typedef {{type: 'stroke-rrect', x: number, y: number, w: number, h: number, radius: Radius, width: number, color: Rgba, dash: number[]|null, z: number, seq: number}} StrokeRRectItem
19
+ * @typedef {{type: 'image', x: number, y: number, w: number, h: number, image: import('./image.js').DecodedImage, clip: Box|null, alpha: number, z: number, seq: number}} ImageItem
20
+ * @typedef {{gid: number, cp: number, x: number, advance: number}} Glyph advance は px
21
+ * @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
+ * @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
+ * @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
25
+ *
26
+ * @typedef {{top: number, bottom: number}} Atom ページ境界を跨いではいけない縦範囲(行・表の行・画像・break-inside: avoid)
27
+ * @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
29
+ */
30
+
31
+ /**
32
+ * @typedef {object} WalkContext
33
+ * @property {import('../font/registry.js').FontRegistry} registry
34
+ * @property {string[]} fontFallback
35
+ * @property {(w: import('../index.js').ConversionWarning) => void} warn
36
+ * @property {'font'|'measure'|'auto'} textMeasure
37
+ */
38
+
39
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BASE', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', 'SVG', 'OBJECT', 'EMBED']);
40
+
41
+ /** 未対応 CSS プロパティ: [computedStyle のキー, 「指定されている」判定] */
42
+ const UNSUPPORTED = /** @type {[keyof CSSStyleDeclaration & string, (v: string) => boolean][]} */ ([
43
+ ['boxShadow', (v) => v !== 'none'],
44
+ ['textShadow', (v) => v !== 'none'],
45
+ ['filter', (v) => v !== 'none'],
46
+ ['backdropFilter', (v) => v !== 'none' && v !== ''],
47
+ ['writingMode', (v) => v.startsWith('vertical')],
48
+ ['outlineStyle', (v) => v !== 'none'],
49
+ ['clipPath', (v) => v !== 'none'],
50
+ ['mixBlendMode', (v) => v !== 'normal'],
51
+ ]);
52
+
53
+ /**
54
+ * @param {HTMLElement} root
55
+ * @param {WalkContext} ctx
56
+ * @returns {Promise<WalkResult>}
57
+ */
58
+ export async function walk(root, ctx) {
59
+ const win = /** @type {Window} */ (root.ownerDocument.defaultView);
60
+ const sx = win.scrollX;
61
+ const sy = win.scrollY;
62
+ /** @type {DisplayItem[]} 現在の出力先(transform グループ内では差し替わる) */
63
+ let out = [];
64
+ const rootItems = out;
65
+ /** @type {Atom[]} */
66
+ const atoms = [];
67
+ /** @type {number[]} */
68
+ const breaks = [];
69
+ /** @type {TableInfo[]} */
70
+ const tables = [];
71
+ /** @type {TableInfo|null} 走査中のテーブル(thead の描画命令を記録する先) */
72
+ let currentTable = null;
73
+ let seq = 0;
74
+ /** @type {Set<string>} */
75
+ const warned = new Set();
76
+ /** @type {Set<string>} */
77
+ const missingFamilies = new Set();
78
+
79
+ /**
80
+ * @param {string} key
81
+ * @param {import('../index.js').ConversionWarning} w
82
+ */
83
+ function warnOnce(key, w) {
84
+ if (warned.has(key)) return;
85
+ warned.add(key);
86
+ ctx.warn(w);
87
+ }
88
+
89
+ /**
90
+ * @typedef {{z: number, alpha: number, decorations: {line: string, color: Rgba}[]}} Inherited
91
+ */
92
+
93
+ /**
94
+ * @param {Element} el
95
+ * @param {Inherited} inherited
96
+ */
97
+ async function visit(el, inherited) {
98
+ if (SKIP_TAGS.has(el.tagName)) return;
99
+ const style = win.getComputedStyle(el);
100
+ if (style.display === 'none') return;
101
+
102
+ // transform: 一時的に無効化して無変形の座標で走査し、グループとして包む
103
+ const matrix = parseTransform(style.transform);
104
+ // 注意: iframe 内の要素は親ウィンドウの HTMLElement の instanceof に失敗するので、タグ名・プロパティで判定する
105
+ if (matrix && 'style' in el) {
106
+ const hel = /** @type {HTMLElement} */ (el);
107
+ const transformedRect = hel.getBoundingClientRect();
108
+ const prev = hel.style.transform;
109
+ hel.style.transform = 'none';
110
+ void hel.offsetWidth;
111
+ const plainRect = hel.getBoundingClientRect();
112
+ const [ox, oy] = parseOrigin(style.transformOrigin);
113
+ const origin = { x: plainRect.left + ox + sx, y: plainRect.top + oy + sy };
114
+
115
+ const saved = out;
116
+ out = [];
117
+ await visitInner(el, win.getComputedStyle(el), inherited);
118
+ const items = out;
119
+ out = saved;
120
+ hel.style.transform = prev;
121
+ void hel.offsetWidth;
122
+
123
+ out.push({
124
+ type: 'group',
125
+ matrix,
126
+ origin,
127
+ items,
128
+ top: transformedRect.top + sy,
129
+ bottom: transformedRect.bottom + sy,
130
+ z: zOf(style, inherited.z),
131
+ seq: seq++,
132
+ });
133
+ return;
134
+ }
135
+ await visitInner(el, style, inherited);
136
+ }
137
+
138
+ /** @param {CSSStyleDeclaration} style @param {number} inheritedZ */
139
+ function zOf(style, inheritedZ) {
140
+ if (style.position !== 'static' && style.zIndex !== 'auto') {
141
+ const zi = parseInt(style.zIndex, 10);
142
+ if (Number.isFinite(zi)) return zi;
143
+ }
144
+ return inheritedZ;
145
+ }
146
+
147
+ /**
148
+ * @param {Element} el
149
+ * @param {CSSStyleDeclaration} style
150
+ * @param {Inherited} inherited
151
+ */
152
+ async function visitInner(el, style, inherited) {
153
+ const z = zOf(style, inherited.z);
154
+ const opacity = parseFloat(style.opacity);
155
+ const alpha = inherited.alpha * (Number.isFinite(opacity) ? opacity : 1);
156
+ const visible = style.visibility === 'visible' && alpha > 0;
157
+
158
+ // ページ分割のヒント(非表示でも位置は持つので visible に関係なく集める)
159
+ if (style.display !== 'contents' && style.display !== 'inline') {
160
+ const r = el.getBoundingClientRect();
161
+ const top = r.top + sy;
162
+ const bottom = r.bottom + sy;
163
+ if (r.height > 0) {
164
+ const bb = style.breakBefore || style.pageBreakBefore;
165
+ const ba = style.breakAfter || style.pageBreakAfter;
166
+ if (/^(page|always|left|right|recto|verso)$/.test(bb)) breaks.push(top);
167
+ if (/^(page|always|left|right|recto|verso)$/.test(ba)) breaks.push(bottom);
168
+ const bi = style.breakInside || style.pageBreakInside;
169
+ // 表の行・行グループ・画像・avoid 指定は分割しない
170
+ if (bi === 'avoid' || bi === 'avoid-page' || style.display === 'table-row' || style.display === 'table-header-group' || style.display === 'table-footer-group' || el.tagName === 'IMG') {
171
+ atoms.push({ top, bottom });
172
+ }
173
+ }
174
+ }
175
+ /** @type {TableInfo|null} */
176
+ let openedTable = null;
177
+ if (style.display === 'table' || style.display === 'inline-table') {
178
+ const r = el.getBoundingClientRect();
179
+ openedTable = { top: r.top + sy, bottom: r.bottom + sy, headTop: 0, headBottom: 0, headItems: [], footTop: 0, footBottom: 0, footItems: [] };
180
+ tables.push(openedTable);
181
+ }
182
+ const savedTable = currentTable;
183
+ if (openedTable) currentTable = openedTable;
184
+ const isHead = style.display === 'table-header-group' && currentTable && !currentTable.headItems.length;
185
+ const isFoot = style.display === 'table-footer-group' && currentTable && !currentTable.footItems.length;
186
+ const groupStart = isHead || isFoot ? out.length : -1;
187
+
188
+ // overflow: hidden / clip / auto / scroll → 子孫を padding-box でクリップする
189
+ const clips = style.overflowX !== 'visible' || style.overflowY !== 'visible';
190
+ /** @type {DisplayItem[]|null} */
191
+ let clipSaved = null;
192
+ /** @type {Box|null} */
193
+ let clipBox = null;
194
+ if (clips && style.display !== 'inline' && style.display !== 'contents' && el !== root) {
195
+ const r = el.getBoundingClientRect();
196
+ clipBox = boxFor(r, style, 'padding-box', parseRadius(style, r));
197
+ }
198
+
199
+ if (visible && style.display !== 'contents') {
200
+ warnUnsupported(el, style);
201
+ const rects = style.display === 'inline' ? [...el.getClientRects()] : [el.getBoundingClientRect()];
202
+ const collapse = style.borderCollapse === 'collapse' && /^table/.test(style.display) && style.display !== 'table-caption';
203
+ const radius = rects.length === 1 ? parseRadius(style, /** @type {DOMRect} */ (rects[0])) : null;
204
+ for (const r of rects) {
205
+ if (r.width <= 0 && r.height <= 0) continue;
206
+ paintBackground(r, style, alpha, z, radius);
207
+ await paintBackgroundImage(el, r, style, alpha, z, radius);
208
+ paintBorders(r, style, alpha, z, collapse, radius);
209
+ }
210
+ if (el.tagName === 'IMG' && rects[0]) {
211
+ await paintImg(/** @type {HTMLImageElement} */ (el), /** @type {DOMRect} */ (rects[0]), style, alpha, z, radius);
212
+ }
213
+ }
214
+
215
+ // text-decoration は子孫テキストへ伝播する
216
+ let decorations = inherited.decorations;
217
+ const decoLine = style.textDecorationLine;
218
+ if (decoLine && decoLine !== 'none') {
219
+ const color = parseColor(style.textDecorationColor) ?? parseColor(style.color) ?? { r: 0, g: 0, b: 0, a: 1 };
220
+ decorations = [...decorations, { line: decoLine, color }];
221
+ }
222
+
223
+ if (clipBox) {
224
+ clipSaved = out;
225
+ out = [];
226
+ }
227
+ const next = { z, alpha, decorations };
228
+ for (const node of [...el.childNodes]) {
229
+ if (node.nodeType === Node.TEXT_NODE) {
230
+ if (visible) paintText(/** @type {Text} */ (node), el, style, next);
231
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
232
+ await visit(/** @type {Element} */ (node), next);
233
+ }
234
+ }
235
+ if (clipBox && clipSaved) {
236
+ // 完全に外にある命令は捨てる(クリップで消えた文字が抽出テキストに残らないように)
237
+ const inside = out.filter((it) => intersects(it, /** @type {Box} */ (clipBox)));
238
+ out = clipSaved;
239
+ if (inside.length) {
240
+ out.push({ type: 'clip', box: clipBox, items: inside, top: clipBox.y, bottom: clipBox.y + clipBox.h, z, seq: seq++ });
241
+ }
242
+ }
243
+
244
+ if ((isHead || isFoot) && currentTable && groupStart >= 0) {
245
+ const r = el.getBoundingClientRect();
246
+ const items = out.slice(groupStart);
247
+ if (isHead) {
248
+ currentTable.headTop = r.top + sy;
249
+ currentTable.headBottom = r.bottom + sy;
250
+ currentTable.headItems = items;
251
+ } else {
252
+ currentTable.footTop = r.top + sy;
253
+ currentTable.footBottom = r.bottom + sy;
254
+ currentTable.footItems = items;
255
+ }
256
+ }
257
+ currentTable = savedTable;
258
+ }
259
+
260
+ /** @param {Element} el @param {CSSStyleDeclaration} style */
261
+ function warnUnsupported(el, style) {
262
+ for (const [prop, isSet] of UNSUPPORTED) {
263
+ const v = String(style[prop] ?? '');
264
+ if (isSet(v)) {
265
+ warnOnce(`css:${prop}`, {
266
+ code: 'unsupported-css',
267
+ message: `CSS property "${camelToKebab(prop)}" is not supported in this version and will be ignored (first seen on <${el.tagName.toLowerCase()}>: ${v})`,
268
+ element: el,
269
+ property: camelToKebab(prop),
270
+ });
271
+ }
272
+ }
273
+ if (/^matrix3d/.test(style.transform)) {
274
+ warnOnce('css:transform3d', { code: 'unsupported-css', message: '3D transforms are not supported; the element is drawn untransformed', element: el, property: 'transform' });
275
+ }
276
+ }
277
+
278
+ /**
279
+ * @param {DOMRect} r
280
+ * @param {CSSStyleDeclaration} style
281
+ * @param {number} alpha
282
+ * @param {number} z
283
+ * @param {Radius|null} radius
284
+ */
285
+ function paintBackground(r, style, alpha, z, radius) {
286
+ const bg = parseColor(style.backgroundColor);
287
+ if (!bg || bg.a <= 0) return;
288
+ /** @type {RectItem} */
289
+ const item = { type: 'rect', x: r.left + sx, y: r.top + sy, w: r.width, h: r.height, color: withAlpha(bg, alpha), z, seq: seq++ };
290
+ if (radius) item.radius = radius;
291
+ out.push(item);
292
+ }
293
+
294
+ /**
295
+ * @param {Element} el
296
+ * @param {DOMRect} r
297
+ * @param {CSSStyleDeclaration} style
298
+ * @param {number} alpha
299
+ * @param {number} z
300
+ * @param {Radius|null} radius
301
+ */
302
+ async function paintBackgroundImage(el, r, style, alpha, z, radius) {
303
+ if (style.backgroundImage === 'none') return;
304
+ const url = parseBackgroundUrl(style.backgroundImage);
305
+ if (!url) {
306
+ warnOnce('css:backgroundImage', {
307
+ code: 'unsupported-css',
308
+ message: `background-image "${style.backgroundImage}" is not supported (only a single url() is); ignored`,
309
+ element: el,
310
+ property: 'background-image',
311
+ });
312
+ return;
313
+ }
314
+ const img = await loadImage(new URL(url, el.ownerDocument.baseURI).href, ctx.warn, el);
315
+ if (!img) return;
316
+ if (style.backgroundRepeat !== 'no-repeat') {
317
+ warnOnce('css:backgroundRepeat', {
318
+ code: 'unsupported-css',
319
+ message: `background-repeat "${style.backgroundRepeat}" is not supported; drawn once as no-repeat`,
320
+ element: el,
321
+ property: 'background-repeat',
322
+ });
323
+ }
324
+ // background-origin / clip(既定: padding-box / border-box)
325
+ const clipBox = boxFor(r, style, style.backgroundClip || 'border-box', radius);
326
+ const originBox = boxFor(r, style, style.backgroundOrigin || 'padding-box', null);
327
+ const fit = fitImage(originBox, img.width, img.height, style.backgroundSize, style.backgroundPosition);
328
+ out.push({ type: 'image', ...fit, image: img, clip: clipBox, alpha, z, seq: seq++ });
329
+ }
330
+
331
+ /**
332
+ * @param {HTMLImageElement} el
333
+ * @param {DOMRect} r
334
+ * @param {CSSStyleDeclaration} style
335
+ * @param {number} alpha
336
+ * @param {number} z
337
+ * @param {Radius|null} radius
338
+ */
339
+ async function paintImg(el, r, style, alpha, z, radius) {
340
+ const src = el.currentSrc || el.src;
341
+ if (!src) return;
342
+ const img = await loadImage(src, ctx.warn, el);
343
+ if (!img) return;
344
+ const content = boxFor(r, style, 'content-box', radius);
345
+ const fit = fitImage(content, img.width, img.height, objectFitToSize(style.objectFit), style.objectPosition);
346
+ if (style.objectFit === 'scale-down' && (fit.w > img.width || fit.h > img.height)) {
347
+ Object.assign(fit, fitImage(content, img.width, img.height, 'auto', style.objectPosition));
348
+ }
349
+ out.push({ type: 'image', ...fit, image: img, clip: content, alpha, z, seq: seq++ });
350
+ }
351
+
352
+ /**
353
+ * border-box の矩形から指定ボックスを求める。
354
+ * @param {DOMRect} r
355
+ * @param {CSSStyleDeclaration} style
356
+ * @param {string} box 'border-box' | 'padding-box' | 'content-box'
357
+ * @param {Radius|null} radius
358
+ * @returns {Box}
359
+ */
360
+ function boxFor(r, style, box, radius) {
361
+ let x = r.left + sx;
362
+ let y = r.top + sy;
363
+ let w = r.width;
364
+ let h = r.height;
365
+ if (box === 'padding-box' || box === 'content-box') {
366
+ const bt = cssPx(style.borderTopWidth);
367
+ const br = cssPx(style.borderRightWidth);
368
+ const bb = cssPx(style.borderBottomWidth);
369
+ const bl = cssPx(style.borderLeftWidth);
370
+ x += bl;
371
+ y += bt;
372
+ w -= bl + br;
373
+ h -= bt + bb;
374
+ if (radius) radius = /** @type {Radius} */ (radius.map((v) => Math.max(0, v - Math.max(bt, br, bb, bl))));
375
+ }
376
+ if (box === 'content-box') {
377
+ const pt = cssPx(style.paddingTop);
378
+ const pr = cssPx(style.paddingRight);
379
+ const pb = cssPx(style.paddingBottom);
380
+ const pl = cssPx(style.paddingLeft);
381
+ x += pl;
382
+ y += pt;
383
+ w -= pl + pr;
384
+ h -= pt + pb;
385
+ }
386
+ /** @type {Box} */
387
+ const b = { x, y, w: Math.max(0, w), h: Math.max(0, h) };
388
+ if (radius && radius.some((v) => v > 0)) b.radius = radius;
389
+ return b;
390
+ }
391
+
392
+ /**
393
+ * @param {DOMRect} r
394
+ * @param {CSSStyleDeclaration} style
395
+ * @param {number} alpha
396
+ * @param {number} z
397
+ * @param {boolean} collapse border-collapse: collapse のテーブル要素か(境界線を辺の中心に描く)
398
+ * @param {Radius|null} radius
399
+ */
400
+ function paintBorders(r, style, alpha, z, collapse, radius) {
401
+ const x = r.left + sx;
402
+ const y = r.top + sy;
403
+ const w = r.width;
404
+ const h = r.height;
405
+ /** @type {('Top'|'Right'|'Bottom'|'Left')[]} */
406
+ const names = ['Top', 'Right', 'Bottom', 'Left'];
407
+ const sides = names.map((side) => ({
408
+ side,
409
+ width: cssPx(/** @type {string} */ (style[`border${side}Width`])),
410
+ style: /** @type {string} */ (style[`border${side}Style`]),
411
+ color: parseColor(/** @type {string} */ (style[`border${side}Color`])),
412
+ }));
413
+ const drawn = sides.filter((s) => s.width > 0 && s.style !== 'none' && s.style !== 'hidden' && s.color && s.color.a > 0);
414
+ if (!drawn.length) return;
415
+
416
+ // 角丸: 4 辺が同じ幅・色・スタイルなら角丸パスをストロークする
417
+ if (radius && radius.some((v) => v > 0)) {
418
+ const f = /** @type {typeof drawn[0]} */ (drawn[0]);
419
+ const uniform =
420
+ drawn.length === 4 &&
421
+ drawn.every((s) => s.width === f.width && s.style === f.style && JSON.stringify(s.color) === JSON.stringify(f.color));
422
+ if (uniform) {
423
+ const bw = f.width;
424
+ out.push({
425
+ type: 'stroke-rrect',
426
+ x: x + bw / 2,
427
+ y: y + bw / 2,
428
+ w: w - bw,
429
+ h: h - bw,
430
+ radius: /** @type {Radius} */ (radius.map((v) => Math.max(0, v - bw / 2))),
431
+ width: bw,
432
+ color: withAlpha(/** @type {Rgba} */ (f.color), alpha),
433
+ dash: dashFor(f.style, bw),
434
+ z,
435
+ seq: seq++,
436
+ });
437
+ return;
438
+ }
439
+ warnOnce('css:borderRadiusNonUniform', {
440
+ code: 'unsupported-css',
441
+ message: 'border-radius with non-uniform borders is approximated with straight borders',
442
+ property: 'border-radius',
443
+ });
444
+ }
445
+
446
+ for (const s of drawn) {
447
+ const bw = s.width;
448
+ const c = withAlpha(/** @type {Rgba} */ (s.color), alpha);
449
+ const horizontal = s.side === 'Top' || s.side === 'Bottom';
450
+ const dir = s.side === 'Top' || s.side === 'Left' ? 1 : -1;
451
+ const edge = s.side === 'Top' ? y : s.side === 'Bottom' ? y + h : s.side === 'Left' ? x : x + w;
452
+ const dash = dashFor(s.style, bw);
453
+ if (dash) {
454
+ const center = collapse ? edge : edge + (bw / 2) * dir;
455
+ out.push({
456
+ type: 'line',
457
+ x1: horizontal ? x : center,
458
+ y1: horizontal ? center : y,
459
+ x2: horizontal ? x + w : center,
460
+ y2: horizontal ? center : y + h,
461
+ width: bw,
462
+ color: c,
463
+ dash,
464
+ z,
465
+ seq: seq++,
466
+ });
467
+ continue;
468
+ }
469
+ // solid / double / groove / ridge / inset / outset は塗り矩形で近似
470
+ const start = collapse ? edge - bw / 2 : dir > 0 ? edge : edge - bw;
471
+ if (horizontal) out.push({ type: 'rect', x, y: start, w, h: bw, color: c, z, seq: seq++ });
472
+ else out.push({ type: 'rect', x: start, y, w: bw, h, color: c, z, seq: seq++ });
473
+ }
474
+ }
475
+
476
+ /**
477
+ * @param {Text} node
478
+ * @param {Element} parent
479
+ * @param {CSSStyleDeclaration} style
480
+ * @param {Inherited} inh
481
+ */
482
+ function paintText(node, parent, style, inh) {
483
+ const raw = node.data;
484
+ if (!raw) return;
485
+ if (!/\S/.test(raw) && !raw.includes(' ')) {
486
+ const range = node.ownerDocument.createRange();
487
+ range.selectNodeContents(node);
488
+ if (![...range.getClientRects()].some((r) => r.width > 0)) return;
489
+ }
490
+
491
+ const color = withAlpha(parseColor(style.color) ?? { r: 0, g: 0, b: 0, a: 1 }, inh.alpha);
492
+ const size = cssPx(style.fontSize);
493
+ if (size <= 0) return;
494
+ const families = splitFamilies(style.fontFamily);
495
+ const weight = parseWeight(style.fontWeight);
496
+ const fstyle = /** @type {'normal'|'italic'} */ (style.fontStyle === 'italic' || style.fontStyle === 'oblique' ? 'italic' : 'normal');
497
+
498
+ const primary = ctx.registry.match(families, weight, fstyle) ?? ctx.registry.match(ctx.fontFallback, weight, fstyle);
499
+ if (!primary) {
500
+ const key = families.join(',');
501
+ if (!missingFamilies.has(key)) {
502
+ missingFamilies.add(key);
503
+ ctx.warn({
504
+ code: 'missing-font',
505
+ message: `No registered font matches font-family "${style.fontFamily}" and no fallback is available; text will be skipped`,
506
+ element: parent,
507
+ });
508
+ }
509
+ return;
510
+ }
511
+
512
+ const lines = measureText(node, style, {
513
+ registry: ctx.registry,
514
+ families,
515
+ fallback: ctx.fontFallback,
516
+ primary,
517
+ weight,
518
+ fstyle,
519
+ size,
520
+ textMeasure: ctx.textMeasure,
521
+ warn: ctx.warn,
522
+ element: parent,
523
+ });
524
+
525
+ for (const line of lines) {
526
+ if (!line.glyphs.length) continue;
527
+ atoms.push({ top: line.top + sy, bottom: line.bottom + sy });
528
+ out.push({
529
+ type: 'text',
530
+ x: line.glyphs[0]?.x ?? 0,
531
+ y: line.baseline + sy,
532
+ top: line.top + sy,
533
+ bottom: line.bottom + sy,
534
+ size,
535
+ color,
536
+ font: line.font,
537
+ glyphs: line.glyphs.map((g) => ({ ...g, x: g.x + sx })),
538
+ z: inh.z,
539
+ seq: seq++,
540
+ });
541
+ for (const deco of inh.decorations) {
542
+ const first = line.glyphs[0];
543
+ const last = line.glyphs[line.glyphs.length - 1];
544
+ if (!first || !last) continue;
545
+ const x1 = first.x + sx;
546
+ const x2 = last.x + last.advance + sx;
547
+ const thickness = Math.max(1, size / 14);
548
+ const c = withAlpha(deco.color, inh.alpha);
549
+ if (deco.line.includes('underline')) {
550
+ out.push({ type: 'rect', x: x1, y: line.baseline + sy + size * 0.08, w: x2 - x1, h: thickness, color: c, z: inh.z, seq: seq++ });
551
+ }
552
+ if (deco.line.includes('line-through')) {
553
+ out.push({ type: 'rect', x: x1, y: line.baseline + sy - size * 0.3, w: x2 - x1, h: thickness, color: c, z: inh.z, seq: seq++ });
554
+ }
555
+ }
556
+ }
557
+ }
558
+
559
+ await visit(root, { z: 0, alpha: 1, decorations: [] });
560
+ sortItems(rootItems);
561
+ // 文書の高さ: body の下端と、はみ出した命令(絶対配置など)の下端の大きい方。
562
+ // body.scrollHeight はビューポート高さに膨らむことがあるので使わない。
563
+ let height = root.getBoundingClientRect().bottom + sy;
564
+ for (const it of rootItems) {
565
+ if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') height = Math.max(height, it.y + it.h);
566
+ else if (it.type === 'line') height = Math.max(height, it.y1, it.y2);
567
+ else if (it.type === 'text' || it.type === 'group' || it.type === 'clip') height = Math.max(height, it.bottom);
568
+ }
569
+ return { items: rootItems, atoms, breaks, tables, height };
570
+ }
571
+
572
+ /** @param {DisplayItem[]} items */
573
+ function sortItems(items) {
574
+ items.sort((a, b) => a.z - b.z || a.seq - b.seq);
575
+ for (const it of items) if (it.type === 'group' || it.type === 'clip') sortItems(it.items);
576
+ }
577
+
578
+ /**
579
+ * @param {string} style border-style
580
+ * @param {number} bw
581
+ * @returns {number[]|null}
582
+ */
583
+ function dashFor(style, bw) {
584
+ if (style === 'dashed') return [bw * 3, bw * 3];
585
+ if (style === 'dotted') return [bw, bw];
586
+ return null;
587
+ }
588
+
589
+ /**
590
+ * computed border-*-radius('4px' / '4px 6px' / '50%')を px の Radius にする。
591
+ * 楕円は水平方向の半径で近似する。
592
+ * @param {CSSStyleDeclaration} style
593
+ * @param {DOMRect} r
594
+ * @returns {Radius|null}
595
+ */
596
+ function parseRadius(style, r) {
597
+ const one = (/** @type {string} */ v) => {
598
+ const first = v.trim().split(/\s+/)[0] ?? '0px';
599
+ return first.endsWith('%') ? (parseFloat(first) / 100) * r.width : cssPx(first);
600
+ };
601
+ const radius = /** @type {Radius} */ ([
602
+ one(style.borderTopLeftRadius),
603
+ one(style.borderTopRightRadius),
604
+ one(style.borderBottomRightRadius),
605
+ one(style.borderBottomLeftRadius),
606
+ ]);
607
+ return radius.some((v) => v > 0) ? radius : null;
608
+ }
609
+
610
+ /**
611
+ * computed transform('matrix(a, b, c, d, e, f)')を解析する。none / 3D は null。
612
+ * @param {string} value
613
+ * @returns {[number, number, number, number, number, number]|null}
614
+ */
615
+ export function parseTransform(value) {
616
+ if (!value || value === 'none') return null;
617
+ const m = /^matrix\(([^)]+)\)$/.exec(value.trim());
618
+ if (!m) return null;
619
+ const n = /** @type {string} */ (m[1]).split(',').map((s) => parseFloat(s));
620
+ if (n.length !== 6 || n.some((v) => !Number.isFinite(v))) return null;
621
+ const [a, b, c, d, e, f] = /** @type {[number, number, number, number, number, number]} */ (n);
622
+ if (a === 1 && b === 0 && c === 0 && d === 1 && e === 0 && f === 0) return null;
623
+ return [a, b, c, d, e, f];
624
+ }
625
+
626
+ /**
627
+ * computed transform-origin('40px 20px' または 3 値)→ [x, y] px
628
+ * @param {string} value
629
+ * @returns {[number, number]}
630
+ */
631
+ function parseOrigin(value) {
632
+ const parts = value.trim().split(/\s+/);
633
+ return [cssPx(parts[0] ?? '0'), cssPx(parts[1] ?? '0')];
634
+ }
635
+
636
+ /**
637
+ * 命令の外接矩形がボックスと重なるか(クリップで完全に消える命令の除去に使う)。
638
+ * @param {DisplayItem} it
639
+ * @param {Box} b
640
+ */
641
+ function intersects(it, b) {
642
+ let x1;
643
+ let y1;
644
+ let x2;
645
+ let y2;
646
+ if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') {
647
+ x1 = it.x; y1 = it.y; x2 = it.x + it.w; y2 = it.y + it.h;
648
+ } else if (it.type === 'line') {
649
+ x1 = Math.min(it.x1, it.x2) - it.width; y1 = Math.min(it.y1, it.y2) - it.width;
650
+ x2 = Math.max(it.x1, it.x2) + it.width; y2 = Math.max(it.y1, it.y2) + it.width;
651
+ } else if (it.type === 'text') {
652
+ const last = it.glyphs[it.glyphs.length - 1];
653
+ x1 = it.x; y1 = it.top; x2 = last ? last.x + last.advance : it.x; y2 = it.bottom;
654
+ } else if (it.type === 'clip') {
655
+ x1 = it.box.x; y1 = it.box.y; x2 = it.box.x + it.box.w; y2 = it.box.y + it.box.h;
656
+ } else {
657
+ return true; // group(transform)は境界が回転するので常に残す
658
+ }
659
+ return x2 > b.x && x1 < b.x + b.w && y2 > b.y && y1 < b.y + b.h;
660
+ }
661
+
662
+ /** @param {Rgba} c @param {number} alpha */
663
+ function withAlpha(c, alpha) {
664
+ return alpha === 1 ? c : { ...c, a: c.a * alpha };
665
+ }
666
+
667
+ /** @param {string} s */
668
+ function camelToKebab(s) {
669
+ return s.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
670
+ }