@quario/pdf 0.4.0 → 0.6.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,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-07
11
+
12
+ ### Added
13
+
14
+ - **A cell's own `currency` code is honoured**, ahead of the instance's
15
+ default, so a listing whose rows arrive in different currencies presents each
16
+ in its own. A code the engine could not accept leaves the cell as plain
17
+ display text rather than presenting it in the instance's currency.
18
+
19
+ ### Changed
20
+
21
+ - **Numbers presented through `format` now show a fixed two fraction digits,
22
+ matching every other target.** `format: "number"` on `1000` renders
23
+ `1,000.00` where it rendered `1,000`, and `0.12345` renders `0.12` where it
24
+ rendered `0.123`; `format: "percent"` on `0.21` renders `21.00%` where it
25
+ rendered `21%`. `format: "currency"` follows the currency's own minor units,
26
+ so a JPY amount loses the two decimals it never had. `date` is unchanged, as
27
+ are grouping separators and symbol placement. The digits come from the
28
+ engine, so a cell reads the same here as it does in a workbook.
29
+
30
+ ### Fixed
31
+
32
+ - **An image these bytes cannot embed now fails with the item's name on it.**
33
+ A PNG whose pixel data is corrupt past the size in its header used to reject
34
+ the render with `Invalid typed array length: 0` — the PDF writer's own words,
35
+ naming neither an image nor an item, so a report with two pictures gave no
36
+ way to tell which was at fault. The message now begins with the item's
37
+ `source` path and says the image could not be embedded, keeping the writer's
38
+ account after it and on `cause`. A JPEG is unchanged: its header is read but
39
+ its pixel data never is, so a corrupt one still embeds without complaint.
40
+
41
+ ## [0.5.0] - 2026-09-05
42
+
43
+ ### Added
44
+
45
+ - **A spanning cell draws as one box** across the columns it covers, through
46
+ `@quario/layout`. It takes no part in measuring them, and a spanning row that
47
+ slices across pages or page-column strips follows the geometry of the strip
48
+ each slice lands in.
49
+ - **`valign`** on table cells and split slots, through `@quario/layout`: middle
50
+ and bottom place the content in the height its row or split leaves over it.
51
+
52
+ ### Changed
53
+
54
+ - **A row's box is drawn by the row's cells**, through `@quario/layout`. A box
55
+ declared on a table row used to be one rect across the columns; each covered
56
+ cell now draws its own. A row's `borderBottom` still reads as one continuous
57
+ edge, a row's `borderLeft` becomes an edge on every cell rather than one at
58
+ the row's outer left, and a bordered row is taller by its border's width,
59
+ since a border occupies height as a cell's always has.
60
+
61
+ - **Embedding TrueType fonts now needs `fontkit`, not `@pdf-lib/fontkit`.**
62
+ Install `fontkit` instead; nothing else about `options.fonts` changes. The
63
+ old package's bundle crashed with a bare `ReferenceError` on any OpenType
64
+ face needing a shaping state machine — which is every Devanagari, Bengali,
65
+ Tamil, Khmer or Myanmar webfont in practice, and some fifty further scripts
66
+ besides. Those faces now measure, embed and draw. Which scripts a face
67
+ supports remains the font's and the parser's to answer, not this package's.
68
+
69
+ - **`pdf-lib` is now `@cantoo/pdf-lib`.** A maintained fork, and what
70
+ `fontkit`'s subsetting requires. Rendered documents are unchanged in what
71
+ they draw: every drawn string is identical and every filled area lands in
72
+ the same place, but the file's bytes differ, so a host comparing digests
73
+ against stored output will see them move once.
74
+
10
75
  ## [0.4.0] - 2026-09-03
11
76
 
12
77
  ### Changed
package/README.md CHANGED
@@ -5,7 +5,7 @@ definition straight to a paginated PDF. No headless browser, no print CSS, deter
5
5
 
6
6
  quario does the typesetting — pagination, keep-together, tables, fonts, the document outline —
