@quario/pdf 0.3.0 → 0.5.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/painter.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * The pdf-lib painter: the list's pages become the document's, op by op. The
3
+ * only module in this target that talks to pdf-lib page primitives.
4
+ *
5
+ * The list reads down from the page's top-left corner; a PDF page reads up
6
+ * from its bottom-left, so every `y` turns over on its way in. Three passes
7
+ * per page rather than one walk over the ops: the marks in their order, then
8
+ * the images, then the marking — the images last because embedding is async
9
+ * and one logo on every page is embedded once, the marking after them so an
10
+ * image can never cover it (LICENSE section 6).
11
+ */
12
+ import { breathe } from "quario";
13
+ import { degrees, drawImage, drawText, rgb } from "@cantoo/pdf-lib";
14
+ import { fontFor } from "./embed.js";
15
+
16
+ /** @typedef {import('@cantoo/pdf-lib').PDFPage} PDFPage */
17
+ /** @typedef {import('@cantoo/pdf-lib').PDFName} PDFName */
18
+
19
+ // The operator builder wants explicit rotation and skew; report text has none.
20
+ let NO_TURN = degrees(0);
21
+ let GREY = rgb(0.5, 0.5, 0.5);
22
+
23
+ /** @type {(color: { r: number, g: number, b: number }) => any} */
24
+ let colorOf = ({ r, g, b }) => rgb(r, g, b);
25
+
26
+ /**
27
+ * The resource name a page refers to a face or an image by, minted once per
28
+ * resource per page.
29
+ *
30
+ * pdf-lib's own page-level state cannot do this: `setFont` mints a fresh
31
+ * random-suffixed name on every call, and `drawText({ font })` calls it twice
32
+ * — once to select the face and once to restore the previous one. A report
33
+ * that alternates bold and regular therefore grows its font dictionary with
34
+ * its text rather than with its faces, and measured 4.8x larger for the same
35
+ * content. `drawImage` on the page has the same shape, so one logo down a
36
+ * thousand rows would put a thousand entries in one XObject dictionary,
37
+ * every one of them pointing at the same stream.
38
+ *
39
+ * @type {(mint: (on: PDFPage, resource: any) => PDFName) =>
40
+ * (on: PDFPage, resource: any) => PDFName}
41
+ */
42
+ let perPage = (mint) => {
43
+ /** @type {WeakMap<PDFPage, Map<any, PDFName>>} */
44
+ let keys = new WeakMap();
45
+ return (on, resource) => {
46
+ let minted = keys.get(on);
47
+ if (!minted) keys.set(on, (minted = new Map()));
48
+ let key = minted.get(resource);
49
+ if (!key) minted.set(resource, (key = mint(on, resource)));
50
+ return key;
51
+ };
52
+ };
53
+
54
+ /**
55
+ * @typedef {{ doc: any, fonts: import('./embed.js').Fonts, page: PDFPage,
56
+ * height: number,
57
+ * faceKey: (on: PDFPage, font: any) => PDFName,
58
+ * imageKey: (on: PDFPage, image: any) => PDFName,
59
+ * embedded: Map<Uint8Array, any> }} Painter
60
+ */
61
+
62
+ /** @type {(painter: Painter, op: any) => void} */
63
+ let paintRect = ({ page, height }, op) =>
64
+ page.drawRectangle({
65
+ x: op.x,
66
+ y: height - op.y - op.h,
67
+ width: op.w,
68
+ height: op.h,
69
+ color: colorOf(op.color),
70
+ });
71
+
72
+ /** @type {(painter: Painter, op: any) => void} */
73
+ let paintLine = ({ page, height }, op) =>
74
+ page.drawLine({
75
+ start: { x: op.x1, y: height - op.y1 },
76
+ end: { x: op.x2, y: height - op.y2 },
77
+ thickness: op.width,
78
+ color: colorOf(op.color),
79
+ ...(op.dash ? { dashArray: op.dash, dashPhase: 0 } : {}),
80
+ });
81
+
82
+ /** @type {(painter: Painter, op: any) => void} */
83
+ let paintText = ({ page, height, fonts, faceKey }, op) => {
84
+ let font = fontFor(fonts, op.font);
85
+ page.pushOperators(
86
+ ...drawText(font.encodeText(op.text), {
87
+ font: faceKey(page, font),
88
+ size: op.size,
89
+ color: colorOf(op.color),
90
+ x: op.x,
91
+ y: height - op.y - op.asc,
92
+ rotate: NO_TURN,
93
+ xSkew: NO_TURN,
94
+ ySkew: NO_TURN,
95
+ }),
96
+ );
97
+ };
98
+
99
+ // Draw one page's marking through the same operator path as body text, so the
100
+ // face reuses the page's `faceKey` resource instead of growing the dictionary
101
+ // per draw.
102
+ /** @type {(painter: Painter, op: any) => void} */
103
+ let paintMark = ({ page, height, fonts, faceKey }, op) => {
104
+ let font = fontFor(fonts, op.font);
105
+ page.pushOperators(
106
+ ...drawText(font.encodeText(op.text), {
107
+ font: faceKey(page, font),
108
+ size: op.size,
109
+ color: GREY,
110
+ x: op.x,
111
+ y: height - op.y,
112
+ rotate: degrees(op.angle),
113
+ xSkew: NO_TURN,
114
+ ySkew: NO_TURN,
115
+ // Opacity needs a named ExtGState resource on the page, and pdf-lib
116
+ // declares the only method that mints one private, with no public
117
+ // equivalent. The cast is deliberate and stays as narrow as the call.
118
+ graphicsState: /** @type {any} */ (page).maybeEmbedGraphicsState({ opacity: 0.15 }),
119
+ }),
120
+ );
121
+ };
122
+
123
+ // Keyed by the array the source expression yielded, so one logo reused
124
+ // across pages is embedded once and every placement references it.
125
+ /** @type {(painter: Painter, op: any) => Promise<any>} */
126
+ let imageOf = async ({ doc, embedded }, op) => {
127
+ let image = embedded.get(op.bytes);
128
+ if (!image) {
129
+ image = await (op.format === "png" ? doc.embedPng(op.bytes) : doc.embedJpg(op.bytes));
130
+ embedded.set(op.bytes, image);
131
+ }
132
+ return image;
133
+ };
134
+
135
+ /** @type {(painter: Painter, op: any) => Promise<void>} */
136
+ let paintImage = async (painter, op) => {
137
+ let { page, height, imageKey } = painter;
138
+ page.pushOperators(
139
+ ...drawImage(imageKey(page, await imageOf(painter, op)), {
140
+ x: op.x,
141
+ y: height - op.y - op.h,
142
+ width: op.w,
143
+ height: op.h,
144
+ rotate: NO_TURN,
145
+ xSkew: NO_TURN,
146
+ ySkew: NO_TURN,
147
+ }),
148
+ );
149
+ };
150
+
151
+ /** @type {Record<string, (painter: Painter, op: any) => void>} */
152
+ let MARKS = { rect: paintRect, line: paintLine, text: paintText };
153
+
154
+ /** @type {(painter: Painter, ops: any[]) => void} */
155
+ let paintMarks = (painter, ops) => {
156
+ for (let op of ops) if (Object.hasOwn(MARKS, op.kind)) MARKS[op.kind](painter, op);
157
+ };
158
+
159
+ /** @type {(painter: Painter, ops: any[]) => Promise<void>} */
160
+ let paintImages = async (painter, ops) => {
161
+ for (let op of ops) if (op.kind === "image") await paintImage(painter, op);
162
+ };
163
+
164
+ /** @type {(painter: Painter, ops: any[]) => void} */
165
+ let paintMarking = (painter, ops) => {
166
+ for (let op of ops) if (op.kind === "mark") paintMark(painter, op);
167
+ };
168
+
169
+ /** @type {(painter: Painter, ops: any[]) => Promise<void>} */
170
+ let paintPage = async (painter, ops) => {
171
+ paintMarks(painter, ops);
172
+ await paintImages(painter, ops);
173
+ paintMarking(painter, ops);
174
+ };
175
+
176
+ /**
177
+ * Paint every page of the list onto the document. Hands the loop back between
178
+ * pages, so a long document stays cooperative.
179
+ *
180
+ * @param {any} doc The pdf-lib document.
181
+ * @param {import('./embed.js').Fonts} fonts The embedded faces.
182
+ * @param {any} list The display list, from `@quario/layout`.
183
+ * @returns {Promise<any[]>} The pages' refs, in document order, for the outline.
184
+ */
185
+ export async function paint(doc, fonts, list) {
186
+ /** @type {Painter} */
187
+ let painter = {
188
+ doc,
189
+ fonts,
190
+ page: /** @type {any} */ (null),
191
+ height: list.height,
192
+ faceKey: perPage((on, font) => on.node.newFontDictionary(font.name, font.ref)),
193
+ imageKey: perPage((on, image) => on.node.newXObject("Image", image.ref)),
194
+ embedded: new Map(),
195
+ };
196
+ /** @type {any[]} */
197
+ let refs = [];
198
+ for (let [i, page] of list.pages.entries()) {
199
+ painter.page = doc.addPage([list.width, list.height]);
200
+ refs.push(painter.page.ref);
201
+ await paintPage(painter, page.ops);
202
+ if (i % 50 === 49) await breathe();
203
+ }
204
+ return refs;
205
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/pdf",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The browserless, paginated PDF render target for quario — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -36,22 +36,24 @@
36
36
  "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
37
37
  },
