@quario/pdf 0.1.0 → 0.2.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 +42 -0
- package/README.md +3 -3
- package/lib/balance.js +1 -1
- package/lib/canvas.js +19 -18
- package/lib/fonts.js +2 -3
- package/lib/image.js +2 -7
- package/lib/index.d.ts +0 -2
- package/lib/index.js +27 -50
- package/lib/layout.js +202 -38
- package/lib/outline.js +18 -22
- package/lib/style.js +28 -6
- package/lib/text.js +12 -6
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **The report default sets the document's face and base size.** A report's
|
|
15
|
+
top-level `style` replaces this target's own baseline, so its `size` scales
|
|
16
|
+
row heights and band gaps with the type rather than leaving them at a size
|
|
17
|
+
nothing is set in. It is the layer _under_ the band-role defaults: a report
|
|
18
|
+
declaring `size: 12` still renders its report header at 14, and an item's own
|
|
19
|
+
style wins over both.
|
|
20
|
+
|
|
21
|
+
- **`uppercase` draws capitals.** With no text-transform to defer to, this
|
|
22
|
+
target capitalises the string before measuring it, so wrapping and column
|
|
23
|
+
widths are those of the text actually drawn. The mapping is Unicode default
|
|
24
|
+
case, never the host's locale, so output stays byte-reproducible.
|
|
25
|
+
|
|
26
|
+
- **Splits lay out across the content width.** Each slot is measured and
|
|
27
|
+
wrapped inside its own share, all slots draw from a common top, and the
|
|
28
|
+
split takes the height of its tallest slot so the band below clears them
|
|
29
|
+
all. **A split is never broken across a page**: one that does not fit the
|
|
30
|
+
remaining height moves whole, exactly as an image does, and one taller than
|
|
31
|
+
any page renders in full past the bottom margin. The split's own style is
|
|
32
|
+
the layer under each slot's, and its `background` fills the whole split
|
|
33
|
+
behind them.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **A bare `Date` draws as ISO 8601 UTC, the same on every machine.** Cell
|
|
38
|
+
text and outline bookmark titles for `Date` values used `String(date)`,
|
|
39
|
+
which bakes the host's timezone and locale into the document — at odds with
|
|
40
|
+
this target's byte-reproducibility guarantee. Both now render through the
|
|
41
|
+
engine's shared display rule, so a `Date` group key titles its bookmark
|
|
42
|
+
with the same ISO text its cells draw.
|
|
43
|
+
|
|
44
|
+
### Removed
|
|
45
|
+
|
|
46
|
+
- **`options.baseSize`.** A document's type size is the document's own, so it
|
|
47
|
+
is the report's `style.size` — portable, travelling with the definition to
|
|
48
|
+
every target — rather than a host option one target honoured. Text with
|
|
49
|
+
nothing declared still renders at 10 points. Replace `pdf({ baseSize: 11 })`
|
|
50
|
+
with `"style": { "size": 11 }` on the report.
|
|
51
|
+
|
|
10
52
|
## [0.1.0] - 2026-08-27
|
|
11
53
|
|
|
12
54
|
### Added
|
package/README.md
CHANGED
|
@@ -89,7 +89,6 @@ Page geometry is target configuration, never schema:
|
|
|
89
89
|
```js
|
|
90
90
|
{
|
|
91
91
|
page: { size: "A4", margin: 54 }, // 'A4' | 'letter' | [width, height] in points
|
|
92
|
-
baseSize: 10, // base font size in points
|
|
93
92
|
meta: { title, author, subject }, // optional /Info — strings only, never a date
|
|
94
93
|
fonts: { Inter: { regular, bold, italic, boldItalic } }, // TrueType bytes
|
|
95
94
|
}
|
|
@@ -97,8 +96,9 @@ Page geometry is target configuration, never schema:
|
|
|
97
96
|
|
|
98
97
|
`size` defaults to `A4` (595.28 × 841.89 pt); an array is a custom `[width, height]` in points. An
|
|
99
98
|
unknown size name is a definition error, thrown by the factory. `margin` applies to all four sides and defaults to 54
|
|
100
|
-
(0.75 in).
|
|
101
|
-
leading is 1.4× a line's
|
|
99
|
+
(0.75 in). Text renders at 10pt when nothing declares a size: a document's type size is the
|
|
100
|
+
document's own, so it is the report's `style.size`, not a host option. Line leading is 1.4× a line's
|
|
101
|
+
largest font size.
|
|
102
102
|
|
|
103
103
|
## Layout
|
|
104
104
|
|
package/lib/balance.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The one number a balanced region decides: how tall each of its strips is
|
|
2
|
-
// (ADR
|
|
2
|
+
// (ADR 0027 — balancing is a strip height, not a pass). Pure arithmetic over
|
|
3
3
|
// what the buffer holds, so nothing here reaches a canvas or the band flow.
|
|
4
4
|
//
|
|
5
5
|
// Balancing by height alone under-fills, because most of what a region holds
|
package/lib/canvas.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* mid-measure and return a silently wrong height, so `newPage` throws there.
|
|
13
13
|
*/
|
|
14
14
|
import { degrees, drawImage, drawText, rgb } from "pdf-lib";
|
|
15
|
-
import { BLACK, shift } from "./style.js";
|
|
15
|
+
import { BLACK, dressed, shift } from "./style.js";
|
|
16
16
|
|
|
17
17
|
// The operator builder wants explicit rotation and skew; report text has none.
|
|
18
18
|
let NO_TURN = degrees(0);
|
|
@@ -23,24 +23,32 @@ let NO_TURN = degrees(0);
|
|
|
23
23
|
/** @typedef {import('pdf-lib').PDFFont} PDFFont */
|
|
24
24
|
/** @typedef {import('pdf-lib').PDFName} PDFName */
|
|
25
25
|
|
|
26
|
-
// The page box and the content box.
|
|
27
|
-
//
|
|
28
|
-
// take
|
|
29
|
-
// page
|
|
26
|
+
// The page box and the content box. Settled at `report-start` and fixed
|
|
27
|
+
// thereafter: the geometry is the factory's and never moves, while `base` and
|
|
28
|
+
// `style` take the report default one event into the render, before anything is
|
|
29
|
+
// measured. The page bands are measured against the page box and take their
|
|
30
|
+
// height off `top`/`bottom` through `adopt` below, while the first page is
|
|
31
|
+
// still untouched.
|
|
30
32
|
/**
|
|
33
|
+
* `style` is the report default — the outermost style layer, resolved once per
|
|
34
|
+
* render from `report-start` and carried here so a measuring canvas built off
|
|
35
|
+
* this frame reads the same one the drawing canvas does. Null when the report
|
|
36
|
+
* declares none.
|
|
37
|
+
*
|
|
31
38
|
* @typedef {{ width: number, height: number, margin: number, base: number,
|
|
32
|
-
* content: number, top: number, bottom: number }} Frame
|
|
39
|
+
* content: number, top: number, bottom: number, style: any }} Frame
|
|
33
40
|
*/
|
|
34
41
|
|
|
35
42
|
// A frame from the page box and the base size: how `content`/`top`/`bottom`
|
|
36
43
|
// fall out of a page and a margin is derived here, once, so no caller and no
|
|
37
44
|
// suite has to restate it and drift from what a real render uses.
|
|
38
|
-
/** @type {(width: number, height: number, margin: number, base: number) => Frame} */
|
|
39
|
-
let frame = (width, height, margin, base) => ({
|
|
45
|
+
/** @type {(width: number, height: number, margin: number, base: number, style?: any) => Frame} */
|
|
46
|
+
let frame = (width, height, margin, base, style = null) => ({
|
|
40
47
|
width,
|
|
41
48
|
height,
|
|
42
49
|
margin,
|
|
43
50
|
base,
|
|
51
|
+
style,
|
|
44
52
|
content: width - 2 * margin,
|
|
45
53
|
top: height - margin,
|
|
46
54
|
bottom: margin,
|
|
@@ -84,17 +92,11 @@ let GREY = rgb(0.5, 0.5, 0.5);
|
|
|
84
92
|
// line's ascender (the face metric already measured for baseline placement).
|
|
85
93
|
// Empty lines (no width) draw nothing. Shared by the drawing adapter and the
|
|
86
94
|
// layout recorder so both exercise the same path.
|
|
87
|
-
/** @type {{ flag: 'underline' | 'strikethrough', at: (baseline: number, asc: number) => number }[]} */
|
|
88
|
-
let DECO = [
|
|
89
|
-
{ flag: "underline", at: (baseline, asc) => baseline - asc * 0.12 },
|
|
90
|
-
{ flag: "strikethrough", at: (baseline, asc) => baseline + asc * 0.35 },
|
|
91
|
-
];
|
|
92
|
-
|
|
93
95
|
/** @type {(line: Line) => any} */
|
|
94
96
|
let ink = (line) => (line.pieces[0] && line.pieces[0].color) || BLACK;
|
|
95
97
|
|
|
96
98
|
/** @type {(line: Line) => boolean} */
|
|
97
|
-
let wantsDeco = (line) => !!
|
|
99
|
+
let wantsDeco = (line) => !!line.w && dressed(line);
|
|
98
100
|
|
|
99
101
|
/**
|
|
100
102
|
* @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: any) => void,
|
|
@@ -105,9 +107,8 @@ let decorateLine = (stroke, line, left, baseline) => {
|
|
|
105
107
|
let thickness = Math.max(line.asc / 12, 0.5);
|
|
106
108
|
let right = left + line.w;
|
|
107
109
|
let color = ink(line);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
110
|
+
if (line.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
|
|
111
|
+
if (line.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
|
|
111
112
|
};
|
|
112
113
|
|
|
113
114
|
/**
|
package/lib/fonts.js
CHANGED
|
@@ -49,8 +49,6 @@ let useFontkit = async (doc) => {
|
|
|
49
49
|
doc.registerFontkit(fontkit);
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
let VARIANTS = ["regular", "bold", "italic", "boldItalic"];
|
|
53
|
-
|
|
54
52
|
/** @type {(doc: any) => Promise<Record<string, any[]>>} */
|
|
55
53
|
let embedBase = async (doc) => {
|
|
56
54
|
/** @type {Record<string, any[]>} */
|
|
@@ -91,7 +89,8 @@ let familyOf = async (doc, name, def) => {
|
|
|
91
89
|
let path = asFamily(name, def);
|
|
92
90
|
/** @type {any[]} */
|
|
93
91
|
let faces = [];
|
|
94
|
-
for (let variant of
|
|
92
|
+
for (let variant of ["regular", "bold", "italic", "boldItalic"])
|
|
93
|
+
faces.push(await embedVariant(doc, def, path, variant, faces));
|
|
95
94
|
return faces;
|
|
96
95
|
};
|
|
97
96
|
|
package/lib/image.js
CHANGED
|
@@ -18,9 +18,6 @@ let PER_PX = 72 / 96;
|
|
|
18
18
|
/** @type {(bytes: Uint8Array, at: number) => number} */
|
|
19
19
|
let word = (bytes, at) => (bytes[at] << 8) | bytes[at + 1];
|
|
20
20
|
|
|
21
|
-
/** @type {(bytes: Uint8Array) => { w: number, h: number }} */
|
|
22
|
-
let pngSize = (bytes) => ({ w: word(bytes, 18), h: word(bytes, 22) });
|
|
23
|
-
|
|
24
21
|
/** @type {(code: number) => boolean} */
|
|
25
22
|
let inSof = (code) => code >= 0xc0 && code <= 0xcf;
|
|
26
23
|
|
|
@@ -42,9 +39,6 @@ let jpegSize = (bytes) => {
|
|
|
42
39
|
return { w: 0, h: 0 };
|
|
43
40
|
};
|
|
44
41
|
|
|
45
|
-
/** @type {(bytes: Uint8Array, format: string) => { w: number, h: number }} */
|
|
46
|
-
let pixels = (bytes, format) => (format === "png" ? pngSize(bytes) : jpegSize(bytes));
|
|
47
|
-
|
|
48
42
|
/**
|
|
49
43
|
* The image's intrinsic size in points. The format is the engine's sniff,
|
|
50
44
|
* riding on the event, so nothing here decides it a second time.
|
|
@@ -54,7 +48,8 @@ let pixels = (bytes, format) => (format === "png" ? pngSize(bytes) : jpegSize(by
|
|
|
54
48
|
* @returns {{ w: number, h: number }} The size, in points.
|
|
55
49
|
*/
|
|
56
50
|
export let intrinsic = (bytes, format) => {
|
|
57
|
-
|
|
51
|
+
// A PNG carries the two numbers in its IHDR at a fixed offset.
|
|
52
|
+
let { w, h } = format === "png" ? { w: word(bytes, 18), h: word(bytes, 22) } : jpegSize(bytes);
|
|
58
53
|
// The engine vouched for the magic numbers, not for the rest of the file:
|
|
59
54
|
// a truncated header reaches here as a zero, and failing loudly beats
|
|
60
55
|
// drawing an image with no size (SCHEMA.md, "Image item").
|
package/lib/index.d.ts
CHANGED
|
@@ -26,8 +26,6 @@ export interface PdfFontFamily {
|
|
|
26
26
|
/** Host controls, taken and validated at the factory call. */
|
|
27
27
|
export interface PdfOptions {
|
|
28
28
|
page?: PdfPage;
|
|
29
|
-
/** Base font size in points — the size styles override per element. Default `10`. */
|
|
30
|
-
baseSize?: number;
|
|
31
29
|
meta?: PdfMeta;
|
|
32
30
|
/**
|
|
33
31
|
* TrueType families to embed (subset), selected from styles by
|
package/lib/index.js
CHANGED
|
@@ -36,29 +36,17 @@ let err = (msg) => {
|
|
|
36
36
|
throw Error(msg);
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
/** @type {(value: any) => boolean} */
|
|
40
|
-
let finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
41
|
-
|
|
42
39
|
/** @type {(value: number) => boolean} */
|
|
43
40
|
let positive = (value) => Number.isFinite(value) && value > 0;
|
|
44
41
|
|
|
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
42
|
/** @type {(size: any) => any} */
|
|
52
|
-
let named = (size) => {
|
|
43
|
+
let named = (size = "A4") => {
|
|
53
44
|
let dimensions = Array.isArray(size) ? size : SIZES[size];
|
|
54
45
|
// oxlint-disable-next-line no-unused-expressions
|
|
55
46
|
dimensions || err('options.page.size: unknown page size "' + size + '"');
|
|
56
47
|
return dimensions;
|
|
57
48
|
};
|
|
58
49
|
|
|
59
|
-
/** @type {(page: any) => any} */
|
|
60
|
-
let pageSize = (page) => named(page.size ?? "A4");
|
|
61
|
-
|
|
62
50
|
/** @type {(dimensions: any) => { width: number, height: number }} */
|
|
63
51
|
let pair = (dimensions) => {
|
|
64
52
|
let width = +dimensions[0],
|
|
@@ -69,26 +57,19 @@ let pair = (dimensions) => {
|
|
|
69
57
|
return { width, height };
|
|
70
58
|
};
|
|
71
59
|
|
|
72
|
-
/** @type {(page: any) => any} */
|
|
73
|
-
let marginArg = (page) => page.margin ?? 54;
|
|
74
|
-
|
|
75
60
|
/** @type {(margin: any, width: number, height: number) => number} */
|
|
76
|
-
let marginOf = (margin, width, height) => {
|
|
77
|
-
let fits =
|
|
61
|
+
let marginOf = (margin = 54, width, height) => {
|
|
62
|
+
let fits = Number.isFinite(margin) && margin >= 0 && 2 * margin < Math.min(width, height);
|
|
78
63
|
// oxlint-disable-next-line no-unused-expressions
|
|
79
64
|
fits || err("options.page.margin: expected a non-negative number smaller than half the page");
|
|
80
65
|
return margin;
|
|
81
66
|
};
|
|
82
67
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
let
|
|
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
|
-
};
|
|
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;
|
|
92
73
|
|
|
93
74
|
// The page box and the content box in one, settled here beside the validation
|
|
94
75
|
// that produced them. This frame never moves at all: it serves every render the
|
|
@@ -97,30 +78,27 @@ let baseOf = (base) => {
|
|
|
97
78
|
// band flow's, off that render's own canvas.
|
|
98
79
|
/** @type {(options: any) => import('./canvas.js').Frame} */
|
|
99
80
|
let geometry = (options) => {
|
|
100
|
-
let page =
|
|
101
|
-
let { width, height } = pair(
|
|
102
|
-
return frame(width, height, marginOf(
|
|
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]);
|
|
81
|
+
let { page = {} } = options ?? {};
|
|
82
|
+
let { width, height } = pair(named(page.size));
|
|
83
|
+
return frame(width, height, marginOf(page.margin, width, height), BASE);
|
|
114
84
|
};
|
|
115
85
|
|
|
116
86
|
// Optional document information. Never a date: pdf-lib stamps the current time
|
|
117
87
|
// unless told otherwise, and a timestamp would make the same input render
|
|
118
|
-
// different bytes on every run.
|
|
88
|
+
// different bytes on every run. Each key names the pdf-lib setter it feeds,
|
|
89
|
+
// here rather than in a table, and a value of any other shape is left off.
|
|
119
90
|
/** @type {(doc: any, meta: any) => void} */
|
|
120
91
|
let describe = (doc, meta) => {
|
|
121
92
|
doc.setCreationDate(new Date(0));
|
|
122
93
|
doc.setModificationDate(new Date(0));
|
|
123
|
-
|
|
94
|
+
let field = (/** @type {string} */ method, /** @type {any} */ value) => {
|
|
95
|
+
if (typeof value === "string") doc[method](value);
|
|
96
|
+
};
|
|
97
|
+
if (meta) {
|
|
98
|
+
field("setTitle", meta.title);
|
|
99
|
+
field("setAuthor", meta.author);
|
|
100
|
+
field("setSubject", meta.subject);
|
|
101
|
+
}
|
|
124
102
|
};
|
|
125
103
|
|
|
126
104
|
/** @type {(canvas: import('./canvas.js').Drawing, draw: (i: number) => void) => Promise<void>} */
|
|
@@ -132,9 +110,6 @@ let overPages = async (canvas, draw) => {
|
|
|
132
110
|
}
|
|
133
111
|
};
|
|
134
112
|
|
|
135
|
-
/** @type {(opening: any) => { bands: any, marking: any }} */
|
|
136
|
-
let openingOf = (opening) => ({ bands: opening?.page, marking: opening?.marking });
|
|
137
|
-
|
|
138
113
|
/** @type {(canvas: import('./canvas.js').Drawing, bands: any,
|
|
139
114
|
* pages: { number: number, total: number }[]) => Promise<void>} */
|
|
140
115
|
let furnish = async (canvas, bands, pages) => {
|
|
@@ -162,7 +137,8 @@ let markPages = async (canvas, marking) => {
|
|
|
162
137
|
*/
|
|
163
138
|
export function pdf(options) {
|
|
164
139
|
let geo = geometry(options);
|
|
165
|
-
let
|
|
140
|
+
let meta = options?.meta,
|
|
141
|
+
custom = options?.fonts;
|
|
166
142
|
/** @type {(stream: any) => (data?: any) => Promise<Uint8Array>} */
|
|
167
143
|
let compile = (stream) => async (data) => {
|
|
168
144
|
let doc = await PDFDocument.create();
|
|
@@ -179,12 +155,13 @@ export function pdf(options) {
|
|
|
179
155
|
// page bands off it — and hands it back with the marks. What is left on it
|
|
180
156
|
// is what the passes below want: the band closures to render per page, and
|
|
181
157
|
// the marking's wording to stamp.
|
|
158
|
+
// Null when nothing settled one, which reads as a document owing neither.
|
|
182
159
|
let { marks, opening, pages } = finish();
|
|
183
|
-
|
|
160
|
+
opening = opening || {};
|
|
184
161
|
// The passes below run over the finished pages, not the stream — no walk
|
|
185
162
|
// at all — so they open each page themselves and breathe on their own
|
|
186
163
|
// rather than through the driver.
|
|
187
|
-
await furnish(canvas,
|
|
164
|
+
await furnish(canvas, opening.page, pages);
|
|
188
165
|
// Every image the walk and the furniture pass placed, embedded and drawn
|
|
189
166
|
// now: a walk handler cannot await, and the page bands' images are not
|
|
190
167
|
// placed until the pass above has run. Before the marking, so an image can
|
|
@@ -193,7 +170,7 @@ export function pdf(options) {
|
|
|
193
170
|
// The unlicensed marking goes on last, over content and page furniture
|
|
194
171
|
// alike, once per page (LICENSE section 6). Its wording rode in on
|
|
195
172
|
// `report-start`; only the placement is this target's.
|
|
196
|
-
await markPages(canvas, marking);
|
|
173
|
+
await markPages(canvas, opening.marking);
|
|
197
174
|
outline(doc, marks, canvas.refs);
|
|
198
175
|
return doc.save();
|
|
199
176
|
};
|
package/lib/layout.js
CHANGED
|
@@ -21,11 +21,11 @@
|
|
|
21
21
|
* document's bands claim before anything is placed, and where they are drawn
|
|
22
22
|
* on each finished page once the count is known.
|
|
23
23
|
*/
|
|
24
|
-
import { isReportBand, text } from "quario";
|
|
24
|
+
import { display, isReportBand, text } from "quario";
|
|
25
25
|
import { balance } from "./balance.js";
|
|
26
26
|
import { adopt, frame, measuring } from "./canvas.js";
|
|
27
27
|
import { intrinsic } from "./image.js";
|
|
28
|
-
import { BAND, GUTTER, LEAD, PADX, PADY, col,
|
|
28
|
+
import { BAND, GUTTER, LEAD, PADX, PADY, col, roled, shift, sizeOf, stack } from "./style.js";
|
|
29
29
|
import { atoms, dress, heightOf, wrap } from "./text.js";
|
|
30
30
|
|
|
31
31
|
/** @typedef {import('./balance.js').Unit} Unit */
|
|
@@ -57,27 +57,38 @@ import { atoms, dress, heightOf, wrap } from "./text.js";
|
|
|
57
57
|
* @typedef {{ bytes: Uint8Array, format: string, w: number, h: number }} Picture
|
|
58
58
|
*/
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
60
|
+
* `align` is absent on a split, which has none of its own: alignment is a
|
|
61
|
+
* slot's, reached through the style layering.
|
|
62
|
+
* @typedef {{ lines: Line[], picture?: Picture, parts?: SlotPart[],
|
|
63
|
+
* bg: string | null, align?: any, h: number }} Block
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* One slot of a split, as laid out: the block it drew to and the width it was
|
|
67
|
+
* measured at. There is no offset — a slot's is the sum of the widths before
|
|
68
|
+
* it, which `drawSplit` accumulates as it walks them.
|
|
69
|
+
* @typedef {{ block: Block, w: number }} SlotPart
|
|
62
70
|
*/
|
|
63
71
|
|
|
64
72
|
// How `fit` sizes a picture against the width it has: `natural` is the
|
|
65
73
|
// image's own size in points, never wider than the content box; `width`
|
|
66
74
|
// scales to the content box either way. The ratio is kept in both, so a
|
|
67
75
|
// height is never anything but the width's consequence.
|
|
68
|
-
/** @type {(
|
|
69
|
-
let pictureOf = (
|
|
76
|
+
/** @type {(event: any, avail: number) => Picture} */
|
|
77
|
+
let pictureOf = (event, avail) => {
|
|
70
78
|
let { w, h } = intrinsic(event.bytes, event.format);
|
|
71
79
|
let width = event.fit === "width" ? avail : Math.min(w, avail);
|
|
72
80
|
return { bytes: event.bytes, format: event.format, w: width, h: (h * width) / w };
|
|
73
81
|
};
|
|
74
82
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
// `under` is the enclosing style a slot's own layers over — a split's, when
|
|
84
|
+
// this is one of its slots. Null everywhere else, where nothing encloses.
|
|
85
|
+
/** @type {(canvas: Canvas, event: any, avail: number, under?: any) => Block} */
|
|
86
|
+
let blockOf = (canvas, event, avail, under = null) => {
|
|
87
|
+
let style = stack(canvas, under, event.style);
|
|
78
88
|
let bg = col(style.background);
|
|
89
|
+
if (event.type === "split") return splitBlock(canvas, event, avail, style, bg);
|
|
79
90
|
if (event.type === "image") {
|
|
80
|
-
let picture = pictureOf(
|
|
91
|
+
let picture = pictureOf(event, avail);
|
|
81
92
|
return { lines: [], picture, bg, align: style.align, h: picture.h };
|
|
82
93
|
}
|
|
83
94
|
let lines = dress(wrap(canvas, atoms(canvas, event.tokens, style), Math.max(avail, 1)), style);
|
|
@@ -118,9 +129,99 @@ let drawLines = (canvas, block, x, avail) => {
|
|
|
118
129
|
canvas.fresh = false;
|
|
119
130
|
};
|
|
120
131
|
|
|
132
|
+
// An authored share fixes its slot; the width-less ones divide what is left,
|
|
133
|
+
// evenly. Evenly rather than by natural width, because a split is a line of
|
|
134
|
+
// furniture whose geometry should read off the document — the same answer the
|
|
135
|
+
// HTML target's `flex:1` gives, so one definition places alike in both.
|
|
136
|
+
// Each slot is measured at its own share and drawn from a common top, so the
|
|
137
|
+
// split is as tall as its tallest slot and the line's geometry does not move
|
|
138
|
+
// with the data. The split's own declarations are the layer under each slot's,
|
|
139
|
+
// so a split styled `bold` reads the same here as it does through CSS
|
|
140
|
+
// inheritance in the HTML target and the `under` layer in XLSX. It has no
|
|
141
|
+
// `align` of its own: alignment is a slot's, reached through that layering.
|
|
142
|
+
/**
|
|
143
|
+
* @type {(canvas: Canvas, event: any, avail: number, style: any,
|
|
144
|
+
* bg: string | null) => Block}
|
|
145
|
+
*/
|
|
146
|
+
let splitBlock = (canvas, event, avail, style, bg) => {
|
|
147
|
+
let widths = slotWidths(event.slots, avail);
|
|
148
|
+
/** @type {SlotPart[]} */
|
|
149
|
+
let parts = [];
|
|
150
|
+
let h = 0;
|
|
151
|
+
for (let [i, slot] of event.items.entries()) {
|
|
152
|
+
let block = blockOf(canvas, slot, Math.max(widths[i], 1), style);
|
|
153
|
+
if (block.h > h) h = block.h;
|
|
154
|
+
parts.push({ block, w: widths[i] });
|
|
155
|
+
}
|
|
156
|
+
return { lines: [], parts, bg, h };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** @type {(slots: { width?: number }[], avail: number) => number[]} */
|
|
160
|
+
let slotWidths = (slots, avail) => {
|
|
161
|
+
let autos = 0,
|
|
162
|
+
spent = 0;
|
|
163
|
+
/** @type {(number | null)[]} */
|
|
164
|
+
let fixed = slots.map(({ width }) => {
|
|
165
|
+
// Bound to a local first: `Number.isFinite` does not narrow a property.
|
|
166
|
+
if (width === undefined || !Number.isFinite(width)) return (autos++, null);
|
|
167
|
+
let share = (width / 100) * avail;
|
|
168
|
+
spent += share;
|
|
169
|
+
return share;
|
|
170
|
+
});
|
|
171
|
+
let each = autos ? Math.max(avail - spent, 0) / autos : 0;
|
|
172
|
+
return fixed.map((width) => width ?? each);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// A split reaches this target as a bracket around ordinary item and image
|
|
176
|
+
// events. Folding it into one event is what lets the rest of the target treat
|
|
177
|
+
// it as a single block -- measured, kept together, and paged by exactly the
|
|
178
|
+
// machinery every other block goes through. Stateful rather than a transform
|
|
179
|
+
// over an array, because the body walk is streaming; the page-band pass drives
|
|
180
|
+
// the same folder over its own list, so the two cannot drift.
|
|
181
|
+
// The event types that become a `Block`. They are also exactly the three the
|
|
182
|
+
// split folder owns: item and image reach `route` through it, so a slot's are
|
|
183
|
+
// collected rather than placed, and `split` is minted by the folder and never
|
|
184
|
+
// arrives from the stream at all.
|
|
185
|
+
let BLOCKS = new Set(["item", "image", "split"]);
|
|
186
|
+
|
|
187
|
+
/** @type {(emit: (event: any) => void) => (event: any) => void} */
|
|
188
|
+
let splitFolder = (emit) => {
|
|
189
|
+
/** @type {{ event: any, items: any[] } | null} */
|
|
190
|
+
let bracket = null;
|
|
191
|
+
// The engine emits the bracket as one array, so `split-end` always has its
|
|
192
|
+
// opening and this never reads a null.
|
|
193
|
+
let close = () => {
|
|
194
|
+
let { event, items } = /** @type {{ event: any, items: any[] }} */ (bracket);
|
|
195
|
+
bracket = null;
|
|
196
|
+
emit({ type: "split", role: event.role, style: event.style, slots: event.slots, items });
|
|
197
|
+
};
|
|
198
|
+
return (event) => {
|
|
199
|
+
if (event.type === "split-start") bracket = { event, items: [] };
|
|
200
|
+
else if (event.type === "split-end") close();
|
|
201
|
+
else if (bracket) bracket.items.push(event);
|
|
202
|
+
else emit(event);
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** @type {(canvas: Canvas, block: Block, x: number, avail: number) => void} */
|
|
207
|
+
let drawSplit = (canvas, block, x, avail) => {
|
|
208
|
+
if (block.bg) canvas.rect(block.bg, x, canvas.y - block.h, avail, block.h);
|
|
209
|
+
let top = canvas.y;
|
|
210
|
+
let at = x;
|
|
211
|
+
for (let part of block.parts || []) {
|
|
212
|
+
canvas.y = top;
|
|
213
|
+
drawBlock(canvas, part.block, at, part.w);
|
|
214
|
+
at += part.w;
|
|
215
|
+
}
|
|
216
|
+
// The split takes its own height whatever the slots took, so a short slot
|
|
217
|
+
// beside a wrapped one does not pull the band up.
|
|
218
|
+
canvas.y = top - block.h;
|
|
219
|
+
canvas.fresh = false;
|
|
220
|
+
};
|
|
221
|
+
|
|
121
222
|
/** @type {(canvas: Canvas, block: Block, x: number, avail: number) => void} */
|
|
122
223
|
let drawBlock = (canvas, block, x, avail) =>
|
|
123
|
-
(block.picture ? drawPicture : drawLines)(canvas, block, x, avail);
|
|
224
|
+
(block.parts ? drawSplit : block.picture ? drawPicture : drawLines)(canvas, block, x, avail);
|
|
124
225
|
|
|
125
226
|
// A region is a run of strips: what a columned node's content flows down
|
|
126
227
|
// (CONTEXT.md "Strip"). `index` is the strip being filled, `top` the y every
|
|
@@ -229,17 +330,23 @@ let fitLines = (canvas, block, i, floor, fresh) => {
|
|
|
229
330
|
/** @type {(canvas: Canvas, block: Block, floor: number) => boolean} */
|
|
230
331
|
let whole = (canvas, block, floor) => canvas.y - block.h >= floor;
|
|
231
332
|
|
|
232
|
-
//
|
|
233
|
-
// column
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
333
|
+
// The unsplittable blocks -- a picture and a split -- move whole to a fresh
|
|
334
|
+
// column rather than breaking, so a balanced floor never cuts one: only the
|
|
335
|
+
// page's own floor bears on it. A picture that no column can hold is scaled
|
|
336
|
+
// down to the one it lands on, since obeying the guarantee literally would
|
|
337
|
+
// mean dropping it (SCHEMA.md, best-effort); a split has nothing to scale, so
|
|
338
|
+
// one taller than any column renders in full past the bottom margin, the
|
|
339
|
+
// posture a paragraph line taller than a page already has.
|
|
340
|
+
//
|
|
341
|
+
// A split also *cannot* be sliced: its own `lines` are empty -- the content
|
|
342
|
+
// lives in its slots -- so the slicing path would draw nothing and leave the
|
|
343
|
+
// cursor where it stood, taking everything after it down with it.
|
|
237
344
|
/** @type {(state: Flow, block: Block) => void} */
|
|
238
|
-
let
|
|
345
|
+
let placeWhole = (state, block) => {
|
|
239
346
|
advance(state);
|
|
240
347
|
let canvas = state.canvas;
|
|
241
348
|
let room = canvas.y - canvas.bottom;
|
|
242
|
-
if (block.h > room) shrink(block, room);
|
|
349
|
+
if (block.picture && block.h > room) shrink(block, room);
|
|
243
350
|
drawBlock(canvas, block, originOf(state), widthOf(state));
|
|
244
351
|
};
|
|
245
352
|
|
|
@@ -287,10 +394,16 @@ let item = (state, event, measured = null) => {
|
|
|
287
394
|
let block = measured || blockOf(canvas, event, avail);
|
|
288
395
|
// Whole block fits where it stands: one piece, no slicing.
|
|
289
396
|
if (whole(canvas, block, floorOf(state))) return drawBlock(canvas, block, originOf(state), avail);
|
|
290
|
-
|
|
291
|
-
slice(state, block);
|
|
397
|
+
overflow(state, block);
|
|
292
398
|
};
|
|
293
399
|
|
|
400
|
+
// What a block does when it does not fit where it stands. A picture and a
|
|
401
|
+
// split never break -- each moves whole to a fresh column; anything else flows
|
|
402
|
+
// down it, breaking where `fitLines` says.
|
|
403
|
+
/** @type {(state: Flow, block: Block) => void} */
|
|
404
|
+
let overflow = (state, block) =>
|
|
405
|
+
block.picture || block.parts ? placeWhole(state, block) : slice(state, block);
|
|
406
|
+
|
|
294
407
|
// The height a page turn carries over: the drawn headers of the groups still
|
|
295
408
|
// open, which every page their instances continue onto replays. Computed rather
|
|
296
409
|
// than kept — a stored total is a second copy of `open` that can drift from it.
|
|
@@ -481,9 +594,16 @@ let UNITS = {
|
|
|
481
594
|
},
|
|
482
595
|
row: restated,
|
|
483
596
|
"total-row": restated,
|
|
484
|
-
image:
|
|
597
|
+
image: unbroken,
|
|
598
|
+
split: unbroken,
|
|
485
599
|
};
|
|
486
600
|
|
|
601
|
+
// An unsplittable block owes a strip nothing but its own height.
|
|
602
|
+
/** @type {(entry: any) => Unit} */
|
|
603
|
+
function unbroken(entry) {
|
|
604
|
+
return unsplit(entry.h, entry.h);
|
|
605
|
+
}
|
|
606
|
+
|
|
487
607
|
// A row opening a strip carries the column headings restated above it.
|
|
488
608
|
/** @type {(entry: any, table: Restated) => Unit} */
|
|
489
609
|
function restated(entry, table) {
|
|
@@ -664,7 +784,7 @@ let entryFor = (state, event, block, width) => {
|
|
|
664
784
|
// decided anything. Events that are not blocks contribute an `estimate`.
|
|
665
785
|
/** @type {(state: Flow, event: any, width: number) => Block | null} */
|
|
666
786
|
let measureFor = (state, event, width) =>
|
|
667
|
-
|
|
787
|
+
BLOCKS.has(event.type) ? blockOf(state.canvas, event, width) : null;
|
|
668
788
|
|
|
669
789
|
// Give every outline mark still waiting a position: where its content begins.
|
|
670
790
|
/** @type {(state: Flow) => void} */
|
|
@@ -684,7 +804,7 @@ let pending = (state) => state.open.filter((group) => group.held.length);
|
|
|
684
804
|
|
|
685
805
|
// A held run, as blocks. A region measured its headers while buffering them,
|
|
686
806
|
// at this same width, so a replayed run reuses those rather than wrapping the
|
|
687
|
-
// same text a second time (ADR
|
|
807
|
+
// same text a second time (ADR 0027).
|
|
688
808
|
/** @type {(canvas: Canvas, held: any[], avail: number) => Block[]} */
|
|
689
809
|
let blocksOf = (canvas, held, avail) =>
|
|
690
810
|
held.map((entry) => entry.block || blockOf(canvas, entry.event, avail));
|
|
@@ -805,7 +925,9 @@ let breakFor = (state, event) => {
|
|
|
805
925
|
|
|
806
926
|
/** @type {(event: any) => any} */
|
|
807
927
|
let markFor = (event) => ({
|
|
808
|
-
|
|
928
|
+
// display(), not String(): a Date group key must title its outline bookmark
|
|
929
|
+
// with the same ISO 8601 UTC text its cells render, on every machine.
|
|
930
|
+
title: event.name + ": " + display(event.key),
|
|
809
931
|
titled: false,
|
|
810
932
|
depth: event.depth,
|
|
811
933
|
page: -1,
|
|
@@ -848,7 +970,7 @@ let headOf = (canvas, span) => span - instanceGap(canvas);
|
|
|
848
970
|
// gives flow spacing no meaning inside a table row, in either target.
|
|
849
971
|
/** @type {(canvas: Canvas, cell: any, rowStyle: any) => any} */
|
|
850
972
|
let cellOf = (canvas, cell, rowStyle) => {
|
|
851
|
-
let style =
|
|
973
|
+
let style = stack(canvas, rowStyle, cell.style);
|
|
852
974
|
let list = atoms(canvas, cell.tokens, style);
|
|
853
975
|
let natural = wrap(canvas, list, Infinity).reduce((widest, line) => Math.max(widest, line.w), 0);
|
|
854
976
|
return {
|
|
@@ -945,7 +1067,7 @@ let sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
|
945
1067
|
// same fail-loud posture as the measuring canvas's `newPage`.
|
|
946
1068
|
/** @type {(cols: any[], natural: number[], avail: number) => number[]} */
|
|
947
1069
|
let columnWidths = (cols, natural, avail) => {
|
|
948
|
-
let fixed = cols.map((
|
|
1070
|
+
let fixed = cols.map((column) => Number.isFinite(column.width));
|
|
949
1071
|
if (!fixed.some(Boolean)) {
|
|
950
1072
|
// Natural widths always carry the cell padding, so the sum is never zero.
|
|
951
1073
|
let wanted = sum(natural);
|
|
@@ -1211,7 +1333,12 @@ let band = (canvas, items, yTop) => {
|
|
|
1211
1333
|
fresh = canvas.fresh;
|
|
1212
1334
|
canvas.y = yTop;
|
|
1213
1335
|
canvas.fresh = false;
|
|
1214
|
-
|
|
1336
|
+
// Folded straight into the draw, the way the body walk folds straight into
|
|
1337
|
+
// `route`: the folder emits in order and nothing here needs the band whole.
|
|
1338
|
+
let fold = splitFolder((event) =>
|
|
1339
|
+
drawBlock(canvas, blockOf(canvas, event, avail), canvas.margin, avail),
|
|
1340
|
+
);
|
|
1341
|
+
for (let event of items) fold(event);
|
|
1215
1342
|
let h = yTop - canvas.y;
|
|
1216
1343
|
canvas.y = saved;
|
|
1217
1344
|
canvas.fresh = fresh;
|
|
@@ -1265,7 +1392,8 @@ let reserve = (geo, fonts, bands) => {
|
|
|
1265
1392
|
// want it: reservation to measure against, the draw pass to hang the header
|
|
1266
1393
|
// from.
|
|
1267
1394
|
/** @type {(canvas: Canvas) => Frame} */
|
|
1268
|
-
let pageBox = (canvas) =>
|
|
1395
|
+
let pageBox = (canvas) =>
|
|
1396
|
+
frame(canvas.width, canvas.height, canvas.margin, canvas.base, canvas.style);
|
|
1269
1397
|
|
|
1270
1398
|
// One finished page's furniture, drawn in the strips `reserve` left for it:
|
|
1271
1399
|
// the header hanging from the top margin, the footer resting on the bottom
|
|
@@ -1335,6 +1463,10 @@ let flow = (canvas) => {
|
|
|
1335
1463
|
// content would have taken had it never been held.
|
|
1336
1464
|
route: () => {},
|
|
1337
1465
|
};
|
|
1466
|
+
// The body walk's own folder, emitting each finished event onward to `route`
|
|
1467
|
+
// below. Declared here and bound after `route` exists.
|
|
1468
|
+
/** @type {(event: any) => void} */
|
|
1469
|
+
let fold;
|
|
1338
1470
|
// A table is buffered whole before it is laid out: column widths come from
|
|
1339
1471
|
// every cell in it, so the last row has to be in hand first.
|
|
1340
1472
|
/** @type {any[]} */
|
|
@@ -1350,6 +1482,14 @@ let flow = (canvas) => {
|
|
|
1350
1482
|
// What each event does once it is placed for real. A buffered region replays
|
|
1351
1483
|
// through this same table, so held content lays out by exactly the path it
|
|
1352
1484
|
// would have taken had it never been held.
|
|
1485
|
+
// A block with no title of its own: held back under a group header, placed
|
|
1486
|
+
// anywhere else. Both the image and the split flow this way.
|
|
1487
|
+
/** @type {(event: any, block: Block | null) => void} */
|
|
1488
|
+
let heldOrPlaced = (event, block) =>
|
|
1489
|
+
event.role === "group-header"
|
|
1490
|
+
? holdHeader(state, event, null, block)
|
|
1491
|
+
: placeItem(state, event, block);
|
|
1492
|
+
|
|
1353
1493
|
/** @type {Record<string, (event: any, block: Block | null) => void>} */
|
|
1354
1494
|
let placed = {
|
|
1355
1495
|
// A header is held rather than placed, and holding is bookkeeping the
|
|
@@ -1362,10 +1502,11 @@ let flow = (canvas) => {
|
|
|
1362
1502
|
// An image flows exactly as an item does, held back in a group header and
|
|
1363
1503
|
// placed anywhere else -- the block it becomes carries a height like any
|
|
1364
1504
|
// other. It wears no role default: those describe text.
|
|
1365
|
-
image:
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1505
|
+
image: heldOrPlaced,
|
|
1506
|
+
// A split flows exactly as an image does, for the same reason: one block,
|
|
1507
|
+
// held back in a group header and placed anywhere else. It wears no role
|
|
1508
|
+
// default of its own -- its slots carry theirs.
|
|
1509
|
+
split: heldOrPlaced,
|
|
1369
1510
|
"group-start": (event) => openGroup(state, event),
|
|
1370
1511
|
"group-end": () => {
|
|
1371
1512
|
flush(state, 0);
|
|
@@ -1405,6 +1546,7 @@ let flow = (canvas) => {
|
|
|
1405
1546
|
};
|
|
1406
1547
|
|
|
1407
1548
|
state.route = route;
|
|
1549
|
+
fold = splitFolder(route);
|
|
1408
1550
|
|
|
1409
1551
|
// Everything drawn across the page rather than down a strip: the declaring
|
|
1410
1552
|
// node's own bands, its footer among them.
|
|
@@ -1458,10 +1600,25 @@ let flow = (canvas) => {
|
|
|
1458
1600
|
buffer(state, event, block);
|
|
1459
1601
|
};
|
|
1460
1602
|
|
|
1603
|
+
// The report default onto the canvas, before anything is measured. Its
|
|
1604
|
+
// `size` replaces this target's baseline outright, so row heights and band
|
|
1605
|
+
// gaps scale with the document's type rather than staying at a size nothing
|
|
1606
|
+
// is set in; the block itself becomes the outermost layer `stack` composes
|
|
1607
|
+
// (docs/adr/0033). `sizeOf` is what decides a size is usable anywhere in this
|
|
1608
|
+
// target, so it decides it here too -- a computed `size` reaches this
|
|
1609
|
+
// unchecked, and two spellings of that leniency would drift.
|
|
1610
|
+
/** @type {(style: any) => void} */
|
|
1611
|
+
let adoptDefault = (style) => {
|
|
1612
|
+
if (!style) return;
|
|
1613
|
+
canvas.style = style;
|
|
1614
|
+
canvas.base = sizeOf(style, canvas.base);
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1461
1617
|
return {
|
|
1462
1618
|
handlers: {
|
|
1463
1619
|
"report-start": (event) => {
|
|
1464
1620
|
opening = event;
|
|
1621
|
+
adoptDefault(event.style);
|
|
1465
1622
|
if (event.columns) state.pending = { count: event.columns, owner: -1 };
|
|
1466
1623
|
if (!event.page) return;
|
|
1467
1624
|
// A frame of its own, never the canvas itself: `measuring` builds
|
|
@@ -1473,14 +1630,21 @@ let flow = (canvas) => {
|
|
|
1473
1630
|
adopt(canvas, reserve(pageBox(canvas), canvas.fonts, event.page));
|
|
1474
1631
|
},
|
|
1475
1632
|
// Everything else routes: `placed` above says what each event does, and
|
|
1476
|
-
// one entry point is what lets a buffered region replay through it.
|
|
1477
|
-
//
|
|
1633
|
+
// one entry point is what lets a buffered region replay through it. The
|
|
1634
|
+
// three the folder owns are left out — `split` is not a stream event at
|
|
1635
|
+
// all (the folder mints it) and item and image reach `route` through it.
|
|
1478
1636
|
...Object.fromEntries(
|
|
1479
|
-
Object.keys(placed)
|
|
1480
|
-
type
|
|
1481
|
-
type
|
|
1482
|
-
]),
|
|
1637
|
+
Object.keys(placed)
|
|
1638
|
+
.filter((type) => !BLOCKS.has(type))
|
|
1639
|
+
.map((type) => [type, (/** @type {any} */ event) => route(event)]),
|
|
1483
1640
|
),
|
|
1641
|
+
// Item, image and both bracket events all go through the folder, which
|
|
1642
|
+
// routes what is not inside a split straight onward. The item is the one
|
|
1643
|
+
// that wears a band-role default on the way in.
|
|
1644
|
+
"split-start": (event) => fold(event),
|
|
1645
|
+
"split-end": (event) => fold(event),
|
|
1646
|
+
item: (event) => fold(roled(event)),
|
|
1647
|
+
image: (event) => fold(event),
|
|
1484
1648
|
"group-end": (event) => {
|
|
1485
1649
|
endOwned(event.depth);
|
|
1486
1650
|
route(event);
|
package/lib/outline.js
CHANGED
|
@@ -49,26 +49,6 @@ let linkKids = (entry, refs, kids, n) => {
|
|
|
49
49
|
entry.Count = n;
|
|
50
50
|
};
|
|
51
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
52
|
/**
|
|
73
53
|
* Attach an outline built from `marks` to the document.
|
|
74
54
|
*
|
|
@@ -82,8 +62,24 @@ export function outline(doc, marks, pageRefs) {
|
|
|
82
62
|
let parent = parents(marks);
|
|
83
63
|
let refs = marks.map(() => ctx.nextRef());
|
|
84
64
|
let root = ctx.nextRef();
|
|
85
|
-
for (let [i, mark] of marks.entries())
|
|
86
|
-
|
|
65
|
+
for (let [i, mark] of marks.entries()) {
|
|
66
|
+
let siblings = kidsOf(parent, parent[i]);
|
|
67
|
+
let index = siblings.indexOf(i);
|
|
68
|
+
let kids = kidsOf(parent, i);
|
|
69
|
+
let target = pageRefs[Math.min(mark.page, pageRefs.length - 1)];
|
|
70
|
+
/** @type {Record<string, any>} */
|
|
71
|
+
let entry = {
|
|
72
|
+
Title: PDFHexString.fromText(mark.title),
|
|
73
|
+
Parent: parentRef(parent, i, root, refs),
|
|
74
|
+
// `/XYZ <x> <y> null`: scroll to the mark's line, keep the zoom. The
|
|
75
|
+
// left edge is the mark's own column, so two instances sharing a y in
|
|
76
|
+
// two page columns are two distinct destinations.
|
|
77
|
+
Dest: [target, PDFName.of("XYZ"), PDFNumber.of(mark.x), PDFNumber.of(mark.y), null],
|
|
78
|
+
};
|
|
79
|
+
linkSiblings(entry, refs, siblings, index);
|
|
80
|
+
linkKids(entry, refs, kids, descendants(marks, i));
|
|
81
|
+
ctx.assign(refs[i], ctx.obj(entry));
|
|
82
|
+
}
|
|
87
83
|
|
|
88
84
|
let top = kidsOf(parent, -1);
|
|
89
85
|
ctx.assign(
|
package/lib/style.js
CHANGED
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
* render: a value of the wrong shape contributes nothing rather than reaching
|
|
5
5
|
* the page.
|
|
6
6
|
*
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
7
|
+
* `HEX` is restated per target on purpose: the coercion is each target's own
|
|
8
|
+
* edge, never shared engine code. Finiteness is not one of those coercions —
|
|
9
|
+
* `Number.isFinite` never coerces, so this target asks it directly.
|
|
9
10
|
*/
|
|
10
11
|
import { rgb } from "pdf-lib";
|
|
11
12
|
|
|
12
|
-
/** @type {(value: any) => boolean} */
|
|
13
|
-
let finite = (value) => typeof value === "number" && Number.isFinite(value);
|
|
14
13
|
let HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
15
14
|
|
|
16
15
|
// Leading as a fraction of the font size, cell padding, the gap between a
|
|
@@ -51,12 +50,24 @@ let col = (value) => {
|
|
|
51
50
|
};
|
|
52
51
|
|
|
53
52
|
/** @type {(style: any, base: number) => number} */
|
|
54
|
-
let sizeOf = (style, base) => (
|
|
53
|
+
let sizeOf = (style, base) => (Number.isFinite(style.size) && style.size > 0 ? style.size : base);
|
|
55
54
|
|
|
56
55
|
// Style blocks layer outward-in: row under cell.
|
|
57
56
|
/** @type {(under: any, over: any) => any} */
|
|
58
57
|
let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
|
|
59
58
|
|
|
59
|
+
// The whole stack a resolved style is composed of, outermost first: the report
|
|
60
|
+
// default the canvas carries, the enclosing block's style (a table row's, a
|
|
61
|
+
// split's), and the node's own. This target's reading of docs/adr/0033, in one
|
|
62
|
+
// place, so a third resolution funnel cannot quietly omit the outer layer.
|
|
63
|
+
//
|
|
64
|
+
// Only `family` is load-bearing in the outer layer: `settle` has already put
|
|
65
|
+
// the report default's `size` on `canvas.base`, which `sizeOf` falls back to
|
|
66
|
+
// for every drawn atom, so the size reaches the page whether or not it is
|
|
67
|
+
// merged here.
|
|
68
|
+
/** @type {(canvas: { style?: any }, under: any, own: any) => any} */
|
|
69
|
+
let stack = (canvas, under, own) => merge(canvas.style, merge(under, own));
|
|
70
|
+
|
|
60
71
|
// Band-role omakase defaults — the outermost layer of that same stack, under
|
|
61
72
|
// the author's own style, which therefore always wins. Only the headline
|
|
62
73
|
// roles carry one; the XLSX target carries the same two, byte-identical, in
|
|
@@ -81,4 +92,15 @@ let roled = (event) =>
|
|
|
81
92
|
? { ...event, style: merge(ROLES[event.role], event.style) }
|
|
82
93
|
: event;
|
|
83
94
|
|
|
84
|
-
|
|
95
|
+
// Whether a resolved style asks for either text decoration -- one reading for
|
|
96
|
+
// the wrapper that stamps it on a line and the canvas that draws it.
|
|
97
|
+
/** @type {(style: any) => boolean} */
|
|
98
|
+
let dressed = (style) => !!(style && (style.underline || style.strikethrough));
|
|
99
|
+
|
|
100
|
+
// Whether a resolved style asks for capitals. This target has no
|
|
101
|
+
// text-transform to defer to, so the reading is here beside the rest of the
|
|
102
|
+
// vocabulary and `text.js` applies it before measuring.
|
|
103
|
+
/** @type {(style: any) => boolean} */
|
|
104
|
+
let upper = (style) => !!(style && style.uppercase);
|
|
105
|
+
|
|
106
|
+
export { BAND, BLACK, GUTTER, LEAD, PADX, PADY, col, dressed, roled, shift, sizeOf, stack, upper };
|
package/lib/text.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* their resolved typography, and a greedy breaker turns those into lines. Pure
|
|
4
4
|
* measurement against the font registry — nothing here touches the page.
|
|
5
5
|
*/
|
|
6
|
+
import { display } from "quario";
|
|
6
7
|
import { ascOf, face, printable, width } from "./fonts.js";
|
|
7
|
-
import { LEAD, col, sizeOf } from "./style.js";
|
|
8
|
+
import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
|
|
8
9
|
|
|
9
10
|
// What measuring needs and no more: the embedded faces to measure against
|
|
10
11
|
// and the base size a style falls back to. A `Canvas` satisfies it, so
|
|
@@ -27,7 +28,15 @@ import { LEAD, col, sizeOf } from "./style.js";
|
|
|
27
28
|
*/
|
|
28
29
|
|
|
29
30
|
/** @type {(token: any) => string} */
|
|
30
|
-
let rawOf = (token) => ("literal" in token ? token.literal :
|
|
31
|
+
let rawOf = (token) => ("literal" in token ? token.literal : display(token.value));
|
|
32
|
+
|
|
33
|
+
// This target has no text-transform to defer to, so `uppercase` is applied to
|
|
34
|
+
// the string before it is measured -- the widths have to be the widths of what
|
|
35
|
+
// is actually drawn. `toUpperCase` rather than `toLocaleUpperCase`: this
|
|
36
|
+
// target's output is byte-reproducible, so the host's locale must not reach
|
|
37
|
+
// the glyphs.
|
|
38
|
+
/** @type {(text: string, style: any) => string} */
|
|
39
|
+
let cased = (text, style) => (upper(style) ? text.toUpperCase() : text);
|
|
31
40
|
|
|
32
41
|
/** @type {(font: any, line: string) => string[]} */
|
|
33
42
|
let partsOf = (font, line) => printable(font, line).split(/( +)/).filter(Boolean);
|
|
@@ -61,7 +70,7 @@ let atoms = (metrics, tokens, style) => {
|
|
|
61
70
|
let out = /** @type {Atom[]} */ ([]);
|
|
62
71
|
let { font, size, color } = look(metrics, style);
|
|
63
72
|
for (let token of tokens)
|
|
64
|
-
for (let [i, line] of rawOf(token).split("\n").entries())
|
|
73
|
+
for (let [i, line] of cased(rawOf(token), style).split("\n").entries())
|
|
65
74
|
pushLine(out, font, size, color, i, line);
|
|
66
75
|
return out;
|
|
67
76
|
};
|
|
@@ -225,9 +234,6 @@ let stamp = (line, underline, strikethrough) => {
|
|
|
225
234
|
if (strikethrough) line.strikethrough = true;
|
|
226
235
|
};
|
|
227
236
|
|
|
228
|
-
/** @type {(style: any) => boolean} */
|
|
229
|
-
let dressed = (style) => !!(style && (style.underline || style.strikethrough));
|
|
230
|
-
|
|
231
237
|
/** @type {(lines: Line[], style: any) => Line[]} */
|
|
232
238
|
let dress = (lines, style) => {
|
|
233
239
|
if (!dressed(style)) return lines;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quario/pdf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
"@arethetypeswrong/cli": "^0.18.3",
|
|
43
43
|
"@pdf-lib/fontkit": "^1.1.1",
|
|
44
44
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
45
|
-
"quario": "^0.
|
|
45
|
+
"quario": "^0.2.0",
|
|
46
46
|
"size-limit": "^13.0.3",
|
|
47
47
|
"typescript": "^7.0.2"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@pdf-lib/fontkit": "^1.1.1",
|
|
51
|
-
"quario": "^0.
|
|
51
|
+
"quario": "^0.2.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@pdf-lib/fontkit": {
|