@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,328 @@
1
+ // @ts-check
2
+ /**
3
+ * 非表示 iframe に HTML を描画し、レイアウト計測可能な Document を用意する。
4
+ */
5
+
6
+ /**
7
+ * @typedef {object} RenderedDocument
8
+ * @property {HTMLIFrameElement} iframe
9
+ * @property {Document} doc
10
+ * @property {Window} win
11
+ * @property {HTMLElement} root 走査の起点(body)
12
+ * @property {() => void} destroy
13
+ */
14
+
15
+ /**
16
+ * @param {import('./index.js').ConvertInput} input
17
+ * @param {{widthPx: number, stylesheets: 'inherit'|'none'|string[], mediaPrint: boolean, baseUrl?: string, warn?: (w: import('./index.js').ConversionWarning) => void}} opts
18
+ * @returns {Promise<RenderedDocument>}
19
+ */
20
+ export async function renderDocument(input, opts) {
21
+ const iframe = document.createElement('iframe');
22
+ iframe.setAttribute('aria-hidden', 'true');
23
+ iframe.style.cssText = `position:fixed;left:-100000px;top:0;width:${opts.widthPx}px;height:1000px;border:0;visibility:hidden;pointer-events:none;`;
24
+ document.body.appendChild(iframe);
25
+
26
+ const win = iframe.contentWindow;
27
+ const doc = iframe.contentDocument;
28
+ if (!win || !doc) throw new Error('Failed to create rendering iframe');
29
+
30
+ const html = buildHtml(input, opts);
31
+ doc.open();
32
+ doc.write(html);
33
+ doc.close();
34
+
35
+ // 高さを内容に合わせる(スクロールが発生しないようにする)
36
+ const fit = () => {
37
+ iframe.style.height = `${Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight, 100)}px`;
38
+ };
39
+ fit();
40
+
41
+ await waitForStylesheets(doc);
42
+ fit();
43
+ await waitForFonts(doc);
44
+ await waitForImages(doc);
45
+ materializePseudoElements(doc, opts.warn ?? (() => {}));
46
+ fit();
47
+ // レイアウトを確定させる
48
+ void doc.body.offsetHeight;
49
+
50
+ return {
51
+ iframe,
52
+ doc,
53
+ win,
54
+ root: doc.body,
55
+ destroy: () => iframe.remove(),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * @param {import('./index.js').ConvertInput} input
61
+ * @param {{stylesheets: 'inherit'|'none'|string[], mediaPrint: boolean, baseUrl?: string}} opts
62
+ * @returns {string}
63
+ */
64
+ function buildHtml(input, opts) {
65
+ const baseUrl = opts.baseUrl ?? document.baseURI;
66
+ 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>`;
68
+
69
+ if (typeof input === 'string') {
70
+ // 完全な HTML 文書ならそのまま。<head> の直後に base とリセットを差し込む。
71
+ if (/<html[\s>]/i.test(input)) {
72
+ let html = input;
73
+ html = /<head[^>]*>/i.test(html)
74
+ ? html.replace(/<head[^>]*>/i, (m) => `${m}${base}${reset}`)
75
+ : html.replace(/<html[^>]*>/i, (m) => `${m}<head>${base}${reset}</head>`);
76
+ return opts.mediaPrint ? expandPrintMedia(html) : html;
77
+ }
78
+ const styles = collectStyles(opts.stylesheets, opts.mediaPrint);
79
+ return `<!DOCTYPE html><html><head><meta charset="utf-8">${base}${reset}${styles}</head><body>${input}</body></html>`;
80
+ }
81
+
82
+ // 要素: outerHTML と親文書のスタイルを持ち込む。
83
+ // <html> / <body> の属性(class, lang, data-* …)も写し、`body.reissue .x` のような祖先依存のセレクタを効かせる。
84
+ const styles = collectStyles(opts.stylesheets, opts.mediaPrint);
85
+ const htmlAttrs = copyAttrs(document.documentElement);
86
+ const bodyAttrs = copyAttrs(document.body);
87
+ const body = input === document.body || input === document.documentElement ? document.body.innerHTML : input.outerHTML;
88
+ return `<!DOCTYPE html><html${htmlAttrs}><head><meta charset="utf-8">${base}${reset}${styles}</head><body${bodyAttrs}>${body}</body></html>`;
89
+ }
90
+
91
+ /**
92
+ * @param {'inherit'|'none'|string[]} stylesheets
93
+ * @param {boolean} mediaPrint
94
+ * @returns {string}
95
+ */
96
+ function collectStyles(stylesheets, mediaPrint) {
97
+ if (stylesheets === 'none') return '';
98
+ /** @type {string[]} */
99
+ const parts = [];
100
+ if (stylesheets === 'inherit') {
101
+ for (const el of document.querySelectorAll('style, link[rel~="stylesheet"]')) {
102
+ if (el.hasAttribute('data-rhtp-reset')) continue;
103
+ if (el instanceof HTMLStyleElement) {
104
+ const css = el.textContent ?? '';
105
+ parts.push(`<style>${mediaPrint ? expandPrintMediaCss(css) : css}</style>`);
106
+ } else if (el instanceof HTMLLinkElement) {
107
+ parts.push(`<link rel="stylesheet" href="${escapeAttr(el.href)}"${el.media ? ` media="${escapeAttr(el.media)}"` : ''}>`);
108
+ }
109
+ }
110
+ return parts.join('');
111
+ }
112
+ for (const s of stylesheets) {
113
+ if (/^(https?:)?\/\/|^\.{0,2}\/|\.css(\?|$)/i.test(s) && !s.includes('{')) {
114
+ parts.push(`<link rel="stylesheet" href="${escapeAttr(s)}">`);
115
+ } else {
116
+ parts.push(`<style>${mediaPrint ? expandPrintMediaCss(s) : s}</style>`);
117
+ }
118
+ }
119
+ return parts.join('');
120
+ }
121
+
122
+ /**
123
+ * HTML 文字列中の <style> 内の @media print を展開する。
124
+ * @param {string} html
125
+ */
126
+ function expandPrintMedia(html) {
127
+ return html.replace(/<style([^>]*)>([\s\S]*?)<\/style>/gi, (_m, attrs, css) => `<style${attrs}>${expandPrintMediaCss(css)}</style>`);
128
+ }
129
+
130
+ /**
131
+ * `@media print { ... }` ブロックを通常ルールとして展開し、`@media screen { ... }` を除去する。
132
+ * 単純な括弧の対応で処理する(ネストした @media は想定しない)。
133
+ * @param {string} css
134
+ * @returns {string}
135
+ */
136
+ export function expandPrintMediaCss(css) {
137
+ let out = '';
138
+ let i = 0;
139
+ while (i < css.length) {
140
+ const m = /@media\s*([^{]+)\{/g;
141
+ m.lastIndex = i;
142
+ const hit = m.exec(css);
143
+ if (!hit) {
144
+ out += css.slice(i);
145
+ break;
146
+ }
147
+ out += css.slice(i, hit.index);
148
+ // ブロックの終わりを探す
149
+ let depth = 1;
150
+ let j = m.lastIndex;
151
+ while (j < css.length && depth > 0) {
152
+ if (css[j] === '{') depth++;
153
+ else if (css[j] === '}') depth--;
154
+ j++;
155
+ }
156
+ const query = /** @type {string} */ (hit[1]).trim();
157
+ const body = css.slice(m.lastIndex, j - 1);
158
+ if (/\bprint\b/.test(query)) out += body;
159
+ else if (/^\s*(only\s+)?screen\b/.test(query) && !/\band\b/.test(query)) out += '';
160
+ else out += css.slice(hit.index, j);
161
+ i = j;
162
+ }
163
+ return out;
164
+ }
165
+
166
+ /**
167
+ * 要素の属性を ` name="value"` の並びにする(イベントハンドラ属性は除く)。
168
+ * @param {Element} el
169
+ */
170
+ function copyAttrs(el) {
171
+ let out = '';
172
+ for (const a of el.attributes) {
173
+ if (/^on/i.test(a.name)) continue;
174
+ out += ` ${a.name}="${escapeAttr(a.value)}"`;
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /** @param {string} s */
180
+ function escapeAttr(s) {
181
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
182
+ }
183
+
184
+ /** @param {Document} doc */
185
+ async function waitForStylesheets(doc) {
186
+ const links = [...doc.querySelectorAll('link[rel~="stylesheet"]')];
187
+ await Promise.all(
188
+ links.map(
189
+ (l) =>
190
+ new Promise((resolve) => {
191
+ const link = /** @type {HTMLLinkElement} */ (l);
192
+ if (link.sheet) return resolve(undefined);
193
+ link.addEventListener('load', () => resolve(undefined), { once: true });
194
+ link.addEventListener('error', () => resolve(undefined), { once: true });
195
+ setTimeout(() => resolve(undefined), 10000);
196
+ }),
197
+ ),
198
+ );
199
+ }
200
+
201
+ /** @param {Document} doc */
202
+ async function waitForFonts(doc) {
203
+ // 使用中のフォント読み込みを起動させるためにレイアウトを強制
204
+ void doc.body.offsetHeight;
205
+ if (doc.fonts) {
206
+ await doc.fonts.ready;
207
+ // ready 後に新たに読み込みが始まることがあるので、loading 状態のものを待つ
208
+ const loading = [...doc.fonts].filter((f) => f.status === 'loading').map((f) => f.loaded.catch(() => undefined));
209
+ if (loading.length) await Promise.all(loading);
210
+ await doc.fonts.ready;
211
+ }
212
+ }
213
+
214
+ /** @param {Document} doc */
215
+ async function waitForImages(doc) {
216
+ const imgs = [...doc.images];
217
+ await Promise.all(
218
+ imgs.map((img) =>
219
+ img.complete
220
+ ? img.decode().catch(() => undefined)
221
+ : new Promise((resolve) => {
222
+ img.addEventListener('load', () => img.decode().catch(() => undefined).then(resolve), { once: true });
223
+ img.addEventListener('error', () => resolve(undefined), { once: true });
224
+ setTimeout(() => resolve(undefined), 10000);
225
+ }),
226
+ ),
227
+ );
228
+ }
229
+
230
+ /**
231
+ * ::before / ::after を実体の <span> に置き換える。
232
+ * 擬似要素は DOM ノードを持たず Range で計測できないため、computed style をすべて写した span を
233
+ * 同じ位置に挿入し、元の擬似要素は content: none で消す。文字列 content のみ対応(counter / url は警告)。
234
+ * @param {Document} doc
235
+ * @param {(w: import('./index.js').ConversionWarning) => void} warn
236
+ */
237
+ export function materializePseudoElements(doc, warn) {
238
+ const win = doc.defaultView;
239
+ if (!win) return;
240
+ /** @type {{el: Element, pseudo: 'before'|'after', text: string, styles: [string, string][]}[]} */
241
+ const jobs = [];
242
+ for (const el of doc.body.querySelectorAll('*')) {
243
+ for (const pseudo of /** @type {('before'|'after')[]} */ (['before', 'after'])) {
244
+ const ps = win.getComputedStyle(el, `::${pseudo}`);
245
+ const content = ps.content;
246
+ if (!content || content === 'none' || content === 'normal' || ps.display === 'none') continue;
247
+ const text = parseContentString(content);
248
+ if (text === null) {
249
+ warn({
250
+ code: 'unsupported-css',
251
+ message: `::${pseudo} content "${content}" is not supported (only quoted strings are); the pseudo-element is skipped`,
252
+ element: el,
253
+ property: 'content',
254
+ });
255
+ continue;
256
+ }
257
+ /** @type {[string, string][]} */
258
+ const styles = [];
259
+ for (let i = 0; i < ps.length; i++) {
260
+ const prop = /** @type {string} */ (ps[i]);
261
+ if (prop === 'content' || prop.startsWith('-webkit-') || prop.startsWith('-moz-')) continue;
262
+ styles.push([prop, ps.getPropertyValue(prop)]);
263
+ }
264
+ jobs.push({ el, pseudo, text, styles });
265
+ }
266
+ }
267
+ if (!jobs.length) return;
268
+
269
+ const style = doc.createElement('style');
270
+ style.setAttribute('data-rhtp-pseudo', '');
271
+ style.textContent =
272
+ '[data-rhtp-pseudo-host~="before"]::before{content:none!important;display:none!important}' +
273
+ '[data-rhtp-pseudo-host~="after"]::after{content:none!important;display:none!important}';
274
+ doc.head.appendChild(style);
275
+
276
+ for (const job of jobs) {
277
+ const span = doc.createElement('span');
278
+ span.setAttribute('data-rhtp-pseudo', job.pseudo);
279
+ for (const [prop, value] of job.styles) span.style.setProperty(prop, value);
280
+ span.textContent = job.text;
281
+ const hosts = (job.el.getAttribute('data-rhtp-pseudo-host') ?? '').split(' ').filter(Boolean);
282
+ hosts.push(job.pseudo);
283
+ job.el.setAttribute('data-rhtp-pseudo-host', hosts.join(' '));
284
+ if (job.pseudo === 'before') job.el.insertBefore(span, job.el.firstChild);
285
+ else job.el.appendChild(span);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * computed content 値('"※ "' / '"a" "b"' / 'counter(x)' …)から文字列を取り出す。
291
+ * 文字列以外のトークンが含まれる場合は null。
292
+ * @param {string} value
293
+ * @returns {string|null}
294
+ */
295
+ export function parseContentString(value) {
296
+ let out = '';
297
+ let i = 0;
298
+ const v = value.trim();
299
+ while (i < v.length) {
300
+ const ch = v[i];
301
+ if (ch === ' ' || ch === '\t') {
302
+ i++;
303
+ continue;
304
+ }
305
+ if (ch !== '"' && ch !== "'") return null;
306
+ const quote = ch;
307
+ i++;
308
+ while (i < v.length && v[i] !== quote) {
309
+ if (v[i] === '\\') {
310
+ i++;
311
+ const hex = /^[0-9a-fA-F]{1,6}/.exec(v.slice(i));
312
+ if (hex) {
313
+ out += String.fromCodePoint(parseInt(hex[0], 16));
314
+ i += hex[0].length;
315
+ if (v[i] === ' ') i++;
316
+ } else {
317
+ out += v[i] ?? '';
318
+ i++;
319
+ }
320
+ } else {
321
+ out += v[i];
322
+ i++;
323
+ }
324
+ }
325
+ i++; // closing quote
326
+ }
327
+ return out;
328
+ }
package/src/units.js ADDED
@@ -0,0 +1,127 @@
1
+ // @ts-check
2
+ /**
3
+ * 単位・色の変換ユーティリティ。
4
+ * 内部座標は CSS px(ブラウザ計測値)で持ち、PDF 出力時に pt へ変換する。
5
+ */
6
+
7
+ /** 1 CSS px = 0.75 pt(96dpi 基準) */
8
+ export const PX_TO_PT = 0.75;
9
+ /** 1 mm = 72 / 25.4 pt */
10
+ export const MM_TO_PT = 72 / 25.4;
11
+
12
+ /** @type {Record<string, {width: number, height: number}>} 用紙サイズ(pt) */
13
+ export const PAGE_SIZES = {
14
+ A3: { width: 297 * MM_TO_PT, height: 420 * MM_TO_PT },
15
+ A4: { width: 210 * MM_TO_PT, height: 297 * MM_TO_PT },
16
+ A5: { width: 148 * MM_TO_PT, height: 210 * MM_TO_PT },
17
+ B4: { width: 257 * MM_TO_PT, height: 364 * MM_TO_PT },
18
+ B5: { width: 182 * MM_TO_PT, height: 257 * MM_TO_PT },
19
+ Letter: { width: 612, height: 792 },
20
+ Legal: { width: 612, height: 1008 },
21
+ };
22
+
23
+ /**
24
+ * CSS 長さ文字列('15mm', '1in', '12pt', '100px', '2cm')を pt に変換する。
25
+ * 単位なしの数値は px とみなす。
26
+ * @param {string|number} value
27
+ * @returns {number}
28
+ */
29
+ export function lengthToPt(value) {
30
+ if (typeof value === 'number') return value * PX_TO_PT;
31
+ const m = /^\s*(-?[\d.]+)\s*([a-z%]*)\s*$/i.exec(value);
32
+ if (!m) throw new Error(`Invalid CSS length: ${value}`);
33
+ const n = parseFloat(/** @type {string} */ (m[1]));
34
+ switch ((m[2] ?? '').toLowerCase()) {
35
+ case '':
36
+ case 'px':
37
+ return n * PX_TO_PT;
38
+ case 'pt':
39
+ return n;
40
+ case 'mm':
41
+ return n * MM_TO_PT;
42
+ case 'cm':
43
+ return n * MM_TO_PT * 10;
44
+ case 'in':
45
+ return n * 72;
46
+ case 'pc':
47
+ return n * 12;
48
+ default:
49
+ throw new Error(`Unsupported CSS unit: ${value}`);
50
+ }
51
+ }
52
+
53
+ /** @param {number} pt @returns {number} */
54
+ export function ptToPx(pt) {
55
+ return pt / PX_TO_PT;
56
+ }
57
+
58
+ /**
59
+ * getComputedStyle が返す px 文字列('12px')を数値にする。
60
+ * @param {string} value
61
+ * @returns {number}
62
+ */
63
+ export function cssPx(value) {
64
+ const n = parseFloat(value);
65
+ return Number.isFinite(n) ? n : 0;
66
+ }
67
+
68
+ /**
69
+ * @typedef {{r: number, g: number, b: number, a: number}} Rgba 各成分 0〜1
70
+ */
71
+
72
+ /**
73
+ * getComputedStyle の色文字列('rgb(34, 34, 34)' / 'rgba(0, 0, 0, 0.5)' / 'transparent'
74
+ * / 'color(srgb ...)')をパースする。パースできない場合は null。
75
+ * @param {string} value
76
+ * @returns {Rgba|null}
77
+ */
78
+ export function parseColor(value) {
79
+ if (!value) return null;
80
+ const v = value.trim();
81
+ if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
82
+ let m = /^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i.exec(v);
83
+ if (m) {
84
+ const a = m[4] === undefined ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
85
+ return {
86
+ r: clamp01(parseFloat(/** @type {string} */ (m[1])) / 255),
87
+ g: clamp01(parseFloat(/** @type {string} */ (m[2])) / 255),
88
+ b: clamp01(parseFloat(/** @type {string} */ (m[3])) / 255),
89
+ a: clamp01(a),
90
+ };
91
+ }
92
+ m = /^color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/i.exec(v);
93
+ if (m) {
94
+ const a = m[4] === undefined ? 1 : m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
95
+ return {
96
+ r: clamp01(parseFloat(/** @type {string} */ (m[1]))),
97
+ g: clamp01(parseFloat(/** @type {string} */ (m[2]))),
98
+ b: clamp01(parseFloat(/** @type {string} */ (m[3]))),
99
+ a: clamp01(a),
100
+ };
101
+ }
102
+ m = /^#([0-9a-f]{3,8})$/i.exec(v);
103
+ if (m) {
104
+ let h = /** @type {string} */ (m[1]);
105
+ if (h.length === 3 || h.length === 4) h = h.split('').map((c) => c + c).join('');
106
+ const n = parseInt(h.padEnd(8, 'f'), 16);
107
+ return { r: ((n >>> 24) & 255) / 255, g: ((n >>> 16) & 255) / 255, b: ((n >>> 8) & 255) / 255, a: (n & 255) / 255 };
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /** @param {number} n */
113
+ function clamp01(n) {
114
+ return n < 0 ? 0 : n > 1 ? 1 : n;
115
+ }
116
+
117
+ /**
118
+ * PDF 用に数値を丸めて文字列化する(小数第 3 位まで、末尾ゼロ除去)。
119
+ * @param {number} n
120
+ * @returns {string}
121
+ */
122
+ export function num(n) {
123
+ if (!Number.isFinite(n)) return '0';
124
+ const s = n.toFixed(3);
125
+ const t = s.includes('.') ? s.replace(/\.?0+$/, '') : s;
126
+ return t === '' || t === '-0' || t === '-' ? '0' : t;
127
+ }