38
38
  "dependencies": {
39
- "pdf-lib": "^1.17.1"
39
+ "@cantoo/pdf-lib": "^2.9.1",
40
+ "@quario/layout": "^0.2.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@arethetypeswrong/cli": "^0.18.3",
43
- "@pdf-lib/fontkit": "^1.1.1",
44
44
  "@size-limit/preset-small-lib": "^13.0.3",
45
- "quario": "^0.3.0",
45
+ "@types/fontkit": "^2.0.9",
46
+ "fontkit": "^2.0.4",
47
+ "quario": "^0.5.0",
46
48
  "size-limit": "^13.0.3",
47
49
  "typescript": "^7.0.2"
48
50
  },
49
51
  "peerDependencies": {
50
- "@pdf-lib/fontkit": "^1.1.1",
51
- "quario": "^0.3.0"
52
+ "fontkit": "^2.0.4",
53
+ "quario": "^0.5.0"
52
54
  },
53
55
  "peerDependenciesMeta": {
54
- "@pdf-lib/fontkit": {
56
+ "fontkit": {
55
57
  "optional": true
56
58
  }
57
59
  },
@@ -60,9 +62,10 @@
60
62
  "path": "lib/index.js",
61
63
  "ignore": [
62
64
  "quario",
63
- "pdf-lib"
65
+ "@quario/layout",
66
+ "@cantoo/pdf-lib"
64
67
  ],
65
- "limit": "10.5 kB"
68
+ "limit": "6 kB"
66
69
  }
