@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,264 @@
1
+ // @ts-check
2
+ /**
3
+ * 画像の読み込みとデコード。
4
+ * - JPEG は元バイト列をそのまま DCTDecode で埋め込む(再圧縮しない)
5
+ * - それ以外(PNG, GIF, WebP, SVG …)は <canvas> でデコードして RGB + アルファに分離する
6
+ * 同じ URL は 1 回だけデコードし、PDF 上でも 1 つの XObject を共有する。
7
+ */
8
+
9
+ /**
10
+ * @typedef {object} DecodedImage
11
+ * @property {string} key 重複排除キー(URL)
12
+ * @property {number} width ピクセル
13
+ * @property {number} height
14
+ * @property {Uint8Array|null} jpeg JPEG の生バイト列(DCTDecode)
15
+ * @property {Uint8Array|null} rgb 幅×高さ×3
16
+ * @property {Uint8Array|null} alpha 幅×高さ(完全不透明なら null)
17
+ */
18
+
19
+ /** @type {Map<string, Promise<DecodedImage|null>>} */
20
+ const cache = new Map();
21
+
22
+ /**
23
+ * @param {string} url 絶対 URL(img.currentSrc または background-image の url())
24
+ * @param {(w: import('../index.js').ConversionWarning) => void} warn
25
+ * @param {Element} [element]
26
+ * @returns {Promise<DecodedImage|null>}
27
+ */
28
+ export function loadImage(url, warn, element) {
29
+ let p = cache.get(url);
30
+ if (!p) {
31
+ p = decode(url, warn, element).catch((e) => {
32
+ warn({ code: 'image-failed', message: `Failed to load image ${url}: ${e instanceof Error ? e.message : String(e)}`, element });
33
+ return null;
34
+ });
35
+ cache.set(url, p);
36
+ }
37
+ return p;
38
+ }
39
+
40
+ /**
41
+ * @param {string} url
42
+ * @param {(w: import('../index.js').ConversionWarning) => void} warn
43
+ * @param {Element} [element]
44
+ * @returns {Promise<DecodedImage|null>}
45
+ */
46
+ async function decode(url, warn, element) {
47
+ // 1. バイト列を取得できれば JPEG 判定
48
+ /** @type {Uint8Array|null} */
49
+ let bytes = null;
50
+ try {
51
+ const res = await fetch(url, { mode: 'cors', credentials: 'same-origin' });
52
+ if (res.ok) bytes = new Uint8Array(await res.arrayBuffer());
53
+ } catch {
54
+ bytes = null; // CORS 等で読めない場合は canvas 経由にフォールバック
55
+ }
56
+
57
+ if (bytes && bytes[0] === 0xff && bytes[1] === 0xd8) {
58
+ const dims = jpegDimensions(bytes);
59
+ if (dims && dims.components === 3) {
60
+ return { key: url, width: dims.width, height: dims.height, jpeg: bytes, rgb: null, alpha: null };
61
+ }
62
+ // CMYK / グレースケール JPEG は canvas 経由で RGB 化する
63
+ }
64
+
65
+ // 2. canvas でデコード
66
+ const img = new Image();
67
+ img.crossOrigin = 'anonymous';
68
+ img.decoding = 'sync';
69
+ const loaded = new Promise((resolve, reject) => {
70
+ img.onload = () => resolve(undefined);
71
+ img.onerror = () => reject(new Error('image failed to load'));
72
+ });
73
+ if (bytes) {
74
+ const blobUrl = URL.createObjectURL(new Blob([/** @type {Uint8Array<ArrayBuffer>} */ (bytes)]));
75
+ img.src = blobUrl;
76
+ try {
77
+ await loaded;
78
+ } finally {
79
+ URL.revokeObjectURL(blobUrl);
80
+ }
81
+ } else {
82
+ img.src = url;
83
+ await loaded;
84
+ }
85
+ const w = img.naturalWidth;
86
+ const h = img.naturalHeight;
87
+ if (!w || !h) throw new Error('image has no intrinsic size');
88
+
89
+ const canvas = document.createElement('canvas');
90
+ canvas.width = w;
91
+ canvas.height = h;
92
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
93
+ if (!ctx) throw new Error('2D canvas unavailable');
94
+ ctx.drawImage(img, 0, 0);
95
+ /** @type {ImageData} */
96
+ let data;
97
+ try {
98
+ data = ctx.getImageData(0, 0, w, h);
99
+ } catch {
100
+ warn({
101
+ code: 'image-failed',
102
+ message: `Image ${url} is cross-origin without CORS headers; add crossorigin="anonymous" and Access-Control-Allow-Origin. Skipped.`,
103
+ element,
104
+ });
105
+ return null;
106
+ }
107
+ const px = data.data;
108
+ const rgb = new Uint8Array(w * h * 3);
109
+ const alpha = new Uint8Array(w * h);
110
+ let opaque = true;
111
+ for (let i = 0, j = 0, k = 0; i < px.length; i += 4, j += 3, k++) {
112
+ const a = /** @type {number} */ (px[i + 3]);
113
+ // getImageData は非プリマルチプライ。透明ピクセルの色はノイズになりうるので白に寄せる
114
+ if (a === 0) {
115
+ rgb[j] = rgb[j + 1] = rgb[j + 2] = 255;
116
+ } else {
117
+ rgb[j] = /** @type {number} */ (px[i]);
118
+ rgb[j + 1] = /** @type {number} */ (px[i + 1]);
119
+ rgb[j + 2] = /** @type {number} */ (px[i + 2]);
120
+ }
121
+ alpha[k] = a;
122
+ if (a !== 255) opaque = false;
123
+ }
124
+ return { key: url, width: w, height: h, jpeg: null, rgb, alpha: opaque ? null : alpha };
125
+ }
126
+
127
+ /**
128
+ * JPEG の SOF マーカーから寸法と成分数を読む。
129
+ * @param {Uint8Array} b
130
+ * @returns {{width: number, height: number, components: number}|null}
131
+ */
132
+ export function jpegDimensions(b) {
133
+ let p = 2;
134
+ while (p + 9 < b.length) {
135
+ if (b[p] !== 0xff) {
136
+ p++;
137
+ continue;
138
+ }
139
+ const marker = /** @type {number} */ (b[p + 1]);
140
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01 || marker === 0xff) {
141
+ p += marker === 0xff ? 1 : 2;
142
+ continue;
143
+ }
144
+ const len = ((/** @type {number} */ (b[p + 2])) << 8) | /** @type {number} */ (b[p + 3]);
145
+ if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) {
146
+ return {
147
+ height: ((/** @type {number} */ (b[p + 5])) << 8) | /** @type {number} */ (b[p + 6]),
148
+ width: ((/** @type {number} */ (b[p + 7])) << 8) | /** @type {number} */ (b[p + 8]),
149
+ components: /** @type {number} */ (b[p + 9]),
150
+ };
151
+ }
152
+ if (marker === 0xda) break; // SOS: 以降は画像データ
153
+ p += 2 + len;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * computed background-image の `url("...")` を取り出す。複数指定・グラデーションは null。
160
+ * @param {string} value
161
+ * @returns {string|null}
162
+ */
163
+ export function parseBackgroundUrl(value) {
164
+ if (!value || value === 'none') return null;
165
+ const parts = splitTopLevelCommas(value);
166
+ if (parts.length !== 1) return null;
167
+ const m = /^url\((?:"([^"]*)"|'([^']*)'|([^)]*))\)$/.exec(/** @type {string} */ (parts[0]).trim());
168
+ if (!m) return null;
169
+ return m[1] ?? m[2] ?? m[3] ?? null;
170
+ }
171
+
172
+ /** @param {string} s */
173
+ function splitTopLevelCommas(s) {
174
+ /** @type {string[]} */
175
+ const out = [];
176
+ let depth = 0;
177
+ let cur = '';
178
+ for (const ch of s) {
179
+ if (ch === '(') depth++;
180
+ else if (ch === ')') depth--;
181
+ if (ch === ',' && depth === 0) {
182
+ out.push(cur);
183
+ cur = '';
184
+ } else cur += ch;
185
+ }
186
+ if (cur.trim()) out.push(cur);
187
+ return out;
188
+ }
189
+
190
+ /**
191
+ * background-size / background-position / object-fit / object-position から描画矩形を計算する。
192
+ * @param {{x: number, y: number, w: number, h: number}} box 描画領域(px)
193
+ * @param {number} iw 画像の固有幅
194
+ * @param {number} ih
195
+ * @param {string} size 'auto' | 'cover' | 'contain' | '<w> <h>'(computed 値、px または %)
196
+ * @param {string} position '0% 0%' | '50% 50%' | '10px 20px' …(computed 値、2 値)
197
+ * @returns {{x: number, y: number, w: number, h: number}}
198
+ */
199
+ export function fitImage(box, iw, ih, size, position) {
200
+ let w;
201
+ let h;
202
+ const s = size.trim();
203
+ if (s === 'cover' || s === 'contain') {
204
+ const scale = s === 'cover' ? Math.max(box.w / iw, box.h / ih) : Math.min(box.w / iw, box.h / ih);
205
+ w = iw * scale;
206
+ h = ih * scale;
207
+ } else {
208
+ const [sw = 'auto', sh = 'auto'] = s.split(/\s+/);
209
+ const rw = sizeComponent(sw, box.w);
210
+ const rh = sizeComponent(sh, box.h);
211
+ if (rw === null && rh === null) {
212
+ w = iw;
213
+ h = ih;
214
+ } else if (rw === null) {
215
+ h = /** @type {number} */ (rh);
216
+ w = (iw * h) / ih;
217
+ } else if (rh === null) {
218
+ w = rw;
219
+ h = (ih * w) / iw;
220
+ } else {
221
+ w = rw;
222
+ h = rh;
223
+ }
224
+ }
225
+ const [px = '0%', py = '0%'] = position.trim().split(/\s+/);
226
+ const x = box.x + positionComponent(px, box.w - w);
227
+ const y = box.y + positionComponent(py, box.h - h);
228
+ return { x, y, w, h };
229
+ }
230
+
231
+ /** @param {string} v @param {number} ref @returns {number|null} */
232
+ function sizeComponent(v, ref) {
233
+ if (v === 'auto') return null;
234
+ if (v.endsWith('%')) return (parseFloat(v) / 100) * ref;
235
+ return parseFloat(v) || 0;
236
+ }
237
+
238
+ /** @param {string} v @param {number} free */
239
+ function positionComponent(v, free) {
240
+ if (v === 'left' || v === 'top') return 0;
241
+ if (v === 'center') return free / 2;
242
+ if (v === 'right' || v === 'bottom') return free;
243
+ if (v.endsWith('%')) return (parseFloat(v) / 100) * free;
244
+ return parseFloat(v) || 0;
245
+ }
246
+
247
+ /**
248
+ * object-fit を background-size 相当の値に変換する。
249
+ * @param {string} fit
250
+ * @returns {string}
251
+ */
252
+ export function objectFitToSize(fit) {
253
+ switch (fit) {
254
+ case 'contain':
255
+ case 'scale-down':
256
+ return 'contain';
257
+ case 'cover':
258
+ return 'cover';
259
+ case 'none':
260
+ return 'auto';
261
+ default:
262
+ return '100% 100%';
263
+ }
264
+ }
@@ -0,0 +1,199 @@
1
+ // @ts-check
2
+ /**
3
+ * テキストノードの計測。
4
+ * Range を 1 文字ずつ張って各グリフの矩形を実測し、行ごとにグリフ列(GID・ペン位置)を返す。
5
+ * ブラウザのカーニング・letter-spacing・禁則・両端揃えの結果がそのまま位置に反映される。
6
+ */
7
+
8
+ /**
9
+ * @typedef {object} MeasuredLine
10
+ * @property {import('../font/registry.js').RegisteredFont} font
11
+ * @property {number} baseline ベースライン y(ビューポート座標 px)
12
+ * @property {number} top 行内グリフ矩形の上端
13
+ * @property {number} bottom 行内グリフ矩形の下端
14
+ * @property {import('./walk.js').Glyph[]} glyphs
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} MeasureOptions
19
+ * @property {import('../font/registry.js').FontRegistry} registry
20
+ * @property {string[]} families
21
+ * @property {string[]} fallback
22
+ * @property {import('../font/registry.js').RegisteredFont} primary
23
+ * @property {number} weight
24
+ * @property {'normal'|'italic'} fstyle
25
+ * @property {number} size px
26
+ * @property {'font'|'measure'|'auto'} textMeasure
27
+ * @property {(w: import('../index.js').ConversionWarning) => void} warn
28
+ * @property {Element} element
29
+ */
30
+
31
+ const WHITESPACE = new Set([0x20, 0x09, 0x0a, 0x0d, 0x0c, 0xa0, 0x3000, 0x2002, 0x2003, 0x2009, 0x200a, 0x202f, 0x205f]);
32
+
33
+ /**
34
+ * @param {Text} node
35
+ * @param {CSSStyleDeclaration} style
36
+ * @param {MeasureOptions} o
37
+ * @returns {MeasuredLine[]}
38
+ */
39
+ export function measureText(node, style, o) {
40
+ const doc = node.ownerDocument;
41
+ const text = applyTextTransform(node.data, style.textTransform);
42
+ const range = doc.createRange();
43
+ const baselineOffset = probeBaseline(doc, style);
44
+
45
+ /** @type {MeasuredLine[]} */
46
+ const lines = [];
47
+ /** @type {MeasuredLine|null} */
48
+ let cur = null;
49
+ let curTop = NaN;
50
+ /** @type {Set<number>} */
51
+ const warnedCps = new Set();
52
+
53
+ for (let i = 0; i < text.length; ) {
54
+ const cp = /** @type {number} */ (text.codePointAt(i));
55
+ const len = cp > 0xffff ? 2 : 1;
56
+ const end = i + len;
57
+
58
+ range.setStart(node, i);
59
+ range.setEnd(node, Math.min(end, node.data.length));
60
+ const rect = pickRect(range.getClientRects());
61
+ i = end;
62
+ if (!rect) continue; // 折り畳まれた空白・改行
63
+
64
+ const resolved = resolveGlyph(cp, o, warnedCps);
65
+ if (!resolved) continue;
66
+ const { font, gid, cpForUnicode } = resolved;
67
+
68
+ const top = rect.top;
69
+ if (!cur || cur.font !== font || Math.abs(top - curTop) > 0.5) {
70
+ cur = { font, baseline: top + baselineOffset, top, bottom: rect.bottom, glyphs: [] };
71
+ curTop = top;
72
+ lines.push(cur);
73
+ }
74
+ cur.top = Math.min(cur.top, top);
75
+ cur.bottom = Math.max(cur.bottom, rect.bottom);
76
+ const advance = ((font.parsed.advances[gid] ?? 0) * o.size) / font.parsed.unitsPerEm;
77
+ cur.glyphs.push({ gid, cp: cpForUnicode, x: rect.left, advance });
78
+ }
79
+ return lines;
80
+ }
81
+
82
+ /**
83
+ * 1 文字分の矩形のうち幅を持つものを選ぶ。行末の折り返し位置では 2 つ返ることがある。
84
+ * @param {DOMRectList} rects
85
+ * @returns {DOMRect|null}
86
+ */
87
+ function pickRect(rects) {
88
+ /** @type {DOMRect|null} */
89
+ let best = null;
90
+ for (const r of rects) {
91
+ if (r.width > 0.01 && (!best || r.width > best.width)) best = r;
92
+ }
93
+ return best;
94
+ }
95
+
96
+ /**
97
+ * コードポイントに対応するフォントと GID を決める。
98
+ * @param {number} cp
99
+ * @param {MeasureOptions} o
100
+ * @param {Set<number>} warnedCps
101
+ * @returns {{font: import('../font/registry.js').RegisteredFont, gid: number, cpForUnicode: number}|null}
102
+ */
103
+ function resolveGlyph(cp, o, warnedCps) {
104
+ const tryFont = (/** @type {import('../font/registry.js').RegisteredFont|null} */ f, /** @type {number} */ c) => {
105
+ const gid = f?.parsed.cmap.get(c);
106
+ return f && gid ? { font: f, gid, cpForUnicode: cp } : null;
107
+ };
108
+
109
+ // 空白類: 主フォントの同じ文字 → U+0020 で代替(位置は実測値で補正されるので幅は問わない)
110
+ if (WHITESPACE.has(cp)) {
111
+ return tryFont(o.primary, cp) ?? tryFont(o.primary, 0x20) ?? tryFont(o.registry.anyWithGlyph(0x20, o.weight, o.fstyle), 0x20);
112
+ }
113
+
114
+ let hit = tryFont(o.primary, cp);
115
+ if (hit) return hit;
116
+ hit = tryFont(o.registry.match(o.families, o.weight, o.fstyle, cp), cp);
117
+ if (hit) return hit;
118
+ hit = tryFont(o.registry.match(o.fallback, o.weight, o.fstyle, cp), cp);
119
+ if (hit) return hit;
120
+ hit = tryFont(o.registry.anyWithGlyph(cp, o.weight, o.fstyle), cp);
121
+ if (hit) return hit;
122
+
123
+ if (!warnedCps.has(cp)) {
124
+ warnedCps.add(cp);
125
+ o.warn({
126
+ code: 'missing-glyph',
127
+ message: `No registered font has a glyph for U+${cp.toString(16).toUpperCase().padStart(4, '0')} "${String.fromCodePoint(cp)}"; substituted with U+25A1`,
128
+ element: o.element,
129
+ text: String.fromCodePoint(cp),
130
+ });
131
+ }
132
+ // 豆腐(□)で代替。ToUnicode には元の文字を残す
133
+ const sub = tryFont(o.primary, 0x25a1) ?? tryFont(o.registry.anyWithGlyph(0x25a1, o.weight, o.fstyle), 0x25a1);
134
+ return sub ? { ...sub, cpForUnicode: cp } : null;
135
+ }
136
+
137
+ /**
138
+ * @param {string} s
139
+ * @param {string} transform computed text-transform
140
+ * @returns {string}
141
+ */
142
+ function applyTextTransform(s, transform) {
143
+ if (!transform || transform === 'none') return s;
144
+ let out = s;
145
+ if (transform.includes('uppercase')) out = s.toUpperCase();
146
+ else if (transform.includes('lowercase')) out = s.toLowerCase();
147
+ else if (transform.includes('capitalize')) out = s.replace(/(^|\s)(\p{L})/gu, (m, sp, ch) => sp + ch.toUpperCase());
148
+ // 長さが変わる変換(ß → SS など)は Range のオフセットと対応が取れないので諦める
149
+ return out.length === s.length ? out : s;
150
+ }
151
+
152
+ /** @type {WeakMap<Document, Map<string, number>>} */
153
+ const probeCache = new WeakMap();
154
+
155
+ /**
156
+ * この font 指定で描いたテキストの Range 矩形の上端から、ベースラインまでの距離(px)を実測する。
157
+ * inline-block(vertical-align: baseline)の下端がベースライン位置に一致することを利用する。
158
+ * @param {Document} doc
159
+ * @param {CSSStyleDeclaration} style
160
+ * @returns {number}
161
+ */
162
+ function probeBaseline(doc, style) {
163
+ const key = [style.fontFamily, style.fontSize, style.fontWeight, style.fontStyle, style.fontStretch, style.fontVariant, style.fontFeatureSettings].join('|');
164
+ let cache = probeCache.get(doc);
165
+ if (!cache) {
166
+ cache = new Map();
167
+ probeCache.set(doc, cache);
168
+ }
169
+ const cached = cache.get(key);
170
+ if (cached !== undefined) return cached;
171
+
172
+ const probe = doc.createElement('div');
173
+ probe.setAttribute('data-rhtp-probe', '');
174
+ probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;white-space:pre;line-height:normal;margin:0;padding:0;border:0;letter-spacing:0;text-indent:0;';
175
+ probe.style.fontFamily = style.fontFamily;
176
+ probe.style.fontSize = style.fontSize;
177
+ probe.style.fontWeight = style.fontWeight;
178
+ probe.style.fontStyle = style.fontStyle;
179
+ probe.style.fontStretch = style.fontStretch;
180
+ probe.style.fontVariant = style.fontVariant;
181
+ probe.style.fontFeatureSettings = style.fontFeatureSettings;
182
+ const textNode = doc.createTextNode('Agあ');
183
+ probe.appendChild(textNode);
184
+ const mark = doc.createElement('span');
185
+ mark.style.cssText = 'display:inline-block;width:0;height:0;vertical-align:baseline;margin:0;padding:0;border:0;';
186
+ probe.appendChild(mark);
187
+ doc.body.appendChild(probe);
188
+
189
+ const range = doc.createRange();
190
+ range.selectNodeContents(textNode);
191
+ const textRect = range.getBoundingClientRect();
192
+ const markRect = mark.getBoundingClientRect();
193
+ const offset = markRect.bottom - textRect.top;
194
+ probe.remove();
195
+
196
+ const result = Number.isFinite(offset) && offset > 0 ? offset : parseFloat(style.fontSize) * 0.8;
197
+ cache.set(key, result);
198
+ return result;
199
+ }