@hidemikimura/receipt-html-to-pdf 0.2.1 → 0.4.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,193 @@
1
+ // @ts-check
2
+ /**
3
+ * CSS の linear-gradient を PDF の軸シェーディング(ShadingType 2)に落とすための解析。
4
+ *
5
+ * 入力は computed style の値なので、色は rgb()/rgba() に、角度は deg に正規化済み。
6
+ * 出力は箱のローカル座標(左上原点・y 下向き・CSS px)でのグラデーション線と、
7
+ * 0〜1 に正規化した色止め。
8
+ */
9
+ import { parseColor } from '../units.js';
10
+
11
+ /**
12
+ * @typedef {{t: number, color: import('../units.js').Rgba}} GradientStop
13
+ * @typedef {{x0: number, y0: number, x1: number, y1: number, stops: GradientStop[]}} LinearGradient
14
+ */
15
+
16
+ /**
17
+ * computed の background-image が単一の linear-gradient ならそれを解析する。
18
+ * 対応しない書式(repeating / radial / conic / 複数レイヤー)は null。
19
+ *
20
+ * @param {string} value computed の background-image
21
+ * @param {number} width 箱の幅(px)
22
+ * @param {number} height 箱の高さ(px)
23
+ * @returns {LinearGradient|null}
24
+ */
25
+ export function parseLinearGradient(value, width, height) {
26
+ const m = /^linear-gradient\((.*)\)$/s.exec(value.trim());
27
+ if (!m) return null;
28
+ const args = splitTopLevelCommas(/** @type {string} */ (m[1]));
29
+ if (args.length < 2) return null;
30
+
31
+ let i = 0;
32
+ let angle = 180; // 既定は「to bottom」
33
+ const first = /** @type {string} */ (args[0]).trim();
34
+ // `in oklab` などの補間指定は無視して sRGB で近似する
35
+ const head = first.replace(/^in\s+\S+(\s+\S+\s+hue)?\s*/i, '').trim();
36
+ const dir = parseDirection(head);
37
+ if (dir !== null) {
38
+ angle = dir;
39
+ i = 1;
40
+ } else if (/^in\s/i.test(first) && head === '') {
41
+ i = 1;
42
+ }
43
+
44
+ const rawStops = args.slice(i).map((s) => s.trim());
45
+ if (rawStops.length < 2) return null;
46
+
47
+ // グラデーション線の長さ(CSS 仕様: |W·sin θ| + |H·cos θ|)
48
+ const rad = (angle * Math.PI) / 180;
49
+ const sin = Math.sin(rad);
50
+ const cos = Math.cos(rad);
51
+ const length = Math.abs(width * sin) + Math.abs(height * cos);
52
+ if (!(length > 0)) return null;
53
+
54
+ /** @type {GradientStop[]} */
55
+ const stops = [];
56
+ for (const raw of rawStops) {
57
+ // "rgb(255, 0, 0) 30%" / "rgba(0, 0, 0, 0.5)" / "red 10px 20px"(二重指定)
58
+ const sp = splitColorAndPositions(raw);
59
+ if (!sp) return null;
60
+ const color = parseColor(sp.color);
61
+ if (!color) return null;
62
+ if (!sp.positions.length) {
63
+ stops.push({ t: NaN, color });
64
+ continue;
65
+ }
66
+ for (const pos of sp.positions) {
67
+ const t = resolvePosition(pos, length);
68
+ if (t === null) return null;
69
+ stops.push({ t, color });
70
+ }
71
+ }
72
+ if (stops.length < 2) return null;
73
+
74
+ fillMissingPositions(stops);
75
+
76
+ // 中心を通る線分。CSS の角度は 0deg = 上向き、時計回り。y は下向きなので cos を反転する。
77
+ const cx = width / 2;
78
+ const cy = height / 2;
79
+ const dx = sin;
80
+ const dy = -cos;
81
+ return {
82
+ x0: cx - (dx * length) / 2,
83
+ y0: cy - (dy * length) / 2,
84
+ x1: cx + (dx * length) / 2,
85
+ y1: cy + (dy * length) / 2,
86
+ stops,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * `45deg` / `to right` / `to right bottom` を CSS 角度(0 = 上、時計回り)にする。
92
+ * 方向指定でなければ null。
93
+ * @param {string} s
94
+ * @returns {number|null}
95
+ */
96
+ function parseDirection(s) {
97
+ const deg = /^(-?[\d.]+)deg$/.exec(s);
98
+ if (deg) return ((parseFloat(/** @type {string} */ (deg[1])) % 360) + 360) % 360;
99
+ const to = /^to\s+(.+)$/.exec(s);
100
+ if (!to) return null;
101
+ const words = /** @type {string} */ (to[1]).trim().split(/\s+/).sort().join(' ');
102
+ /** @type {Record<string, number>} */
103
+ const table = {
104
+ top: 0,
105
+ right: 90,
106
+ bottom: 180,
107
+ left: 270,
108
+ 'right top': 45,
109
+ 'bottom right': 135,
110
+ 'bottom left': 225,
111
+ 'left top': 315,
112
+ };
113
+ return table[words] ?? null;
114
+ }
115
+
116
+ /**
117
+ * 色止めを「色」と「位置(0〜2 個)」に分ける。
118
+ * @param {string} s
119
+ * @returns {{color: string, positions: string[]}|null}
120
+ */
121
+ function splitColorAndPositions(s) {
122
+ // 関数記法(rgb(...))を先に切り出す
123
+ const fn = /^([a-z-]+\([^()]*\))\s*(.*)$/i.exec(s);
124
+ if (fn) return { color: /** @type {string} */ (fn[1]), positions: splitWords(/** @type {string} */ (fn[2])) };
125
+ const kw = /^(\S+)\s*(.*)$/.exec(s);
126
+ if (!kw) return null;
127
+ return { color: /** @type {string} */ (kw[1]), positions: splitWords(/** @type {string} */ (kw[2])) };
128
+ }
129
+
130
+ /** @param {string} s */
131
+ function splitWords(s) {
132
+ const t = s.trim();
133
+ return t ? t.split(/\s+/) : [];
134
+ }
135
+
136
+ /**
137
+ * 位置指定(30% / 10px)をグラデーション線上の 0〜1 にする。
138
+ * @param {string} pos
139
+ * @param {number} length
140
+ * @returns {number|null}
141
+ */
142
+ function resolvePosition(pos, length) {
143
+ const pct = /^(-?[\d.]+)%$/.exec(pos);
144
+ if (pct) return parseFloat(/** @type {string} */ (pct[1])) / 100;
145
+ const px = /^(-?[\d.]+)px$/.exec(pos);
146
+ if (px) return parseFloat(/** @type {string} */ (px[1])) / length;
147
+ return null;
148
+ }
149
+
150
+ /**
151
+ * 位置の無い色止めを埋める。両端は 0 と 1、間は等間隔。
152
+ * さらに前の位置を下回らないよう単調にする(CSS 仕様)。
153
+ * @param {GradientStop[]} stops
154
+ */
155
+ function fillMissingPositions(stops) {
156
+ const last = stops.length - 1;
157
+ if (Number.isNaN(/** @type {number} */ (stops[0]?.t))) /** @type {GradientStop} */ (stops[0]).t = 0;
158
+ if (Number.isNaN(/** @type {number} */ (stops[last]?.t))) /** @type {GradientStop} */ (stops[last]).t = 1;
159
+ for (let i = 1; i < last; i++) {
160
+ if (!Number.isNaN(/** @type {number} */ (stops[i]?.t))) continue;
161
+ // 次に位置が決まっている色止めまでを等分する
162
+ let j = i + 1;
163
+ while (j < last && Number.isNaN(/** @type {number} */ (stops[j]?.t))) j++;
164
+ const from = /** @type {number} */ (stops[i - 1]?.t);
165
+ const to = /** @type {number} */ (stops[j]?.t);
166
+ for (let k = i; k < j; k++) {
167
+ /** @type {GradientStop} */ (stops[k]).t = from + ((to - from) * (k - i + 1)) / (j - i + 1);
168
+ }
169
+ i = j - 1;
170
+ }
171
+ for (let i = 1; i < stops.length; i++) {
172
+ const prev = /** @type {number} */ (stops[i - 1]?.t);
173
+ if (/** @type {number} */ (stops[i]?.t) < prev) /** @type {GradientStop} */ (stops[i]).t = prev;
174
+ }
175
+ }
176
+
177
+ /** @param {string} s */
178
+ function splitTopLevelCommas(s) {
179
+ /** @type {string[]} */
180
+ const out = [];
181
+ let depth = 0;
182
+ let cur = '';
183
+ for (const ch of s) {
184
+ if (ch === '(') depth++;
185
+ else if (ch === ')') depth--;
186
+ if (ch === ',' && depth === 0) {
187
+ out.push(cur);
188
+ cur = '';
189
+ } else cur += ch;
190
+ }
191
+ if (cur.trim()) out.push(cur);
192
+ return out;
193
+ }
@@ -262,3 +262,80 @@ export function objectFitToSize(fit) {
262
262
  return '100% 100%';
263
263
  }
264
264
  }
265
+
266
+ /**
267
+ * `background-repeat` の computed 値を軸ごとに分ける。
268
+ * `repeat-x` / `repeat-y` は 2 値表記に展開する。
269
+ * @param {string} value
270
+ * @returns {['repeat'|'no-repeat'|'space'|'round', 'repeat'|'no-repeat'|'space'|'round']}
271
+ */
272
+ export function splitRepeat(value) {
273
+ const v = (value || 'repeat').trim();
274
+ if (v === 'repeat-x') return ['repeat', 'no-repeat'];
275
+ if (v === 'repeat-y') return ['no-repeat', 'repeat'];
276
+ const parts = v.split(/\s+/);
277
+ const norm = (/** @type {string} */ s) => (s === 'repeat' || s === 'no-repeat' || s === 'space' || s === 'round' ? s : 'repeat');
278
+ const a = norm(/** @type {string} */ (parts[0] ?? 'repeat'));
279
+ const b = norm(/** @type {string} */ (parts[1] ?? parts[0] ?? 'repeat'));
280
+ return [a, b];
281
+ }
282
+
283
+ /**
284
+ * 1 軸ぶんのタイル位置を求める。
285
+ *
286
+ * - `repeat`: 指定位置を基準に、描画領域を覆うまで両方向へ並べる
287
+ * - `no-repeat`: 指定位置に 1 枚
288
+ * - `round`: 描画領域に整数個収まるようタイルの大きさを調整して並べる(大きさが変わる)
289
+ * - `space`: 整数個を等間隔に置き、余りを隙間に配る。1 枚しか入らないなら先頭に 1 枚
290
+ *
291
+ * @param {'repeat'|'no-repeat'|'space'|'round'} mode
292
+ * @param {number} start 指定位置(background-position の結果)
293
+ * @param {number} size タイルの大きさ
294
+ * @param {number} areaStart 描画領域の開始
295
+ * @param {number} areaEnd 描画領域の終わり
296
+ * @returns {{positions: number[], size: number}}
297
+ */
298
+ export function tileAxis(mode, start, size, areaStart, areaEnd) {
299
+ if (!(size > 0)) return { positions: [start], size };
300
+ const area = areaEnd - areaStart;
301
+ if (mode === 'no-repeat') return { positions: [start], size };
302
+
303
+ if (mode === 'round') {
304
+ const n = Math.max(1, Math.round(area / size));
305
+ const s = area / n;
306
+ return { positions: Array.from({ length: n }, (_, i) => areaStart + i * s), size: s };
307
+ }
308
+
309
+ if (mode === 'space') {
310
+ const n = Math.floor(area / size);
311
+ if (n < 2) return { positions: [areaStart], size };
312
+ const gap = (area - n * size) / (n - 1);
313
+ return { positions: Array.from({ length: n }, (_, i) => areaStart + i * (size + gap)), size };
314
+ }
315
+
316
+ // repeat: 指定位置から前後へ伸ばす
317
+ const first = start - Math.ceil((start - areaStart) / size) * size;
318
+ /** @type {number[]} */
319
+ const positions = [];
320
+ for (let p = first; p < areaEnd; p += size) {
321
+ if (p + size > areaStart) positions.push(p);
322
+ if (positions.length > 10000) break; // 異常な繰り返しの保険
323
+ }
324
+ return { positions: positions.length ? positions : [start], size };
325
+ }
326
+
327
+ /**
328
+ * 埋め込みが済んだ画像のピクセルデータを手放す。
329
+ * デコード結果(RGB・アルファ・JPEG のバイト列)は元画像より桁違いに大きいので、
330
+ * PDF に書き出したあとも抱えていると大きな文書でピーク使用量が跳ね上がる。
331
+ *
332
+ * キャッシュからも外すので、次の変換では読み直しになる(速度よりメモリを優先する)。
333
+ *
334
+ * @param {DecodedImage} img
335
+ */
336
+ export function releasePixels(img) {
337
+ img.jpeg = null;
338
+ img.rgb = null;
339
+ img.alpha = null;
340
+ cache.delete(img.key);
341
+ }
@@ -0,0 +1,342 @@
1
+ // @ts-check
2
+ /**
3
+ * SVG のパスデータと基本図形を、PDF に出せる形(絶対座標の M / L / C / Z)へ正規化する。
4
+ *
5
+ * 円弧(A)は 3 次ベジェへ、二次ベジェ(Q / T)も 3 次へ変換する。
6
+ * 座標は要素のユーザー単位のまま(変換行列は描画時に cm で適用する)。
7
+ */
8
+
9
+ /**
10
+ * @typedef {['M', number, number]|['L', number, number]|['C', number, number, number, number, number, number]|['Z']} PathSeg
11
+ */
12
+
13
+ /**
14
+ * `d` 属性を絶対座標の M / L / C / Z 列にする。
15
+ * @param {string} d
16
+ * @returns {PathSeg[]}
17
+ */
18
+ export function parsePathData(d) {
19
+ /** @type {PathSeg[]} */
20
+ const out = [];
21
+ const tokens = tokenize(d);
22
+ let i = 0;
23
+ let x = 0;
24
+ let y = 0;
25
+ let startX = 0;
26
+ let startY = 0;
27
+ // 直前の制御点(S / T の反射用)
28
+ /** @type {[number, number]|null} */
29
+ let lastC = null;
30
+ /** @type {[number, number]|null} */
31
+ let lastQ = null;
32
+ let cmd = '';
33
+
34
+ const num = () => {
35
+ const t = tokens[i++];
36
+ return typeof t === 'number' ? t : NaN;
37
+ };
38
+ const hasNum = () => typeof tokens[i] === 'number';
39
+
40
+ while (i < tokens.length) {
41
+ if (typeof tokens[i] === 'string') cmd = /** @type {string} */ (tokens[i++]);
42
+ else if (!cmd) break; // 数値から始まる不正なデータ
43
+ const rel = cmd === cmd.toLowerCase();
44
+ const C = cmd.toUpperCase();
45
+ const ox = rel ? x : 0;
46
+ const oy = rel ? y : 0;
47
+
48
+ if (C === 'M') {
49
+ x = num() + ox;
50
+ y = num() + oy;
51
+ out.push(['M', x, y]);
52
+ startX = x;
53
+ startY = y;
54
+ lastC = lastQ = null;
55
+ // 続く座標対は L / l 扱い
56
+ cmd = rel ? 'l' : 'L';
57
+ continue;
58
+ }
59
+ if (C === 'Z') {
60
+ out.push(['Z']);
61
+ x = startX;
62
+ y = startY;
63
+ lastC = lastQ = null;
64
+ continue;
65
+ }
66
+ if (C === 'L') {
67
+ x = num() + ox;
68
+ y = num() + oy;
69
+ out.push(['L', x, y]);
70
+ lastC = lastQ = null;
71
+ } else if (C === 'H') {
72
+ x = num() + ox;
73
+ out.push(['L', x, y]);
74
+ lastC = lastQ = null;
75
+ } else if (C === 'V') {
76
+ y = num() + oy;
77
+ out.push(['L', x, y]);
78
+ lastC = lastQ = null;
79
+ } else if (C === 'C') {
80
+ const x1 = num() + ox;
81
+ const y1 = num() + oy;
82
+ const x2 = num() + ox;
83
+ const y2 = num() + oy;
84
+ x = num() + ox;
85
+ y = num() + oy;
86
+ out.push(['C', x1, y1, x2, y2, x, y]);
87
+ lastC = [x2, y2];
88
+ lastQ = null;
89
+ } else if (C === 'S') {
90
+ const rx = lastC ? 2 * x - lastC[0] : x;
91
+ const ry = lastC ? 2 * y - lastC[1] : y;
92
+ const x2 = num() + ox;
93
+ const y2 = num() + oy;
94
+ x = num() + ox;
95
+ y = num() + oy;
96
+ out.push(['C', rx, ry, x2, y2, x, y]);
97
+ lastC = [x2, y2];
98
+ lastQ = null;
99
+ } else if (C === 'Q' || C === 'T') {
100
+ let qx;
101
+ let qy;
102
+ if (C === 'Q') {
103
+ qx = num() + ox;
104
+ qy = num() + oy;
105
+ } else {
106
+ qx = lastQ ? 2 * x - lastQ[0] : x;
107
+ qy = lastQ ? 2 * y - lastQ[1] : y;
108
+ }
109
+ const px = num() + ox;
110
+ const py = num() + oy;
111
+ // 二次 → 三次
112
+ out.push(['C', x + (2 / 3) * (qx - x), y + (2 / 3) * (qy - y), px + (2 / 3) * (qx - px), py + (2 / 3) * (qy - py), px, py]);
113
+ x = px;
114
+ y = py;
115
+ lastQ = [qx, qy];
116
+ lastC = null;
117
+ } else if (C === 'A') {
118
+ const rx = num();
119
+ const ry = num();
120
+ const rot = num();
121
+ const large = num();
122
+ const sweep = num();
123
+ const px = num() + ox;
124
+ const py = num() + oy;
125
+ for (const seg of arcToCurves(x, y, px, py, rx, ry, rot, large !== 0, sweep !== 0)) out.push(seg);
126
+ x = px;
127
+ y = py;
128
+ lastC = lastQ = null;
129
+ } else {
130
+ break; // 未知のコマンド
131
+ }
132
+ if (!hasNum() && typeof tokens[i] !== 'string') break;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * `d` を数値とコマンド文字に分解する。
139
+ * @param {string} d
140
+ * @returns {(string|number)[]}
141
+ */
142
+ function tokenize(d) {
143
+ /** @type {(string|number)[]} */
144
+ const out = [];
145
+ const re = /([MmLlHhVvCcSsQqTtAaZz])|(-?(?:\d*\.\d+|\d+)(?:[eE][-+]?\d+)?)/g;
146
+ let m;
147
+ while ((m = re.exec(d))) {
148
+ if (m[1]) out.push(m[1]);
149
+ else out.push(parseFloat(/** @type {string} */ (m[2])));
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * 楕円円弧を 3 次ベジェ列にする(SVG 仕様 F.6 の実装)。
156
+ * @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2
157
+ * @param {number} rx @param {number} ry @param {number} rotDeg
158
+ * @param {boolean} large @param {boolean} sweep
159
+ * @returns {PathSeg[]}
160
+ */
161
+ export function arcToCurves(x1, y1, x2, y2, rx, ry, rotDeg, large, sweep) {
162
+ if (x1 === x2 && y1 === y2) return [];
163
+ rx = Math.abs(rx);
164
+ ry = Math.abs(ry);
165
+ if (rx === 0 || ry === 0) return [['L', x2, y2]];
166
+
167
+ const phi = (rotDeg * Math.PI) / 180;
168
+ const cosP = Math.cos(phi);
169
+ const sinP = Math.sin(phi);
170
+ const dx = (x1 - x2) / 2;
171
+ const dy = (y1 - y2) / 2;
172
+ const x1p = cosP * dx + sinP * dy;
173
+ const y1p = -sinP * dx + cosP * dy;
174
+
175
+ // 半径が小さすぎる場合は広げる(仕様 F.6.6)
176
+ const lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
177
+ if (lambda > 1) {
178
+ const s = Math.sqrt(lambda);
179
+ rx *= s;
180
+ ry *= s;
181
+ }
182
+
183
+ const sign = large === sweep ? -1 : 1;
184
+ const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
185
+ const den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
186
+ const co = sign * Math.sqrt(Math.max(0, num / den));
187
+ const cxp = (co * rx * y1p) / ry;
188
+ const cyp = (-co * ry * x1p) / rx;
189
+ const cx = cosP * cxp - sinP * cyp + (x1 + x2) / 2;
190
+ const cy = sinP * cxp + cosP * cyp + (y1 + y2) / 2;
191
+
192
+ const angle = (/** @type {number} */ ux, /** @type {number} */ uy, /** @type {number} */ vx, /** @type {number} */ vy) => {
193
+ const dot = ux * vx + uy * vy;
194
+ const len = Math.hypot(ux, uy) * Math.hypot(vx, vy);
195
+ let a = Math.acos(Math.min(1, Math.max(-1, dot / (len || 1))));
196
+ if (ux * vy - uy * vx < 0) a = -a;
197
+ return a;
198
+ };
199
+ const theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
200
+ let dTheta = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
201
+ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI;
202
+ if (sweep && dTheta < 0) dTheta += 2 * Math.PI;
203
+
204
+ // 1 区間あたり 90 度以下になるよう分割する
205
+ const parts = Math.max(1, Math.ceil(Math.abs(dTheta / (Math.PI / 2))));
206
+ const delta = dTheta / parts;
207
+ const t = ((4 / 3) * Math.tan(delta / 4));
208
+ /** @type {PathSeg[]} */
209
+ const out = [];
210
+ let th = theta1;
211
+ for (let i = 0; i < parts; i++) {
212
+ const th2 = th + delta;
213
+ const cos1 = Math.cos(th);
214
+ const sin1 = Math.sin(th);
215
+ const cos2 = Math.cos(th2);
216
+ const sin2 = Math.sin(th2);
217
+ /** 楕円上の点とその接線から制御点を作る */
218
+ const map = (/** @type {number} */ ex, /** @type {number} */ ey) => [cosP * rx * ex - sinP * ry * ey + cx, sinP * rx * ex + cosP * ry * ey + cy];
219
+ const [px1, py1] = map(cos1, sin1);
220
+ const [px2, py2] = map(cos2, sin2);
221
+ const [c1x, c1y] = map(cos1 - t * sin1, sin1 + t * cos1);
222
+ const [c2x, c2y] = map(cos2 + t * sin2, sin2 - t * cos2);
223
+ void px1;
224
+ void py1;
225
+ out.push(['C', /** @type {number} */ (c1x), /** @type {number} */ (c1y), /** @type {number} */ (c2x), /** @type {number} */ (c2y), /** @type {number} */ (px2), /** @type {number} */ (py2)]);
226
+ th = th2;
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * 基本図形をパスにする。対応しない要素は null。
233
+ * @param {Element} el
234
+ * @param {(name: string) => string} attr 属性値(プレゼンテーション属性は computed style を優先しない純粋な幾何属性)
235
+ * @returns {PathSeg[]|null}
236
+ */
237
+ export function shapeToPath(el, attr) {
238
+ const n = (/** @type {string} */ name, /** @type {number} */ dflt = 0) => {
239
+ const v = parseFloat(attr(name));
240
+ return Number.isFinite(v) ? v : dflt;
241
+ };
242
+ switch (el.tagName) {
243
+ case 'path': {
244
+ const d = attr('d');
245
+ return d ? parsePathData(d) : [];
246
+ }
247
+ case 'rect': {
248
+ const x = n('x');
249
+ const y = n('y');
250
+ const w = n('width');
251
+ const h = n('height');
252
+ if (w <= 0 || h <= 0) return [];
253
+ let rx = attr('rx') === '' || attr('rx') === 'auto' ? NaN : n('rx');
254
+ let ry = attr('ry') === '' || attr('ry') === 'auto' ? NaN : n('ry');
255
+ if (Number.isNaN(rx) && Number.isNaN(ry)) return rectPath(x, y, w, h);
256
+ if (Number.isNaN(rx)) rx = /** @type {number} */ (ry);
257
+ if (Number.isNaN(ry)) ry = /** @type {number} */ (rx);
258
+ rx = Math.min(rx, w / 2);
259
+ ry = Math.min(ry, h / 2);
260
+ if (rx <= 0 || ry <= 0) return rectPath(x, y, w, h);
261
+ return roundRectPath(x, y, w, h, rx, ry);
262
+ }
263
+ case 'circle': {
264
+ const r = n('r');
265
+ if (r <= 0) return [];
266
+ return ellipsePath(n('cx'), n('cy'), r, r);
267
+ }
268
+ case 'ellipse': {
269
+ const rx = n('rx');
270
+ const ry = n('ry');
271
+ if (rx <= 0 || ry <= 0) return [];
272
+ return ellipsePath(n('cx'), n('cy'), rx, ry);
273
+ }
274
+ case 'line':
275
+ return [
276
+ ['M', n('x1'), n('y1')],
277
+ ['L', n('x2'), n('y2')],
278
+ ];
279
+ case 'polyline':
280
+ case 'polygon': {
281
+ const pts = attr('points')
282
+ .split(/[\s,]+/)
283
+ .map(parseFloat)
284
+ .filter((v) => Number.isFinite(v));
285
+ if (pts.length < 4) return [];
286
+ /** @type {PathSeg[]} */
287
+ const out = [['M', /** @type {number} */ (pts[0]), /** @type {number} */ (pts[1])]];
288
+ for (let i = 2; i + 1 < pts.length; i += 2) out.push(['L', /** @type {number} */ (pts[i]), /** @type {number} */ (pts[i + 1])]);
289
+ if (el.tagName === 'polygon') out.push(['Z']);
290
+ return out;
291
+ }
292
+ default:
293
+ return null;
294
+ }
295
+ }
296
+
297
+ /** @returns {PathSeg[]} */
298
+ function rectPath(/** @type {number} */ x, /** @type {number} */ y, /** @type {number} */ w, /** @type {number} */ h) {
299
+ return [
300
+ ['M', x, y],
301
+ ['L', x + w, y],
302
+ ['L', x + w, y + h],
303
+ ['L', x, y + h],
304
+ ['Z'],
305
+ ];
306
+ }
307
+
308
+ const K = 0.5522847498307936; // 4/3·(√2−1)
309
+
310
+ /** @returns {PathSeg[]} */
311
+ function roundRectPath(/** @type {number} */ x, /** @type {number} */ y, /** @type {number} */ w, /** @type {number} */ h, /** @type {number} */ rx, /** @type {number} */ ry) {
312
+ const cx = rx * K;
313
+ const cy = ry * K;
314
+ const r = x + w;
315
+ const b = y + h;
316
+ return [
317
+ ['M', x + rx, y],
318
+ ['L', r - rx, y],
319
+ ['C', r - rx + cx, y, r, y + ry - cy, r, y + ry],
320
+ ['L', r, b - ry],
321
+ ['C', r, b - ry + cy, r - rx + cx, b, r - rx, b],
322
+ ['L', x + rx, b],
323
+ ['C', x + rx - cx, b, x, b - ry + cy, x, b - ry],
324
+ ['L', x, y + ry],
325
+ ['C', x, y + ry - cy, x + rx - cx, y, x + rx, y],
326
+ ['Z'],
327
+ ];
328
+ }
329
+
330
+ /** @returns {PathSeg[]} */
331
+ function ellipsePath(/** @type {number} */ cx, /** @type {number} */ cy, /** @type {number} */ rx, /** @type {number} */ ry) {
332
+ const ox = rx * K;
333
+ const oy = ry * K;
334
+ return [
335
+ ['M', cx + rx, cy],
336
+ ['C', cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry],
337
+ ['C', cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy],
338
+ ['C', cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry],
339
+ ['C', cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy],
340
+ ['Z'],
341
+ ];
342
+ }
@@ -4,6 +4,7 @@
4
4
  * Range を 1 文字ずつ張って各グリフの矩形を実測し、行ごとにグリフ列(GID・ペン位置)を返す。
5
5
  * ブラウザのカーニング・letter-spacing・禁則・両端揃えの結果がそのまま位置に反映される。
6
6
  */
7
+ import { buildSubstitution } from '../font/gsub.js';
7
8
 
8
9
  /**
9
10
  * @typedef {object} MeasuredLine
@@ -24,6 +25,7 @@
24
25
  * @property {'normal'|'italic'} fstyle
25
26
  * @property {number} size px
26
27
  * @property {'font'|'measure'|'auto'} textMeasure
28
+ * @property {string[]} features ブラウザが有効にしている OpenType 機能タグ(GSUB の単一置換を再現する)
27
29
  * @property {(w: import('../index.js').ConversionWarning) => void} warn
28
30
  * @property {Element} element
29
31
  */
@@ -73,12 +75,37 @@ export function measureText(node, style, o) {
73
75
  }
74
76
  cur.top = Math.min(cur.top, top);
75
77
  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
+ // GSUB(単一置換): ブラウザが有効にした機能と同じ置換を適用する
79
+ const subst = o.features.length ? substitutionFor(font.parsed, o.features) : null;
80
+ const outGid = subst ? subst(gid) : gid;
81
+ const advance = ((font.parsed.advances[outGid] ?? 0) * o.size) / font.parsed.unitsPerEm;
82
+ cur.glyphs.push({ gid: outGid, cp: cpForUnicode, x: rect.left, advance });
78
83
  }
79
84
  return lines;
80
85
  }
81
86
 
87
+ /** @type {WeakMap<import('../font/parse.js').ParsedFont, Map<string, ((gid: number) => number)|null>>} */
88
+ const substCache = new WeakMap();
89
+
90
+ /**
91
+ * (フォント, 機能集合) ごとの置換関数。結果は null も含めてキャッシュする。
92
+ * @param {import('../font/parse.js').ParsedFont} parsed
93
+ * @param {string[]} features
94
+ * @returns {((gid: number) => number)|null}
95
+ */
96
+ function substitutionFor(parsed, features) {
97
+ let byKey = substCache.get(parsed);
98
+ if (!byKey) {
99
+ byKey = new Map();
100
+ substCache.set(parsed, byKey);
101
+ }
102
+ const key = [...features].sort().join(',');
103
+ if (byKey.has(key)) return byKey.get(key) ?? null;
104
+ const fn = buildSubstitution(parsed, features);
105
+ byKey.set(key, fn);
106
+ return fn;
107
+ }
108
+
82
109
  /**
83
110
  * 1 文字分の矩形のうち幅を持つものを選ぶ。行末の折り返し位置では 2 つ返ることがある。
84
111
  * @param {DOMRectList} rects