@hidemikimura/receipt-html-to-pdf 0.1.0 → 0.2.1

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/src/renderer.js CHANGED
@@ -32,6 +32,10 @@ export async function renderDocument(input, opts) {
32
32
  doc.write(html);
33
33
  doc.close();
34
34
 
35
+ // 宣言的シャドウ DOM で復元した要素はアップグレードされないため :defined がマッチしない。
36
+ // 中身の無いスタブを定義してマッチさせる(描画には影響しない)。
37
+ defineStubElements(doc, win);
38
+
35
39
  // 高さを内容に合わせる(スクロールが発生しないようにする)
36
40
  const fit = () => {
37
41
  iframe.style.height = `${Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight, 100)}px`;
@@ -46,6 +50,7 @@ export async function renderDocument(input, opts) {
46
50
  fit();
47
51
  // レイアウトを確定させる
48
52
  void doc.body.offsetHeight;
53
+ warnIfTooWide(doc, opts.widthPx, opts.warn ?? (() => {}));
49
54
 
50
55
  return {
51
56
  iframe,
@@ -58,13 +63,15 @@ export async function renderDocument(input, opts) {
58
63
 
59
64
  /**
60
65
  * @param {import('./index.js').ConvertInput} input
61
- * @param {{stylesheets: 'inherit'|'none'|string[], mediaPrint: boolean, baseUrl?: string}} opts
66
+ * @param {{stylesheets: 'inherit'|'none'|string[], mediaPrint: boolean, baseUrl?: string, warn?: (w: import('./index.js').ConversionWarning) => void}} opts
62
67
  * @returns {string}
63
68
  */
64
69
  function buildHtml(input, opts) {
65
70
  const baseUrl = opts.baseUrl ?? document.baseURI;
66
71
  const base = `<base href="${escapeAttr(baseUrl)}">`;
67
- const reset = `<style data-rhtp-reset>html,body{margin:0;padding:0;background:transparent}html{-webkit-text-size-adjust:100%}</style>`;
72
+ // 親文書の body マージンが PDF に持ち込まれると内容が右へずれて右端が切れるので、
73
+ // !important で確実に打ち消す(用紙の余白は options.page.margin が受け持つ)。
74
+ const reset = `<style data-rhtp-reset>html,body{margin:0 !important;padding:0 !important;background:transparent}html{-webkit-text-size-adjust:100%}</style>`;
68
75
 
69
76
  if (typeof input === 'string') {
70
77
  // 完全な HTML 文書ならそのまま。<head> の直後に base とリセットを差し込む。
@@ -81,10 +88,10 @@ function buildHtml(input, opts) {
81
88
 
82
89
  // 要素: outerHTML と親文書のスタイルを持ち込む。
83
90
  // <html> / <body> の属性(class, lang, data-* …)も写し、`body.reissue .x` のような祖先依存のセレクタを効かせる。
84
- const styles = collectStyles(opts.stylesheets, opts.mediaPrint);
91
+ const styles = collectStyles(opts.stylesheets, opts.mediaPrint) + shadowHostStyles(input, opts.stylesheets, opts.mediaPrint);
85
92
  const htmlAttrs = copyAttrs(document.documentElement);
86
93
  const bodyAttrs = copyAttrs(document.body);
87
- const body = input === document.body || input === document.documentElement ? document.body.innerHTML : input.outerHTML;
94
+ const body = serializeElement(input, opts.warn ?? (() => {}));
88
95
  return `<!DOCTYPE html><html${htmlAttrs}><head><meta charset="utf-8">${base}${reset}${styles}</head><body${bodyAttrs}>${body}</body></html>`;
89
96
  }
90
97
 
@@ -107,6 +114,9 @@ function collectStyles(stylesheets, mediaPrint) {
107
114
  parts.push(`<link rel="stylesheet" href="${escapeAttr(el.href)}"${el.media ? ` media="${escapeAttr(el.media)}"` : ''}>`);
108
115
  }
109
116
  }
117
+ // document.adoptedStyleSheets(CSSOM で組み立てたスタイル)は <style> に出てこないので取り出す
118
+ const adopted = cssTextOf(document.adoptedStyleSheets);
119
+ if (adopted) parts.push(`<style>${mediaPrint ? expandPrintMediaCss(adopted) : adopted}</style>`);
110
120
  return parts.join('');
111
121
  }
112
122
  for (const s of stylesheets) {
@@ -119,6 +129,193 @@ function collectStyles(stylesheets, mediaPrint) {
119
129
  return parts.join('');
120
130
  }
121
131
 
132
+ /**
133
+ * シャドウツリーの中の要素を渡されたとき、その要素が属するシャドウルート
134
+ * (およびその外側のシャドウルート)のスタイルを取り出す。
135
+ * 文書のスタイルシートはシャドウツリーに届かないため、`stylesheets: 'inherit'` では拾えない。
136
+ *
137
+ * @param {Element} input
138
+ * @param {'inherit'|'none'|string[]} stylesheets
139
+ * @param {boolean} mediaPrint
140
+ * @returns {string}
141
+ */
142
+ function shadowHostStyles(input, stylesheets, mediaPrint) {
143
+ if (stylesheets !== 'inherit') return '';
144
+ /** @type {string[]} */
145
+ const parts = [];
146
+ let node = /** @type {Node|null} */ (input.getRootNode());
147
+ // 内側のツリーから順に集め、外側ほど先(優先度が低い位置)に置く
148
+ while (node && node !== document && 'host' in node) {
149
+ const root = /** @type {ShadowRoot} */ (node);
150
+ const css = [...root.querySelectorAll('style')].map((el) => el.textContent ?? '').join('\n') + '\n' + cssTextOf(root.adoptedStyleSheets);
151
+ if (css.trim()) parts.unshift(css);
152
+ node = root.host.getRootNode();
153
+ }
154
+ return parts.map((css) => `<style>${mediaPrint ? expandPrintMediaCss(css) : css}</style>`).join('');
155
+ }
156
+
157
+ /**
158
+ * 内容が本文領域より横に広いと、右側が切れたまま気づかれにくいので警告する。
159
+ * 固定幅 + `box-sizing: content-box` や、畳めない表が原因になりやすい。
160
+ *
161
+ * @param {Document} doc
162
+ * @param {number} widthPx 本文領域の幅(px)
163
+ * @param {(w: import('./index.js').ConversionWarning) => void} warn
164
+ */
165
+ function warnIfTooWide(doc, widthPx, warn) {
166
+ const width = Math.max(doc.documentElement.scrollWidth, doc.body.scrollWidth);
167
+ const over = width - widthPx;
168
+ // 1px 未満は丸め誤差とみなす
169
+ if (over < 1) return;
170
+ const mm = (/** @type {number} */ px) => Math.round((px / 96) * 25.4 * 10) / 10;
171
+ warn({
172
+ code: 'other',
173
+ message:
174
+ `Content is ${Math.round(over)}px (${mm(over)}mm) wider than the page content area ` +
175
+ `(${Math.round(width)}px vs ${Math.round(widthPx)}px); the right side will be clipped. ` +
176
+ 'Common causes: a fixed width plus padding/border without box-sizing: border-box, or a table that cannot shrink.',
177
+ });
178
+ }
179
+
180
+ /** シャドウルートを持てない要素(void 要素)。outerHTML にフォールバックする。 */
181
+ const VOID_TAGS = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR']);
182
+
183
+ /**
184
+ * 要素を outerHTML 相当の文字列にする。開いているシャドウルートは
185
+ * `<template shadowrootmode>`(宣言的シャドウ DOM)として一緒に直列化し、
186
+ * iframe 側のパーサに本物の ShadowRoot として復元させる。
187
+ *
188
+ * `adoptedStyleSheets` は直列化されないので、一時的に `<style>` として差し込んでから直列化する
189
+ * (同期処理のうちに元へ戻すので画面には影響しない)。
190
+ *
191
+ * @param {Element} input
192
+ * @param {(w: import('./index.js').ConversionWarning) => void} warn
193
+ * @returns {string}
194
+ */
195
+ function serializeElement(input, warn) {
196
+ const whole = input === document.body || input === document.documentElement;
197
+ const roots = collectShadowRoots(whole ? document.body : input);
198
+ if (!roots.length) return whole ? document.body.innerHTML : input.outerHTML;
199
+
200
+ const host = whole ? document.body : input;
201
+ if (typeof (/** @type {any} */ (host).getHTML) !== 'function') {
202
+ warn({
203
+ code: 'unsupported-css',
204
+ message:
205
+ 'This browser lacks Element.getHTML(); shadow DOM content cannot be serialized and will be missing from the PDF. ' +
206
+ 'Pass an element inside the shadow root instead.',
207
+ element: input,
208
+ });
209
+ return whole ? document.body.innerHTML : input.outerHTML;
210
+ }
211
+
212
+ const restore = inlineAdoptedStyleSheets(roots);
213
+ try {
214
+ const opts = { serializableShadowRoots: true, shadowRoots: roots };
215
+ const inner = /** @type {string} */ (/** @type {any} */ (host).getHTML(opts));
216
+ if (whole) return inner;
217
+ const tag = input.tagName.toLowerCase();
218
+ // void 要素はシャドウルートも子も持たないので通常の outerHTML でよい
219
+ if (VOID_TAGS.has(input.tagName)) return input.outerHTML;
220
+ return `<${tag}${copyAttrs(input)}>${inner}</${tag}>`;
221
+ } finally {
222
+ restore();
223
+ }
224
+ }
225
+
226
+ /**
227
+ * root 以下(シャドウツリーの中も含む)の open なシャドウルートを集める。
228
+ * closed なシャドウルートは参照できないため対象外。
229
+ * @param {Element|ShadowRoot} root
230
+ * @returns {ShadowRoot[]}
231
+ */
232
+ function collectShadowRoots(root) {
233
+ /** @type {ShadowRoot[]} */
234
+ const out = [];
235
+ /** @param {Element|ShadowRoot} node */
236
+ const walk = (node) => {
237
+ if (node instanceof Element && node.shadowRoot) {
238
+ out.push(node.shadowRoot);
239
+ walk(node.shadowRoot);
240
+ }
241
+ for (const child of node.children) walk(child);
242
+ };
243
+ walk(root);
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * 各シャドウルートの adoptedStyleSheets を一時的に `<style>` として差し込む。
249
+ * 戻り値を呼ぶと取り除く。
250
+ * @param {ShadowRoot[]} roots
251
+ * @returns {() => void}
252
+ */
253
+ function inlineAdoptedStyleSheets(roots) {
254
+ /** @type {HTMLStyleElement[]} */
255
+ const added = [];
256
+ for (const root of roots) {
257
+ const css = cssTextOf(root.adoptedStyleSheets);
258
+ if (!css) continue;
259
+ const style = document.createElement('style');
260
+ style.setAttribute('data-rhtp-adopted', '');
261
+ style.textContent = css;
262
+ root.insertBefore(style, root.firstChild);
263
+ added.push(style);
264
+ }
265
+ return () => {
266
+ for (const style of added) style.remove();
267
+ };
268
+ }
269
+
270
+ /**
271
+ * CSSStyleSheet の配列を CSS テキストにする。読めないもの(クロスオリジン)は飛ばす。
272
+ * @param {readonly CSSStyleSheet[]|undefined} sheets
273
+ * @returns {string}
274
+ */
275
+ function cssTextOf(sheets) {
276
+ if (!sheets || !sheets.length) return '';
277
+ const parts = [];
278
+ for (const sheet of sheets) {
279
+ try {
280
+ for (const rule of sheet.cssRules) parts.push(rule.cssText);
281
+ } catch {
282
+ // クロスオリジンのスタイルシートは読めない
283
+ }
284
+ }
285
+ return parts.join('\n');
286
+ }
287
+
288
+ /**
289
+ * iframe 内に、文書に出てくるカスタム要素名の空のスタブを定義する。
290
+ * これをしないと要素が未定義のままで `:defined` がマッチせず、既定の `display: inline` で組まれてしまう。
291
+ * @param {Document} doc
292
+ * @param {Window} win
293
+ */
294
+ function defineStubElements(doc, win) {
295
+ const registry = /** @type {CustomElementRegistry|undefined} */ (/** @type {any} */ (win).customElements);
296
+ const Base = /** @type {typeof HTMLElement|undefined} */ (/** @type {any} */ (win).HTMLElement);
297
+ if (!registry || !Base) return;
298
+ /** @type {Set<string>} */
299
+ const names = new Set();
300
+ /** @param {ParentNode} node */
301
+ const scan = (node) => {
302
+ for (const el of node.querySelectorAll('*')) {
303
+ // `is=` によるカスタマイズド組み込み要素は対象外(定義に extends が必要なため)
304
+ if (el.tagName.includes('-') && !el.hasAttribute('is')) names.add(el.tagName.toLowerCase());
305
+ if (el.shadowRoot) scan(el.shadowRoot);
306
+ }
307
+ };
308
+ scan(doc);
309
+ for (const name of names) {
310
+ if (registry.get(name)) continue;
311
+ try {
312
+ registry.define(name, class extends Base {});
313
+ } catch {
314
+ // 不正な名前などは無視する
315
+ }
316
+ }
317
+ }
318
+
122
319
  /**
123
320
  * HTML 文字列中の <style> 内の @media print を展開する。
124
321
  * @param {string} html
@@ -239,7 +436,7 @@ export function materializePseudoElements(doc, warn) {
239
436
  if (!win) return;
240
437
  /** @type {{el: Element, pseudo: 'before'|'after', text: string, styles: [string, string][]}[]} */
241
438
  const jobs = [];
242
- for (const el of doc.body.querySelectorAll('*')) {
439
+ for (const el of deepQueryAll(doc.body)) {
243
440
  for (const pseudo of /** @type {('before'|'after')[]} */ (['before', 'after'])) {
244
441
  const ps = win.getComputedStyle(el, `::${pseudo}`);
245
442
  const content = ps.content;
@@ -266,12 +463,20 @@ export function materializePseudoElements(doc, warn) {
266
463
  }
267
464
  if (!jobs.length) return;
268
465
 
269
- const style = doc.createElement('style');
270
- style.setAttribute('data-rhtp-pseudo', '');
271
- style.textContent =
466
+ const disableCss =
272
467
  '[data-rhtp-pseudo-host~="before"]::before{content:none!important;display:none!important}' +
273
468
  '[data-rhtp-pseudo-host~="after"]::after{content:none!important;display:none!important}';
469
+ const style = doc.createElement('style');
470
+ style.setAttribute('data-rhtp-pseudo', '');
471
+ style.textContent = disableCss;
274
472
  doc.head.appendChild(style);
473
+ // 文書のスタイルはシャドウツリーに届かないので、対象を含むシャドウルートにも同じ規則を入れる
474
+ for (const root of new Set(jobs.map((j) => j.el.getRootNode()).filter((r) => r !== doc))) {
475
+ const inner = doc.createElement('style');
476
+ inner.setAttribute('data-rhtp-pseudo', '');
477
+ inner.textContent = disableCss;
478
+ /** @type {ShadowRoot} */ (root).appendChild(inner);
479
+ }
275
480
 
276
481
  for (const job of jobs) {
277
482
  const span = doc.createElement('span');
@@ -286,6 +491,25 @@ export function materializePseudoElements(doc, warn) {
286
491
  }
287
492
  }
288
493
 
494
+ /**
495
+ * root 以下のすべての要素を、シャドウツリーの中も含めて列挙する。
496
+ * @param {Element|ShadowRoot} root
497
+ * @returns {Element[]}
498
+ */
499
+ function deepQueryAll(root) {
500
+ /** @type {Element[]} */
501
+ const out = [];
502
+ /** @param {Element|ShadowRoot} node */
503
+ const walk = (node) => {
504
+ for (const el of node.querySelectorAll('*')) {
505
+ out.push(el);
506
+ if (el.shadowRoot) walk(el.shadowRoot);
507
+ }
508
+ };
509
+ walk(root);
510
+ return out;
511
+ }
512
+
289
513
  /**
290
514
  * computed content 値('"※ "' / '"a" "b"' / 'counter(x)' …)から文字列を取り出す。
291
515
  * 文字列以外のトークンが含まれる場合は null。
@@ -38,6 +38,28 @@ import { loadImage, parseBackgroundUrl, fitImage, objectFitToSize } from './imag
38
38
 
39
39
  const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'META', 'LINK', 'TITLE', 'BASE', 'IFRAME', 'CANVAS', 'VIDEO', 'AUDIO', 'SVG', 'OBJECT', 'EMBED']);
40
40
 
41
+ /**
42
+ * flat tree(シャドウ DOM を展開した木)での子ノードを返す。
43
+ *
44
+ * - シャドウホスト → シャドウルートの子(light DOM の子は `<slot>` 経由で現れる)
45
+ * - `<slot>` → 割り当てられたノード(無ければフォールバック内容)
46
+ * - それ以外 → 通常の子ノード
47
+ *
48
+ * @param {Element} el
49
+ * @returns {ChildNode[]}
50
+ */
51
+ function flatChildNodes(el) {
52
+ const shadow = el.shadowRoot;
53
+ if (shadow) return [...shadow.childNodes];
54
+ if (el.tagName === 'SLOT') {
55
+ const slot = /** @type {HTMLSlotElement} */ (/** @type {unknown} */ (el));
56
+ if (typeof slot.assignedNodes === 'function') {
57
+ return /** @type {ChildNode[]} */ (slot.assignedNodes({ flatten: true }));
58
+ }
59
+ }
60
+ return [...el.childNodes];
61
+ }
62
+
41
63
  /** 未対応 CSS プロパティ: [computedStyle のキー, 「指定されている」判定] */
42
64
  const UNSUPPORTED = /** @type {[keyof CSSStyleDeclaration & string, (v: string) => boolean][]} */ ([
43
65
  ['boxShadow', (v) => v !== 'none'],
@@ -225,7 +247,7 @@ export async function walk(root, ctx) {
225
247
  out = [];
226
248
  }
227
249
  const next = { z, alpha, decorations };
228
- for (const node of [...el.childNodes]) {
250
+ for (const node of flatChildNodes(el)) {
229
251
  if (node.nodeType === Node.TEXT_NODE) {
230
252
  if (visible) paintText(/** @type {Text} */ (node), el, style, next);
231
253
  } else if (node.nodeType === Node.ELEMENT_NODE) {
package/types/index.d.ts CHANGED
@@ -93,7 +93,7 @@ export { expandPrintMediaCss } from "./renderer.js";
93
93
  * @typedef {Element|string} ConvertInput
94
94
  */
95
95
  /** ライブラリのバージョン(package.json と同期) */
96
- export const version: "0.1.0";
96
+ export const version: "0.2.1";
97
97
  /**
98
98
  * 登録するフォントの定義。
99
99
  * `src` は TrueType アウトライン(glyf)を持つ静的 TTF のみ対応。