@hidemikimura/receipt-html-to-pdf 0.2.0 → 0.3.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 +50 -0
- package/README.md +33 -9
- package/dist/receipt-html-to-pdf.min.js +15 -14
- package/dist/receipt-html-to-pdf.min.js.map +4 -4
- package/examples/cdn.html +110 -0
- package/package.json +1 -1
- package/skills/receipt-html-to-pdf/SKILL.md +34 -4
- package/skills/receipt-html-to-pdf/references/receipt-format.md +3 -1
- package/skills/receipt-html-to-pdf/references/troubleshooting.md +1 -0
- package/src/font/gsub.js +229 -0
- package/src/index.js +22 -1
- package/src/pacer.js +50 -0
- package/src/page.js +101 -8
- package/src/paginate.js +19 -0
- package/src/pdf/content.js +44 -2
- package/src/pdf/shading.js +113 -0
- package/src/renderer.js +27 -1
- package/src/walker/gradient.js +193 -0
- package/src/walker/image.js +77 -0
- package/src/walker/svg-path.js +342 -0
- package/src/walker/text.js +29 -2
- package/src/walker/walk.js +273 -24
- package/types/font/gsub.d.ts +47 -0
- package/types/index.d.ts +28 -1
- package/types/pacer.d.ts +11 -0
- package/types/page.d.ts +4 -1
- package/types/paginate.d.ts +1 -0
- package/types/pdf/content.d.ts +18 -1
- package/types/pdf/shading.d.ts +44 -0
- package/types/walker/gradient.d.ts +25 -0
- package/types/walker/image.d.ts +36 -0
- package/types/walker/svg-path.d.ts +31 -0
- package/types/walker/text.d.ts +4 -0
- package/types/walker/walk.d.ts +50 -1
package/src/walker/image.js
CHANGED
|
@@ -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
|
+
}
|
package/src/walker/text.js
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|