@quario/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/lib/index.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @quario/pdf — the PDF render target: a pure consumer of the engine's public
3
+ * event stream, passed to a compiled report's `render`, laying the banded walk out on
4
+ * pages. Layout is ours — wrapped paragraphs, keep-together, repeated table
5
+ * headers, page bands, `break: "page"`, `reset: "page"` — while the writing is pdf-lib's, so
6
+ * this target carries a typesetter rather than a PDF implementation. Output
7
+ * is deterministic: the document carries no dates of its own, so the same
8
+ * input renders the same bytes. Page geometry is target configuration
9
+ * (`pdf({ page })`), never schema.
10
+ *
11
+ * This file is options, fonts and geometry, the walk itself, and the passes
12
+ * that follow it over the finished pages — page furniture and the marking,
13
+ * neither of which can be drawn until the page count is known. Every handler
14
+ * the driver dispatches to is the band flow's, next door in `layout.js`; this
15
+ * file adds none. The work lives in the sibling modules, which ship beside it
16
+ * — the package publishes `lib/` verbatim.
17
+ */
18
+ import { PDFDocument } from "pdf-lib";
19
+ import { breathe, walk } from "quario";
20
+ import { drawing, frame, stamp } from "./canvas.js";
21
+ import { embedFonts } from "./fonts.js";
22
+ import { flow, furniture } from "./layout.js";
23
+ import { outline } from "./outline.js";
24
+
25
+ // The options are described once, in the hand-written public declarations, and
26
+ // read back here — a second copy in JSDoc is a copy that drifts.
27
+ /** @import { PdfOptions } from './index.d.ts' */
28
+
29
+ let SIZES = /** @type {Record<string, [number, number]>} */ ({
30
+ A4: [595.28, 841.89],
31
+ letter: [612, 792],
32
+ });
33
+
34
+ /** @type {(msg: string) => never} */
35
+ let err = (msg) => {
36
+ throw Error(msg);
37
+ };
38
+
39
+ /** @type {(value: any) => boolean} */
40
+ let finite = (value) => typeof value === "number" && Number.isFinite(value);
41
+
42
+ /** @type {(value: number) => boolean} */
43
+ let positive = (value) => Number.isFinite(value) && value > 0;
44
+
45
+ /** @type {(options: any) => any} */
46
+ let pageOf = (options) => options?.page ?? {};
47
+
48
+ /** @type {(options: any) => { meta: any, fonts: any }} */
49
+ let host = (options) => ({ meta: options?.meta, fonts: options?.fonts });
50
+
51
+ /** @type {(size: any) => any} */
52
+ let named = (size) => {
53
+ let dimensions = Array.isArray(size) ? size : SIZES[size];
54
+ // oxlint-disable-next-line no-unused-expressions
55
+ dimensions || err('options.page.size: unknown page size "' + size + '"');
56
+ return dimensions;
57
+ };
58
+
59
+ /** @type {(page: any) => any} */
60
+ let pageSize = (page) => named(page.size ?? "A4");
61
+
62
+ /** @type {(dimensions: any) => { width: number, height: number }} */
63
+ let pair = (dimensions) => {
64
+ let width = +dimensions[0],
65
+ height = +dimensions[1];
66
+ // oxlint-disable-next-line no-unused-expressions
67
+ (positive(width) && positive(height)) ||
68
+ err("options.page.size: expected finite positive dimensions");
69
+ return { width, height };
70
+ };
71
+
72
+ /** @type {(page: any) => any} */
73
+ let marginArg = (page) => page.margin ?? 54;
74
+
75
+ /** @type {(margin: any, width: number, height: number) => number} */
76
+ let marginOf = (margin, width, height) => {
77
+ let fits = finite(margin) && margin >= 0 && 2 * margin < Math.min(width, height);
78
+ // oxlint-disable-next-line no-unused-expressions
79
+ fits || err("options.page.margin: expected a non-negative number smaller than half the page");
80
+ return margin;
81
+ };
82
+
83
+ /** @type {(options: any) => any} */
84
+ let baseArg = (options) => options?.baseSize ?? 10;
85
+
86
+ /** @type {(base: any) => number} */
87
+ let baseOf = (base) => {
88
+ // oxlint-disable-next-line no-unused-expressions
89
+ (finite(base) && base > 0) || err("options.baseSize: expected a positive number of points");
90
+ return base;
91
+ };
92
+
93
+ // The page box and the content box in one, settled here beside the validation
94
+ // that produced them. This frame never moves at all: it serves every render the
95
+ // target compiles, so a narrowing here would be one document's page bands
96
+ // reserved out of the next one too. The one narrowing a render does make is the
97
+ // band flow's, off that render's own canvas.
98
+ /** @type {(options: any) => import('./canvas.js').Frame} */
99
+ let geometry = (options) => {
100
+ let page = pageOf(options);
101
+ let { width, height } = pair(pageSize(page));
102
+ return frame(width, height, marginOf(marginArg(page), width, height), baseOf(baseArg(options)));
103
+ };
104
+
105
+ let META = [
106
+ ["title", "setTitle"],
107
+ ["author", "setAuthor"],
108
+ ["subject", "setSubject"],
109
+ ];
110
+
111
+ /** @type {(doc: any, meta: any, entry: string[]) => void} */
112
+ let field = (doc, meta, [key, method]) => {
113
+ if (typeof meta[key] === "string") doc[method](meta[key]);
114
+ };
115
+
116
+ // Optional document information. Never a date: pdf-lib stamps the current time
117
+ // unless told otherwise, and a timestamp would make the same input render
118
+ // different bytes on every run.
119
+ /** @type {(doc: any, meta: any) => void} */
120
+ let describe = (doc, meta) => {
121
+ doc.setCreationDate(new Date(0));
122
+ doc.setModificationDate(new Date(0));
123
+ if (meta) for (let entry of META) field(doc, meta, entry);
124
+ };
125
+
126
+ /** @type {(canvas: import('./canvas.js').Drawing, draw: (i: number) => void) => Promise<void>} */
127
+ let overPages = async (canvas, draw) => {
128
+ for (let i = 0; i < canvas.count; i++) {
129
+ canvas.select(i);
130
+ draw(i);
131
+ if (i % 50 === 49) await breathe();
132
+ }
133
+ };
134
+
135
+ /** @type {(opening: any) => { bands: any, marking: any }} */
136
+ let openingOf = (opening) => ({ bands: opening?.page, marking: opening?.marking });
137
+
138
+ /** @type {(canvas: import('./canvas.js').Drawing, bands: any,
139
+ * pages: { number: number, total: number }[]) => Promise<void>} */
140
+ let furnish = async (canvas, bands, pages) => {
141
+ if (!bands) return;
142
+ await overPages(canvas, (i) => furniture(canvas, bands, pages[i]));
143
+ };
144
+
145
+ /** @type {(canvas: import('./canvas.js').Drawing, marking: any) => Promise<void>} */
146
+ let markPages = async (canvas, marking) => {
147
+ if (!marking) return;
148
+ let mark = stamp(canvas, marking);
149
+ await overPages(canvas, () => canvas.watermark(mark));
150
+ };
151
+
152
+ /**
153
+ * The PDF render target:
154
+ * `quario().report(schema).render(pdf({ page }), data)` resolves the
155
+ * complete document as a `Uint8Array` (the host writes the file). Rendering
156
+ * hands the loop back between event and page batches, so large reports stay
157
+ * cooperative. Geometry and options are validated here, at the factory call.
158
+ *
159
+ * @param {PdfOptions} [options] Host controls (see SCHEMA.md, "The PDF target").
160
+ * @returns {{name: "pdf", compile: (stream: any) => (data?: any) => Promise<Uint8Array>}}
161
+ * The target (see SCHEMA.md, "Instances and targets").
162
+ */
163
+ export function pdf(options) {
164
+ let geo = geometry(options);
165
+ let { meta, fonts: custom } = host(options);
166
+ /** @type {(stream: any) => (data?: any) => Promise<Uint8Array>} */
167
+ let compile = (stream) => async (data) => {
168
+ let doc = await PDFDocument.create();
169
+ describe(doc, meta);
170
+ // Every face is embedded before the walk, so resolving a style to a
171
+ // font during layout stays synchronous.
172
+ let fonts = await embedFonts(doc, custom);
173
+ let canvas = drawing(doc, geo, fonts);
174
+ // The band flow owns the placement state and every handler over it, and
175
+ // opens the first page as it is built; this file only hands it the stream.
176
+ let { handlers, finish } = flow(canvas);
177
+ await walk(stream(data), handlers);
178
+ // The flow settled the opening event on its way past — it reserves the
179
+ // page bands off it — and hands it back with the marks. What is left on it
180
+ // is what the passes below want: the band closures to render per page, and
181
+ // the marking's wording to stamp.
182
+ let { marks, opening, pages } = finish();
183
+ let { bands, marking } = openingOf(opening);
184
+ // The passes below run over the finished pages, not the stream — no walk
185
+ // at all — so they open each page themselves and breathe on their own
186
+ // rather than through the driver.
187
+ await furnish(canvas, bands, pages);
188
+ // Every image the walk and the furniture pass placed, embedded and drawn
189
+ // now: a walk handler cannot await, and the page bands' images are not
190
+ // placed until the pass above has run. Before the marking, so an image can
191
+ // never cover it.
192
+ await canvas.pictures();
193
+ // The unlicensed marking goes on last, over content and page furniture
194
+ // alike, once per page (LICENSE section 6). Its wording rode in on
195
+ // `report-start`; only the placement is this target's.
196
+ await markPages(canvas, marking);
197
+ outline(doc, marks, canvas.refs);
198
+ return doc.save();
199
+ };
200
+ return { name: "pdf", compile };
201
+ }