@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.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/receipt-html-to-pdf.min.js +39 -0
- package/dist/receipt-html-to-pdf.min.js.map +7 -0
- package/examples/lit.js +52 -0
- package/examples/react.jsx +56 -0
- package/examples/vanilla.html +55 -0
- package/package.json +82 -0
- package/src/font/cid.js +187 -0
- package/src/font/parse.js +336 -0
- package/src/font/registry.js +168 -0
- package/src/font/subset.js +211 -0
- package/src/index.js +239 -0
- package/src/page.js +429 -0
- package/src/paginate.js +119 -0
- package/src/pdf/compress.js +30 -0
- package/src/pdf/content.js +192 -0
- package/src/pdf/image.js +33 -0
- package/src/pdf/writer.js +253 -0
- package/src/renderer.js +328 -0
- package/src/units.js +127 -0
- package/src/walker/image.js +264 -0
- package/src/walker/text.js +199 -0
- package/src/walker/walk.js +670 -0
- package/types/font/cid.d.ts +40 -0
- package/types/font/parse.d.ts +113 -0
- package/types/font/registry.d.ts +63 -0
- package/types/font/subset.d.ts +26 -0
- package/types/index.d.ts +198 -0
- package/types/page.d.ts +64 -0
- package/types/paginate.d.ts +59 -0
- package/types/pdf/compress.d.ts +13 -0
- package/types/pdf/content.d.ts +65 -0
- package/types/pdf/image.d.ts +9 -0
- package/types/pdf/writer.d.ts +125 -0
- package/types/renderer.d.ts +55 -0
- package/types/units.d.ts +53 -0
- package/types/walker/image.d.ts +72 -0
- package/types/walker/text.d.ts +38 -0
- package/types/walker/walk.d.ts +150 -0
|
@@ -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
|
+
}
|