@quario/pdf 0.2.0 → 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 CHANGED
@@ -7,6 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-03
11
+
12
+ ### Changed
13
+
14
+ - **A split slot's box now fills the split's height.** A slot's background and
15
+ border stopped at that slot's own content and now take the split's height,
16
+ the way a table cell's take their row's. See the `@quario/layout` changelog
17
+ for the rule and for what it costs a report that relied on the short box.
18
+
19
+ - **A date string under `format: "date"` now presents as a date.** The
20
+ drawn text comes from the engine's `format()` helper, which revives the two
21
+ read forms. See the `quario` changelog for the forms and the timezone rule.
22
+
23
+ ### Changed
24
+
25
+ - **The layout moved to `@quario/layout`; this target paints its list.**
26
+ Measurement, wrapping, pagination, page furniture, the marking's geometry
27
+ and the page-size table now live in the layout package, which this target
28
+ depends on and runs first; pdf-lib writes the resulting display list. **The
29
+ move itself changed no output**, byte for byte, for the same input; the fix
30
+ below is a separate change and does move some. The `page` and `fonts`
31
+ options are the layout's own, so the same object configures `pdf()`, the
32
+ viewer and the editor. The font mapping's shape is now refused at the
33
+ factory call rather than at render.
34
+
35
+ ### Fixed
36
+
37
+ - **A nested group's footer keeps the page columns.** Inside a page-column
38
+ region, the first footer of a group nested in it ended the region: the
39
+ strips collapsed and every band after it was laid out across the page, as
40
+ if `columns` were absent. Only the node that declared `columns` closes its
41
+ own region now — a nested group's header and footer are region content,
42
+ laid out in the strips.
43
+
44
+ - **A padded or bordered item or row that breaks across a page keeps its
45
+ box.** Content too tall for one page is drawn as slices, and a slice now
46
+ carries the box sides the break left it — the top on the first, the bottom
47
+ on the last. Previously an item that overflowed lost its box entirely, so
48
+ the same declaration drew a border on short data and none on long; a
49
+ bordered table cell drew all four sides on every slice, putting an edge
50
+ across the middle of its own text; and a row's own border vanished when
51
+ the row was too tall for a page. A slice's lines are also drawn inside
52
+ the horizontal padding they were wrapped for, rather than flush to the
53
+ column. **This changes output** for reports where such content breaks.
54
+
55
+ ## [0.3.0] - 2026-09-02
56
+
57
+ ### Added
58
+
59
+ - **`format` stringifies at the edge from the instance locale.** A PDF
60
+ without a host locale still uses `en-US` / UTC, so the bytes stay
61
+ reproducible.
62
+
63
+ - **A height-declared report header pins from the page top.** Leftover sits
64
+ under the items; the next band starts at the pin. The half-line group gap
65
+ drops after it. `page.margin` on the document is the inset when the host
66
+ omits it; both is a render error.
67
+
68
+ - **`spaceBefore` / `spaceAfter` skip the cursor.** Adjacent gaps add.
69
+ `spaceBefore` drops at a fresh body page or strip top; page-band items
70
+ keep it.
71
+
72
+ - **The box is honoured in layout.** Padding and border inset wrap width; a
73
+ named padding `0` beats the cell omakase (`PADX` 6 / `PADY` 2) on that
74
+ side. Incomplete sides draw nothing.
75
+
76
+ ### Changed
77
+
78
+ - **The last data row keeps the whole emitted total block.** A stack taller
79
+ than a page degrades like a tall row.
80
+
81
+ - **An unstyled table has no rules.** This target used to draw two 0.5 pt
82
+ rules, under the header and above the total, that no declaration could
83
+ ask for or refuse. Those strokes are gone. The strokes an author wants
84
+ are the box they declared. Cell padding omakase stays on undeclared cell
85
+ sides.
86
+
87
+ - **A visible text item occupies a line at its own `size`, empty or not.** An
88
+ empty or whitespace-only value used to sit at the report's base leading
89
+ (~14 pt at the 10 pt baseline). It now occupies `1.4 ×` the item's size, the
90
+ same as a glyph line. A literal newline is a line break; the blank line
91
+ among `"a\n\nb"` is that size too. A table cell that is empty or only
92
+ horizontal whitespace still has no content height.
93
+
94
+ ### Fixed
95
+
96
+ - **A missing TrueType glyph draws as `?`, not `.notdef`.** Characters
97
+ outside WinAnsi already substituted `?`; an embedded face's cmap holes
98
+ drew a box instead, and text extraction hid it. Same rule for every
99
+ face: a character the face cannot draw becomes `?`.
100
+
101
+ - **CR, LF, and CRLF are one hard line break.** Cell text already broke
102
+ on LF; a CRLF left a CR on the previous line, and a lone CR did not
103
+ break. All three are now one break, matching `SCHEMA.md` Cell values.
104
+ Wrapping still applies within each line.
105
+
10
106
  ## [0.2.0] - 2026-09-01
