@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/outline.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The group tree as the document's bookmark outline.
3
+ *
4
+ * Every group instance leaves a mark while it lays out (`layout.js` anchors it
5
+ * at the page and cursor where its content begins). Here those marks become
6
+ * `/Outlines`: nested by group depth, in document order, each targeting its
7
+ * own position. pdf-lib has no bookmark API, so the objects are built through
8
+ * its low-level context — the only place in this target that does.
9
+ */
10
+ import { PDFHexString, PDFName, PDFNumber } from "pdf-lib";
11
+
12
+ // A PDF *text string*: UTF-16BE with a BOM, so a title survives whatever the
13
+ // author wrote. `PDFHexString.fromText` encodes exactly that.
14
+ /** @type {(marks: any[]) => number[]} */
15
+ let parents = (marks) =>
16
+ marks.map((mark, i) => {
17
+ // The nearest earlier mark shallower than this one, or the root.
18
+ for (let j = i - 1; j >= 0; j--) if (marks[j].depth < mark.depth) return j;
19
+ return -1;
20
+ });
21
+
22
+ /** @type {(parent: number[], of: number) => number[]} */
23
+ let kidsOf = (parent, of) => parent.map((_, i) => i).filter((i) => parent[i] === of);
24
+
25
+ // An open entry's /Count is its open descendants at every level
26
+ // (ISO 32000-1 §12.3.3). Marks are in document order, so a mark's
27
+ // descendants are the following marks until the depth returns.
28
+ /** @type {(marks: any[], i: number) => number} */
29
+ let descendants = (marks, i) => {
30
+ let n = 0;
31
+ for (let j = i + 1; j < marks.length && marks[j].depth > marks[i].depth; j++) n++;
32
+ return n;
33
+ };
34
+
35
+ /** @type {(parent: number[], i: number, root: any, refs: any[]) => any} */
36
+ let parentRef = (parent, i, root, refs) => (parent[i] < 0 ? root : refs[parent[i]]);
37
+
38
+ /** @type {(entry: Record<string, any>, refs: any[], siblings: number[], index: number) => void} */
39
+ let linkSiblings = (entry, refs, siblings, index) => {
40
+ if (index > 0) entry.Prev = refs[siblings[index - 1]];
41
+ if (index < siblings.length - 1) entry.Next = refs[siblings[index + 1]];
42
+ };
43
+
44
+ /** @type {(entry: Record<string, any>, refs: any[], kids: number[], n: number) => void} */
45
+ let linkKids = (entry, refs, kids, n) => {
46
+ if (!kids.length) return;
47
+ entry.First = refs[kids[0]];
48
+ entry.Last = refs[kids[kids.length - 1]];
49
+ entry.Count = n;
50
+ };
51
+
52
+ /** @type {(ctx: any, refs: any[], root: any, parent: number[], marks: any[], pageRefs: any[], i: number, mark: any) => void} */
53
+ let writeEntry = (ctx, refs, root, parent, marks, pageRefs, i, mark) => {
54
+ let siblings = kidsOf(parent, parent[i]);
55
+ let index = siblings.indexOf(i);
56
+ let kids = kidsOf(parent, i);
57
+ let target = pageRefs[Math.min(mark.page, pageRefs.length - 1)];
58
+ /** @type {Record<string, any>} */
59
+ let entry = {
60
+ Title: PDFHexString.fromText(mark.title),
61
+ Parent: parentRef(parent, i, root, refs),
62
+ // `/XYZ <x> <y> null`: scroll to the mark's line, keep the zoom. The
63
+ // left edge is the mark's own column, so two instances sharing a y in
64
+ // two page columns are two distinct destinations.
65
+ Dest: [target, PDFName.of("XYZ"), PDFNumber.of(mark.x), PDFNumber.of(mark.y), null],
66
+ };
67
+ linkSiblings(entry, refs, siblings, index);
68
+ linkKids(entry, refs, kids, descendants(marks, i));
69
+ ctx.assign(refs[i], ctx.obj(entry));
70
+ };
71
+
72
+ /**
73
+ * Attach an outline built from `marks` to the document.
74
+ *
75
+ * @param {any} doc The pdf-lib document.
76
+ * @param {any[]} marks Group marks in document order, each `{ title, depth, page, x, y }`.
77
+ * @param {any[]} pageRefs The pages' refs, in document order, indexed by a mark's `page`.
78
+ */
79
+ export function outline(doc, marks, pageRefs) {
80
+ if (!marks.length) return;
81
+ let ctx = doc.context;
82
+ let parent = parents(marks);
83
+ let refs = marks.map(() => ctx.nextRef());
84
+ let root = ctx.nextRef();
85
+ for (let [i, mark] of marks.entries())
86
+ writeEntry(ctx, refs, root, parent, marks, pageRefs, i, mark);
87
+
88
+ let top = kidsOf(parent, -1);
89
+ ctx.assign(
90
+ root,
91
+ ctx.obj({
92
+ Type: "Outlines",
93
+ First: refs[top[0]],
94
+ Last: refs[top[top.length - 1]],
95
+ Count: marks.length,
96
+ }),
97
+ );
98
+ doc.catalog.set(PDFName.of("Outlines"), root);
99
+ doc.catalog.set(PDFName.of("PageMode"), PDFName.of("UseOutlines"));
100
+ }
package/lib/style.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The PDF target's reading of the closed style vocabulary — the counterpart of
3
+ * the CSS table in the HTML target package. Declarations are lenient at
4
+ * render: a value of the wrong shape contributes nothing rather than reaching
5
+ * the page.
6
+ *
7
+ * `finite`/`HEX` are restated per target on purpose: the coercions are each
8
+ * target's own edge, never shared engine code.
9
+ */
10
+ import { rgb } from "pdf-lib";
11
+
12
+ /** @type {(value: any) => boolean} */
13
+ let finite = (value) => typeof value === "number" && Number.isFinite(value);
14
+ let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
15
+
16
+ // Leading as a fraction of the font size, cell padding, the gap between a
17
+ // page band and the body, and the gutter between two page-column strips.
18
+ // `example/print.css` is the reference look (10pt/1.4) — body type stays a
19
+ // host decision, so `@quario/html/style.css` carries no `body` rule and this
20
+ // pairs with the example rather than it.
21
+ //
22
+ // GUTTER is this target's structural default: SCHEMA.md gives page columns no
23
+ // gap knob deliberately, so the space between strips is the target's to pick
24
+ // and an author never writes it. Twice the cell padding, which is the widest
25
+ // space this target already puts between two columns of anything.
26
+ let LEAD = 1.4;
27
+ let PADX = 6;
28
+ let PADY = 2;
29
+ let BAND = 8;
30
+ let GUTTER = 2 * PADX;
31
+
32
+ let BLACK = rgb(0, 0, 0);
33
+
34
+ // Horizontal placement within a given leftover width: left keeps it, right
35
+ // takes all of it, center takes half. Own-key so an authored `constructor`
36
+ // cannot resolve an inherited member.
37
+ /** @type {Record<string, number>} */
38
+ let SHIFT = { right: 1, center: 0.5 };
39
+ /** @type {(align: any, extra: number) => number} */
40
+ let shift = (align, extra) => (Object.hasOwn(SHIFT, align) ? SHIFT[align] : 0) * extra;
41
+
42
+ // A declared colour as a pdf-lib colour, or null when the value is not one.
43
+ /** @type {(value: any) => any} */
44
+ let col = (value) => {
45
+ let match = typeof value === "string" && HEX.exec(value);
46
+ if (!match) return null;
47
+ // `#abc` is `#aabbcc`.
48
+ let digits = match[1].length === 3 ? match[1].replace(/./g, (d) => d + d) : match[1];
49
+ let [r, g, b] = [0, 2, 4].map((i) => parseInt(digits.slice(i, i + 2), 16) / 255);
50
+ return rgb(r, g, b);
51
+ };
52
+
53
+ /** @type {(style: any, base: number) => number} */
54
+ let sizeOf = (style, base) => (finite(style.size) && style.size > 0 ? style.size : base);
55
+
56
+ // Style blocks layer outward-in: row under cell.
57
+ /** @type {(under: any, over: any) => any} */
58
+ let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
59
+
60
+ // Band-role omakase defaults — the outermost layer of that same stack, under
61
+ // the author's own style, which therefore always wins. Only the headline
62
+ // roles carry one; the XLSX target carries the same two, byte-identical, in
63
+ // `packages/xlsx/lib/index.js`, and they move together (SCHEMA.md states the
64
+ // pair for both). A target can only import public engine helpers, so there is
65
+ // nowhere to share this from and the two copies are synced by hand.
66
+ //
67
+ // `@quario/html` deliberately carries none of this, nor the leading, padding
68
+ // and band gap below: its consumer has a stylesheet and the `q-*` classes are
69
+ // the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
70
+ // a target supplies defaults only where its consumer has no seam to supply
71
+ // them. `@quario/html/style.css` and `packages/viewer/lib/style.js` are what supply
72
+ // them there; keep all four in agreement.
73
+ /** @type {Record<string, any>} */
74
+ let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
75
+
76
+ // One item event wearing its role's defaults. Events reach layout as events,
77
+ // so the default is folded into a copy rather than passed alongside.
78
+ /** @type {(event: any) => any} */
79
+ let roled = (event) =>
80
+ Object.hasOwn(ROLES, event.role)
81
+ ? { ...event, style: merge(ROLES[event.role], event.style) }
82
+ : event;
83
+
84
+ export { BAND, BLACK, GUTTER, LEAD, PADX, PADY, col, merge, roled, shift, sizeOf };
package/lib/text.js ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Measuring and breaking text: tokens flatten to word and space atoms carrying
3
+ * their resolved typography, and a greedy breaker turns those into lines. Pure
4
+ * measurement against the font registry — nothing here touches the page.
5
+ */
6
+ import { ascOf, face, printable, width } from "./fonts.js";
7
+ import { LEAD, col, sizeOf } from "./style.js";
8
+
9
+ // What measuring needs and no more: the embedded faces to measure against
10
+ // and the base size a style falls back to. A `Canvas` satisfies it, so
11
+ // callers pass theirs straight in — but nothing here can touch a page.
12
+ /** @typedef {{ fonts: import('./fonts.js').Fonts, base: number }} Metrics */
13
+
14
+ /**
15
+ * @typedef {{ text: string, font: any, size: number, color: any,
16
+ * space: boolean, hard: boolean }} Atom
17
+ */
18
+ /**
19
+ * @typedef {{ pieces: { text: string, font: any, size: number, color: any,
20
+ * w: number }[], w: number, h: number, size: number, asc: number,
21
+ * underline?: boolean, strikethrough?: boolean }} Line
22
+ */
23
+
24
+ /**
25
+ * @typedef {{ cur: Atom[], w: number, lines: Line[], base: number,
26
+ * fonts: import('./fonts.js').Fonts }} Wrap
27
+ */
28
+
29
+ /** @type {(token: any) => string} */
30
+ let rawOf = (token) => ("literal" in token ? token.literal : String(token.value ?? ""));
31
+
32
+ /** @type {(font: any, line: string) => string[]} */
33
+ let partsOf = (font, line) => printable(font, line).split(/( +)/).filter(Boolean);
34
+
35
+ /** @type {(out: Atom[], font: any, size: number, color: any, line: string) => void} */
36
+ let pushParts = (out, font, size, color, line) => {
37
+ for (let part of partsOf(font, line))
38
+ out.push({ text: part, font, size, color, space: part[0] === " ", hard: false });
39
+ };
40
+
41
+ /** @type {(out: Atom[], font: any, size: number, color: any, i: number, line: string) => void} */
42
+ let pushLine = (out, font, size, color, i, line) => {
43
+ if (i) out.push({ text: "", font, size, color, space: false, hard: true });
44
+ pushParts(out, font, size, color, line);
45
+ };
46
+
47
+ /** @type {(metrics: Metrics, style: any) => { font: any, size: number, color: any }} */
48
+ let look = (metrics, style) => {
49
+ let resolved = style || {};
50
+ return {
51
+ font: face(metrics.fonts, resolved),
52
+ size: sizeOf(resolved, metrics.base),
53
+ color: col(resolved.color),
54
+ };
55
+ };
56
+
57
+ // Flatten a cell's tokens to word/space atoms carrying the cell's resolved
58
+ // typography — one face, size and colour for the whole cell.
59
+ /** @type {(metrics: Metrics, tokens: any[], style: any) => Atom[]} */
60
+ let atoms = (metrics, tokens, style) => {
61
+ let out = /** @type {Atom[]} */ ([]);
62
+ let { font, size, color } = look(metrics, style);
63
+ for (let token of tokens)
64
+ for (let [i, line] of rawOf(token).split("\n").entries())
65
+ pushLine(out, font, size, color, i, line);
66
+ return out;
67
+ };
68
+
69
+ /** @type {(cur: Atom[]) => void} */
70
+ let trimEnd = (cur) => {
71
+ while (cur.length && cur[cur.length - 1].space) cur.pop();
72
+ };
73
+
74
+ /** @type {(pieces: Line['pieces'], atom: Atom, atomWidth: number) => void} */
75
+ let mergeAtom = (pieces, atom, atomWidth) => {
76
+ let last = pieces[pieces.length - 1];
77
+ if (last) {
78
+ last.text += atom.text;
79
+ last.w += atomWidth;
80
+ return;
81
+ }
82
+ pieces.push({
83
+ text: atom.text,
84
+ font: atom.font,
85
+ size: atom.size,
86
+ color: atom.color,
87
+ w: atomWidth,
88
+ });
89
+ };
90
+
91
+ /** @typedef {{ pieces: Line['pieces'], w: number, size: number, asc: number }} Run */
92
+
93
+ /** @type {(run: Run, atom: Atom) => void} */
94
+ let growPiece = (run, atom) => {
95
+ let atomWidth = width(atom.font, atom.text, atom.size);
96
+ run.w += atomWidth;
97
+ if (atom.size > run.size) run.size = atom.size;
98
+ let atomAsc = ascOf(atom.font, atom.size);
99
+ if (atomAsc > run.asc) run.asc = atomAsc;
100
+ mergeAtom(run.pieces, atom, atomWidth);
101
+ };
102
+
103
+ /** @type {(state: Wrap, run: Run) => void} */
104
+ let finish = (state, run) => {
105
+ if (!run.size) run.size = state.base;
106
+ if (!run.asc) run.asc = ascOf(face(state.fonts, {}), run.size);
107
+ state.lines.push({
108
+ pieces: run.pieces,
109
+ w: run.w,
110
+ h: LEAD * run.size,
111
+ size: run.size,
112
+ asc: run.asc,
113
+ });
114
+ state.cur = [];
115
+ state.w = 0;
116
+ };
117
+
118
+ // `force` keeps deliberately blank lines (hard breaks, an empty value);
119
+ // a word-overflow close passes false so a line of pure trimmed spaces
120
+ // vanishes instead of becoming a phantom line.
121
+ /** @type {(state: Wrap, force?: boolean) => void} */
122
+ let emit = (state, force = true) => {
123
+ trimEnd(state.cur);
124
+ if (!force && !state.cur.length) {
125
+ state.w = 0;
126
+ return;
127
+ }
128
+ /** @type {Run} */
129
+ let run = { pieces: [], w: 0, size: 0, asc: 0 };
130
+ for (let atom of state.cur) growPiece(run, atom);
131
+ finish(state, run);
132
+ };
133
+
134
+ /** @type {(atom: Atom, rest: string[], avail: number) => number} */
135
+ let fitChars = (atom, rest, avail) => {
136
+ let n = 1;
137
+ while (n < rest.length) {
138
+ let longer = rest.slice(0, n + 1).join("");
139
+ if (width(atom.font, longer, atom.size) > avail) break;
140
+ n++;
141
+ }
142
+ return n;
143
+ };
144
+
145
+ /** @type {(state: Wrap, atom: Atom, rest: string[], avail: number) => string[]} */
146
+ let takeChunk = (state, atom, rest, avail) => {
147
+ let n = fitChars(atom, rest, avail);
148
+ let take = rest.slice(0, n).join("");
149
+ state.cur.push({ ...atom, text: take });
150
+ state.w = width(atom.font, take, atom.size);
151
+ return rest.slice(n);
152
+ };
153
+
154
+ // An over-wide word alone on its line breaks by character. Both callers
155
+ // enter with the line empty, and `emit()` empties it again between
156
+ // chunks, so each chunk appends to a bare line. The final chunk stays in
157
+ // `cur` (with `w` set) instead of closing, so following atoms may join its
158
+ // line. Split by code point, so an astral character is never halved.
159
+ /** @type {(state: Wrap, atom: Atom, avail: number) => void} */
160
+ let chunk = (state, atom, avail) => {
161
+ // Slicing is per code point throughout this module — `no-misused-spread`
162
+ // is warning about the grapheme clusters no base-14 face can encode.
163
+ // oxlint-disable-next-line typescript/no-misused-spread
164
+ let rest = [...atom.text];
165
+ while (rest.length) {
166
+ rest = takeChunk(state, atom, rest, avail);
167
+ if (rest.length) emit(state);
168
+ }
169
+ };
170
+
171
+ /** @type {(atom: Atom, atomWidth: number, avail: number) => boolean} */
172
+ let isWide = (atom, atomWidth, avail) => {
173
+ // oxlint-disable-next-line typescript/no-misused-spread
174
+ return atomWidth > avail && [...atom.text].length > 1 && !atom.space;
175
+ };
176
+
177
+ /** @type {(state: Wrap, atom: Atom, atomWidth: number, avail: number) => boolean} */
178
+ let canFit = (state, atom, atomWidth, avail) =>
179
+ state.w + atomWidth <= avail || !state.cur.length || atom.space;
180
+
181
+ /** @type {(state: Wrap, atom: Atom, atomWidth: number) => void} */
182
+ let append = (state, atom, atomWidth) => {
183
+ state.cur.push(atom);
184
+ state.w += atomWidth;
185
+ };
186
+
187
+ /** @type {(state: Wrap, atom: Atom, atomWidth: number, avail: number, wide: boolean) => void} */
188
+ let overflow = (state, atom, atomWidth, avail, wide) => {
189
+ emit(state, false);
190
+ if (atom.space) return;
191
+ if (wide) chunk(state, atom, avail);
192
+ else append(state, atom, atomWidth);
193
+ };
194
+
195
+ /** @type {(state: Wrap, wide: boolean) => boolean} */
196
+ let startWide = (state, wide) => !state.cur.length && wide;
197
+
198
+ /** @type {(state: Wrap, atom: Atom, avail: number) => void} */
199
+ let placeAtom = (state, atom, avail) => {
200
+ if (atom.hard) return emit(state);
201
+ let atomWidth = width(atom.font, atom.text, atom.size);
202
+ let wide = isWide(atom, atomWidth, avail);
203
+ if (!canFit(state, atom, atomWidth, avail)) return overflow(state, atom, atomWidth, avail, wide);
204
+ if (startWide(state, wide)) return chunk(state, atom, avail);
205
+ append(state, atom, atomWidth);
206
+ };
207
+
208
+ // Greedy wrap against `avail`: spaces never start a line, an over-wide word
209
+ // breaks by character, hard breaks always break. A cell's atoms share one
210
+ // typography, so a line's atoms merge into a single draw piece.
211
+ /** @type {(metrics: Metrics, list: Atom[], avail: number) => Line[]} */
212
+ let wrap = (metrics, list, avail) => {
213
+ /** @type {Wrap} */
214
+ let state = { cur: [], w: 0, lines: [], base: metrics.base, fonts: metrics.fonts };
215
+ for (let atom of list) placeAtom(state, atom, avail);
216
+ emit(state);
217
+ return state.lines;
218
+ };
219
+
220
+ // Stamp cell-level decorations onto every wrapped fragment. Measurement does
221
+ // not need them; drawing does, and wrapping must not lose them.
222
+ /** @type {(line: Line, underline: boolean, strikethrough: boolean) => void} */
223
+ let stamp = (line, underline, strikethrough) => {
224
+ if (underline) line.underline = true;
225
+ if (strikethrough) line.strikethrough = true;
226
+ };
227
+
228
+ /** @type {(style: any) => boolean} */
229
+ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
230
+
231
+ /** @type {(lines: Line[], style: any) => Line[]} */
232
+ let dress = (lines, style) => {
233
+ if (!dressed(style)) return lines;
234
+ for (let line of lines) stamp(line, !!style.underline, !!style.strikethrough);
235
+ return lines;
236
+ };
237
+
238
+ /** @type {(lines: Line[]) => number} */
239
+ let heightOf = (lines) => lines.reduce((total, line) => total + line.h, 0);
240
+
241
+ export { atoms, dress, heightOf, wrap };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@quario/pdf",
3
+ "version": "0.1.0",
4
+ "description": "The browserless, paginated PDF render target for quario — in the makings, not yet released",
5
+ "homepage": "https://getquario.com",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/getquario/quario.git",
10
+ "directory": "packages/pdf"
11
+ },
12
+ "files": [
13
+ "CHANGELOG.md",
14
+ "lib"
15
+ ],
16
+ "type": "module",
17
+ "types": "lib/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./lib/index.d.ts",
21
+ "default": "./lib/index.js"
22
+ },
23
+ "./package.json": "./package.json"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "scripts": {
29
+ "check": "npm run size && npm test && npm run test:browser",
30
+ "size": "size-limit",
31
+ "test": "npm run test:unit && npm run test:types",
32
+ "test:browser": "node test/browser/setup.js",
33
+ "test:types": "tsc && attw --pack . --profile esm-only",
34
+ "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
35
+ "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
36
+ "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
37
+ },
38
+ "dependencies": {
39
+ "pdf-lib": "^1.17.1"
40
+ },
41
+ "devDependencies": {
42
+ "@arethetypeswrong/cli": "^0.18.3",
43
+ "@pdf-lib/fontkit": "^1.1.1",
44
+ "@size-limit/preset-small-lib": "^13.0.3",
45
+ "quario": "^0.1.0",
46
+ "size-limit": "^13.0.3",
47
+ "typescript": "^7.0.2"
48
+ },
49
+ "peerDependencies": {
50
+ "@pdf-lib/fontkit": "^1.1.1",
51
+ "quario": "^0.1.0"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "@pdf-lib/fontkit": {
55
+ "optional": true
56
+ }
57
+ },
58
+ "size-limit": [
59
+ {
60
+ "path": "lib/index.js",
61
+ "ignore": [
62
+ "quario",
63
+ "pdf-lib"
64
+ ],
65
+ "limit": "9.2 kB"
66
+ }
67
+ ],
68
+ "engines": {
69
+ "node": ">=22.0.0"
70
+ }
71
+ }