@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/page.js
CHANGED
|
@@ -9,6 +9,8 @@ import { EmbeddedFont, hex4 } from './font/cid.js';
|
|
|
9
9
|
import { embedImage } from './pdf/image.js';
|
|
10
10
|
import { PX_TO_PT, PAGE_SIZES, lengthToPt, num } from './units.js';
|
|
11
11
|
import { paginate } from './paginate.js';
|
|
12
|
+
import { releasePixels, loadImage } from './walker/image.js';
|
|
13
|
+
import { buildAxialShading, uniformAlpha, buildAlphaMaskGState } from './pdf/shading.js';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* @typedef {object} PageGeometry
|
|
@@ -56,7 +58,7 @@ export function resolvePage(page = {}) {
|
|
|
56
58
|
/**
|
|
57
59
|
* @param {import('./walker/walk.js').WalkResult} body
|
|
58
60
|
* @param {PageGeometry} geo
|
|
59
|
-
* @param {{compress: boolean, metadata?: import('./index.js').PdfMetadata, header?: PageDecoration|null, footer?: PageDecoration|null}} opts
|
|
61
|
+
* @param {{compress: boolean, metadata?: import('./index.js').PdfMetadata, header?: PageDecoration|null, footer?: PageDecoration|null, pacer?: import('./pacer.js').Pacer, progress?: (p: import('./index.js').ConversionProgress) => void, warn?: (w: import('./index.js').ConversionWarning) => void}} opts
|
|
60
62
|
* @returns {Promise<Uint8Array>}
|
|
61
63
|
*/
|
|
62
64
|
export async function buildPdf(body, geo, opts) {
|
|
@@ -113,33 +115,77 @@ export async function buildPdf(body, geo, opts) {
|
|
|
113
115
|
for (const ef of fonts.values()) fontDict[ef.resourceName] = await ef.embed(writer);
|
|
114
116
|
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
115
117
|
const xobjDict = {};
|
|
116
|
-
for (const im of images.values())
|
|
118
|
+
for (const im of images.values()) {
|
|
119
|
+
let img = im.image;
|
|
120
|
+
// 並行して走る別の変換が先に解放していた場合は読み直す
|
|
121
|
+
if (!img.jpeg && !img.rgb) {
|
|
122
|
+
const again = await loadImage(img.key, opts.warn ?? (() => {}));
|
|
123
|
+
if (!again) continue;
|
|
124
|
+
img = again;
|
|
125
|
+
}
|
|
126
|
+
xobjDict[im.name] = await embedImage(writer, img);
|
|
127
|
+
// 埋め込みが済めばピクセルデータは不要。大きな文書のピーク使用量を抑える
|
|
128
|
+
releasePixels(img);
|
|
129
|
+
if (opts.pacer) await opts.pacer();
|
|
130
|
+
}
|
|
117
131
|
|
|
118
132
|
// ExtGState(透明度)
|
|
119
133
|
/** @type {Map<string, string>} */
|
|
120
134
|
const gstates = new Map();
|
|
121
135
|
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
122
136
|
const gstateDict = {};
|
|
123
|
-
const gsName = (/** @type {number} */ a) => {
|
|
124
|
-
const key = num(a)
|
|
137
|
+
const gsName = (/** @type {number} */ a, /** @type {number} */ strokeA = a) => {
|
|
138
|
+
const key = `${num(a)}/${num(strokeA)}`;
|
|
125
139
|
let name = gstates.get(key);
|
|
126
140
|
if (!name) {
|
|
127
141
|
name = `GS${gstates.size + 1}`;
|
|
128
142
|
gstates.set(key, name);
|
|
129
|
-
gstateDict[name] = writer.add({ Type: 'ExtGState', ca: a, CA:
|
|
143
|
+
gstateDict[name] = writer.add({ Type: 'ExtGState', ca: a, CA: strokeA });
|
|
130
144
|
}
|
|
131
145
|
return name;
|
|
132
146
|
};
|
|
133
147
|
|
|
148
|
+
// Shading(linear-gradient)。座標は箱ローカルの CSS px で持ち、描画時に cm で用紙座標へ写す。
|
|
149
|
+
// こうするとページごとに作り直さずに済む。
|
|
150
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
151
|
+
const shadingDict = {};
|
|
152
|
+
/** @type {Map<import('./walker/walk.js').DisplayItem, {sh: string, gs: string|null}>} */
|
|
153
|
+
const gradients = new Map();
|
|
154
|
+
/** @param {import('./walker/walk.js').DisplayItem[]} list */
|
|
155
|
+
const prepareGradients = async (list) => {
|
|
156
|
+
for (const it of list) {
|
|
157
|
+
if (it.type === 'gradient') {
|
|
158
|
+
const name = `Sh${Object.keys(shadingDict).length + 1}`;
|
|
159
|
+
shadingDict[name] = buildAxialShading(writer, it.gradient, it.gradient.stops, 'rgb');
|
|
160
|
+
const alpha = uniformAlpha(it.gradient.stops);
|
|
161
|
+
let gs = null;
|
|
162
|
+
if (alpha === null) {
|
|
163
|
+
// 色止めごとにアルファが変わる → 輝度ソフトマスクで再現する
|
|
164
|
+
const gsRef = await buildAlphaMaskGState(writer, it.gradient, it.gradient.stops, { x: 0, y: 0, w: it.box.w, h: it.box.h }, it.alpha);
|
|
165
|
+
gs = `GM${gradients.size + 1}`;
|
|
166
|
+
gstateDict[gs] = gsRef;
|
|
167
|
+
}
|
|
168
|
+
gradients.set(it, { sh: name, gs });
|
|
169
|
+
} else if (it.type === 'group' || it.type === 'clip') {
|
|
170
|
+
await prepareGradients(it.items);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
await prepareGradients(body.items);
|
|
175
|
+
for (const w of [...headers, ...footers]) if (w) await prepareGradients(w.items);
|
|
176
|
+
|
|
134
177
|
const pagesRef = writer.reserve();
|
|
135
178
|
/** @type {import('./pdf/writer.js').Ref[]} */
|
|
136
179
|
const pageRefs = [];
|
|
137
180
|
|
|
138
181
|
// 4. ページごとに描画
|
|
182
|
+
opts.progress?.({ phase: 'layout', totalPages });
|
|
139
183
|
for (let p = 0; p < totalPages; p++) {
|
|
184
|
+
if (opts.pacer) await opts.pacer();
|
|
185
|
+
opts.progress?.({ phase: 'page', page: p + 1, totalPages });
|
|
140
186
|
const range = /** @type {import('./paginate.js').PageRange} */ (ranges[p]);
|
|
141
187
|
const cs = new ContentStream();
|
|
142
|
-
const painter = new Painter(cs, geo, fonts, images, gsName);
|
|
188
|
+
const painter = new Painter(cs, geo, fonts, images, gsName, gradients);
|
|
143
189
|
|
|
144
190
|
// 本文: ドキュメント y = range.start が本文領域の上端 + 繰り返し thead の高さ に来る
|
|
145
191
|
const bodyShiftPt = range.headShift * PX_TO_PT;
|
|
@@ -200,6 +246,7 @@ export async function buildPdf(body, geo, opts) {
|
|
|
200
246
|
Font: fontDict,
|
|
201
247
|
XObject: xobjDict,
|
|
202
248
|
ExtGState: gstateDict,
|
|
249
|
+
Shading: shadingDict,
|
|
203
250
|
ProcSet: [new Name('PDF'), new Name('Text'), new Name('ImageC')],
|
|
204
251
|
},
|
|
205
252
|
Contents: contentRef,
|
|
@@ -252,14 +299,16 @@ class Painter {
|
|
|
252
299
|
* @param {PageGeometry} geo
|
|
253
300
|
* @param {Map<import('./font/registry.js').RegisteredFont, EmbeddedFont>} fonts
|
|
254
301
|
* @param {Map<string, {name: string, image: import('./walker/image.js').DecodedImage}>} images
|
|
255
|
-
* @param {(alpha: number) => string} gsName
|
|
302
|
+
* @param {(alpha: number, strokeAlpha?: number) => string} gsName
|
|
303
|
+
* @param {Map<import('./walker/walk.js').DisplayItem, {sh: string, gs: string|null}>} gradients
|
|
256
304
|
*/
|
|
257
|
-
constructor(cs, geo, fonts, images, gsName) {
|
|
305
|
+
constructor(cs, geo, fonts, images, gsName, gradients) {
|
|
258
306
|
this.cs = cs;
|
|
259
307
|
this.geo = geo;
|
|
260
308
|
this.fonts = fonts;
|
|
261
309
|
this.images = images;
|
|
262
310
|
this.gsName = gsName;
|
|
311
|
+
this.gradients = gradients;
|
|
263
312
|
this.pdfTop = geo.height - geo.top;
|
|
264
313
|
this.docTop = 0;
|
|
265
314
|
this.curAlpha = 1;
|
|
@@ -339,6 +388,46 @@ class Painter {
|
|
|
339
388
|
cs.roundedRect(this.X(it.x), this.Y(it.y + it.h), it.w * PX_TO_PT, it.h * PX_TO_PT, this.radiusPt(it.radius)).stroke();
|
|
340
389
|
cs.restore();
|
|
341
390
|
this.curAlpha = 1;
|
|
391
|
+
} else if (it.type === 'path') {
|
|
392
|
+
if (!it.segs.length) continue;
|
|
393
|
+
const [a, b2, c, d, e, f] = it.matrix;
|
|
394
|
+
// ユーザー単位 → ドキュメント px → PDF pt(y 反転)を 1 つの行列にまとめる
|
|
395
|
+
const S = PX_TO_PT;
|
|
396
|
+
const tx = this.geo.left;
|
|
397
|
+
const ty = this.pdfTop + this.docTop * S;
|
|
398
|
+
cs.save();
|
|
399
|
+
cs.transform(S * a, -S * b2, S * c, -S * d, S * e + tx, -S * f + ty);
|
|
400
|
+
if (it.fill) cs.fillColor(it.fill.r, it.fill.g, it.fill.b);
|
|
401
|
+
if (it.stroke) {
|
|
402
|
+
cs.strokeColor(it.stroke.color.r, it.stroke.color.g, it.stroke.color.b);
|
|
403
|
+
cs.lineWidth(it.stroke.width);
|
|
404
|
+
cs.lineCap(it.stroke.cap);
|
|
405
|
+
cs.lineJoin(it.stroke.join);
|
|
406
|
+
if (it.stroke.join === 0) cs.miterLimit(it.stroke.miter);
|
|
407
|
+
if (it.stroke.dash) cs.dash(it.stroke.dash, it.stroke.dashOffset);
|
|
408
|
+
}
|
|
409
|
+
// 塗りと線でアルファが違うことがあるので ExtGState には両方を渡す
|
|
410
|
+
const fa = it.fill ? it.fill.a : 1;
|
|
411
|
+
const sa = it.stroke ? it.stroke.color.a : 1;
|
|
412
|
+
if (fa !== 1 || sa !== 1) cs.setGState(this.gsName(fa, sa));
|
|
413
|
+
cs.path(it.segs);
|
|
414
|
+
if (it.fill && it.stroke) cs.fillAndStroke(it.evenOdd);
|
|
415
|
+
else if (it.fill) cs.fill(it.evenOdd);
|
|
416
|
+
else cs.stroke();
|
|
417
|
+
cs.restore();
|
|
418
|
+
this.curAlpha = 1;
|
|
419
|
+
} else if (it.type === 'gradient') {
|
|
420
|
+
const g = this.gradients.get(it);
|
|
421
|
+
if (!g || it.box.w <= 0 || it.box.h <= 0) continue;
|
|
422
|
+
cs.save();
|
|
423
|
+
this.clipBox(it.clip);
|
|
424
|
+
// 箱ローカルの CSS px 空間(左上原点・y 下向き)へ写す。シェーディングの座標系もこれ。
|
|
425
|
+
cs.transform(PX_TO_PT, 0, 0, -PX_TO_PT, this.X(it.box.x), this.Y(it.box.y));
|
|
426
|
+
if (g.gs) cs.setGState(g.gs);
|
|
427
|
+
else this.setAlpha(it.alpha);
|
|
428
|
+
cs.shading(g.sh);
|
|
429
|
+
cs.restore();
|
|
430
|
+
this.curAlpha = 1;
|
|
342
431
|
} else if (it.type === 'image') {
|
|
343
432
|
const im = this.images.get(it.image.key);
|
|
344
433
|
if (!im || it.w <= 0 || it.h <= 0) continue;
|
|
@@ -415,6 +504,8 @@ function buildTJ(it, ef) {
|
|
|
415
504
|
/** @param {import('./walker/walk.js').DisplayItem} it */
|
|
416
505
|
function itemTop(it) {
|
|
417
506
|
if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y;
|
|
507
|
+
if (it.type === 'gradient') return it.clip.y;
|
|
508
|
+
if (it.type === 'path') return it.top;
|
|
418
509
|
if (it.type === 'line') return Math.min(it.y1, it.y2) - it.width / 2;
|
|
419
510
|
if (it.type === 'group' || it.type === 'clip') return it.top;
|
|
420
511
|
return it.top;
|
|
@@ -423,6 +514,8 @@ function itemTop(it) {
|
|
|
423
514
|
/** @param {import('./walker/walk.js').DisplayItem} it */
|
|
424
515
|
function itemBottom(it) {
|
|
425
516
|
if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y + it.h;
|
|
517
|
+
if (it.type === 'gradient') return it.clip.y + it.clip.h;
|
|
518
|
+
if (it.type === 'path') return it.bottom;
|
|
426
519
|
if (it.type === 'line') return Math.max(it.y1, it.y2) + it.width / 2;
|
|
427
520
|
if (it.type === 'group' || it.type === 'clip') return it.bottom;
|
|
428
521
|
return it.bottom;
|
package/src/paginate.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 方針: 命令は動かさず、ページごとに「この y 範囲を描く」と決めるだけにする。
|
|
6
6
|
* 境界は、アトム(テキスト行・表の行・画像・break-inside: avoid)を跨がない位置まで上へ戻す。
|
|
7
|
+
* `break-before/after: avoid` で結ばれた箱の間にも境界を置かず、置きそうなら前の箱の先頭まで戻す。
|
|
7
8
|
* テーブルが次ページへ続くときは thead を各ページ先頭で繰り返し、その高さ分だけ本文を下げる。
|
|
8
9
|
*/
|
|
9
10
|
|
|
@@ -27,10 +28,17 @@
|
|
|
27
28
|
* @returns {PageRange[]}
|
|
28
29
|
*/
|
|
29
30
|
export function paginate(walk, pageHeightPx) {
|
|
31
|
+
const EPS_INIT = 0.01;
|
|
30
32
|
const H = pageHeightPx;
|
|
31
33
|
const total = walk.height;
|
|
32
34
|
const atoms = [...walk.atoms].sort((a, b) => a.top - b.top);
|
|
33
35
|
const breaks = [...new Set(walk.breaks)].sort((a, b) => a - b);
|
|
36
|
+
// break-before/after: avoid で結ばれた箱。
|
|
37
|
+
// 境界は次の箱の「先頭」ではなく「最初の行」まで許せない(line-height の半行分だけ箱の上端より下に来るため)。
|
|
38
|
+
const joins = (walk.joins ?? []).map((j) => {
|
|
39
|
+
const first = atoms.find((a) => a.top >= j.end - EPS_INIT);
|
|
40
|
+
return { start: j.start, limit: first ? Math.max(first.top, j.end) : j.end, pullTo: j.pullTo };
|
|
41
|
+
});
|
|
34
42
|
const EPS = 0.01;
|
|
35
43
|
|
|
36
44
|
/** @type {PageRange[]} */
|
|
@@ -110,6 +118,17 @@ export function paginate(walk, pageHeightPx) {
|
|
|
110
118
|
moved = true;
|
|
111
119
|
}
|
|
112
120
|
}
|
|
121
|
+
// break-before/after: avoid — 結ばれた 2 つの箱の間に境界があれば、前の箱の先頭まで戻す
|
|
122
|
+
for (const j of joins) {
|
|
123
|
+
// 結んだ範囲がページに収まらないなら諦める(戻しても同じ位置で切ることになる)
|
|
124
|
+
if (j.limit - j.pullTo > capacity) continue;
|
|
125
|
+
// 戻し先がページ先頭以前だと空ページになるので諦める
|
|
126
|
+
if (j.pullTo <= start + EPS) continue;
|
|
127
|
+
if (end >= j.start - EPS && end <= j.limit + EPS && end > j.pullTo + EPS) {
|
|
128
|
+
end = j.pullTo;
|
|
129
|
+
moved = true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
113
132
|
}
|
|
114
133
|
// 上げ過ぎて空ページになる場合(ページより大きいアトム)は容量いっぱいで切る
|
|
115
134
|
if (end <= start + EPS) end = Math.min(start + capacity, total);
|
package/src/pdf/content.js
CHANGED
|
@@ -136,8 +136,41 @@ export class ContentStream {
|
|
|
136
136
|
return this;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
/** @param {0|1|2} join */
|
|
140
|
+
lineJoin(join) {
|
|
141
|
+
this.ops.push(`${join} j`);
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @param {number} limit */
|
|
146
|
+
miterLimit(limit) {
|
|
147
|
+
this.ops.push(`${num(limit)} M`);
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @param {boolean} [evenOdd] */
|
|
152
|
+
fill(evenOdd = false) {
|
|
153
|
+
this.ops.push(evenOdd ? 'f*' : 'f');
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 塗りと線の両方(B / B*) @param {boolean} [evenOdd] */
|
|
158
|
+
fillAndStroke(evenOdd = false) {
|
|
159
|
+
this.ops.push(evenOdd ? 'B*' : 'B');
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 正規化済みのパス(M / L / C / Z)を出力する。
|
|
165
|
+
* @param {import('../walker/svg-path.js').PathSeg[]} segs
|
|
166
|
+
*/
|
|
167
|
+
path(segs) {
|
|
168
|
+
for (const s of segs) {
|
|
169
|
+
if (s[0] === 'M') this.moveTo(s[1], s[2]);
|
|
170
|
+
else if (s[0] === 'L') this.lineTo(s[1], s[2]);
|
|
171
|
+
else if (s[0] === 'C') this.curveTo(s[1], s[2], s[3], s[4], s[5], s[6]);
|
|
172
|
+
else this.closePath();
|
|
173
|
+
}
|
|
141
174
|
return this;
|
|
142
175
|
}
|
|
143
176
|
|
|
@@ -146,6 +179,15 @@ export class ContentStream {
|
|
|
146
179
|
return this;
|
|
147
180
|
}
|
|
148
181
|
|
|
182
|
+
/**
|
|
183
|
+
* シェーディングを現在のクリップ範囲いっぱいに塗る。
|
|
184
|
+
* @param {string} name Shading リソース名
|
|
185
|
+
*/
|
|
186
|
+
shading(name) {
|
|
187
|
+
this.ops.push(`/${name} sh`);
|
|
188
|
+
return this;
|
|
189
|
+
}
|
|
190
|
+
|
|
149
191
|
/** 現在のパスでクリップして新しいパスを開始する */
|
|
150
192
|
clip() {
|
|
151
193
|
this.ops.push('W n');
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 軸シェーディング(ShadingType 2)の生成。
|
|
4
|
+
*
|
|
5
|
+
* 色は Type 2(指数補間)関数を色止めの数だけ作り、Type 3(継ぎ合わせ)でつなぐ。
|
|
6
|
+
* 色止めごとにアルファが変わる場合は、同じ形のグレースケールシェーディングを
|
|
7
|
+
* 輝度ソフトマスクにして再現する(PDF には色とアルファを同時に持つシェーディングが無いため)。
|
|
8
|
+
*/
|
|
9
|
+
import { Name } from './writer.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {import('../walker/gradient.js').GradientStop} GradientStop
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 色止めから Type 2/3 関数を作る。
|
|
17
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
18
|
+
* @param {GradientStop[]} stops
|
|
19
|
+
* @param {(c: import('../units.js').Rgba) => number[]} pick 色から成分配列を取り出す(RGB か グレー)
|
|
20
|
+
* @returns {import('./writer.js').Ref}
|
|
21
|
+
*/
|
|
22
|
+
function buildFunction(writer, stops, pick) {
|
|
23
|
+
const first = /** @type {GradientStop} */ (stops[0]);
|
|
24
|
+
const last = /** @type {GradientStop} */ (stops[stops.length - 1]);
|
|
25
|
+
const span = last.t - first.t;
|
|
26
|
+
// 全区間が 1 点に潰れている場合は単色
|
|
27
|
+
if (!(span > 0)) {
|
|
28
|
+
return writer.add({ FunctionType: 2, Domain: [0, 1], C0: pick(first.color), C1: pick(last.color), N: 1 });
|
|
29
|
+
}
|
|
30
|
+
if (stops.length === 2) {
|
|
31
|
+
return writer.add({ FunctionType: 2, Domain: [0, 1], C0: pick(first.color), C1: pick(last.color), N: 1 });
|
|
32
|
+
}
|
|
33
|
+
/** @type {import('./writer.js').PdfValue[]} */
|
|
34
|
+
const functions = [];
|
|
35
|
+
/** @type {number[]} */
|
|
36
|
+
const bounds = [];
|
|
37
|
+
/** @type {number[]} */
|
|
38
|
+
const encode = [];
|
|
39
|
+
for (let i = 0; i < stops.length - 1; i++) {
|
|
40
|
+
const a = /** @type {GradientStop} */ (stops[i]);
|
|
41
|
+
const b = /** @type {GradientStop} */ (stops[i + 1]);
|
|
42
|
+
functions.push(writer.add({ FunctionType: 2, Domain: [0, 1], C0: pick(a.color), C1: pick(b.color), N: 1 }));
|
|
43
|
+
encode.push(0, 1);
|
|
44
|
+
if (i > 0) bounds.push((a.t - first.t) / span);
|
|
45
|
+
}
|
|
46
|
+
return writer.add({ FunctionType: 3, Domain: [0, 1], Functions: functions, Bounds: bounds, Encode: encode });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 軸シェーディングの辞書を作る。座標は PDF 座標(pt)。
|
|
51
|
+
*
|
|
52
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
53
|
+
* @param {{x0: number, y0: number, x1: number, y1: number}} coords
|
|
54
|
+
* @param {GradientStop[]} stops
|
|
55
|
+
* @param {'rgb'|'gray'} space
|
|
56
|
+
* @returns {import('./writer.js').Ref}
|
|
57
|
+
*/
|
|
58
|
+
export function buildAxialShading(writer, coords, stops, space) {
|
|
59
|
+
const pick =
|
|
60
|
+
space === 'rgb'
|
|
61
|
+
? (/** @type {import('../units.js').Rgba} */ c) => [c.r, c.g, c.b]
|
|
62
|
+
: (/** @type {import('../units.js').Rgba} */ c) => [c.a];
|
|
63
|
+
return writer.add({
|
|
64
|
+
ShadingType: 2,
|
|
65
|
+
ColorSpace: new Name(space === 'rgb' ? 'DeviceRGB' : 'DeviceGray'),
|
|
66
|
+
Coords: [coords.x0, coords.y0, coords.x1, coords.y1],
|
|
67
|
+
Function: buildFunction(writer, stops, pick),
|
|
68
|
+
Extend: [true, true],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 色止めのアルファが一定ならその値、そうでなければ null。
|
|
74
|
+
* @param {GradientStop[]} stops
|
|
75
|
+
* @returns {number|null}
|
|
76
|
+
*/
|
|
77
|
+
export function uniformAlpha(stops) {
|
|
78
|
+
const a = /** @type {GradientStop} */ (stops[0]).color.a;
|
|
79
|
+
return stops.every((s) => Math.abs(s.color.a - a) < 0.002) ? a : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* アルファが変化するグラデーション用の輝度ソフトマスクを作る。
|
|
84
|
+
* グレースケールのシェーディングを描くフォーム XObject を /SMask に入れた ExtGState を返す。
|
|
85
|
+
*
|
|
86
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
87
|
+
* @param {{x0: number, y0: number, x1: number, y1: number}} coords PDF 座標
|
|
88
|
+
* @param {GradientStop[]} stops
|
|
89
|
+
* @param {{x: number, y: number, w: number, h: number}} bbox マスクを塗る範囲(PDF 座標)
|
|
90
|
+
* @param {number} groupAlpha 要素から継承した不透明度(グラデーション全体に掛かる)
|
|
91
|
+
* @returns {Promise<import('./writer.js').Ref>} ExtGState の参照
|
|
92
|
+
*/
|
|
93
|
+
export async function buildAlphaMaskGState(writer, coords, stops, bbox, groupAlpha) {
|
|
94
|
+
const scaled = groupAlpha === 1 ? stops : stops.map((s) => ({ t: s.t, color: { ...s.color, a: s.color.a * groupAlpha } }));
|
|
95
|
+
const shading = buildAxialShading(writer, coords, scaled, 'gray');
|
|
96
|
+
const content = new TextEncoder().encode(`q ${bbox.x} ${bbox.y} ${bbox.w} ${bbox.h} re W n /Sh0 sh Q\n`);
|
|
97
|
+
const form = await writer.addStream(
|
|
98
|
+
{
|
|
99
|
+
Type: new Name('XObject'),
|
|
100
|
+
Subtype: new Name('Form'),
|
|
101
|
+
BBox: [bbox.x, bbox.y, bbox.x + bbox.w, bbox.y + bbox.h],
|
|
102
|
+
Group: { Type: new Name('Group'), S: new Name('Transparency'), CS: new Name('DeviceGray') },
|
|
103
|
+
Resources: { Shading: { Sh0: shading } },
|
|
104
|
+
},
|
|
105
|
+
content,
|
|
106
|
+
);
|
|
107
|
+
return writer.add({
|
|
108
|
+
Type: new Name('ExtGState'),
|
|
109
|
+
SMask: { Type: new Name('Mask'), S: new Name('Luminosity'), G: form, BC: [0] },
|
|
110
|
+
ca: 1,
|
|
111
|
+
CA: 1,
|
|
112
|
+
});
|
|
113
|
+
}
|
package/src/renderer.js
CHANGED
|
@@ -50,6 +50,7 @@ export async function renderDocument(input, opts) {
|
|
|
50
50
|
fit();
|
|
51
51
|
// レイアウトを確定させる
|
|
52
52
|
void doc.body.offsetHeight;
|
|
53
|
+
warnIfTooWide(doc, opts.widthPx, opts.warn ?? (() => {}));
|
|
53
54
|
|
|
54
55
|
return {
|
|
55
56
|
iframe,
|
|
@@ -68,7 +69,9 @@ export async function renderDocument(input, opts) {
|
|
|
68
69
|
function buildHtml(input, opts) {
|
|
69
70
|
const baseUrl = opts.baseUrl ?? document.baseURI;
|
|
70
71
|
const base = `<base href="${escapeAttr(baseUrl)}">`;
|
|
71
|
-
|
|
72
|
+
// 親文書の body マージンが PDF に持ち込まれると内容が右へずれて右端が切れるので、
|
|
73
|
+
// !important で確実に打ち消す(用紙の余白は options.page.margin が受け持つ)。
|
|
74
|
+
const reset = `<style data-rhtp-reset>html,body{margin:0 !important;padding:0 !important;background:transparent}html{-webkit-text-size-adjust:100%}</style>`;
|
|
72
75
|
|
|
73
76
|
if (typeof input === 'string') {
|
|
74
77
|
// 完全な HTML 文書ならそのまま。<head> の直後に base とリセットを差し込む。
|
|
@@ -151,6 +154,29 @@ function shadowHostStyles(input, stylesheets, mediaPrint) {
|
|
|
151
154
|
return parts.map((css) => `<style>${mediaPrint ? expandPrintMediaCss(css) : css}</style>`).join('');
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
/**
|
|
158
|
+
* 内容が本文領域より横に広いと、右側が切れたまま気づかれにくいので警告する。
|
|
159
|
+
* 固定幅 + `box-sizing: content-box` や、畳めない表が原因になりやすい。
|
|
160
|
+
*
|
|
161
|
+
* @param {Document} doc
|
|
162
|
+
* @param {number} widthPx 本文領域の幅(px)
|
|
163
|
+
* @param {(w: import('./index.js').ConversionWarning) => void} warn
|
|
164
|
+
*/
|
|
165
|
+
function warnIfTooWide(doc, widthPx, warn) {
|
|
166
|
+
const width = Math.max(doc.documentElement.scrollWidth, doc.body.scrollWidth);
|
|
167
|
+
const over = width - widthPx;
|
|
168
|
+
// 1px 未満は丸め誤差とみなす
|
|
169
|
+
if (over < 1) return;
|
|
170
|
+
const mm = (/** @type {number} */ px) => Math.round((px / 96) * 25.4 * 10) / 10;
|
|
171
|
+
warn({
|
|
172
|
+
code: 'other',
|
|
173
|
+
message:
|
|
174
|
+
`Content is ${Math.round(over)}px (${mm(over)}mm) wider than the page content area ` +
|
|
175
|
+
`(${Math.round(width)}px vs ${Math.round(widthPx)}px); the right side will be clipped. ` +
|
|
176
|
+
'Common causes: a fixed width plus padding/border without box-sizing: border-box, or a table that cannot shrink.',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
154
180
|
/** シャドウルートを持てない要素(void 要素)。outerHTML にフォールバックする。 */
|
|
155
181
|
const VOID_TAGS = new Set(['AREA', 'BASE', 'BR', 'COL', 'EMBED', 'HR', 'IMG', 'INPUT', 'LINK', 'META', 'SOURCE', 'TRACK', 'WBR']);
|
|
156
182
|
|
|
@@ -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
|
+
}
|