67
70
  ],
68
71
  "engines": {
package/lib/balance.js DELETED
@@ -1,90 +0,0 @@
1
- // The one number a balanced region decides: how tall each of its strips is
2
- // (ADR 0027 — balancing is a strip height, not a pass). Pure arithmetic over
3
- // what the buffer holds, so nothing here reaches a canvas or the band flow.
4
- //
5
- // Balancing by height alone under-fills, because most of what a region holds
6
- // does not divide: a group instance, a table row and an image each move whole.
7
- // A share that is not a whole number of them holds one fewer than it should,
8
- // and the page-bounded last strip absorbs the remainder (beads `quario-s5c`,
9
- // `quario-cgk`). So the height is not `measured / count` but the shortest
10
- // whole-line height at which placing the units in order lands them all in
11
- // `count` strips.
12
- //
13
- // The candidates are line multiples, for the reason the average was rounded to
14
- // one before this: a floor cuts between lines, so a height that is not a whole
15
- // number of them leaves every strip a fraction short. They are scanned from the
16
- // shortest upward rather than searched, because the range is bounded by the
17
- // lines a page holds and a scan owes nothing to an argument about the fill
18
- // being monotone in the height. The average is not the starting point and not
19
- // a lower bound: an instance drops its opening gap at a strip head, so `count`
20
- // of those gaps are measured that no strip ever pays.
21
-
22
- // A **unit** is one thing a strip places: `h` is what it costs mid-strip and
23
- // `head` what it costs when it is the first thing in a strip — which can be
24
- // less (a group instance drops its opening gap there) or more (a table row
25
- // pays for the column headings restated above it). `whole` units move
26
- // undivided and are the only ones `head` is read for; the rest flow, and
27
- // nothing that flows costs a different amount for opening a strip. What fills each of these in is the caller's, in
28
- // `layout.js`, which is also where the model's stated limits are.
29
- /**
30
- * @typedef {{ whole: boolean, h: number, head: number }} Unit
31
- */
32
- // How far one trial has filled: the strips it has opened and the height taken
33
- // out of the one it is in.
34
- /**
35
- * @typedef {{ strips: number, used: number }} Fill
36
- */
37
-
38
- // The height, or `null` when no candidate packs the units into `count` strips
39
- // and the region should fill instead. `room` is what one strip could hold at
40
- // most — the page — and `line` the height a floor cuts on.
41
- /** @type {(units: Unit[], count: number, room: number, line: number) => number | null} */
42
- export let balance = (units, count, room, line) => {
43
- for (let lines = 1; lines * line <= room; lines++) {
44
- let height = lines * line;
45
- if (stripsFor(units, height) <= count) return height;
46
- }
47
- return null;
48
- };
49
-
50
- // How many strips this height needs. `Infinity` when a unit is too tall for a
51
- // strip of it at all — the scan answers that by looking higher, and the region
52
- // fills once even the page is too short.
53
- // The only way a height fails outright is a whole unit taller than a strip of
54
- // it, so that case is answered here rather than through a result both helpers
55
- // would have to carry.
56
- /** @type {(units: Unit[], height: number) => number} */
57
- let stripsFor = (units, height) => {
58
- /** @type {Fill} */
59
- let fill = { strips: 1, used: 0 };
60
- for (let unit of units) {
61
- if (!unit.whole) placeFlow(fill, unit, height);
62
- else if (unit.head > height) return Infinity;
63
- else placeWhole(fill, unit, height);
64
- }
65
- return fill.strips;
66
- };
67
-
68
- // A whole unit that would cross the floor opens the next strip instead, where
69
- // its opening gap is dropped — the rule `breaksFor` applies during the replay.
70
- /** @type {(fill: Fill, unit: Unit, height: number) => void} */
71
- let placeWhole = (fill, unit, height) => {
72
- if (!fill.used) fill.used = unit.head;
73
- else if (fill.used + unit.h <= height) fill.used += unit.h;
74
- else {
75
- fill.strips++;
76
- fill.used = unit.head;
77
- }
78
- };
79
-
80
- // Breakable content fills to the floor and continues in the next strip, so it
81
- // spills across as many as it takes rather than moving whole. No height is too
82
- // short for it: content that splits always goes somewhere.
83
- /** @type {(fill: Fill, unit: Unit, height: number) => void} */
84
- let placeFlow = (fill, unit, height) => {
85
- fill.used += unit.h;
86
- while (fill.used > height) {
87
- fill.strips++;
88
- fill.used -= height;
89
- }
90
- };
package/lib/box.js DELETED
@@ -1,111 +0,0 @@
1
- /**
2
- * The box model this target honours: per-side padding and border, border-box,
3
- * no collapse. Cell omakase (PADX / PADY) applies only where the author named
4
- * no padding on that side; a named 0 wins. A border side contributes only when
5
- * width, style and colour all resolve and width is positive — an incomplete
6
- * result at render is nothing, not a solid black stroke.
7
- */
8
- import { PADX, PADY, col } from "./style.js";
9
-
10
- let SIDES = ["Top", "Right", "Bottom", "Left"];
11
- /** @type {Record<string, number[] | null>} */
12
- let DASH = { solid: null, dashed: [3, 2], dotted: [1, 1.5] };
13
-
14
- /** @typedef {{ t: number, r: number, b: number, l: number }} Inset */
15
-
16
- /** @type {Inset} */
17
- let CELL_PAD = { t: PADY, r: PADX, b: PADY, l: PADX };
18
- /** @type {Inset} */
19
- let NO_PAD = { t: 0, r: 0, b: 0, l: 0 };
20
-
21
- /** @type {(name: string) => boolean} */
22
- let isBox = (name) => name.startsWith("padding") || name.startsWith("border");
23
-
24
- /** @type {(style: any) => any} */
25
- let unbox = (style) => {
26
- if (!style) return style;
27
- let names = Object.keys(style).filter((name) => !isBox(name));
28
- if (!names.length) return null;
29
- return Object.fromEntries(names.map((name) => [name, style[name]]));
30
- };
31
-
32
- /** @type {(style: any, side: string, fallback: number) => number} */
33
- let padOf = (style, side, fallback) => {
34
- let value = style?.["padding" + side];
35
- return Number.isFinite(value) && value >= 0 ? value : fallback;
36
- };
37
-
38
- /** @type {(width: any) => boolean} */
39
- let isStroke = (width) => Number.isFinite(width) && width > 0;
40
- /** @type {(line: any) => boolean} */
41
- let isLine = (line) => typeof line === "string" && Object.hasOwn(DASH, line);
42
-
43
- /**
44
- * @type {(width: any, line: any, color: any) =>
45
- * { width: number, dash: number[] | null, color: any } | null}
46
- */
47
- let strokeOf = (width, line, color) => {
48
- if (!isStroke(width) || !isLine(line) || !color) return null;
49
- return { width, dash: DASH[line], color };
50
- };
51
-
52
- /**
53
- * @type {(style: any, side: string) =>
54
- * { width: number, dash: number[] | null, color: any } | null}
55
- */
56
- let edgeOf = (style, side) => {
57
- if (!style) return null;
58
- return strokeOf(
59
- style["border" + side + "Width"],
60
- style["border" + side + "Style"],
61
- col(style["border" + side + "Color"]),
62
- );
63
- };
64
-
65
- /** @type {(style: any, side: string) => number} */
66
- let thick = (style, side) => {
67
- let edge = edgeOf(style, side);
68
- return edge ? edge.width : 0;
69
- };
70
-
71
- /** @type {(style: any, side: string, fallback: number) => number} */
72
- let inset = (style, side, fallback) => padOf(style, side, fallback) + thick(style, side);
73
-
74
- /** @type {(style: any, omakase: Inset) => Inset} */
75
- let insetOf = (style, omakase) => ({
76
- t: inset(style, "Top", omakase.t),
77
- r: inset(style, "Right", omakase.r),
78
- b: inset(style, "Bottom", omakase.b),
79
- l: inset(style, "Left", omakase.l),
80
- });
81
-
82
- /** Endpoints of one inner-centred edge, as stroke() takes them. */
83
- /** @type {Record<string, (x: number, yTop: number, w: number, h: number, half: number) => number[]>} */
84
- let SPAN = {
85
- Top: (x, yTop, w, _h, half) => [x, yTop - half, x + w, yTop - half],
86
- Bottom: (x, yTop, w, h, half) => [x, yTop - h + half, x + w, yTop - h + half],
87
- Left: (x, yTop, _w, h, half) => [x + half, yTop, x + half, yTop - h],
88
- Right: (x, yTop, w, h, half) => [x + w - half, yTop, x + w - half, yTop - h],
89
- };
90
-
91
- /** @type {(canvas: { stroke: Function }, x: number, yTop: number, w: number, h: number, style: any, side: string) => void} */
92
- let paintEdge = (canvas, x, yTop, w, h, style, side) => {
93
- let edge = edgeOf(style, side);
94
- if (!edge) return;
95
- canvas.stroke(...SPAN[side](x, yTop, w, h, edge.width / 2), edge.width, edge.color, edge.dash);
96
- };
97
-
98
- /**
99
- * Fill and stroke one border-box. Background is the whole rect; each edge
100
- * sits inside it, centred on its own width, so a stroke does not spill past
101
- * the box the caller measured.
102
- *
103
- * @type {(canvas: { rect: Function, stroke: Function }, x: number, yTop: number,
104
- * w: number, h: number, style: any, bg: any) => void}
105
- */
106
- let paintBox = (canvas, x, yTop, w, h, style, bg) => {
107
- if (bg) canvas.rect(bg, x, yTop - h, w, h);
108
- if (style) for (let side of SIDES) paintEdge(canvas, x, yTop, w, h, style, side);
109
- };
110
-
111
- export { CELL_PAD, NO_PAD, insetOf, paintBox, unbox };