@hidemikimura/receipt-html-to-pdf 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/receipt-html-to-pdf.min.js +39 -0
- package/dist/receipt-html-to-pdf.min.js.map +7 -0
- package/examples/lit.js +52 -0
- package/examples/react.jsx +56 -0
- package/examples/vanilla.html +55 -0
- package/package.json +82 -0
- package/src/font/cid.js +187 -0
- package/src/font/parse.js +336 -0
- package/src/font/registry.js +168 -0
- package/src/font/subset.js +211 -0
- package/src/index.js +239 -0
- package/src/page.js +429 -0
- package/src/paginate.js +119 -0
- package/src/pdf/compress.js +30 -0
- package/src/pdf/content.js +192 -0
- package/src/pdf/image.js +33 -0
- package/src/pdf/writer.js +253 -0
- package/src/renderer.js +328 -0
- package/src/units.js +127 -0
- package/src/walker/image.js +264 -0
- package/src/walker/text.js +199 -0
- package/src/walker/walk.js +670 -0
- package/types/font/cid.d.ts +40 -0
- package/types/font/parse.d.ts +113 -0
- package/types/font/registry.d.ts +63 -0
- package/types/font/subset.d.ts +26 -0
- package/types/index.d.ts +198 -0
- package/types/page.d.ts +64 -0
- package/types/paginate.d.ts +59 -0
- package/types/pdf/compress.d.ts +13 -0
- package/types/pdf/content.d.ts +65 -0
- package/types/pdf/image.d.ts +9 -0
- package/types/pdf/writer.d.ts +125 -0
- package/types/renderer.d.ts +55 -0
- package/types/units.d.ts +53 -0
- package/types/walker/image.d.ts +72 -0
- package/types/walker/text.d.ts +38 -0
- package/types/walker/walk.d.ts +150 -0
package/src/page.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* DisplayList を用紙に割り付け、PDF ドキュメントを組み立てる。
|
|
4
|
+
* ページ範囲の決定は paginate.js、ここでは各ページに「本文(範囲指定)・繰り返し thead・ヘッダー・フッター」を描く。
|
|
5
|
+
*/
|
|
6
|
+
import { PdfWriter, pdfString, pdfDate, Name } from './pdf/writer.js';
|
|
7
|
+
import { ContentStream } from './pdf/content.js';
|
|
8
|
+
import { EmbeddedFont, hex4 } from './font/cid.js';
|
|
9
|
+
import { embedImage } from './pdf/image.js';
|
|
10
|
+
import { PX_TO_PT, PAGE_SIZES, lengthToPt, num } from './units.js';
|
|
11
|
+
import { paginate } from './paginate.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} PageGeometry
|
|
15
|
+
* @property {number} width pt
|
|
16
|
+
* @property {number} height pt
|
|
17
|
+
* @property {number} top pt 上余白
|
|
18
|
+
* @property {number} right pt
|
|
19
|
+
* @property {number} bottom pt 下余白
|
|
20
|
+
* @property {number} left pt
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} PageDecoration ヘッダー/フッター(ページごとに描画済みの走査結果)
|
|
25
|
+
* @property {number} heightPx
|
|
26
|
+
* @property {(pageNumber: number, totalPages: number) => Promise<import('./walker/walk.js').WalkResult>} render
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {import('./index.js').PageOptions|undefined} page
|
|
31
|
+
* @returns {PageGeometry}
|
|
32
|
+
*/
|
|
33
|
+
export function resolvePage(page = {}) {
|
|
34
|
+
const sizeOpt = page.size ?? 'A4';
|
|
35
|
+
let width;
|
|
36
|
+
let height;
|
|
37
|
+
if (typeof sizeOpt === 'string') {
|
|
38
|
+
const s = PAGE_SIZES[sizeOpt];
|
|
39
|
+
if (!s) throw new Error(`Unknown page size "${sizeOpt}". Use one of ${Object.keys(PAGE_SIZES).join(', ')} or {width, height}.`);
|
|
40
|
+
width = s.width;
|
|
41
|
+
height = s.height;
|
|
42
|
+
} else {
|
|
43
|
+
width = lengthToPt(sizeOpt.width);
|
|
44
|
+
height = lengthToPt(sizeOpt.height);
|
|
45
|
+
}
|
|
46
|
+
if ((page.orientation ?? 'portrait') === 'landscape' && width < height) [width, height] = [height, width];
|
|
47
|
+
|
|
48
|
+
const m = page.margin ?? '15mm';
|
|
49
|
+
const margins =
|
|
50
|
+
typeof m === 'string'
|
|
51
|
+
? { top: lengthToPt(m), right: lengthToPt(m), bottom: lengthToPt(m), left: lengthToPt(m) }
|
|
52
|
+
: { top: lengthToPt(m.top), right: lengthToPt(m.right), bottom: lengthToPt(m.bottom), left: lengthToPt(m.left) };
|
|
53
|
+
return { width, height, ...margins };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {import('./walker/walk.js').WalkResult} body
|
|
58
|
+
* @param {PageGeometry} geo
|
|
59
|
+
* @param {{compress: boolean, metadata?: import('./index.js').PdfMetadata, header?: PageDecoration|null, footer?: PageDecoration|null}} opts
|
|
60
|
+
* @returns {Promise<Uint8Array>}
|
|
61
|
+
*/
|
|
62
|
+
export async function buildPdf(body, geo, opts) {
|
|
63
|
+
const writer = new PdfWriter({ compress: opts.compress });
|
|
64
|
+
const headerPt = (opts.header?.heightPx ?? 0) * PX_TO_PT;
|
|
65
|
+
const footerPt = (opts.footer?.heightPx ?? 0) * PX_TO_PT;
|
|
66
|
+
const contentW = geo.width - geo.left - geo.right;
|
|
67
|
+
const contentTop = geo.height - geo.top - headerPt; // 本文領域の上端(PDF 座標)
|
|
68
|
+
const contentH = geo.height - geo.top - geo.bottom - headerPt - footerPt;
|
|
69
|
+
if (contentH <= 0) throw new Error('Page content area is empty: margins + header + footer exceed the page height');
|
|
70
|
+
const contentHpx = contentH / PX_TO_PT;
|
|
71
|
+
|
|
72
|
+
// 1. ページ範囲を決める
|
|
73
|
+
const ranges = paginate(body, contentHpx);
|
|
74
|
+
const totalPages = ranges.length;
|
|
75
|
+
|
|
76
|
+
// 2. ヘッダー/フッターをページごとに描画して走査結果を得る
|
|
77
|
+
/** @type {(import('./walker/walk.js').WalkResult|null)[]} */
|
|
78
|
+
const headers = [];
|
|
79
|
+
/** @type {(import('./walker/walk.js').WalkResult|null)[]} */
|
|
80
|
+
const footers = [];
|
|
81
|
+
for (let p = 0; p < totalPages; p++) {
|
|
82
|
+
headers.push(opts.header ? await opts.header.render(p + 1, totalPages) : null);
|
|
83
|
+
footers.push(opts.footer ? await opts.footer.render(p + 1, totalPages) : null);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 3. フォント・画像の使用を集める
|
|
87
|
+
/** @type {Map<import('./font/registry.js').RegisteredFont, EmbeddedFont>} */
|
|
88
|
+
const fonts = new Map();
|
|
89
|
+
/** @type {Map<string, {name: string, image: import('./walker/image.js').DecodedImage}>} */
|
|
90
|
+
const images = new Map();
|
|
91
|
+
const collect = (/** @type {import('./walker/walk.js').DisplayItem[]} */ list) => {
|
|
92
|
+
for (const it of list) {
|
|
93
|
+
if (it.type === 'text') {
|
|
94
|
+
let ef = fonts.get(it.font);
|
|
95
|
+
if (!ef) {
|
|
96
|
+
ef = new EmbeddedFont(it.font.parsed, `F${fonts.size + 1}`);
|
|
97
|
+
fonts.set(it.font, ef);
|
|
98
|
+
}
|
|
99
|
+
for (const g of it.glyphs) ef.addGlyph(g.gid, g.cp);
|
|
100
|
+
} else if (it.type === 'image') {
|
|
101
|
+
if (!images.has(it.image.key)) images.set(it.image.key, { name: `Im${images.size + 1}`, image: it.image });
|
|
102
|
+
} else if (it.type === 'group' || it.type === 'clip') {
|
|
103
|
+
collect(it.items);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
collect(body.items);
|
|
108
|
+
for (const w of [...headers, ...footers]) if (w) collect(w.items);
|
|
109
|
+
for (const ef of fonts.values()) ef.finalize();
|
|
110
|
+
|
|
111
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
112
|
+
const fontDict = {};
|
|
113
|
+
for (const ef of fonts.values()) fontDict[ef.resourceName] = await ef.embed(writer);
|
|
114
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
115
|
+
const xobjDict = {};
|
|
116
|
+
for (const im of images.values()) xobjDict[im.name] = await embedImage(writer, im.image);
|
|
117
|
+
|
|
118
|
+
// ExtGState(透明度)
|
|
119
|
+
/** @type {Map<string, string>} */
|
|
120
|
+
const gstates = new Map();
|
|
121
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
122
|
+
const gstateDict = {};
|
|
123
|
+
const gsName = (/** @type {number} */ a) => {
|
|
124
|
+
const key = num(a);
|
|
125
|
+
let name = gstates.get(key);
|
|
126
|
+
if (!name) {
|
|
127
|
+
name = `GS${gstates.size + 1}`;
|
|
128
|
+
gstates.set(key, name);
|
|
129
|
+
gstateDict[name] = writer.add({ Type: 'ExtGState', ca: a, CA: a });
|
|
130
|
+
}
|
|
131
|
+
return name;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const pagesRef = writer.reserve();
|
|
135
|
+
/** @type {import('./pdf/writer.js').Ref[]} */
|
|
136
|
+
const pageRefs = [];
|
|
137
|
+
|
|
138
|
+
// 4. ページごとに描画
|
|
139
|
+
for (let p = 0; p < totalPages; p++) {
|
|
140
|
+
const range = /** @type {import('./paginate.js').PageRange} */ (ranges[p]);
|
|
141
|
+
const cs = new ContentStream();
|
|
142
|
+
const painter = new Painter(cs, geo, fonts, images, gsName);
|
|
143
|
+
|
|
144
|
+
// 本文: ドキュメント y = range.start が本文領域の上端 + 繰り返し thead の高さ に来る
|
|
145
|
+
const bodyShiftPt = range.headShift * PX_TO_PT;
|
|
146
|
+
const footShiftPt = range.footShift * PX_TO_PT;
|
|
147
|
+
cs.save();
|
|
148
|
+
cs.rect(geo.left, contentTop - contentH + footShiftPt, contentW, contentH - bodyShiftPt - footShiftPt).clip();
|
|
149
|
+
painter.setOrigin(contentTop - bodyShiftPt, range.start);
|
|
150
|
+
painter.render(body.items, range);
|
|
151
|
+
cs.restore();
|
|
152
|
+
|
|
153
|
+
// 繰り返し tfoot: ブラウザの印刷と同じく、このページに載った最後の行の直下に置く
|
|
154
|
+
const bodyEndPt = contentTop - bodyShiftPt - (range.end - range.start) * PX_TO_PT;
|
|
155
|
+
for (const f of range.feet) {
|
|
156
|
+
const fPt = (f.table.footBottom - f.table.footTop) * PX_TO_PT;
|
|
157
|
+
const bottomPt = bodyEndPt - f.shift * PX_TO_PT - fPt;
|
|
158
|
+
cs.save();
|
|
159
|
+
cs.rect(geo.left, bottomPt, contentW, fPt).clip();
|
|
160
|
+
painter.setOrigin(bottomPt + fPt, f.table.footTop);
|
|
161
|
+
painter.render(f.table.footItems);
|
|
162
|
+
cs.restore();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 繰り返し thead
|
|
166
|
+
for (const h of range.heads) {
|
|
167
|
+
cs.save();
|
|
168
|
+
const hPt = (h.table.headBottom - h.table.headTop) * PX_TO_PT;
|
|
169
|
+
cs.rect(geo.left, contentTop - h.shift * PX_TO_PT - hPt, contentW, hPt).clip();
|
|
170
|
+
painter.setOrigin(contentTop - h.shift * PX_TO_PT, h.table.headTop);
|
|
171
|
+
painter.render(h.table.headItems);
|
|
172
|
+
cs.restore();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ヘッダー/フッター
|
|
176
|
+
const header = headers[p];
|
|
177
|
+
if (header) {
|
|
178
|
+
cs.save();
|
|
179
|
+
cs.rect(geo.left, geo.height - geo.top - headerPt, contentW, headerPt).clip();
|
|
180
|
+
painter.setOrigin(geo.height - geo.top, 0);
|
|
181
|
+
painter.render(header.items);
|
|
182
|
+
cs.restore();
|
|
183
|
+
}
|
|
184
|
+
const footer = footers[p];
|
|
185
|
+
if (footer) {
|
|
186
|
+
cs.save();
|
|
187
|
+
cs.rect(geo.left, geo.bottom, contentW, footerPt).clip();
|
|
188
|
+
painter.setOrigin(geo.bottom + footerPt, 0);
|
|
189
|
+
painter.render(footer.items);
|
|
190
|
+
cs.restore();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const contentRef = await writer.addStream({}, cs.toBytes());
|
|
194
|
+
pageRefs.push(
|
|
195
|
+
writer.add({
|
|
196
|
+
Type: 'Page',
|
|
197
|
+
Parent: pagesRef,
|
|
198
|
+
MediaBox: [0, 0, geo.width, geo.height],
|
|
199
|
+
Resources: {
|
|
200
|
+
Font: fontDict,
|
|
201
|
+
XObject: xobjDict,
|
|
202
|
+
ExtGState: gstateDict,
|
|
203
|
+
ProcSet: [new Name('PDF'), new Name('Text'), new Name('ImageC')],
|
|
204
|
+
},
|
|
205
|
+
Contents: contentRef,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
writer.set(pagesRef, { Type: 'Pages', Kids: pageRefs, Count: pageRefs.length });
|
|
211
|
+
const catalog = writer.add({ Type: 'Catalog', Pages: pagesRef });
|
|
212
|
+
|
|
213
|
+
const md = opts.metadata ?? {};
|
|
214
|
+
/** @type {{[key: string]: import('./pdf/writer.js').PdfValue}} */
|
|
215
|
+
const info = {
|
|
216
|
+
Producer: pdfString('receipt-html-to-pdf'),
|
|
217
|
+
Creator: pdfString(md.creator ?? 'receipt-html-to-pdf'),
|
|
218
|
+
CreationDate: pdfDate(md.creationDate ?? new Date()),
|
|
219
|
+
};
|
|
220
|
+
if (md.title) info.Title = pdfString(md.title);
|
|
221
|
+
if (md.author) info.Author = pdfString(md.author);
|
|
222
|
+
if (md.subject) info.Subject = pdfString(md.subject);
|
|
223
|
+
if (md.keywords) info.Keywords = pdfString(md.keywords);
|
|
224
|
+
const infoRef = writer.add(info);
|
|
225
|
+
|
|
226
|
+
return writer.build(catalog, infoRef);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 命令がページ範囲 [start, end) に属するか。
|
|
231
|
+
* テキストは行が分割されない前提で行の中心位置で判定し(クリップで消えた文字が抽出テキストに残らないように)、
|
|
232
|
+
* 矩形・線・画像・グループは範囲と重なれば含める(見えない部分はクリップされる)。
|
|
233
|
+
* @param {import('./walker/walk.js').DisplayItem} it
|
|
234
|
+
* @param {{start: number, end: number}} range
|
|
235
|
+
*/
|
|
236
|
+
function inRange(it, range) {
|
|
237
|
+
const EPS = 0.01;
|
|
238
|
+
if (it.type === 'text') {
|
|
239
|
+
const mid = (it.top + it.bottom) / 2;
|
|
240
|
+
return mid >= range.start - EPS && mid < range.end - EPS;
|
|
241
|
+
}
|
|
242
|
+
return itemBottom(it) > range.start + EPS && itemTop(it) < range.end - EPS;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* DisplayList を PDF コンテンツストリームへ描く。
|
|
247
|
+
* setOrigin() で「ドキュメント y = docY を PDF の y = pdfY に置く」対応を切り替える。
|
|
248
|
+
*/
|
|
249
|
+
class Painter {
|
|
250
|
+
/**
|
|
251
|
+
* @param {ContentStream} cs
|
|
252
|
+
* @param {PageGeometry} geo
|
|
253
|
+
* @param {Map<import('./font/registry.js').RegisteredFont, EmbeddedFont>} fonts
|
|
254
|
+
* @param {Map<string, {name: string, image: import('./walker/image.js').DecodedImage}>} images
|
|
255
|
+
* @param {(alpha: number) => string} gsName
|
|
256
|
+
*/
|
|
257
|
+
constructor(cs, geo, fonts, images, gsName) {
|
|
258
|
+
this.cs = cs;
|
|
259
|
+
this.geo = geo;
|
|
260
|
+
this.fonts = fonts;
|
|
261
|
+
this.images = images;
|
|
262
|
+
this.gsName = gsName;
|
|
263
|
+
this.pdfTop = geo.height - geo.top;
|
|
264
|
+
this.docTop = 0;
|
|
265
|
+
this.curAlpha = 1;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {number} pdfY PDF 座標(pt, 上向き)
|
|
270
|
+
* @param {number} docY ドキュメント座標(px, 下向き)
|
|
271
|
+
*/
|
|
272
|
+
setOrigin(pdfY, docY) {
|
|
273
|
+
this.pdfTop = pdfY;
|
|
274
|
+
this.docTop = docY;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** @param {number} x */
|
|
278
|
+
X(x) {
|
|
279
|
+
return this.geo.left + x * PX_TO_PT;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {number} y */
|
|
283
|
+
Y(y) {
|
|
284
|
+
return this.pdfTop - (y - this.docTop) * PX_TO_PT;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @param {number} a */
|
|
288
|
+
setAlpha(a) {
|
|
289
|
+
if (Math.abs(a - this.curAlpha) < 0.001) return;
|
|
290
|
+
this.cs.setGState(this.gsName(a));
|
|
291
|
+
this.curAlpha = a;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** @param {import('./walker/walk.js').Radius|undefined} r */
|
|
295
|
+
radiusPt(r) {
|
|
296
|
+
return /** @type {[number, number, number, number]} */ ((r ?? [0, 0, 0, 0]).map((v) => v * PX_TO_PT));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** @param {import('./walker/walk.js').Box} b */
|
|
300
|
+
clipBox(b) {
|
|
301
|
+
const cs = this.cs;
|
|
302
|
+
if (b.radius) cs.roundedRect(this.X(b.x), this.Y(b.y + b.h), b.w * PX_TO_PT, b.h * PX_TO_PT, this.radiusPt(b.radius));
|
|
303
|
+
else cs.rect(this.X(b.x), this.Y(b.y + b.h), b.w * PX_TO_PT, b.h * PX_TO_PT);
|
|
304
|
+
cs.clip();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {import('./walker/walk.js').DisplayItem[]} list
|
|
309
|
+
* @param {{start: number, end: number}} [range] 指定するとこの範囲に属する命令だけを描く(子グループにも適用)
|
|
310
|
+
*/
|
|
311
|
+
render(list, range) {
|
|
312
|
+
const cs = this.cs;
|
|
313
|
+
for (const it of list) {
|
|
314
|
+
if (range && !inRange(it, range)) continue;
|
|
315
|
+
if (it.type === 'rect') {
|
|
316
|
+
if (it.w <= 0 || it.h <= 0) continue;
|
|
317
|
+
cs.save();
|
|
318
|
+
this.setAlpha(it.color.a);
|
|
319
|
+
cs.fillColor(it.color.r, it.color.g, it.color.b);
|
|
320
|
+
if (it.radius) 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)).fill();
|
|
321
|
+
else cs.fillRect(this.X(it.x), this.Y(it.y + it.h), it.w * PX_TO_PT, it.h * PX_TO_PT);
|
|
322
|
+
cs.restore();
|
|
323
|
+
this.curAlpha = 1;
|
|
324
|
+
} else if (it.type === 'line') {
|
|
325
|
+
cs.save();
|
|
326
|
+
this.setAlpha(it.color.a);
|
|
327
|
+
cs.strokeColor(it.color.r, it.color.g, it.color.b);
|
|
328
|
+
cs.lineWidth(it.width * PX_TO_PT);
|
|
329
|
+
if (it.dash) cs.dash(it.dash.map((d) => d * PX_TO_PT));
|
|
330
|
+
cs.moveTo(this.X(it.x1), this.Y(it.y1)).lineTo(this.X(it.x2), this.Y(it.y2)).stroke();
|
|
331
|
+
cs.restore();
|
|
332
|
+
this.curAlpha = 1;
|
|
333
|
+
} else if (it.type === 'stroke-rrect') {
|
|
334
|
+
cs.save();
|
|
335
|
+
this.setAlpha(it.color.a);
|
|
336
|
+
cs.strokeColor(it.color.r, it.color.g, it.color.b);
|
|
337
|
+
cs.lineWidth(it.width * PX_TO_PT);
|
|
338
|
+
if (it.dash) cs.dash(it.dash.map((d) => d * PX_TO_PT));
|
|
339
|
+
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
|
+
cs.restore();
|
|
341
|
+
this.curAlpha = 1;
|
|
342
|
+
} else if (it.type === 'image') {
|
|
343
|
+
const im = this.images.get(it.image.key);
|
|
344
|
+
if (!im || it.w <= 0 || it.h <= 0) continue;
|
|
345
|
+
cs.save();
|
|
346
|
+
this.setAlpha(it.alpha);
|
|
347
|
+
if (it.clip) this.clipBox(it.clip);
|
|
348
|
+
cs.image(im.name, this.X(it.x), this.Y(it.y + it.h), it.w * PX_TO_PT, it.h * PX_TO_PT);
|
|
349
|
+
cs.restore();
|
|
350
|
+
this.curAlpha = 1;
|
|
351
|
+
} else if (it.type === 'text') {
|
|
352
|
+
const ef = /** @type {EmbeddedFont} */ (this.fonts.get(it.font));
|
|
353
|
+
const tj = buildTJ(it, ef);
|
|
354
|
+
cs.save();
|
|
355
|
+
this.setAlpha(it.color.a);
|
|
356
|
+
cs.fillColor(it.color.r, it.color.g, it.color.b);
|
|
357
|
+
cs.text(ef.resourceName, it.size * PX_TO_PT, this.X(it.x), this.Y(it.y), tj);
|
|
358
|
+
cs.restore();
|
|
359
|
+
this.curAlpha = 1;
|
|
360
|
+
} else if (it.type === 'group') {
|
|
361
|
+
// CSS 行列 (a b c d e f) を、PDF 座標(y 反転・0.75 倍)で同じ変換になる行列に写す。
|
|
362
|
+
// 線形部は y 反転により b, c の符号が反転し、変換の中心 origin は不動点として残す。
|
|
363
|
+
const [a, b, c, d] = it.matrix;
|
|
364
|
+
const ox = this.X(it.origin.x);
|
|
365
|
+
const oy = this.Y(it.origin.y);
|
|
366
|
+
const a2 = a;
|
|
367
|
+
const b2 = -b;
|
|
368
|
+
const c2 = -c;
|
|
369
|
+
const d2 = d;
|
|
370
|
+
const e2 = ox - (a2 * ox + c2 * oy) + it.matrix[4] * PX_TO_PT;
|
|
371
|
+
const f2 = oy - (b2 * ox + d2 * oy) - it.matrix[5] * PX_TO_PT;
|
|
372
|
+
cs.save();
|
|
373
|
+
cs.transform(a2, b2, c2, d2, e2, f2);
|
|
374
|
+
this.render(it.items, range);
|
|
375
|
+
cs.restore();
|
|
376
|
+
} else if (it.type === 'clip') {
|
|
377
|
+
cs.save();
|
|
378
|
+
this.clipBox(it.box);
|
|
379
|
+
this.render(it.items, range);
|
|
380
|
+
cs.restore();
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* グリフ列を TJ 配列にする。
|
|
388
|
+
* 実測したペン位置と、フォントの advance から計算した位置との差を 1/1000 単位の調整値として挿入する。
|
|
389
|
+
* これによりカーニング・letter-spacing・両端揃えがそのまま再現される。
|
|
390
|
+
* @param {import('./walker/walk.js').TextItem} it
|
|
391
|
+
* @param {EmbeddedFont} ef
|
|
392
|
+
* @returns {Array<string|number>}
|
|
393
|
+
*/
|
|
394
|
+
function buildTJ(it, ef) {
|
|
395
|
+
/** @type {Array<string|number>} */
|
|
396
|
+
const out = [];
|
|
397
|
+
let hex = '';
|
|
398
|
+
for (let i = 0; i < it.glyphs.length; i++) {
|
|
399
|
+
const g = /** @type {import('./walker/walk.js').Glyph} */ (it.glyphs[i]);
|
|
400
|
+
hex += hex4(ef.cid(g.gid));
|
|
401
|
+
const next = it.glyphs[i + 1];
|
|
402
|
+
if (!next) break;
|
|
403
|
+
const expected = g.x + g.advance;
|
|
404
|
+
const gap = next.x - expected; // px
|
|
405
|
+
const adj = (-gap / it.size) * 1000; // 正の値で左へ寄る
|
|
406
|
+
if (Math.abs(adj) >= 0.5) {
|
|
407
|
+
out.push(hex, Math.round(adj * 10) / 10);
|
|
408
|
+
hex = '';
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (hex) out.push(hex);
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @param {import('./walker/walk.js').DisplayItem} it */
|
|
416
|
+
function itemTop(it) {
|
|
417
|
+
if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y;
|
|
418
|
+
if (it.type === 'line') return Math.min(it.y1, it.y2) - it.width / 2;
|
|
419
|
+
if (it.type === 'group' || it.type === 'clip') return it.top;
|
|
420
|
+
return it.top;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** @param {import('./walker/walk.js').DisplayItem} it */
|
|
424
|
+
function itemBottom(it) {
|
|
425
|
+
if (it.type === 'rect' || it.type === 'stroke-rrect' || it.type === 'image') return it.y + it.h;
|
|
426
|
+
if (it.type === 'line') return Math.max(it.y1, it.y2) + it.width / 2;
|
|
427
|
+
if (it.type === 'group' || it.type === 'clip') return it.bottom;
|
|
428
|
+
return it.bottom;
|
|
429
|
+
}
|
package/src/paginate.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Paginator — 走査結果(アトム・強制改ページ・テーブル)から各ページの縦範囲を決める。
|
|
4
|
+
*
|
|
5
|
+
* 方針: 命令は動かさず、ページごとに「この y 範囲を描く」と決めるだけにする。
|
|
6
|
+
* 境界は、アトム(テキスト行・表の行・画像・break-inside: avoid)を跨がない位置まで上へ戻す。
|
|
7
|
+
* テーブルが次ページへ続くときは thead を各ページ先頭で繰り返し、その高さ分だけ本文を下げる。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} RepeatedHead
|
|
12
|
+
* @property {import('./walker/walk.js').TableInfo} table
|
|
13
|
+
* @property {number} shift このページで thead を描く位置(ページ先頭からの px オフセット)
|
|
14
|
+
*
|
|
15
|
+
* @typedef {object} PageRange
|
|
16
|
+
* @property {number} start 本文の描き始め y(ドキュメント px)
|
|
17
|
+
* @property {number} end 本文の描き終わり y(この値未満を描く)
|
|
18
|
+
* @property {RepeatedHead[]} heads ページ先頭で繰り返す thead
|
|
19
|
+
* @property {number} headShift 繰り返し thead の合計高さ(本文はこの分だけ下がる)
|
|
20
|
+
* @property {RepeatedHead[]} feet ページ末尾で繰り返す tfoot(表が次ページへ続くとき)
|
|
21
|
+
* @property {number} footShift 繰り返し tfoot の合計高さ(本文領域はこの分だけ縮む)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import('./walker/walk.js').WalkResult} walk
|
|
26
|
+
* @param {number} pageHeightPx 本文領域の高さ(px)
|
|
27
|
+
* @returns {PageRange[]}
|
|
28
|
+
*/
|
|
29
|
+
export function paginate(walk, pageHeightPx) {
|
|
30
|
+
const H = pageHeightPx;
|
|
31
|
+
const total = walk.height;
|
|
32
|
+
const atoms = [...walk.atoms].sort((a, b) => a.top - b.top);
|
|
33
|
+
const breaks = [...new Set(walk.breaks)].sort((a, b) => a - b);
|
|
34
|
+
const EPS = 0.01;
|
|
35
|
+
|
|
36
|
+
/** @type {PageRange[]} */
|
|
37
|
+
const pages = [];
|
|
38
|
+
let start = 0;
|
|
39
|
+
let guard = 0;
|
|
40
|
+
while (start < total - EPS && guard++ < 10000) {
|
|
41
|
+
// このページの先頭で繰り返す thead: ページ開始位置が表の途中にあり、thead が既に前ページで描かれている表
|
|
42
|
+
/** @type {RepeatedHead[]} */
|
|
43
|
+
const heads = [];
|
|
44
|
+
let headShift = 0;
|
|
45
|
+
for (const t of walk.tables) {
|
|
46
|
+
if (t.headItems.length && t.headBottom <= start + EPS && start < t.bottom - EPS) {
|
|
47
|
+
heads.push({ table: t, shift: headShift });
|
|
48
|
+
headShift += t.headBottom - t.headTop;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// tfoot の繰り返しは「このページで表が終わらない」ときだけ必要で、それは end に依存する。
|
|
52
|
+
// まず tfoot 無しで end を求め、繰り返しが必要な表があれば容量を減らして計算し直す(数回で収束する)。
|
|
53
|
+
/** @type {RepeatedHead[]} */
|
|
54
|
+
let feet = [];
|
|
55
|
+
let footShift = 0;
|
|
56
|
+
let end = start;
|
|
57
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
58
|
+
const capacity = Math.max(H - headShift - footShift, H * 0.25); // thead/tfoot が異常に高い場合の下限
|
|
59
|
+
end = computeEnd(start, capacity);
|
|
60
|
+
/** @type {RepeatedHead[]} */
|
|
61
|
+
const needed = [];
|
|
62
|
+
let shift = 0;
|
|
63
|
+
for (const t of walk.tables) {
|
|
64
|
+
// 表がこのページの途中から始まる・または続いていて、かつこのページで終わらない(本来の tfoot が次ページ以降)
|
|
65
|
+
if (t.footItems.length && t.top < end - EPS && t.footTop >= end - EPS && t.bottom > end + EPS) {
|
|
66
|
+
needed.push({ table: t, shift });
|
|
67
|
+
shift += t.footBottom - t.footTop;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const same = needed.length === feet.length && needed.every((n, i) => n.table === feet[i]?.table);
|
|
71
|
+
feet = needed;
|
|
72
|
+
footShift = shift;
|
|
73
|
+
if (same) break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pages.push({ start, end, heads, headShift, feet, footShift });
|
|
77
|
+
start = end;
|
|
78
|
+
}
|
|
79
|
+
if (!pages.length) pages.push({ start: 0, end: Math.max(total, 1), heads: [], headShift: 0, feet: [], footShift: 0 });
|
|
80
|
+
return pages;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* start から容量 capacity のページの終端を、強制改ページとアトムを考慮して決める。
|
|
84
|
+
* @param {number} start
|
|
85
|
+
* @param {number} capacity
|
|
86
|
+
*/
|
|
87
|
+
function computeEnd(start, capacity) {
|
|
88
|
+
let end = Math.min(start + capacity, total);
|
|
89
|
+
|
|
90
|
+
// 強制改ページ: (start, end) の中で最初のもの
|
|
91
|
+
for (const b of breaks) {
|
|
92
|
+
if (b > start + EPS && b < end - EPS) {
|
|
93
|
+
end = b;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// アトムを跨がない位置まで境界を上げる(他のアトムに連鎖することがあるので繰り返す)
|
|
99
|
+
if (end < total - EPS) {
|
|
100
|
+
let moved = true;
|
|
101
|
+
let iter = 0;
|
|
102
|
+
while (moved && iter++ < 1000) {
|
|
103
|
+
moved = false;
|
|
104
|
+
for (const a of atoms) {
|
|
105
|
+
if (a.top >= end) break;
|
|
106
|
+
// ページ容量より大きいアトムはどこかで切らざるを得ないので無視する
|
|
107
|
+
if (a.bottom - a.top > capacity) continue;
|
|
108
|
+
if (a.top < end - EPS && a.bottom > end + EPS && a.top > start + EPS) {
|
|
109
|
+
end = a.top;
|
|
110
|
+
moved = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// 上げ過ぎて空ページになる場合(ページより大きいアトム)は容量いっぱいで切る
|
|
115
|
+
if (end <= start + EPS) end = Math.min(start + capacity, total);
|
|
116
|
+
}
|
|
117
|
+
return end;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Web 標準 CompressionStream による zlib (FlateDecode) 圧縮。
|
|
4
|
+
* 未対応環境では null を返し、呼び出し側は無圧縮で出力する。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function canCompress() {
|
|
11
|
+
return typeof CompressionStream === 'function';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {Uint8Array} bytes
|
|
16
|
+
* @returns {Promise<Uint8Array|null>} zlib 形式(PDF の FlateDecode が期待する形式)
|
|
17
|
+
*/
|
|
18
|
+
export async function deflate(bytes) {
|
|
19
|
+
if (!canCompress()) return null;
|
|
20
|
+
try {
|
|
21
|
+
const cs = new CompressionStream('deflate');
|
|
22
|
+
const writer = cs.writable.getWriter();
|
|
23
|
+
void writer.write(/** @type {Uint8Array<ArrayBuffer>} */ (bytes));
|
|
24
|
+
void writer.close();
|
|
25
|
+
const buf = await new Response(cs.readable).arrayBuffer();
|
|
26
|
+
return new Uint8Array(buf);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|