11
107
 
12
108
  ### Added
package/README.md CHANGED
@@ -3,8 +3,9 @@
3
3
  **The PDF render target for [quario](https://www.npmjs.com/package/quario).** Renders a report
4
4
  definition straight to a paginated PDF. No headless browser, no print CSS, deterministic bytes.
5
5
 
6
- quario does the typesetting: pagination, keep-together, tables, fonts, the document outline.
7
- [pdf-lib](https://pdf-lib.js.org) writes the file. You get typesetting plus pdf-lib: no Chromium
6
+ quario does the typesetting pagination, keep-together, tables, fonts, the document outline
7
+ through `@quario/layout`, the same layout the viewer paints on screen, so the preview and the
8
+ document break their pages in the same places. [pdf-lib](https://pdf-lib.js.org) writes the file. You get typesetting plus pdf-lib: no Chromium
8
9
  in your container, no page-load race, no fonts-not-ready flake.
9
10
 
10
11
  ## Install
@@ -135,8 +136,9 @@ without authored margins.
135
136
 
136
137
  The base-14 Helvetica, Times, and Courier families carry the default output (`family: "sans"`,
137
138
  `"serif"`, `"mono"`, each with regular, bold, italic, and bold-italic faces), using WinAnsi
138
- encoding. Characters outside WinAnsi render as `?` rather than failing the report, because cell
139
- text is untrusted data and one stray character must not take a document down.
139
+ encoding. Characters a face cannot draw render as `?` rather than failing the report, because cell
140
+ text is untrusted data and one stray character must not take a document down. WinAnsi is the
141
+ base-14 limit; a TrueType face's cmap is its own.
140
142
 
141
143
  For full Unicode, supply TrueType families and select them by name:
142
144
 
package/lib/embed.js ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The faces a document can draw with, embedded before anything is painted.
3
+ *
4
+ * Every face is embedded on the document before the list is painted, so
5
+ * resolving a list op's face to a pdf-lib font is synchronous — pdf-lib's
6
+ * `embedFont` is async, and the paint is not. Embedding the twelve base-14
7
+ * faces up front costs under 200 bytes in the output, which is cheaper than a
8
+ * two-pass split to embed only what is used.
9
+ *
10
+ * The registry mirrors the layout's: the same family names, the same four
11
+ * variants indexed by `bold + 2 * italic`, a missing variant falling back to
12
+ * the family's regular — so a `FaceRef` on the list names one font here.
13
+ */
14
+ import { StandardFonts } from "pdf-lib";
15
+
16
+ let BASE = {
17
+ sans: [
18
+ StandardFonts.Helvetica,
19
+ StandardFonts.HelveticaBold,
20
+ StandardFonts.HelveticaOblique,
21
+ StandardFonts.HelveticaBoldOblique,
22
+ ],
23
+ serif: [
24
+ StandardFonts.TimesRoman,
25
+ StandardFonts.TimesRomanBold,
26
+ StandardFonts.TimesRomanItalic,
27
+ StandardFonts.TimesRomanBoldItalic,
28
+ ],
29
+ mono: [
30
+ StandardFonts.Courier,
31
+ StandardFonts.CourierBold,
32
+ StandardFonts.CourierOblique,
33
+ StandardFonts.CourierBoldOblique,
34
+ ],
35
+ };
36
+
37
+ /** @typedef {{ families: Record<string, any[]> }} Fonts */
38
+
39
+ // Host-supplied TrueType families need a font parser, which is an optional
40
+ // peer: base-14 reports install nothing extra. Loaded only when `fonts` is
41
+ // passed, and a missing package is reported as what it is.
42
+ /** @type {(doc: any) => Promise<void>} */
43
+ let useFontkit = async (doc) => {
44
+ let fontkit;
45
+ try {
46
+ fontkit = (await import("@pdf-lib/fontkit")).default;
47
+ } catch {
48
+ throw Error("options.fonts: install @pdf-lib/fontkit to embed TrueType families");
49
+ }
50
+ doc.registerFontkit(fontkit);
51
+ };
52
+
53
+ /** @type {(doc: any) => Promise<Record<string, any[]>>} */
54
+ let embedBase = async (doc) => {
55
+ /** @type {Record<string, any[]>} */
56
+ let families = {};
57
+ for (let [name, variants] of Object.entries(BASE))
58
+ families[name] = await Promise.all(variants.map((v) => doc.embedFont(v)));
59
+ return families;
60
+ };
61
+
62
+ /** @type {(doc: any, src: any, path: string, variant: string) => Promise<any>} */
63
+ let embedOne = async (doc, src, path, variant) => {
64
+ try {
65
+ return await doc.embedFont(src[variant], { subset: true });
66
+ } catch (e) {
67
+ throw Error(path + "." + variant + ": " + /** @type {Error} */ (e).message);
68
+ }
69
+ };
70
+
71
+ // Missing variants fall back to the family's regular, so a face is always
72
+ // resolvable once the family is named. The shape was checked at the layout's
73
+ // factory call; this only embeds.
74
+ /** @type {(doc: any, name: string, def: any) => Promise<any[]>} */
75
+ let familyOf = async (doc, name, def) => {
76
+ let path = "options.fonts." + name;
77
+ /** @type {any[]} */
78
+ let faces = [];
79
+ for (let variant of ["regular", "bold", "italic", "boldItalic"])
80
+ faces.push(def[variant] == null ? faces[0] : await embedOne(doc, def, path, variant));
81
+ return faces;
82
+ };
83
+
84
+ /** @type {(doc: any, custom: any, families: Record<string, any[]>) => Promise<void>} */
85
+ let embedCustom = async (doc, custom, families) => {
86
+ if (!(custom && Object.keys(custom).length)) return;
87
+ await useFontkit(doc);
88
+ for (let [name, def] of Object.entries(custom))
89
+ families[name.toLowerCase()] = await familyOf(doc, name, def);
90
+ };
91
+
92
+ /**
93
+ * Embed every face the list can name and return the registry the painter
94
+ * reads.
95
+ *
96
+ * @param {any} doc The pdf-lib document.
97
+ * @param {any} custom `options.fonts`, or null.
98
+ * @returns {Promise<Fonts>}
99
+ */
100
+ export async function embedFonts(doc, custom) {
101
+ let families = await embedBase(doc);
102
+ await embedCustom(doc, custom, families);
103
+ return { families };
104
+ }
105
+
106
+ /**
107
+ * The embedded font one list op names.
108
+ *
109
+ * @type {(fonts: Fonts, ref: { family: string, variant: number }) => any}
110
+ */
111
+ export let fontFor = (fonts, ref) => fonts.families[ref.family][ref.variant];
package/lib/index.d.ts CHANGED
@@ -1,12 +1,8 @@
1
+ import type { LayoutFontFamily, LayoutPage } from "@quario/layout";
1
2
  import type { Target } from "quario";
2
3
 
3
- /** Page geometry, in PostScript points. Target configuration, never schema. */
4
- export interface PdfPage {
5
- /** Named size or `[width, height]` in points. Default `'A4'`. */
6
- size?: "A4" | "letter" | [number, number];
7
- /** Margin on all four sides, in points. Default `54` (0.75 in). */
8
- margin?: number;
9
- }
4
+ /** Page geometry, in PostScript points: the layout's own, never schema. */
5
+ export type PdfPage = LayoutPage;
10
6
 
11
7
  /** Optional document information; never includes dates, so output stays deterministic. */
12
8
  export interface PdfMeta {
@@ -15,13 +11,8 @@ export interface PdfMeta {
15
11
  subject?: string;
16
12
  }
17
13
 
18
- /** One embeddable TrueType family; missing variants fall back to `regular`. */
19
- export interface PdfFontFamily {
20
- regular: Uint8Array | ArrayBuffer;
21
- bold?: Uint8Array | ArrayBuffer;
22
- italic?: Uint8Array | ArrayBuffer;
23
- boldItalic?: Uint8Array | ArrayBuffer;
24
- }
14
+ /** One embeddable TrueType family: the layout's own, measured and embedded from the same bytes. */
15
+ export type PdfFontFamily = LayoutFontFamily;
25
16
 
26
17
  /** Host controls, taken and validated at the factory call. */
27
18
  export interface PdfOptions {
package/lib/index.js CHANGED
@@ -1,88 +1,28 @@
1
1
  /**
2
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.
3
+ * event stream, passed to a compiled report's `render`. The layout is
4
+ * `@quario/layout`'s one paged display list, the same one the viewer paints
5
+ * on screen (docs/adr/0039) and **pdf-lib writes the file**, so this target
6
+ * is a painter of that list rather than a typesetter or a PDF implementation.
7
+ * Output is deterministic: the document carries no dates of its own, so the
8
+ * same input renders the same bytes. Page size is target configuration
9
+ * (`pdf({ page.size })`); `page.margin` may also be a document field.
10
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.
11
+ * This file is options, the layout pass, and the document: faces embedded,
12
+ * pages painted, the group tree attached as bookmarks. The work lives in the
13
+ * sibling modules, which ship beside it the package publishes `lib/`
14
+ * verbatim.
17
15
  */
18
16
  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";
17
+ import { layout } from "@quario/layout";
18
+ import { embedFonts } from "./embed.js";
23
19
  import { outline } from "./outline.js";
20
+ import { paint } from "./painter.js";
24
21
 
25
22
  // The options are described once, in the hand-written public declarations, and
26
23
  // read back here — a second copy in JSDoc is a copy that drifts.
27
24
  /** @import { PdfOptions } from './index.d.ts' */
28
25
 
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: number) => boolean} */
40
- let positive = (value) => Number.isFinite(value) && value > 0;
41
-
42
- /** @type {(size: any) => any} */
43
- let named = (size = "A4") => {
44
- let dimensions = Array.isArray(size) ? size : SIZES[size];
45
- // oxlint-disable-next-line no-unused-expressions
46
- dimensions || err('options.page.size: unknown page size "' + size + '"');
47
- return dimensions;
48
- };
49
-
50
- /** @type {(dimensions: any) => { width: number, height: number }} */
51
- let pair = (dimensions) => {
52
- let width = +dimensions[0],
53
- height = +dimensions[1];
54
- // oxlint-disable-next-line no-unused-expressions
55
- (positive(width) && positive(height)) ||
56
- err("options.page.size: expected finite positive dimensions");
57
- return { width, height };
58
- };
59
-
60
- /** @type {(margin: any, width: number, height: number) => number} */
61
- let marginOf = (margin = 54, width, height) => {
62
- let fits = Number.isFinite(margin) && margin >= 0 && 2 * margin < Math.min(width, height);
63
- // oxlint-disable-next-line no-unused-expressions
64
- fits || err("options.page.margin: expected a non-negative number smaller than half the page");
65
- return margin;
66
- };
67
-
68
- // This target's baseline type size. Not a host option: a document's type size
69
- // is the document's own, so it is `style.size` on the report and the number
70
- // here is only what text renders at when nothing declares one. The XLSX target
71
- // carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
72
- let BASE = 10;
73
-
74
- // The page box and the content box in one, settled here beside the validation
75
- // that produced them. This frame never moves at all: it serves every render the
76
- // target compiles, so a narrowing here would be one document's page bands
77
- // reserved out of the next one too. The one narrowing a render does make is the
78
- // band flow's, off that render's own canvas.
79
- /** @type {(options: any) => import('./canvas.js').Frame} */
80
- let geometry = (options) => {
81
- let { page = {} } = options ?? {};
82
- let { width, height } = pair(named(page.size));
83
- return frame(width, height, marginOf(page.margin, width, height), BASE);
84
- };
85
-
86
26
  // Optional document information. Never a date: pdf-lib stamps the current time
87
27
  // unless told otherwise, and a timestamp would make the same input render
88
28
  // different bytes on every run. Each key names the pdf-lib setter it feeds,
@@ -101,27 +41,11 @@ let describe = (doc, meta) => {
101
41
  }
102
42
  };
103
43
 
104
- /** @type {(canvas: import('./canvas.js').Drawing, draw: (i: number) => void) => Promise<void>} */
105
- let overPages = async (canvas, draw) => {
106
- for (let i = 0; i < canvas.count; i++) {
107
- canvas.select(i);
108
- draw(i);
109
- if (i % 50 === 49) await breathe();
110
- }
111
- };
112
-
113
- /** @type {(canvas: import('./canvas.js').Drawing, bands: any,
114
- * pages: { number: number, total: number }[]) => Promise<void>} */
115
- let furnish = async (canvas, bands, pages) => {
116
- if (!bands) return;
117
- await overPages(canvas, (i) => furniture(canvas, bands, pages[i]));
118
- };
119
-
120
- /** @type {(canvas: import('./canvas.js').Drawing, marking: any) => Promise<void>} */
121
- let markPages = async (canvas, marking) => {
122
- if (!marking) return;
123
- let mark = stamp(canvas, marking);
124
- await overPages(canvas, () => canvas.watermark(mark));
44
+ /** @type {(meta: any, custom: any) => Promise<{ doc: any, fonts: import('./embed.js').Fonts }>} */
45
+ let open = async (meta, custom) => {
46
+ let doc = await PDFDocument.create();
47
+ describe(doc, meta);
48
+ return { doc, fonts: await embedFonts(doc, custom) };
125
49
  };
126
50
 
127
51
  /**
@@ -129,50 +53,29 @@ let markPages = async (canvas, marking) => {
129
53
  * `quario().report(schema).render(pdf({ page }), data)` resolves the
130
54
  * complete document as a `Uint8Array` (the host writes the file). Rendering
131
55
  * hands the loop back between event and page batches, so large reports stay
132
- * cooperative. Geometry and options are validated here, at the factory call.
56
+ * cooperative. Geometry and the font mapping are validated here, at the
57
+ * factory call — by the layout target, whose options they are.
133
58
  *
134
59
  * @param {PdfOptions} [options] Host controls (see SCHEMA.md, "The PDF target").
135
60
  * @returns {{name: "pdf", compile: (stream: any) => (data?: any) => Promise<Uint8Array>}}
136
61
  * The target (see SCHEMA.md, "Instances and targets").
137
62
  */
138
63
  export function pdf(options) {
139
- let geo = geometry(options);
140
- let meta = options?.meta,
141
- custom = options?.fonts;
64
+ let { page, fonts: custom, meta } = options ?? {};
65
+ let paged = layout({ page, fonts: custom });
142
66
  /** @type {(stream: any) => (data?: any) => Promise<Uint8Array>} */
143
- let compile = (stream) => async (data) => {
144
- let doc = await PDFDocument.create();
145
- describe(doc, meta);
146
- // Every face is embedded before the walk, so resolving a style to a
147
- // font during layout stays synchronous.
148
- let fonts = await embedFonts(doc, custom);
149
- let canvas = drawing(doc, geo, fonts);
150
- // The band flow owns the placement state and every handler over it, and
151
- // opens the first page as it is built; this file only hands it the stream.
152
- let { handlers, finish } = flow(canvas);
153
- await walk(stream(data), handlers);
154
- // The flow settled the opening event on its way past — it reserves the
155
- // page bands off it — and hands it back with the marks. What is left on it
156
- // is what the passes below want: the band closures to render per page, and
157
- // the marking's wording to stamp.
158
- // Null when nothing settled one, which reads as a document owing neither.
159
- let { marks, opening, pages } = finish();
160
- opening = opening || {};
161
- // The passes below run over the finished pages, not the stream — no walk
162
- // at all — so they open each page themselves and breathe on their own
163
- // rather than through the driver.
164
- await furnish(canvas, opening.page, pages);
165
- // Every image the walk and the furniture pass placed, embedded and drawn
166
- // now: a walk handler cannot await, and the page bands' images are not
167
- // placed until the pass above has run. Before the marking, so an image can
168
- // never cover it.
169
- await canvas.pictures();
170
- // The unlicensed marking goes on last, over content and page furniture
171
- // alike, once per page (LICENSE section 6). Its wording rode in on
172
- // `report-start`; only the placement is this target's.
173
- await markPages(canvas, opening.marking);
174
- outline(doc, marks, canvas.refs);
175
- return doc.save();
67
+ let compile = (stream) => {
68
+ let lay = paged.compile(stream);
69
+ return async (data) => {
70
+ // The list measures against its own faces, so the document is written
71
+ // from the same breaks the preview shows; opening the document and
72
+ // embedding its faces depend on nothing the list decides, so they
73
+ // overlap the layout pass.
74
+ let [list, { doc, fonts }] = await Promise.all([lay(data), open(meta, custom)]);
75
+ let refs = await paint(doc, fonts, list);
76
+ outline(doc, list.marks, refs);
77
+ return doc.save();
78
+ };
176
79
  };
177
80
  return { name: "pdf", compile };
178
81
  }