7
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
+ document break their pages in the same places. [`@cantoo/pdf-lib`](https://github.com/Cantoo-Scribe/pdf-lib) writes the file. You get typesetting plus pdf-lib: no Chromium
9
9
  in your container, no page-load race, no fonts-not-ready flake.
10
10
 
11
11
  ## Install
@@ -14,13 +14,13 @@ in your container, no page-load race, no fonts-not-ready flake.
14
14
  npm install quario @quario/pdf
15
15
  ```
16
16
 
17
- pdf-lib comes along as a dependency; the engine is a peer, installed beside it. ESM-only, Node 22+, and browser-ready through
17
+ `@cantoo/pdf-lib` comes along as a dependency; the engine is a peer, installed beside it. ESM-only, Node 22+, and browser-ready through
18
18
  any standards-based ESM bundler. The renderer returns bytes, so the host decides where they go.
19
19
 
20
20
  Embedding TrueType fonts also needs the optional peer:
21
21
 
22
22
  ```bash
23
- npm install @pdf-lib/fontkit
23
+ npm install fontkit
24
24
  ```
25
25
 
26
26
  It loads only when you pass `fonts` to the factory. The base-14 families need nothing extra.
@@ -58,8 +58,9 @@ const paged = {
58
58
  ```
59
59
 
60
60
  `visible: "=page.number > 1"` on a band is the cover-page recipe. Gate band _visibility_ on
61
- `page.number` only. Band heights are reserved before the real page count is known, so
62
- `=page.total > 10` reserves nothing. `page.total` is fine in band _text_.
61
+ `page.number` only. Band heights are reserved before the real page count is known, probed as a
62
+ two-page document, so `=page.total > 10` reserves only the 8 pt gap a declared band always costs.
63
+ `page.total` is fine in band _text_.
63
64
 
64
65
  ## API
65
66
 
@@ -85,7 +86,9 @@ host. Render-time failures reject with located errors.
85
86
 
86
87
  ### Options
87
88
 
88
- Page geometry is target configuration, never schema:
89
+ Page size and fonts are target configuration. The margin is the target's unless the document
90
+ declares `page.margin`, in which case the document's is used; a document and a target that both
91
+ declare one reject the render.
89
92
 
90
93
  ```js
91
94
  {
@@ -96,7 +99,7 @@ Page geometry is target configuration, never schema:
96
99
  ```
97
100
 
98
101
  `size` defaults to `A4` (595.28 × 841.89 pt); an array is a custom `[width, height]` in points. An
99
- unknown size name is a definition error, thrown by the factory. `margin` applies to all four sides and defaults to 54
102
+ unknown size name is an error the factory throws. `margin` applies to all four sides and defaults to 54
100
103
  (0.75 in). Text renders at 10pt when nothing declares a size: a document's type size is the
101
104
  document's own, so it is the report's `style.size`, not a host option. Line leading is 1.4× a line's
102
105
  largest font size.
@@ -105,12 +108,12 @@ largest font size.
105
108
 
106
109
  **Guaranteed** normative behavior a conforming target must produce:
107
110
 
108
- - Table header rows repeat after every page break, with a rule under the header and above the
109
- total row.
111
+ - Table header rows repeat after every page break. An unstyled table draws no rules; an
112
+ authored border on a header row or a total row is drawn at the width written.
110
113
  - A group header always travels with its first content unit: the first detail lines, or a table's
111
114
  header plus its first row. An instance's headers also repeat at the top of every page it
112
115
  continues onto (outermost first, then content) and stop when the instance ends.
113
- - The total row keeps the last data row with it.
116
+ - The total rows keep the last data row with them.
114
117
  - A row that fits on a page is never split across one.
115
118
  - `break: "page"` on a group opens a fresh page per instance.
116
119
  - `reset: "page"` on a group does the same and restarts `page.number` / `page.total` for that instance's sequence.
@@ -119,17 +122,32 @@ largest font size.
119
122
  nothing is a definition error, so there is no over-commitment case.
120
123
  - Hidden cells keep their column slot.
121
124
  - Page bands render on every page, their heights reserved out of the body area.
125
+ - A report header declaring `height` pins a box of that many points from the page top; its items
126
+ pack from the top and the next band starts where the box ends.
127
+ - Every group instance opens with a half-line gap, dropped at a page top; authored `spaceBefore`
128
+ drops at a fresh page or strip top the same way.
129
+ - An item or row a page cannot hold whole still carries its box: each slice draws the sides the
130
+ break left it, the top on the first and the bottom on the last.
131
+ - A split lays out as one block at the height of its tallest slot; every slot's box is the split's
132
+ height, and a slot's `valign` places its content in that slack, as a table cell's does in its
133
+ row's.
134
+ - Page columns are strips: the flow fills one to its foot, moves to the next, and turns the page
135
+ once the last is spent; a table restates its headings at a strip head, a group's headers only
136
+ at a page head.
137
+ - The band-role defaults (a bold, larger report header; bold group headers) sit under the
138
+ author's own style, and `uppercase` capitalises the drawn string before it is measured.
122
139
  - An image draws at the size its `fit` dictates (`natural` at 96 dpi capped at the content
123
140
  width, `width` scaled to it, aspect ratio preserved) and is never split across a page break:
124
141
  one that does not fit the remaining height moves whole to a fresh page.
125
142
 
126
- **Best-effort** current implementation, pinned by no test and changeable
127
- without a breaking change: paragraph widow and orphan avoidance, slicing a row taller than the
143
+ **Best-effort** current implementation, pinned only as a regression guard where the layout suite
144
+ holds it, and changeable without a breaking change: paragraph widow and orphan avoidance, the
145
+ gutter between page-column strips and whether a region balances, slicing a row taller than the
128
146
  page, downscaling an image taller than any page, embedding repeated image bytes once, and where
129
147
  an image's page break lands. Do not write definitions that depend on these.
130
148
 
131
149
  Bands flow vertically in walk order, matching the HTML target's divs. Group nesting adds no
132
- indentation, but every instance opens with a structural half-line gap so groups read as blocks
150
+ indentation, but every instance that draws opens with a structural half-line gap so groups read as blocks
133
151
  without authored margins.
134
152
 
135
153
  ## Fonts and text
@@ -153,7 +171,9 @@ const target = pdf({
153
171
 
154
172
  Missing variants fall back to the family's regular. Embedded text uses the font's own metrics, is
155
173
  subset to the glyphs the document uses, and carries a ToUnicode map so extraction and
156
- copy see the original text. Fonts are trusted host assets, like registered functions.
174
+ copy see the original text. Fonts are trusted host assets, like registered functions. A mapping
175
+ without a `regular` is refused by the factory (`options.fonts.<name>.regular: required`); bytes
176
+ the parser cannot read reject the render, located at the same option path.
157
177
 
158
178
  ## Outline
159
179
 
@@ -175,8 +195,8 @@ per-page presence is normative; exact geometry is best-effort. A licensed render
175
195
 
176
196
  ## Page furniture
177
197
 
178
- Paper size, margins, and fonts are options, not schema. Authored watermarks and finer break
179
- control stay out of scope. Keep-together and continuation headers are by construction, above.
198
+ Paper size and fonts are options, not schema; the margin is an option a document may declare in
199
+ its stead. Authored watermarks and finer break control stay out of scope. Keep-together and continuation headers are by construction, above.
180
200
 
181
201
  ## License
182
202
 
package/lib/embed.js CHANGED
@@ -11,27 +11,18 @@
11
11
  * variants indexed by `bold + 2 * italic`, a missing variant falling back to
12
12
  * the family's regular — so a `FaceRef` on the list names one font here.
13
13
  */
14
- import { StandardFonts } from "pdf-lib";
14
+ import { StandardFonts } from "@cantoo/pdf-lib";
15
15
 
16
+ // A family's four names are one stem plus the weight and slant suffixes that
17
+ // family spells, so each family names its two and the four are walked out of
18
+ // them rather than written twelve times over.
19
+ /** @type {(stem: string, slant: string) => string[]} */
20
+ let variants = (stem, slant) =>
21
+ ["", "Bold", slant, "Bold" + slant].map((v) => /** @type {any} */ (StandardFonts)[stem + v]);
16
22
  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
- ],
23
+ sans: variants("Helvetica", "Oblique"),
24
+ serif: variants("TimesRoman", "Italic"),
25
+ mono: variants("Courier", "Oblique"),
35
26
  };
36
27
 
37
28
  /** @typedef {{ families: Record<string, any[]> }} Fonts */
@@ -43,9 +34,9 @@ let BASE = {
43
34
  let useFontkit = async (doc) => {
44
35
  let fontkit;
45
36
  try {
46
- fontkit = (await import("@pdf-lib/fontkit")).default;
37
+ fontkit = await import("fontkit");
47
38
  } catch {
48
- throw Error("options.fonts: install @pdf-lib/fontkit to embed TrueType families");
39
+ throw Error("options.fonts: install fontkit to embed TrueType families");
49
40
  }
50
41
  doc.registerFontkit(fontkit);
51
42
  };
package/lib/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { LayoutFontFamily, LayoutPage } from "@quario/layout";
2
2
  import type { Target } from "quario";
3
3
 
4
- /** Page geometry, in PostScript points: the layout's own, never schema. */
4
+ /** Page geometry, in PostScript points: the layout's own, its margin one a document may declare instead. */
5
5
  export type PdfPage = LayoutPage;
6
6
 
7
7
  /** Optional document information; never includes dates, so output stays deterministic. */
@@ -21,7 +21,7 @@ export interface PdfOptions {
21
21
  /**
22
22
  * TrueType families to embed (subset), selected from styles by
23
23
  * `family: '<name>'`. Fonts are trusted host assets. Requires the optional
24
- * peer `@pdf-lib/fontkit`; the base-14 families need nothing extra.
24
+ * peer `fontkit`; the base-14 families need nothing extra.
25
25
  */
26
26
  fonts?: Record<string, PdfFontFamily>;
27
27
  }
package/lib/index.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * sibling modules, which ship beside it — the package publishes `lib/`
14
14
  * verbatim.
15
15
  */
16
- import { PDFDocument } from "pdf-lib";
16
+ import { PDFDocument } from "@cantoo/pdf-lib";
17
17
  import { layout } from "@quario/layout";
18
18
  import { embedFonts } from "./embed.js";
19
19
  import { outline } from "./outline.js";
package/lib/outline.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * own position. pdf-lib has no bookmark API, so the objects are built through
8
8
  * its low-level context — the only place in this target that does.
9
9
  */
10
- import { PDFHexString, PDFName, PDFNumber } from "pdf-lib";
10
+ import { PDFHexString, PDFName, PDFNumber } from "@cantoo/pdf-lib";
11
11
 
12
12
  // A PDF *text string*: UTF-16BE with a BOM, so a title survives whatever the
13
13
  // author wrote. `PDFHexString.fromText` encodes exactly that.
package/lib/painter.js CHANGED
@@ -9,12 +9,12 @@
9
9
  * and one logo on every page is embedded once, the marking after them so an
10
10
  * image can never cover it (LICENSE section 6).
11
11
  */
12
- import { breathe } from "quario";
13
- import { degrees, drawImage, drawText, rgb } from "pdf-lib";
12
+ import { breathe, imageError } from "quario";
13
+ import { degrees, drawImage, drawText, rgb } from "@cantoo/pdf-lib";
14
14
  import { fontFor } from "./embed.js";
15
15
 
16
- /** @typedef {import('pdf-lib').PDFPage} PDFPage */
17
- /** @typedef {import('pdf-lib').PDFName} PDFName */
16
+ /** @typedef {import('@cantoo/pdf-lib').PDFPage} PDFPage */
17
+ /** @typedef {import('@cantoo/pdf-lib').PDFName} PDFName */
18
18
 
19
19
  // The operator builder wants explicit rotation and skew; report text has none.
20
20
  let NO_TURN = degrees(0);
@@ -122,11 +122,45 @@ let paintMark = ({ page, height, fonts, faceKey }, op) => {
122
122
 
123
123
  // Keyed by the array the source expression yielded, so one logo reused
124
124
  // across pages is embedded once and every placement references it.
125
+ //
126
+ // The embedders parse the bytes, and what they throw when those bytes are bad
127
+ // says nothing an author could act on: a corrupt PNG used to reach a host as a
128
+ // bare `RangeError: Invalid typed array length: 0`, naming no image and no
129
+ // item (quario-e0bz). The engine vouched for the magic numbers and the layout
130
+ // read the size out of the header, so a file corrupt past that point arrives
131
+ // here looking like any other and this is the first place that knows. The
132
+ // embedder's own class and words survive behind the path, which is the shape
133
+ // SCHEMA.md documents for every render error.
134
+ //
135
+ // **What this cannot promise is symmetry between the two formats.** pdf-lib's
136
+ // PNG embedder decompresses the pixel data, while its JPEG embedder reads
137
+ // markers alone and never the scan data, so a JPEG corrupt past its frame
138
+ // header embeds without complaint and arrives here as no failure at all.
139
+ // Corrupt scan data cannot be told from good without decoding the image, which
140
+ // is not a cost a render should pay to find out what it is about to write.
141
+ //
142
+ // Closing part of the gap was weighed and declined (`quario-dqt8`). A JPEG
143
+ // carrying no `SOS` at all is structural, as cheap to read as the size, so the
144
+ // marker walk in `@quario/layout`'s `image.js` could say so — but measuring
145
+ // what that catches settled it: a JPEG's headers are roughly fixed while its
146
+ // scan grows with the image, so the check covers truncation only up to the
147
+ // point the scan starts. On the 882-byte `logo.jpg` fixture that is 85% of the
148
+ // file; on a photograph it is a fraction of a percent, and every truncation
149
+ // worth worrying about lands past it. What it would buy is the shape of a
150
+ // hand-built repro; what it would cost an author is a rule whose seam falls in
151
+ // an arbitrary place — a JPEG whose scan never starts fails, one whose scan is
152
+ // cut does not — and the PNG/JPEG asymmetry survives either way. So the line
153
+ // stays where the engine's own guarantee stops.
125
154
  /** @type {(painter: Painter, op: any) => Promise<any>} */
126
155
  let imageOf = async ({ doc, embedded }, op) => {
127
156
  let image = embedded.get(op.bytes);
128
157
  if (!image) {
129
- image = await (op.format === "png" ? doc.embedPng(op.bytes) : doc.embedJpg(op.bytes));
158
+ try {
159
+ image = await (op.format === "png" ? doc.embedPng(op.bytes) : doc.embedJpg(op.bytes));
160
+ } catch (cause) {
161
+ let said = /** @type {Error} */ (cause).message;
162
+ throw imageError(op.path, "the image could not be embedded: " + said, cause);
163
+ }
130
164
  embedded.set(op.bytes, image);
131
165
  }
132
166
  return image;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/pdf",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,23 +36,24 @@
36
36
  "postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\""
37
37
  },
38
38
  "dependencies": {
39
- "@quario/layout": "^0.1.0",
40
- "pdf-lib": "^1.17.1"
39
+ "@cantoo/pdf-lib": "^2.9.1",
40
+ "@quario/layout": "^0.3.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@arethetypeswrong/cli": "^0.18.3",
44
- "@pdf-lib/fontkit": "^1.1.1",
45
44
  "@size-limit/preset-small-lib": "^13.0.3",
46
- "quario": "^0.4.0",
45
+ "@types/fontkit": "^2.0.9",
46
+ "fontkit": "^2.0.4",
47
+ "quario": "^0.6.0",
47
48
  "size-limit": "^13.0.3",
48
49
  "typescript": "^7.0.2"
49
50
  },
50
51
  "peerDependencies": {
51
- "@pdf-lib/fontkit": "^1.1.1",
52
- "quario": "^0.4.0"
52
+ "fontkit": "^2.0.4",
53
+ "quario": "^0.6.0"
53
54
  },
54
55
  "peerDependenciesMeta": {
55
- "@pdf-lib/fontkit": {
56
+ "fontkit": {
56
57
  "optional": true
57
58
  }
58
59
  },
@@ -62,7 +63,7 @@
62
63
  "ignore": [
63
64
  "quario",
64
65
  "@quario/layout",
65
- "pdf-lib"
66
+ "@cantoo/pdf-lib"
66
67
  ],
67
68
  "limit": "6 kB"
68
69
  }