@quario/layout 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 +70 -0
- package/lib/canvas.js +63 -115
- package/lib/fonts.js +19 -9
- package/lib/image.js +1 -1
- package/lib/index.d.ts +20 -2
- package/lib/index.js +19 -12
- package/lib/layout.js +321 -369
- package/lib/measure.js +263 -0
- package/lib/page.js +40 -2
- package/lib/paint.js +30 -7
- package/lib/settings.js +88 -0
- package/lib/style.js +29 -13
- package/lib/text.js +90 -51
- package/package.json +3 -3
package/lib/measure.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The measured table: everything that turns a buffered table into cells with
|
|
3
|
+
* heights, and nothing that puts them on a page.
|
|
4
|
+
*
|
|
5
|
+
* The split is not where a reader expects it. The table engine's emitting
|
|
6
|
+
* half — slicing a row across a break, carrying the header over, deciding
|
|
7
|
+
* whether to break at all — is bound to the band flow's cursor, and
|
|
8
|
+
* `quario-70jg.15` measured that it can never leave: **emitting a table is
|
|
9
|
+
* participating in the page break, and the page break owns the block layer.**
|
|
10
|
+
* So what closes is the measurement, and only that.
|
|
11
|
+
*
|
|
12
|
+
* One door in. `measureTable` is the single entry `quario-70jg.14` collapsed
|
|
13
|
+
* the two into, which is what makes this a module with a name rather than a
|
|
14
|
+
* region with two.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here reads the cursor, so nothing here can page — which is what
|
|
17
|
+
* buys a suite that can say something about a column width without a `Flow`,
|
|
18
|
+
* a recording canvas, or ten imports.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** @import { Canvas } from './canvas.js' */
|
|
22
|
+
|
|
23
|
+
import { CELL_PAD, insetOf, unbox } from "./box.js";
|
|
24
|
+
import { col, merge, rowHeight, sizeOf, vshift } from "./style.js";
|
|
25
|
+
import { atoms, heightOf, wrap } from "./text.js";
|
|
26
|
+
|
|
27
|
+
/** @typedef {{ cells: any[], spans: number[] | null }} Voting */
|
|
28
|
+
/** The column geometry a row is drawn against.
|
|
29
|
+
* @typedef {{ xOffsets: number[], widths: number[] }} Columns */
|
|
30
|
+
/** One measured row: its cells with their lines, and the height they came to.
|
|
31
|
+
* @typedef {{ cells: any[], h: number, style?: any, spans?: number[] | null }} TableRow */
|
|
32
|
+
|
|
33
|
+
/** The offsets a spanning row's merged columns start at. */
|
|
34
|
+
/** @type {(xOffsets: number[], spans: number[] | null) => number[]} */
|
|
35
|
+
let spanOffsets = (xOffsets, spans) => (spans ? overSpans(spans, (at) => xOffsets[at]) : xOffsets);
|
|
36
|
+
|
|
37
|
+
// Pre-measure one cell: wrapped lines per column width come later; natural
|
|
38
|
+
// width first (no wrapping except hard breaks). `mt`/`mb` go unread: SCHEMA.md
|
|
39
|
+
// gives flow spacing no meaning inside a table row, in either target.
|
|
40
|
+
/** @type {(canvas: Canvas, cell: any, rowStyle: any) => any} */
|
|
41
|
+
let cellOf = (canvas, cell, rowStyle) => {
|
|
42
|
+
// A row's block reaches its cells with no box in it: the engine resolved
|
|
43
|
+
// that half onto the cells themselves before the event was emitted
|
|
44
|
+
// (SCHEMA.md, "Style declarations"), which is why the inset and the ink
|
|
45
|
+
// both read the cell and a row is never asked for a box it cannot have.
|
|
46
|
+
let style = merge(rowStyle, unbox(cell.style));
|
|
47
|
+
let inset = insetOf(cell.style, CELL_PAD);
|
|
48
|
+
let list = atoms(canvas.settings, cell.tokens, style);
|
|
49
|
+
let natural = wrap(list, Infinity, sizeOf(style, canvas.settings.base)).reduce(
|
|
50
|
+
(widest, line) => Math.max(widest, line.w),
|
|
51
|
+
0,
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
list,
|
|
55
|
+
natural: natural + inset.l + inset.r,
|
|
56
|
+
align: style.align,
|
|
57
|
+
valign: style.valign,
|
|
58
|
+
bg: col(style.background),
|
|
59
|
+
inset,
|
|
60
|
+
style: cell.style,
|
|
61
|
+
path: cell.path,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** @type {(canvas: Canvas, cells: any[], widths: number[]) => { cells: any[], h: number }} */
|
|
66
|
+
let rowOf = (canvas, cells, widths) => {
|
|
67
|
+
let h = rowHeight(canvas);
|
|
68
|
+
/** @type {number[]} */
|
|
69
|
+
let owns = [];
|
|
70
|
+
let out = cells.map((cell, i) => {
|
|
71
|
+
let inner = Math.max(widths[i] - cell.inset.l - cell.inset.r, 1);
|
|
72
|
+
// A glyph or a hard break occupies; spaces alone are an empty cell.
|
|
73
|
+
let lines = cell.list.some(
|
|
74
|
+
(/** @type {{ hard: boolean, space: boolean }} */ atom) => atom.hard || !atom.space,
|
|
75
|
+
)
|
|
76
|
+
? wrap(cell.list, inner, cell.list[0].size)
|
|
77
|
+
: [];
|
|
78
|
+
let own = heightOf(lines) + cell.inset.t + cell.inset.b;
|
|
79
|
+
if (own > h) h = own;
|
|
80
|
+
owns.push(own);
|
|
81
|
+
return { ...cell, lines };
|
|
82
|
+
});
|
|
83
|
+
// The row's height is known only now, so the slack each cell's `valign`
|
|
84
|
+
// reads is measured here, as `splitBlock` measures a slot's.
|
|
85
|
+
for (let [i, cell] of out.entries()) cell.drop = vshift(cell.valign, h - owns[i]);
|
|
86
|
+
return { cells: out, h };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// The column geometry one row sees. A cell covering several columns starts
|
|
90
|
+
// where the first of them starts and is as wide as all of them together; a row
|
|
91
|
+
// that spans nothing sees the columns themselves, and pays nothing for the
|
|
92
|
+
// feature. `null` spans is that row -- every data row, and every total row
|
|
93
|
+
// whose cells each cover one column.
|
|
94
|
+
/** @type {(cells: any[]) => number[] | null} */
|
|
95
|
+
let spansOf = (cells) =>
|
|
96
|
+
cells.some((cell) => cell.span > 1) ? cells.map((cell) => cell.span || 1) : null;
|
|
97
|
+
|
|
98
|
+
// One value per cell, walking the columns each of them covers. Reached only
|
|
99
|
+
// for a row that spans, so the closure it takes costs nothing per data row.
|
|
100
|
+
/** @type {(spans: number[], pick: (at: number, span: number) => number) => number[]} */
|
|
101
|
+
let overSpans = (spans, pick) => {
|
|
102
|
+
/** @type {number[]} */
|
|
103
|
+
let out = [];
|
|
104
|
+
let at = 0;
|
|
105
|
+
for (let span of spans) {
|
|
106
|
+
out.push(pick(at, span));
|
|
107
|
+
at += span;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** @type {(widths: number[], spans: number[] | null) => number[]} */
|
|
113
|
+
let spanWidths = (widths, spans) =>
|
|
114
|
+
spans ? overSpans(spans, (at, span) => sum(widths.slice(at, at + span))) : widths;
|
|
115
|
+
|
|
116
|
+
// The widest of each column's header, rows and totals, padding included.
|
|
117
|
+
//
|
|
118
|
+
// A cell covering more than one column has no say in their widths (SCHEMA.md):
|
|
119
|
+
// what a span states is which columns a cell reaches across, never how wide
|
|
120
|
+
// they are. So a column can end up with no voter at all -- every cell above it
|
|
121
|
+
// spans over it -- which only an empty table reaches, since a data row never
|
|
122
|
+
// spans. It opens at the padding floor rather than at nothing, so an empty
|
|
123
|
+
// table still shows the geometry it promises.
|
|
124
|
+
/**
|
|
125
|
+
*/
|
|
126
|
+
/** @type {(cols: any[], header: Voting, rows: Voting[], totals: Voting[]) => number[]} */
|
|
127
|
+
let naturalWidths = (cols, header, rows, totals) => {
|
|
128
|
+
/** @type {(number | null)[]} */
|
|
129
|
+
let widest = cols.map(() => null);
|
|
130
|
+
/** @type {(at: number, natural: number) => void} */
|
|
131
|
+
let widen = (at, natural) => {
|
|
132
|
+
let held = widest[at];
|
|
133
|
+
if (held === null || natural > held) widest[at] = natural;
|
|
134
|
+
};
|
|
135
|
+
/** @type {(row: Voting) => void} */
|
|
136
|
+
let vote = (row) => {
|
|
137
|
+
let at = 0;
|
|
138
|
+
for (let [i, cell] of row.cells.entries()) {
|
|
139
|
+
let span = row.spans ? row.spans[i] : 1;
|
|
140
|
+
if (span === 1) widen(at, cell.natural);
|
|
141
|
+
at += span;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
vote(header);
|
|
145
|
+
for (let row of rows) vote(row);
|
|
146
|
+
for (let row of totals) vote(row);
|
|
147
|
+
return widest.map((width) => (width === null ? CELL_PAD.l + CELL_PAD.r : width));
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** @type {(values: number[]) => number} */
|
|
151
|
+
let sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
152
|
+
|
|
153
|
+
// An authored `width` percentage fixes its column; the rest share what is left
|
|
154
|
+
// in proportion to their natural widths. Three cases, one return each — the
|
|
155
|
+
// fall-through is all-authored within budget, honoured exactly.
|
|
156
|
+
//
|
|
157
|
+
// There is no over-commitment case: the engine rejects a document whose fixed
|
|
158
|
+
// shares leave the width-less columns nothing, so `left` is positive whenever
|
|
159
|
+
// an auto column exists and the fixed shares never exceed the content width.
|
|
160
|
+
// That invariant is asserted rather than trusted — falling through with auto
|
|
161
|
+
// columns unplaced would draw them at zero width, an invisible failure — the
|
|
162
|
+
// same fail-loud posture as the measuring canvas's `newPage`.
|
|
163
|
+
/** @type {(cols: any[], natural: number[], avail: number) => number[]} */
|
|
164
|
+
let columnWidths = (cols, natural, avail) => {
|
|
165
|
+
let fixed = cols.map((column) => Number.isFinite(column.width));
|
|
166
|
+
if (!fixed.some(Boolean)) {
|
|
167
|
+
// Natural widths always carry the cell padding, so the sum is never zero.
|
|
168
|
+
let wanted = sum(natural);
|
|
169
|
+
return natural.map((width) => (width * avail) / wanted);
|
|
170
|
+
}
|
|
171
|
+
let widths = cols.map((col, i) => (fixed[i] ? (col.width / 100) * avail : 0));
|
|
172
|
+
let auto = natural.map((width, i) => (fixed[i] ? 0 : width));
|
|
173
|
+
let left = avail - sum(widths);
|
|
174
|
+
let wanted = sum(auto);
|
|
175
|
+
// Room for the auto columns: they share what the fixed ones left.
|
|
176
|
+
if (wanted > 0) {
|
|
177
|
+
if (left <= 0) throw new Error("over-committed column widths reached the layout");
|
|
178
|
+
return widths.map((width, i) => (fixed[i] ? width : (auto[i] * left) / wanted));
|
|
179
|
+
}
|
|
180
|
+
return widths;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// A buffered table, in the one shape `table` lays out: its columns come from
|
|
184
|
+
// the opening event, its rows are the row events themselves, and its total is
|
|
185
|
+
// the total row's cells. Both readers build it here — the replay, collecting
|
|
186
|
+
// events as they arrive, and the region buffer, reading them back off what it
|
|
187
|
+
// held — so a new table event kind cannot reach one and miss the other.
|
|
188
|
+
/** @type {(events: any[]) => any} */
|
|
189
|
+
let tableOf = (events) => {
|
|
190
|
+
let opening = events[0];
|
|
191
|
+
return {
|
|
192
|
+
columns: opening.columns.map((/** @type {any} */ col) => ({ ...col })),
|
|
193
|
+
// The header row arrives whole (docs/adr/0053), so both readers -- the
|
|
194
|
+
// replay and the region buffer -- take the same list rather than each
|
|
195
|
+
// rebuilding it from the columns.
|
|
196
|
+
header: opening.header.cells,
|
|
197
|
+
headerStyle: opening.header.style,
|
|
198
|
+
rows: events.filter((event) => event.type === "row"),
|
|
199
|
+
// The events themselves, as `rows` are: a total row is a row, and the
|
|
200
|
+
// buffer files each corrected height under the event it was measured from.
|
|
201
|
+
totals: events.filter((event) => event.type === "total-row"),
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @typedef {{ widths: number[], head: any, laid: any[], totalRows: any[] }} Measured
|
|
207
|
+
*/
|
|
208
|
+
// Measure a buffered table at a given width: the column widths every cell has
|
|
209
|
+
// a say in, then each row laid out against them. Everything here is
|
|
210
|
+
// arithmetic over the cells, so the region's buffer can ask for it before
|
|
211
|
+
// anything is drawn — which is the only way a row's real height is known
|
|
212
|
+
// early enough to balance on (bead `quario-cgk`).
|
|
213
|
+
/** @type {(canvas: Canvas, buffered: any, avail: number) => Measured} */
|
|
214
|
+
let measureTable = (canvas, buffered, avail) => {
|
|
215
|
+
let cols = /** @type {any[]} */ (buffered.columns);
|
|
216
|
+
/** @type {(cells: any[], style: any, spans: number[] | null) => Voting & { style: any }} */
|
|
217
|
+
let measured = (cells, style, spans) => ({
|
|
218
|
+
cells: cells.map((/** @type {any} */ cell) => cellOf(canvas, cell, style)),
|
|
219
|
+
spans,
|
|
220
|
+
style,
|
|
221
|
+
});
|
|
222
|
+
let header = measured(buffered.header, buffered.headerStyle, spansOf(buffered.header));
|
|
223
|
+
// A data row's cells are the columns' own, so nothing in one can span
|
|
224
|
+
// (SCHEMA.md, "Span"). Asking each row anyway would scan every cell of the
|
|
225
|
+
// table to rediscover what the schema already guarantees.
|
|
226
|
+
let rows = buffered.rows.map((/** @type {any} */ row) => measured(row.cells, row.style, null));
|
|
227
|
+
let totals = buffered.totals.map((/** @type {any} */ row) =>
|
|
228
|
+
measured(row.cells, row.style, spansOf(row.cells)),
|
|
229
|
+
);
|
|
230
|
+
let widths = columnWidths(cols, naturalWidths(cols, header, rows, totals), avail);
|
|
231
|
+
// Each row wraps against the geometry it sees, which is the columns' own
|
|
232
|
+
// unless one of its cells spans.
|
|
233
|
+
/** @type {(row: Voting & { style: any }) => any} */
|
|
234
|
+
let laidOut = (row) => ({
|
|
235
|
+
...rowOf(canvas, row.cells, spanWidths(widths, row.spans)),
|
|
236
|
+
spans: row.spans,
|
|
237
|
+
style: row.style,
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
widths,
|
|
241
|
+
head: laidOut(header),
|
|
242
|
+
laid: rows.map(laidOut),
|
|
243
|
+
totalRows: totals.map(laidOut),
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// What this row draws against: the grid's own columns, or the merged geometry
|
|
248
|
+
// a spanning row sees. Read at draw time rather than kept on the row, because
|
|
249
|
+
// `rebase` moves the offsets under it every time the table crosses a strip.
|
|
250
|
+
//
|
|
251
|
+
// Typed on the geometry rather than on the flow's `Grid`, which is the seam
|
|
252
|
+
// this module sits on: a grid carries a cursor and this never reads one, so
|
|
253
|
+
// asking for the whole record would claim a coupling that is not here.
|
|
254
|
+
/** @type {<T extends Columns>(grid: T, row: TableRow) => Columns} */
|
|
255
|
+
let gridOf = (grid, row) =>
|
|
256
|
+
row.spans
|
|
257
|
+
? {
|
|
258
|
+
xOffsets: spanOffsets(grid.xOffsets, row.spans),
|
|
259
|
+
widths: spanWidths(grid.widths, row.spans),
|
|
260
|
+
}
|
|
261
|
+
: grid;
|
|
262
|
+
|
|
263
|
+
export { gridOf, measureTable, sum, tableOf };
|
package/lib/page.js
CHANGED
|
@@ -3,8 +3,15 @@
|
|
|
3
3
|
* it is written. Owned here because every consumer of the list — the PDF
|
|
4
4
|
* target, the viewer, the editor — lays out on the same page, and a size that
|
|
5
5
|
* meant one thing on screen and another on paper would be a silent lie.
|
|
6
|
+
*
|
|
7
|
+
* The derivation belongs with the validation: `frame` below turns a validated
|
|
8
|
+
* page and margin into the geometry everything downstream lays out in, so a
|
|
9
|
+
* page becomes a content box here and `canvas.js` only takes the answer,
|
|
10
|
+
* rather than the drawing surface defining the page it draws on. Deriving a
|
|
11
|
+
* frame is not owning its lifetime, and this module claims only the first:
|
|
12
|
+
* `adopt` (`canvas.js`) narrows `top`/`bottom` once for the page bands, and
|
|
13
|
+
* `reserve` (`layout.js`) mints the frame it narrows from.
|
|
6
14
|
*/
|
|
7
|
-
import { frame } from "./canvas.js";
|
|
8
15
|
|
|
9
16
|
// The named sizes. `@quario/pdf` used to own this table, and the two element
|
|
10
17
|
// packages restated it; the layout package is where all three now read it.
|
|
@@ -80,11 +87,42 @@ export let pageBox = (page, at = "page") => {
|
|
|
80
87
|
return { width, height, margin: marginOf(page?.margin, width, height, at + ".margin") };
|
|
81
88
|
};
|
|
82
89
|
|
|
90
|
+
// The page box and the content box, and nothing whatever else: geometry, all
|
|
91
|
+
// of it derived below from a page and a margin. Fixed for the document — the
|
|
92
|
+
// one exception is `top`/`bottom`, which the page bands narrow once through
|
|
93
|
+
// `adopt` (`canvas.js`), while the first page is still untouched. Everything
|
|
94
|
+
// else that holds for a whole render is that module's `Settings`; a frame
|
|
95
|
+
// carrying either half of that was a type with two lifetimes, and the copy that
|
|
96
|
+
// kept a probe reading the same locale as the draw had to be written out by
|
|
97
|
+
// hand.
|
|
98
|
+
/**
|
|
99
|
+
* @typedef {{ width: number, height: number, margin: number,
|
|
100
|
+
* content: number, top: number, bottom: number }} Frame
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
// A frame from the page box: how `content`/`top`/`bottom` fall out of a page
|
|
104
|
+
// and a margin is derived here, once, so no caller and no suite has to restate
|
|
105
|
+
// it and drift from what a real render uses. A render reaches a frame through
|
|
106
|
+
// `geometry` below and never through this; the package entry carries neither,
|
|
107
|
+
// so no host reaches one at all. Exported for `layout.js`, which re-derives the
|
|
108
|
+
// page frame a canvas presents, and for the suites that build a fixture frame
|
|
109
|
+
// — the day that re-derivation goes, the export stands for the suites alone,
|
|
110
|
+
// and they should take `geometry` rather than keep it standing.
|
|
111
|
+
/** @type {(width: number, height: number, margin: number) => Frame} */
|
|
112
|
+
export let frame = (width, height, margin) => ({
|
|
113
|
+
width,
|
|
114
|
+
height,
|
|
115
|
+
margin,
|
|
116
|
+
content: width - 2 * margin,
|
|
117
|
+
top: height - margin,
|
|
118
|
+
bottom: margin,
|
|
119
|
+
});
|
|
120
|
+
|
|
83
121
|
/**
|
|
84
122
|
* The page box and the content box in one, for a render: the host's size,
|
|
85
123
|
* and whichever margin the host and the opening event agreed on.
|
|
86
124
|
*
|
|
87
|
-
* @type {(page: any, opening?: any) =>
|
|
125
|
+
* @type {(page: any, opening?: any) => Frame}
|
|
88
126
|
*/
|
|
89
127
|
export let geometry = (page = {}, opening) => {
|
|
90
128
|
let { width, height } = pageBox(page, "options.page");
|
package/lib/paint.js
CHANGED
|
@@ -123,7 +123,9 @@ let loadFaces = async (fonts) => {
|
|
|
123
123
|
};
|
|
124
124
|
|
|
125
125
|
// Every bitmap decoded so far, by the bytes it was decoded from: one logo on
|
|
126
|
-
// every page is decoded once.
|
|
126
|
+
// every page is decoded once. A rejection is remembered too, so a file the
|
|
127
|
+
// browser refused is refused from memory ever after — a caller repainting the
|
|
128
|
+
// same page never pays for a second decode of bytes that will not decode.
|
|
127
129
|
/** @type {WeakMap<Uint8Array, Promise<ImageBitmap>>} */
|
|
128
130
|
let BITMAPS = new WeakMap();
|
|
129
131
|
|
|
@@ -204,17 +206,30 @@ let PAINTERS = { rect: paintRect, line: paintLine, text: paintText, mark: paintM
|
|
|
204
206
|
|
|
205
207
|
/** @type {(ctx: CanvasRenderingContext2D, op: Op, bitmaps: Map<Op, ImageBitmap>) => void} */
|
|
206
208
|
let paintOp = (ctx, op, bitmaps) => {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
// An image with no bitmap did not decode, and is drawn as nothing. The map
|
|
210
|
+
// is the one place that is known: `decode` keeps no entry for a file the
|
|
211
|
+
// browser refused, so there is no second flag to read and no way to ask
|
|
212
|
+
// this question twice.
|
|
213
|
+
if (op.kind === "image") {
|
|
214
|
+
let bitmap = bitmaps.get(op);
|
|
215
|
+
if (bitmap) ctx.drawImage(bitmap, op.x, op.y, op.w, op.h);
|
|
216
|
+
} else PAINTERS[op.kind](ctx, op);
|
|
210
217
|
};
|
|
211
218
|
|
|
212
|
-
// Every image on the page, decoded together rather than one after another
|
|
219
|
+
// Every image on the page, decoded together rather than one after another,
|
|
220
|
+
// and each settling on its own: gathering with `Promise.all` made one file the
|
|
221
|
+
// browser refused the whole page's failure, thrown before the first draw op.
|
|
222
|
+
// `paint`'s own doc says why that is the wrong price. An image that did not
|
|
223
|
+
// decode has no entry here, which is how `paintOp` knows.
|
|
213
224
|
/** @type {(page: Page) => Promise<Map<Op, ImageBitmap>>} */
|
|
214
225
|
let decode = async (page) => {
|
|
215
226
|
let images = page.ops.filter((op) => op.kind === "image");
|
|
216
|
-
let bitmaps = await Promise.all(images.map(bitmapOf));
|
|
217
|
-
return new Map(
|
|
227
|
+
let bitmaps = await Promise.all(images.map((op) => bitmapOf(op).catch(() => null)));
|
|
228
|
+
return new Map(
|
|
229
|
+
/** @type {[Op, ImageBitmap][]} */ (
|
|
230
|
+
images.map((op, i) => [op, bitmaps[i]]).filter(([, bitmap]) => bitmap)
|
|
231
|
+
),
|
|
232
|
+
);
|
|
218
233
|
};
|
|
219
234
|
|
|
220
235
|
/**
|
|
@@ -230,6 +245,14 @@ let decode = async (page) => {
|
|
|
230
245
|
* superseded call reach a canvas still on screen — which is what the viewer's
|
|
231
246
|
* stage retires a page for (docs/adr/0046).
|
|
232
247
|
*
|
|
248
|
+
* **An image the browser will not decode is drawn as nothing, and the page is
|
|
249
|
+
* drawn around it.** The engine vouched for the magic numbers and the layout
|
|
250
|
+
* read the size out of the header, so a file corrupt past that point is not
|
|
251
|
+
* known to be bad until here; costing the whole page for it — every other op
|
|
252
|
+
* and the marking with it — is a worse answer than costing the image. What is
|
|
253
|
+
* lost is what could not be drawn. A caller wanting the failure instead should
|
|
254
|
+
* decode before it paints.
|
|
255
|
+
*
|
|
233
256
|
* @param {CanvasRenderingContext2D} ctx The context to paint on.
|
|
234
257
|
* @param {Page} page One page of a `Layout`.
|
|
235
258
|
* @param {{ scale?: number, fonts?: any }} [options]
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything that holds for a whole render and is not geometry — the render's
|
|
3
|
+
* `Settings`, and the one reading of what a report default amounts to.
|
|
4
|
+
*
|
|
5
|
+
* A `Frame` (canvas.js) is a page's geometry; a `Settings` is the other half:
|
|
6
|
+
* the faces to measure against, the report default narrowed to `base` and
|
|
7
|
+
* `family`, and the three intl facts a formatted value resolves in. A canvas
|
|
8
|
+
* holds one by reference, so a measuring canvas built for a probe reads exactly
|
|
9
|
+
* what the listing canvas reads and a band cannot be reserved against one
|
|
10
|
+
* locale and drawn in another. This module owns what a settings *is* and how a
|
|
11
|
+
* report's opening event becomes one; canvas.js draws with it, text.js measures
|
|
12
|
+
* against it, and neither has to know what `style.size` or `style.family`
|
|
13
|
+
* amount to.
|
|
14
|
+
*/
|
|
15
|
+
import { familyName } from "./fonts.js";
|
|
16
|
+
import { sizeOf } from "./style.js";
|
|
17
|
+
|
|
18
|
+
// This layout's baseline type size. Not a host option: a document's type size
|
|
19
|
+
// is the document's own, so it is `style.size` on the report and the number
|
|
20
|
+
// here is only what text renders at when nothing declares one. The XLSX target
|
|
21
|
+
// carries the same 10 for the same reason (docs/adr/0014, docs/adr/0033).
|
|
22
|
+
let BASE = 10;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Everything that holds for a whole render and is not geometry: the faces to
|
|
26
|
+
* measure against, the report default narrowed to `family` and `size` (landing
|
|
27
|
+
* in `family` and `base` here), and the three intl facts a formatted value
|
|
28
|
+
* resolves in.
|
|
29
|
+
*
|
|
30
|
+
* A canvas holds one of these by reference, never a copy, which is the whole
|
|
31
|
+
* reason it is an object. A measuring canvas built off the same settings reads
|
|
32
|
+
* exactly what the listing canvas reads, so a page band cannot be reserved
|
|
33
|
+
* against one locale and drawn in another — an agreement that used to rest on
|
|
34
|
+
* a hand-written copy staying in step.
|
|
35
|
+
*
|
|
36
|
+
* `family` is null when the report declares none, and each of the intl three is
|
|
37
|
+
* undefined when the engine settled none.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {{ fonts: import('./fonts.js').Fonts, base: number,
|
|
40
|
+
* family: string | null, locale?: string, currency?: string,
|
|
41
|
+
* timeZone?: string }} Settings
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The render's settings, complete by construction from the opening event the
|
|
46
|
+
* target already peeked. The report default is narrowed to `family` and `size`,
|
|
47
|
+
* and each replaces this target's own baseline outright: row heights and band
|
|
48
|
+
* gaps scale with the document's type rather than staying at a size nothing is
|
|
49
|
+
* set in, and text declaring no family is set in the document's. Reading the
|
|
50
|
+
* pair here is the whole of this target's reading of docs/adr/0033 — the default
|
|
51
|
+
* reaches a node as a fallback the settings carry, never as a layer merged into
|
|
52
|
+
* its style, so a document-wide fact costs no allocation however many cells a
|
|
53
|
+
* report has.
|
|
54
|
+
*
|
|
55
|
+
* `sizeOf` and `familyName` are this target's one reading each of what a
|
|
56
|
+
* declared size and family amount to, so they read the default here too — both
|
|
57
|
+
* reach this unchecked from a computed style, and two spellings of that
|
|
58
|
+
* leniency would drift. Each falls back to this target's own baseline, so a
|
|
59
|
+
* default declaring one of the pair leaves the other at the baseline, and one
|
|
60
|
+
* whose value is unusable leaves the baseline standing.
|
|
61
|
+
*
|
|
62
|
+
* Built once and never settled again: these hold for a whole render, so there
|
|
63
|
+
* is no event that could set them a second time — a second settling is not
|
|
64
|
+
* refused, it is unexpressible. `opening` is null when the stream is empty (no
|
|
65
|
+
* report to read a default from), which yields the bare baseline: the target's
|
|
66
|
+
* own size, no family, and the intl three unset. Those three are read straight
|
|
67
|
+
* off the event whether or not it declares them — a formatted value is the only
|
|
68
|
+
* thing that reads them, and an empty stream produces none, so an unset one
|
|
69
|
+
* never reaches `Intl`.
|
|
70
|
+
*
|
|
71
|
+
* @param {import('./fonts.js').Fonts} fonts The loaded faces.
|
|
72
|
+
* @param {any} [opening] The peeked `report-start` event, or null.
|
|
73
|
+
* @returns {Settings}
|
|
74
|
+
*/
|
|
75
|
+
let settings = (fonts, opening) => {
|
|
76
|
+
let event = opening || {};
|
|
77
|
+
let style = event.style || {};
|
|
78
|
+
return {
|
|
79
|
+
fonts,
|
|
80
|
+
base: sizeOf(style, BASE),
|
|
81
|
+
family: familyName(style) || null,
|
|
82
|
+
locale: event.locale,
|
|
83
|
+
currency: event.currency,
|
|
84
|
+
timeZone: event.timeZone,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export { settings };
|
package/lib/style.js
CHANGED
|
@@ -69,24 +69,26 @@ let sizeOf = (style, base) => (Number.isFinite(style.size) && style.size > 0 ? s
|
|
|
69
69
|
// Style blocks layer outward-in: row under cell, split under slot. Those are
|
|
70
70
|
// the two innermost of the four layers; the outer two reach a node without
|
|
71
71
|
// being merged into it -- the band-role default through `roled` below, and the
|
|
72
|
-
// report default through the render's settings, as `
|
|
73
|
-
// `adoptSettings` explains.
|
|
72
|
+
// report default through the render's settings, as `settings.js` explains.
|
|
74
73
|
/** @type {(under: any, over: any) => any} */
|
|
75
74
|
let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : over || {});
|
|
76
75
|
|
|
77
76
|
// Band-role omakase defaults — the third of those four layers, over the report
|
|
78
77
|
// default and under the author's own, which therefore always wins. Only the
|
|
79
|
-
// headline roles carry one
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
// nowhere to share this from and the two copies are synced by hand.
|
|
78
|
+
// headline roles carry one, and SCHEMA.md states the pair. A target can only
|
|
79
|
+
// import public engine helpers, so there is nowhere to share this from and the
|
|
80
|
+
// copies are separate on purpose.
|
|
83
81
|
//
|
|
84
82
|
// `@quario/html` deliberately carries none of this, nor the leading, padding
|
|
85
83
|
// and band gap below: its consumer has a stylesheet and the `q-*` classes are
|
|
86
84
|
// the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
|
|
87
85
|
// a target supplies defaults only where its consumer has no seam to supply
|
|
88
|
-
// them.
|
|
89
|
-
//
|
|
86
|
+
// them.
|
|
87
|
+
//
|
|
88
|
+
// `test/omakase-defaults.test.js` is what holds the copies in agreement. This
|
|
89
|
+
// comment names the gate rather than listing the files, because the list is
|
|
90
|
+
// what rotted: it used to name partners that did not exist, and the two
|
|
91
|
+
// comments disagreed on how many there were.
|
|
90
92
|
/** @type {Record<string, any>} */
|
|
91
93
|
let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
|
|
92
94
|
|
|
@@ -98,10 +100,13 @@ let roled = (event) =>
|
|
|
98
100
|
? { ...event, style: merge(ROLES[event.role], event.style) }
|
|
99
101
|
: event;
|
|
100
102
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
// Two resolved colours, or the absence of one, compared by value: `col()`
|
|
104
|
+
// mints a fresh object per read and interns nothing, so identity would answer
|
|
105
|
+
// no for two reads of the same declaration.
|
|
106
|
+
/** @type {(a: Color, b: Color) => boolean} */
|
|
107
|
+
let sameChannels = (a, b) => a.r === b.r && a.g === b.g && a.b === b.b;
|
|
108
|
+
/** @type {(a: Color | null, b: Color | null) => boolean} */
|
|
109
|
+
let sameCol = (a, b) => (a && b ? sameChannels(a, b) : a === b);
|
|
105
110
|
|
|
106
111
|
// Whether a resolved style asks for capitals. This target has no
|
|
107
112
|
// text-transform to defer to, so the reading is here beside the rest of the
|
|
@@ -109,6 +114,15 @@ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
|
|
|
109
114
|
/** @type {(style: any) => boolean} */
|
|
110
115
|
let upper = (style) => !!(style && style.uppercase);
|
|
111
116
|
|
|
117
|
+
// A table row's floor height and the gap a group instance opens with. Here
|
|
118
|
+
// rather than in the band flow because both are `k * LEAD * base` and LEAD is
|
|
119
|
+
// here: a constant with two readers and no name drifts between them, and a
|
|
120
|
+
// second copy of the multiplier would drift from the leading itself.
|
|
121
|
+
/** @type {(canvas: any) => number} */
|
|
122
|
+
let rowHeight = (canvas) => LEAD * canvas.settings.base;
|
|
123
|
+
/** @type {(canvas: any) => number} */
|
|
124
|
+
let instanceGap = (canvas) => 0.5 * LEAD * canvas.settings.base;
|
|
125
|
+
|
|
112
126
|
export {
|
|
113
127
|
BAND,
|
|
114
128
|
BLACK,
|
|
@@ -117,9 +131,11 @@ export {
|
|
|
117
131
|
PADX,
|
|
118
132
|
PADY,
|
|
119
133
|
col,
|
|
120
|
-
|
|
134
|
+
instanceGap,
|
|
121
135
|
merge,
|
|
122
136
|
roled,
|
|
137
|
+
rowHeight,
|
|
138
|
+
sameCol,
|
|
123
139
|
shift,
|
|
124
140
|
sizeOf,
|
|
125
141
|
upper,
|