@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.
- package/CHANGELOG.md +45 -0
- package/README.md +20 -11
- package/dist/receipt-html-to-pdf.min.js +17 -16
- package/dist/receipt-html-to-pdf.min.js.map +4 -4
- package/examples/cdn.html +2 -2
- package/package.json +1 -1
- package/skills/receipt-html-to-pdf/SKILL.md +30 -5
- package/skills/receipt-html-to-pdf/references/receipt-format.md +3 -1
- package/src/font/gsub.js +229 -0
- package/src/index.js +26 -1
- package/src/pacer.js +50 -0
- package/src/page.js +195 -22
- package/src/paginate.js +19 -0
- package/src/pdf/content.js +44 -2
- package/src/pdf/outline.js +103 -0
- package/src/pdf/shading.js +113 -0
- 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 +330 -24
- package/types/font/gsub.d.ts +47 -0
- package/types/index.d.ts +38 -1
- package/types/pacer.d.ts +11 -0
- package/types/page.d.ts +6 -1
- package/types/paginate.d.ts +1 -0
- package/types/pdf/content.d.ts +18 -1
- package/types/pdf/outline.d.ts +41 -0
- 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 +72 -1
package/src/page.js
CHANGED
|
@@ -9,6 +9,9 @@ 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';
|
|
14
|
+
import { buildLinkAnnot, buildOutline } from './pdf/outline.js';
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* @typedef {object} PageGeometry
|
|
@@ -56,7 +59,7 @@ export function resolvePage(page = {}) {
|
|
|
56
59
|
/**
|
|
57
60
|
* @param {import('./walker/walk.js').WalkResult} body
|
|
58
61
|
* @param {PageGeometry} geo
|
|
59
|
-
* @param {{compress: boolean, metadata?: import('./index.js').PdfMetadata, header?: PageDecoration|null, footer?: PageDecoration|null}} opts
|
|
62
|
+
* @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, links?: boolean, outline?: boolean}} opts
|
|
60
63
|
* @returns {Promise<Uint8Array>}
|
|
61
64
|
*/
|
|
62
65
|
export async function buildPdf(body, geo, opts) {
|
|
@@ -113,33 +116,97 @@ export async function buildPdf(body, geo, opts) {
|
|
|
113
116
|
for (const ef of fonts.values()) fontDict[ef.resourceName] = await ef.embed(writer);
|
|
114
117
|
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
115
118
|
const xobjDict = {};
|
|
116
|
-
for (const im of images.values())
|
|
119
|
+
for (const im of images.values()) {
|
|
120
|
+
let img = im.image;
|
|
121
|
+
// 並行して走る別の変換が先に解放していた場合は読み直す
|
|
122
|
+
if (!img.jpeg && !img.rgb) {
|
|
123
|
+
const again = await loadImage(img.key, opts.warn ?? (() => {}));
|
|
124
|
+
if (!again) continue;
|
|
125
|
+
img = again;
|
|
126
|
+
}
|
|
127
|
+
xobjDict[im.name] = await embedImage(writer, img);
|
|
128
|
+
// 埋め込みが済めばピクセルデータは不要。大きな文書のピーク使用量を抑える
|
|
129
|
+
releasePixels(img);
|
|
130
|
+
if (opts.pacer) await opts.pacer();
|
|
131
|
+
}
|
|
117
132
|
|
|
118
133
|
// ExtGState(透明度)
|
|
119
134
|
/** @type {Map<string, string>} */
|
|
120
135
|
const gstates = new Map();
|
|
121
136
|
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
122
137
|
const gstateDict = {};
|
|
123
|
-
const gsName = (/** @type {number} */ a) => {
|
|
124
|
-
const key = num(a)
|
|
138
|
+
const gsName = (/** @type {number} */ a, /** @type {number} */ strokeA = a) => {
|
|
139
|
+
const key = `${num(a)}/${num(strokeA)}`;
|
|
125
140
|
let name = gstates.get(key);
|
|
126
141
|
if (!name) {
|
|
127
142
|
name = `GS${gstates.size + 1}`;
|
|
128
143
|
gstates.set(key, name);
|
|
129
|
-
gstateDict[name] = writer.add({ Type: 'ExtGState', ca: a, CA:
|
|
144
|
+
gstateDict[name] = writer.add({ Type: 'ExtGState', ca: a, CA: strokeA });
|
|
130
145
|
}
|
|
131
146
|
return name;
|
|
132
147
|
};
|
|
133
148
|
|
|
149
|
+
// Shading(linear-gradient)。座標は箱ローカルの CSS px で持ち、描画時に cm で用紙座標へ写す。
|
|
150
|
+
// こうするとページごとに作り直さずに済む。
|
|
151
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
152
|
+
const shadingDict = {};
|
|
153
|
+
/** @type {Map<import('./walker/walk.js').DisplayItem, {sh: string, gs: string|null}>} */
|
|
154
|
+
const gradients = new Map();
|
|
155
|
+
/** @param {import('./walker/walk.js').DisplayItem[]} list */
|
|
156
|
+
const prepareGradients = async (list) => {
|
|
157
|
+
for (const it of list) {
|
|
158
|
+
if (it.type === 'gradient') {
|
|
159
|
+
const name = `Sh${Object.keys(shadingDict).length + 1}`;
|
|
160
|
+
shadingDict[name] = buildAxialShading(writer, it.gradient, it.gradient.stops, 'rgb');
|
|
161
|
+
const alpha = uniformAlpha(it.gradient.stops);
|
|
162
|
+
let gs = null;
|
|
163
|
+
if (alpha === null) {
|
|
164
|
+
// 色止めごとにアルファが変わる → 輝度ソフトマスクで再現する
|
|
165
|
+
const gsRef = await buildAlphaMaskGState(writer, it.gradient, it.gradient.stops, { x: 0, y: 0, w: it.box.w, h: it.box.h }, it.alpha);
|
|
166
|
+
gs = `GM${gradients.size + 1}`;
|
|
167
|
+
gstateDict[gs] = gsRef;
|
|
168
|
+
}
|
|
169
|
+
gradients.set(it, { sh: name, gs });
|
|
170
|
+
} else if (it.type === 'group' || it.type === 'clip') {
|
|
171
|
+
await prepareGradients(it.items);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
await prepareGradients(body.items);
|
|
176
|
+
for (const w of [...headers, ...footers]) if (w) await prepareGradients(w.items);
|
|
177
|
+
|
|
134
178
|
const pagesRef = writer.reserve();
|
|
135
179
|
/** @type {import('./pdf/writer.js').Ref[]} */
|
|
136
180
|
const pageRefs = [];
|
|
181
|
+
// リンクの飛び先は前後どちらのページにもなりうるので、ページ参照を先に確保しておく
|
|
182
|
+
const pageSlots = Array.from({ length: totalPages }, () => writer.reserve());
|
|
183
|
+
/** @param {number} i @returns {import('./pdf/writer.js').Ref} */
|
|
184
|
+
const slotOf = (i) => /** @type {import('./pdf/writer.js').Ref} */ (pageSlots[i]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* ドキュメント y が載るページと、そのページでの PDF 座標を求める。
|
|
188
|
+
* @param {number} docY
|
|
189
|
+
* @returns {{page: number, x: number, y: number}|null}
|
|
190
|
+
*/
|
|
191
|
+
const locate = (docY) => {
|
|
192
|
+
for (let i = 0; i < totalPages; i++) {
|
|
193
|
+
const r = /** @type {import('./paginate.js').PageRange} */ (ranges[i]);
|
|
194
|
+
if (docY >= r.start - 0.01 && (docY < r.end - 0.01 || i === totalPages - 1)) {
|
|
195
|
+
const top = contentTop - r.headShift * PX_TO_PT;
|
|
196
|
+
return { page: i, x: geo.left, y: top - (docY - r.start) * PX_TO_PT };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
137
201
|
|
|
138
202
|
// 4. ページごとに描画
|
|
203
|
+
opts.progress?.({ phase: 'layout', totalPages });
|
|
139
204
|
for (let p = 0; p < totalPages; p++) {
|
|
205
|
+
if (opts.pacer) await opts.pacer();
|
|
206
|
+
opts.progress?.({ phase: 'page', page: p + 1, totalPages });
|
|
140
207
|
const range = /** @type {import('./paginate.js').PageRange} */ (ranges[p]);
|
|
141
208
|
const cs = new ContentStream();
|
|
142
|
-
const painter = new Painter(cs, geo, fonts, images, gsName);
|
|
209
|
+
const painter = new Painter(cs, geo, fonts, images, gsName, gradients);
|
|
143
210
|
|
|
144
211
|
// 本文: ドキュメント y = range.start が本文領域の上端 + 繰り返し thead の高さ に来る
|
|
145
212
|
const bodyShiftPt = range.headShift * PX_TO_PT;
|
|
@@ -190,25 +257,85 @@ export async function buildPdf(body, geo, opts) {
|
|
|
190
257
|
cs.restore();
|
|
191
258
|
}
|
|
192
259
|
|
|
260
|
+
// リンク注釈。ページ範囲で切り取ってから用紙座標へ写す
|
|
261
|
+
/** @type {import('./pdf/writer.js').PdfValue[]} */
|
|
262
|
+
const annots = [];
|
|
263
|
+
if (opts.links !== false) {
|
|
264
|
+
/**
|
|
265
|
+
* @param {import('./walker/walk.js').LinkRect[]} list
|
|
266
|
+
* @param {number} pdfTop この帯の上端(PDF 座標)
|
|
267
|
+
* @param {number} docTop その位置に対応するドキュメント y
|
|
268
|
+
* @param {number} docEnd この帯に出せるドキュメント y の終わり
|
|
269
|
+
*/
|
|
270
|
+
const addLinks = (list, pdfTop, docTop, docEnd) => {
|
|
271
|
+
for (const link of list) {
|
|
272
|
+
const y0 = Math.max(link.y, docTop);
|
|
273
|
+
const y1 = Math.min(link.y + link.h, docEnd);
|
|
274
|
+
if (y1 - y0 <= 0.01) continue;
|
|
275
|
+
const rect = {
|
|
276
|
+
x: geo.left + link.x * PX_TO_PT,
|
|
277
|
+
y: pdfTop - (y1 - docTop) * PX_TO_PT,
|
|
278
|
+
w: link.w * PX_TO_PT,
|
|
279
|
+
h: (y1 - y0) * PX_TO_PT,
|
|
280
|
+
};
|
|
281
|
+
if (link.fragment === null) {
|
|
282
|
+
annots.push(buildLinkAnnot(writer, rect, { uri: link.href }));
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const targetY = body.anchors?.get(link.fragment);
|
|
286
|
+
if (targetY === undefined) continue; // 飛び先が無いリンクは注釈にしない
|
|
287
|
+
const at = locate(targetY);
|
|
288
|
+
if (!at) continue;
|
|
289
|
+
annots.push(buildLinkAnnot(writer, rect, { dest: [slotOf(at.page), new Name('XYZ'), at.x, at.y, null] }));
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
addLinks(body.links ?? [], contentTop - bodyShiftPt, range.start, range.end);
|
|
293
|
+
if (header) addLinks(header.links ?? [], geo.height - geo.top, 0, headerPt / PX_TO_PT);
|
|
294
|
+
if (footer) addLinks(footer.links ?? [], geo.bottom + footerPt, 0, footerPt / PX_TO_PT);
|
|
295
|
+
}
|
|
296
|
+
|
|
193
297
|
const contentRef = await writer.addStream({}, cs.toBytes());
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
298
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
299
|
+
const pageDict = {
|
|
300
|
+
Type: 'Page',
|
|
301
|
+
Parent: pagesRef,
|
|
302
|
+
MediaBox: [0, 0, geo.width, geo.height],
|
|
303
|
+
Resources: {
|
|
304
|
+
Font: fontDict,
|
|
305
|
+
XObject: xobjDict,
|
|
306
|
+
ExtGState: gstateDict,
|
|
307
|
+
Shading: shadingDict,
|
|
308
|
+
ProcSet: [new Name('PDF'), new Name('Text'), new Name('ImageC')],
|
|
309
|
+
},
|
|
310
|
+
Contents: contentRef,
|
|
311
|
+
};
|
|
312
|
+
if (annots.length) pageDict.Annots = annots;
|
|
313
|
+
const slot = slotOf(p);
|
|
314
|
+
writer.set(slot, pageDict);
|
|
315
|
+
pageRefs.push(slot);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
writer.set(pagesRef, { Type: 'Pages', Kids: pageRefs, Count: pageRefs.length });
|
|
319
|
+
|
|
320
|
+
// しおり: 見出しから木を作る
|
|
321
|
+
let outlineRef = null;
|
|
322
|
+
if (opts.outline) {
|
|
323
|
+
outlineRef = buildOutline(
|
|
324
|
+
writer,
|
|
325
|
+
(body.headings ?? []).map((h) => {
|
|
326
|
+
const at = locate(h.y);
|
|
327
|
+
return { level: h.level, text: h.text, dest: at ? [slotOf(at.page), new Name('XYZ'), at.x, at.y, null] : null };
|
|
206
328
|
}),
|
|
207
329
|
);
|
|
208
330
|
}
|
|
209
331
|
|
|
210
|
-
|
|
211
|
-
const
|
|
332
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
333
|
+
const catalogDict = { Type: 'Catalog', Pages: pagesRef };
|
|
334
|
+
if (outlineRef) {
|
|
335
|
+
catalogDict.Outlines = outlineRef;
|
|
336
|
+
catalogDict.PageMode = new Name('UseOutlines');
|
|
337
|
+
}
|
|
338
|
+
const catalog = writer.add(catalogDict);
|
|
212
339
|
|
|
213
340
|
const md = opts.metadata ?? {};
|
|
214
341
|
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
@@ -252,14 +379,16 @@ class Painter {
|
|
|
252
379
|
* @param {PageGeometry} geo
|
|
253
380
|
* @param {Map<import('./font/registry.js').RegisteredFont, EmbeddedFont>} fonts
|
|
254
381
|
* @param {Map<string, {name: string, image: import('./walker/image.js').DecodedImage}>} images
|
|
255
|
-
* @param {(alpha: number) => string} gsName
|
|
382
|
+
* @param {(alpha: number, strokeAlpha?: number) => string} gsName
|
|
383
|
+
* @param {Map<import('./walker/walk.js').DisplayItem, {sh: string, gs: string|null}>} gradients
|
|
256
384
|
*/
|
|
257
|
-
constructor(cs, geo, fonts, images, gsName) {
|
|
385
|
+
constructor(cs, geo, fonts, images, gsName, gradients) {
|
|
258
386
|
this.cs = cs;
|
|
259
387
|
this.geo = geo;
|
|
260
388
|
this.fonts = fonts;
|
|
261
389
|
this.images = images;
|
|
262
390
|
this.gsName = gsName;
|
|
391
|
+
this.gradients = gradients;
|
|
263
392
|
this.pdfTop = geo.height - geo.top;
|
|
264
393
|
this.docTop = 0;
|
|
265
394
|
this.curAlpha = 1;
|
|
@@ -339,6 +468,46 @@ class Painter {
|
|
|
339
468
|
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
469
|
cs.restore();
|
|
341
470
|
this.curAlpha = 1;
|
|
471
|
+
} else if (it.type === 'path') {
|
|
472
|
+
if (!it.segs.length) continue;
|
|
473
|
+
const [a, b2, c, d, e, f] = it.matrix;
|
|
474
|
+
// ユーザー単位 → ドキュメント px → PDF pt(y 反転)を 1 つの行列にまとめる
|
|
475
|
+
const S = PX_TO_PT;
|
|
476
|
+
const tx = this.geo.left;
|
|
477
|
+
const ty = this.pdfTop + this.docTop * S;
|
|
478
|
+
cs.save();
|
|
479
|
+
cs.transform(S * a, -S * b2, S * c, -S * d, S * e + tx, -S * f + ty);
|
|
480
|
+
if (it.fill) cs.fillColor(it.fill.r, it.fill.g, it.fill.b);
|
|
481
|
+
if (it.stroke) {
|
|
482
|
+
cs.strokeColor(it.stroke.color.r, it.stroke.color.g, it.stroke.color.b);
|
|
483
|
+
cs.lineWidth(it.stroke.width);
|
|
484
|
+
cs.lineCap(it.stroke.cap);
|
|
485
|
+
cs.lineJoin(it.stroke.join);
|
|
486
|
+
if (it.stroke.join === 0) cs.miterLimit(it.stroke.miter);
|
|
487
|
+
if (it.stroke.dash) cs.dash(it.stroke.dash, it.stroke.dashOffset);
|
|
488
|
+
}
|
|
489
|
+
// 塗りと線でアルファが違うことがあるので ExtGState には両方を渡す
|
|
490
|
+
const fa = it.fill ? it.fill.a : 1;
|
|
491
|
+
const sa = it.stroke ? it.stroke.color.a : 1;
|
|
492
|
+
if (fa !== 1 || sa !== 1) cs.setGState(this.gsName(fa, sa));
|
|
493
|
+
cs.path(it.segs);
|
|
494
|
+
if (it.fill && it.stroke) cs.fillAndStroke(it.evenOdd);
|
|
495
|
+
else if (it.fill) cs.fill(it.evenOdd);
|
|
496
|
+
else cs.stroke();
|
|
497
|
+
cs.restore();
|
|
498
|
+
this.curAlpha = 1;
|
|
499
|
+
} else if (it.type === 'gradient') {
|
|
500
|
+
const g = this.gradients.get(it);
|
|
501
|
+
if (!g || it.box.w <= 0 || it.box.h <= 0) continue;
|
|
502
|
+
cs.save();
|
|
503
|
+
this.clipBox(it.clip);
|
|
504
|
+
// 箱ローカルの CSS px 空間(左上原点・y 下向き)へ写す。シェーディングの座標系もこれ。
|
|
505
|
+
cs.transform(PX_TO_PT, 0, 0, -PX_TO_PT, this.X(it.box.x), this.Y(it.box.y));
|
|
506
|
+
if (g.gs) cs.setGState(g.gs);
|
|
507
|
+
else this.setAlpha(it.alpha);
|
|
508
|
+
cs.shading(g.sh);
|
|
509
|
+
cs.restore();
|
|
510
|
+
this.curAlpha = 1;
|
|
342
511
|
} else if (it.type === 'image') {
|
|
343
512
|
const im = this.images.get(it.image.key);
|
|
344
513
|
if (!im || it.w <= 0 || it.h <= 0) continue;
|
|
@@ -415,6 +584,8 @@ function buildTJ(it, ef) {
|
|
|
415
584
|
/** @param {import('./walker/walk.js').DisplayItem} it */
|
|
416
585
|
function itemTop(it) {
|
|
417
586
|
if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y;
|
|
587
|
+
if (it.type === 'gradient') return it.clip.y;
|
|
588
|
+
if (it.type === 'path') return it.top;
|
|
418
589
|
if (it.type === 'line') return Math.min(it.y1, it.y2) - it.width / 2;
|
|
419
590
|
if (it.type === 'group' || it.type === 'clip') return it.top;
|
|
420
591
|
return it.top;
|
|
@@ -423,6 +594,8 @@ function itemTop(it) {
|
|
|
423
594
|
/** @param {import('./walker/walk.js').DisplayItem} it */
|
|
424
595
|
function itemBottom(it) {
|
|
425
596
|
if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y + it.h;
|
|
597
|
+
if (it.type === 'gradient') return it.clip.y + it.clip.h;
|
|
598
|
+
if (it.type === 'path') return it.bottom;
|
|
426
599
|
if (it.type === 'line') return Math.max(it.y1, it.y2) + it.width / 2;
|
|
427
600
|
if (it.type === 'group' || it.type === 'clip') return it.bottom;
|
|
428
601
|
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,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* しおり(/Outlines)とリンク注釈(/Annots)の組み立て。
|
|
4
|
+
*/
|
|
5
|
+
import { Name, pdfString } from './writer.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{page: number, x: number, y: number}} DestPoint 飛び先(0 始まりのページ番号と、そのページの PDF 座標)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* リンク注釈を作る。
|
|
13
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
14
|
+
* @param {{x: number, y: number, w: number, h: number}} rect PDF 座標(左下原点)
|
|
15
|
+
* @param {{uri: string}|{dest: import('./writer.js').PdfValue}} action
|
|
16
|
+
* @returns {import('./writer.js').Ref}
|
|
17
|
+
*/
|
|
18
|
+
export function buildLinkAnnot(writer, rect, action) {
|
|
19
|
+
/** @type {{[key: string]: import('./writer.js').PdfValue}} */
|
|
20
|
+
const annot = {
|
|
21
|
+
Type: new Name('Annot'),
|
|
22
|
+
Subtype: new Name('Link'),
|
|
23
|
+
Rect: [rect.x, rect.y, rect.x + rect.w, rect.y + rect.h],
|
|
24
|
+
// 既定の枠線を消す(多くのビューアは描かないが、仕様上は残る)
|
|
25
|
+
Border: [0, 0, 0],
|
|
26
|
+
F: 4, // Print
|
|
27
|
+
};
|
|
28
|
+
if ('uri' in action) annot.A = { S: new Name('URI'), URI: pdfString(action.uri) };
|
|
29
|
+
else annot.Dest = action.dest;
|
|
30
|
+
return writer.add(annot);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 見出しの並びから、レベルに応じた木構造のしおりを作り、カタログに入れる参照を返す。
|
|
35
|
+
* 飛び先が解決できない見出しは飛ばす。
|
|
36
|
+
*
|
|
37
|
+
* @param {import('./writer.js').PdfWriter} writer
|
|
38
|
+
* @param {{level: number, text: string, dest: import('./writer.js').PdfValue|null}[]} entries
|
|
39
|
+
* @returns {import('./writer.js').Ref|null}
|
|
40
|
+
*/
|
|
41
|
+
export function buildOutline(writer, entries) {
|
|
42
|
+
const usable = entries.filter((e) => e.dest !== null);
|
|
43
|
+
if (!usable.length) return null;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {{level: number, text: string, dest: import('./writer.js').PdfValue, children: Node[], ref: import('./writer.js').Ref}} Node
|
|
47
|
+
*/
|
|
48
|
+
/** @type {Node[]} */
|
|
49
|
+
const roots = [];
|
|
50
|
+
/** @type {Node[]} 現在の祖先チェーン */
|
|
51
|
+
const stack = [];
|
|
52
|
+
for (const e of usable) {
|
|
53
|
+
/** @type {Node} */
|
|
54
|
+
const node = { level: e.level, text: e.text, dest: /** @type {import('./writer.js').PdfValue} */ (e.dest), children: [], ref: writer.reserve() };
|
|
55
|
+
while (stack.length && /** @type {Node} */ (stack[stack.length - 1]).level >= node.level) stack.pop();
|
|
56
|
+
const parent = stack[stack.length - 1];
|
|
57
|
+
if (parent) parent.children.push(node);
|
|
58
|
+
else roots.push(node);
|
|
59
|
+
stack.push(node);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const outlineRef = writer.reserve();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 兄弟の並びを書き出し、開いている項目数(Count 用)を返す。
|
|
66
|
+
* @param {Node[]} nodes
|
|
67
|
+
* @param {import('./writer.js').Ref} parentRef
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
const emit = (nodes, parentRef) => {
|
|
71
|
+
let total = 0;
|
|
72
|
+
nodes.forEach((node, i) => {
|
|
73
|
+
const childCount = emit(node.children, node.ref);
|
|
74
|
+
/** @type {{[key: string]: import('./writer.js').PdfValue}} */
|
|
75
|
+
const dict = {
|
|
76
|
+
Title: pdfString(node.text),
|
|
77
|
+
Parent: parentRef,
|
|
78
|
+
Dest: node.dest,
|
|
79
|
+
};
|
|
80
|
+
const prev = nodes[i - 1];
|
|
81
|
+
const next = nodes[i + 1];
|
|
82
|
+
if (prev) dict.Prev = prev.ref;
|
|
83
|
+
if (next) dict.Next = next.ref;
|
|
84
|
+
if (node.children.length) {
|
|
85
|
+
dict.First = /** @type {Node} */ (node.children[0]).ref;
|
|
86
|
+
dict.Last = /** @type {Node} */ (node.children[node.children.length - 1]).ref;
|
|
87
|
+
dict.Count = childCount; // 正の値 = 既定で開いた状態
|
|
88
|
+
}
|
|
89
|
+
writer.set(node.ref, dict);
|
|
90
|
+
total += 1 + childCount;
|
|
91
|
+
});
|
|
92
|
+
return total;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const count = emit(roots, outlineRef);
|
|
96
|
+
writer.set(outlineRef, {
|
|
97
|
+
Type: new Name('Outlines'),
|
|
98
|
+
First: /** @type {Node} */ (roots[0]).ref,
|
|
99
|
+
Last: /** @type {Node} */ (roots[roots.length - 1]).ref,
|
|
100
|
+
Count: count,
|
|
101
|
+
});
|
|
102
|
+
return outlineRef;
|
|
103
|
+
}
|
|
@@ -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
|
+
}
|