@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/CHANGELOG.md +36 -0
- package/README.md +45 -6
- package/dist/receipt-html-to-pdf.min.js +14 -11
- package/dist/receipt-html-to-pdf.min.js.map +3 -3
- package/examples/cdn.html +110 -0
- package/examples/lit.js +7 -4
- package/package.json +5 -2
- package/skills/receipt-html-to-pdf/SKILL.md +151 -0
- package/skills/receipt-html-to-pdf/references/receipt-format.md +56 -0
- package/skills/receipt-html-to-pdf/references/troubleshooting.md +45 -0
- package/src/index.js +1 -1
- package/src/renderer.js +232 -8
- package/src/walker/walk.js +23 -1
- package/types/index.d.ts +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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。
|
package/src/walker/walk.js
CHANGED
|
@@ -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
|
|
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
|
|
96
|
+
export const version: "0.2.1";
|
|
97
97
|
/**
|
|
98
98
|
* 登録するフォントの定義。
|
|
99
99
|
* `src` は TrueType アウトライン(glyf)を持つ静的 TTF のみ対応。
|