@quario/layout 0.3.0 → 0.5.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 +91 -43
- package/README.md +16 -6
- package/lib/canvas.js +49 -17
- package/lib/fonts.js +19 -9
- package/lib/index.d.ts +38 -9
- package/lib/index.js +1 -0
- package/lib/layout.js +81 -286
- package/lib/measure.js +263 -0
- package/lib/page.js +1 -0
- package/lib/paint.js +18 -9
- package/lib/style.js +29 -11
- package/lib/text.js +85 -46
- package/package.json +3 -3
- package/lib/image.js +0 -58
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
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
// The named sizes. `@quario/pdf` used to own this table, and the two element
|
|
17
17
|
// packages restated it; the layout package is where all three now read it.
|
|
18
|
+
// fallow-ignore-next-line code-duplication -- @quario/docx keeps a deliberate copy of these rules: a flow target runs no layout, and taking @quario/layout for a two-entry table would install the pagination engine with it. test/page-sizes.test.js holds the two in sync (ADR 0039).
|
|
18
19
|
let SIZES = /** @type {Record<string, [number, number]>} */ ({
|
|
19
20
|
A4: [595.28, 841.89],
|
|
20
21
|
letter: [612, 792],
|
package/lib/paint.js
CHANGED
|
@@ -235,18 +235,20 @@ let decode = async (page) => {
|
|
|
235
235
|
/**
|
|
236
236
|
* Paint one page of the list onto a Canvas 2D context: a white page, then
|
|
237
237
|
* every op in order. `scale` is device pixels per point — the caller sized
|
|
238
|
-
* the canvas, so it knows
|
|
238
|
+
* the canvas, so it knows, and the canvas it sized is the page's own size,
|
|
239
|
+
* which is why the paper is filled over the whole store and no page geometry
|
|
240
|
+
* is asked for (ADR 0069). `fonts` is the host's font mapping, the same
|
|
239
241
|
* record given to `layout()`, so a TrueType family draws in its own face.
|
|
240
242
|
*
|
|
241
243
|
* It awaits its faces and images before it draws, and it draws whatever
|
|
242
|
-
* happened in between: a canvas re-sized under a call still in flight
|
|
243
|
-
*
|
|
244
|
-
* repaints one canvas at changing scales owns that, by not letting
|
|
245
|
-
* superseded call reach a canvas still on screen — which is what the
|
|
246
|
-
* stage retires a page for (docs/adr/0046).
|
|
244
|
+
* happened in between: a canvas re-sized under a call still in flight has its
|
|
245
|
+
* ops placed at that call's own `scale` on a store of the size it now has. A
|
|
246
|
+
* caller that repaints one canvas at changing scales owns that, by not letting
|
|
247
|
+
* a superseded call reach a canvas still on screen — which is what the
|
|
248
|
+
* viewer's stage retires a page for (docs/adr/0046).
|
|
247
249
|
*
|
|
248
250
|
* **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
|
|
251
|
+
* drawn around it.** The engine vouched for the magic numbers and
|
|
250
252
|
* read the size out of the header, so a file corrupt past that point is not
|
|
251
253
|
* known to be bad until here; costing the whole page for it — every other op
|
|
252
254
|
* and the marking with it — is a worse answer than costing the image. What is
|
|
@@ -263,9 +265,16 @@ export async function paint(ctx, page, { scale = 1, fonts } = {}) {
|
|
|
263
265
|
await loadFaces(fonts);
|
|
264
266
|
let bitmaps = await decode(page);
|
|
265
267
|
ctx.save();
|
|
266
|
-
|
|
268
|
+
// The paper, over the whole store rather than over the page's own box: a
|
|
269
|
+
// page carries no geometry to fill to (ADR 0069), and the caller has already
|
|
270
|
+
// sized the canvas to the page — the obligation the ops themselves put on it,
|
|
271
|
+
// since a short store clips them. In device pixels, before the scale goes on,
|
|
272
|
+
// so a store rounded up to a whole pixel is covered to its edge rather than
|
|
273
|
+
// left a sliver of whatever it held.
|
|
274
|
+
ctx.resetTransform();
|
|
267
275
|
ctx.fillStyle = "#fff";
|
|
268
|
-
ctx.fillRect(0, 0,
|
|
276
|
+
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
|
277
|
+
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
|
269
278
|
for (let op of page.ops) paintOp(ctx, op, bitmaps);
|
|
270
279
|
ctx.restore();
|
|
271
280
|
}
|
package/lib/style.js
CHANGED
|
@@ -54,6 +54,7 @@ let vshift = (valign, extra) => (Object.hasOwn(VSHIFT, valign) ? VSHIFT[valign]
|
|
|
54
54
|
|
|
55
55
|
// A declared colour as a `Color`, or null when the value is not one.
|
|
56
56
|
/** @type {(value: any) => Color | null} */
|
|
57
|
+
// fallow-ignore-next-line code-duplication -- each target reads a declared hex into its own type; neither may import a sibling, and the engine's stream is all they share
|
|
57
58
|
let col = (value) => {
|
|
58
59
|
let match = typeof value === "string" && HEX.exec(value);
|
|
59
60
|
if (!match) return null;
|
|
@@ -75,17 +76,20 @@ let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : o
|
|
|
75
76
|
|
|
76
77
|
// Band-role omakase defaults — the third of those four layers, over the report
|
|
77
78
|
// default and under the author's own, which therefore always wins. Only the
|
|
78
|
-
// headline roles carry one
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// nowhere to share this from and the two copies are synced by hand.
|
|
79
|
+
// headline roles carry one, and SCHEMA.md states the pair. A target can only
|
|
80
|
+
// import public engine helpers, so there is nowhere to share this from and the
|
|
81
|
+
// copies are separate on purpose.
|
|
82
82
|
//
|
|
83
83
|
// `@quario/html` deliberately carries none of this, nor the leading, padding
|
|
84
84
|
// and band gap below: its consumer has a stylesheet and the `q-*` classes are
|
|
85
85
|
// the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
|
|
86
86
|
// a target supplies defaults only where its consumer has no seam to supply
|
|
87
|
-
// them.
|
|
88
|
-
//
|
|
87
|
+
// them.
|
|
88
|
+
//
|
|
89
|
+
// `test/omakase-defaults.test.js` is what holds the copies in agreement. This
|
|
90
|
+
// comment names the gate rather than listing the files, because the list is
|
|
91
|
+
// what rotted: it used to name partners that did not exist, and the two
|
|
92
|
+
// comments disagreed on how many there were.
|
|
89
93
|
/** @type {Record<string, any>} */
|
|
90
94
|
let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
|
|
91
95
|
|
|
@@ -97,10 +101,13 @@ let roled = (event) =>
|
|
|
97
101
|
? { ...event, style: merge(ROLES[event.role], event.style) }
|
|
98
102
|
: event;
|
|
99
103
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
+
// Two resolved colours, or the absence of one, compared by value: `col()`
|
|
105
|
+
// mints a fresh object per read and interns nothing, so identity would answer
|
|
106
|
+
// no for two reads of the same declaration.
|
|
107
|
+
/** @type {(a: Color, b: Color) => boolean} */
|
|
108
|
+
let sameChannels = (a, b) => a.r === b.r && a.g === b.g && a.b === b.b;
|
|
109
|
+
/** @type {(a: Color | null, b: Color | null) => boolean} */
|
|
110
|
+
let sameCol = (a, b) => (a && b ? sameChannels(a, b) : a === b);
|
|
104
111
|
|
|
105
112
|
// Whether a resolved style asks for capitals. This target has no
|
|
106
113
|
// text-transform to defer to, so the reading is here beside the rest of the
|
|
@@ -108,6 +115,15 @@ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
|
|
|
108
115
|
/** @type {(style: any) => boolean} */
|
|
109
116
|
let upper = (style) => !!(style && style.uppercase);
|
|
110
117
|
|
|
118
|
+
// A table row's floor height and the gap a group instance opens with. Here
|
|
119
|
+
// rather than in the band flow because both are `k * LEAD * base` and LEAD is
|
|
120
|
+
// here: a constant with two readers and no name drifts between them, and a
|
|
121
|
+
// second copy of the multiplier would drift from the leading itself.
|
|
122
|
+
/** @type {(canvas: any) => number} */
|
|
123
|
+
let rowHeight = (canvas) => LEAD * canvas.settings.base;
|
|
124
|
+
/** @type {(canvas: any) => number} */
|
|
125
|
+
let instanceGap = (canvas) => 0.5 * LEAD * canvas.settings.base;
|
|
126
|
+
|
|
111
127
|
export {
|
|
112
128
|
BAND,
|
|
113
129
|
BLACK,
|
|
@@ -116,9 +132,11 @@ export {
|
|
|
116
132
|
PADX,
|
|
117
133
|
PADY,
|
|
118
134
|
col,
|
|
119
|
-
|
|
135
|
+
instanceGap,
|
|
120
136
|
merge,
|
|
121
137
|
roled,
|
|
138
|
+
rowHeight,
|
|
139
|
+
sameCol,
|
|
122
140
|
shift,
|
|
123
141
|
sizeOf,
|
|
124
142
|
upper,
|
package/lib/text.js
CHANGED
|
@@ -3,9 +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, format } from "quario";
|
|
6
|
+
import { display, format, styledRuns } from "quario";
|
|
7
7
|
import { ascOf, face, printable, width } from "./fonts.js";
|
|
8
|
-
import { LEAD, col,
|
|
8
|
+
import { LEAD, col, merge, sameCol, sizeOf, upper } from "./style.js";
|
|
9
9
|
|
|
10
10
|
// What measuring needs and no more, and it is exactly the render's settings:
|
|
11
11
|
// the faces to measure against, the base size and family a style falls back to,
|
|
@@ -14,14 +14,19 @@ import { LEAD, col, dressed, sizeOf, upper } from "./style.js";
|
|
|
14
14
|
// the canvas, so nothing here can touch a page.
|
|
15
15
|
/** @typedef {import('./settings.js').Settings} Settings */
|
|
16
16
|
|
|
17
|
+
// The typography one styled run wears, resolved once and stamped on every atom
|
|
18
|
+
// of it. Decoration and the highlight ride here rather than on the line,
|
|
19
|
+
// because a run is what wears them: underlining a word has to stop where the
|
|
20
|
+
// word does (ADR 0061).
|
|
17
21
|
/**
|
|
18
|
-
* @typedef {{
|
|
19
|
-
*
|
|
22
|
+
* @typedef {{ font: any, size: number, color: any, bg: any,
|
|
23
|
+
* underline: boolean, strikethrough: boolean }} Look
|
|
20
24
|
*/
|
|
25
|
+
/** @typedef {Look & { text: string, space: boolean, hard: boolean }} Atom */
|
|
26
|
+
/** @typedef {Look & { text: string, w: number }} Piece */
|
|
21
27
|
/**
|
|
22
|
-
* @typedef {{ pieces:
|
|
23
|
-
*
|
|
24
|
-
* underline?: boolean, strikethrough?: boolean }} Line
|
|
28
|
+
* @typedef {{ pieces: Piece[], w: number, h: number, size: number,
|
|
29
|
+
* asc: number }} Line
|
|
25
30
|
*/
|
|
26
31
|
|
|
27
32
|
/** @typedef {{ cur: Atom[], w: number, lines: Line[], base: number }} Wrap */
|
|
@@ -40,40 +45,74 @@ let rawOf = (token, style, settings) => {
|
|
|
40
45
|
/** @type {(text: string, style: any) => string} */
|
|
41
46
|
let cased = (text, style) => (upper(style) ? text.toUpperCase() : text);
|
|
42
47
|
|
|
43
|
-
/** @type {(out: Atom[],
|
|
44
|
-
let pushParts = (out,
|
|
45
|
-
for (let part of printable(font, line).split(/( +)/).filter(Boolean))
|
|
46
|
-
out.push({ text: part,
|
|
48
|
+
/** @type {(out: Atom[], look: Look, line: string) => void} */
|
|
49
|
+
let pushParts = (out, look, line) => {
|
|
50
|
+
for (let part of printable(look.font, line).split(/( +)/).filter(Boolean))
|
|
51
|
+
out.push({ ...look, text: part, space: part[0] === " ", hard: false });
|
|
47
52
|
};
|
|
48
53
|
|
|
49
|
-
/** @type {(out: Atom[],
|
|
50
|
-
let pushLine = (out,
|
|
51
|
-
if (i) out.push({ text: "",
|
|
52
|
-
pushParts(out,
|
|
54
|
+
/** @type {(out: Atom[], look: Look, i: number, line: string) => void} */
|
|
55
|
+
let pushLine = (out, look, i, line) => {
|
|
56
|
+
if (i) out.push({ ...look, text: "", space: false, hard: true });
|
|
57
|
+
pushParts(out, look, line);
|
|
53
58
|
};
|
|
54
59
|
|
|
55
|
-
/** @type {(settings: Settings, style: any) =>
|
|
60
|
+
/** @type {(settings: Settings, style: any) => Look} */
|
|
56
61
|
let look = (settings, style) => {
|
|
57
62
|
let resolved = style || {};
|
|
58
63
|
return {
|
|
59
64
|
font: face(settings.fonts, resolved, settings.family),
|
|
60
65
|
size: sizeOf(resolved, settings.base),
|
|
61
66
|
color: col(resolved.color),
|
|
67
|
+
bg: col(resolved.background),
|
|
68
|
+
underline: resolved.underline === true,
|
|
69
|
+
strikethrough: resolved.strikethrough === true,
|
|
62
70
|
};
|
|
63
71
|
};
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
/** @type {(seen: Look, base: any) => Look} */
|
|
74
|
+
let highlighted = (seen, base) => (sameCol(seen.bg, base) ? { ...seen, bg: null } : seen);
|
|
75
|
+
|
|
76
|
+
// What one styled run draws in: the style it presents its values through, and
|
|
77
|
+
// the look its atoms wear. Its own declarations over the cell's where it
|
|
78
|
+
// carries a block, and the cell's answer unchanged where it does not — asked
|
|
79
|
+
// once, so the run's two questions cannot be branched on separately.
|
|
80
|
+
/** @type {(settings: Settings, style: any, styled: any, bare: Look, base: any) =>
|
|
81
|
+
* { worn: any, seen: Look} }*/
|
|
82
|
+
let wornBy = (settings, style, styled, bare, base) => {
|
|
83
|
+
if (!styled.style) return { worn: style, seen: bare };
|
|
84
|
+
let worn = merge(style, styled.style);
|
|
85
|
+
return { worn, seen: highlighted(look(settings, worn), base) };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// One styled run's tokens flattened to atoms, all of them wearing its look.
|
|
89
|
+
/** @type {(out: Atom[], settings: Settings, styled: any, dress: { worn: any, seen: Look }) => void} */
|
|
90
|
+
let pushStyledRun = (out, settings, styled, { worn, seen }) => {
|
|
91
|
+
for (let token of styled.tokens)
|
|
92
|
+
for (let [i, line] of cased(rawOf(token, worn, settings), worn)
|
|
93
|
+
.split(/\r\n|\r|\n/)
|
|
94
|
+
.entries())
|
|
95
|
+
pushLine(out, seen, i, line);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Flatten a cell's tokens to word/space atoms carrying their run's resolved
|
|
99
|
+
// typography. A run's style is the engine's fully-resolved answer for that
|
|
100
|
+
// stretch, so it lays over the cell's *effective* style -- the row's, the band
|
|
101
|
+
// role's or the split's, whichever reached this cell -- rather than replacing
|
|
102
|
+
// it: the run says what it says and the layers outside the cell stand where it
|
|
103
|
+
// is silent. A cell with no authored runs is one run wearing the cell's own
|
|
104
|
+
// look, which is what it always was. CR, LF, and CRLF are one hard break each
|
|
105
|
+
// (SCHEMA.md, Cell values).
|
|
68
106
|
/** @type {(settings: Settings, tokens: any[], style: any) => Atom[]} */
|
|
69
107
|
let atoms = (settings, tokens, style) => {
|
|
70
108
|
let out = /** @type {Atom[]} */ ([]);
|
|
71
|
-
let
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
109
|
+
let own = look(settings, style);
|
|
110
|
+
// The cell's own `background` is already painted at cell scope, so a run
|
|
111
|
+
// highlights only where it names a different one -- otherwise every ordinary
|
|
112
|
+
// cell with a background would paint it twice, once per line.
|
|
113
|
+
let bare = { ...own, bg: null };
|
|
114
|
+
for (let styled of styledRuns(tokens))
|
|
115
|
+
pushStyledRun(out, settings, styled, wornBy(settings, style, styled, bare, own.bg));
|
|
77
116
|
return out;
|
|
78
117
|
};
|
|
79
118
|
|
|
@@ -82,10 +121,22 @@ let trimEnd = (cur) => {
|
|
|
82
121
|
while (cur.length && cur[cur.length - 1].space) cur.pop();
|
|
83
122
|
};
|
|
84
123
|
|
|
85
|
-
|
|
124
|
+
// Whether two stretches draw identically, and so may be one piece. Colours
|
|
125
|
+
// compare channel-wise because `col()` mints a fresh object per read and
|
|
126
|
+
// interns nothing, so identity would split every run in two.
|
|
127
|
+
/** @type {(a: Look, b: Look) => boolean} */
|
|
128
|
+
let sameFace = (a, b) => a.font === b.font && a.size === b.size;
|
|
129
|
+
/** @type {(a: Look, b: Look) => boolean} */
|
|
130
|
+
let sameInk = (a, b) => sameCol(a.color, b.color) && sameCol(a.bg, b.bg);
|
|
131
|
+
/** @type {(a: Look, b: Look) => boolean} */
|
|
132
|
+
let sameRule = (a, b) => a.underline === b.underline && a.strikethrough === b.strikethrough;
|
|
133
|
+
/** @type {(a: Look, b: Look) => boolean} */
|
|
134
|
+
let sameLook = (a, b) => sameFace(a, b) && sameInk(a, b) && sameRule(a, b);
|
|
135
|
+
|
|
136
|
+
/** @type {(pieces: Piece[], atom: Atom, atomWidth: number) => void} */
|
|
86
137
|
let mergeAtom = (pieces, atom, atomWidth) => {
|
|
87
138
|
let last = pieces[pieces.length - 1];
|
|
88
|
-
if (last) {
|
|
139
|
+
if (last && sameLook(last, atom)) {
|
|
89
140
|
last.text += atom.text;
|
|
90
141
|
last.w += atomWidth;
|
|
91
142
|
return;
|
|
@@ -95,6 +146,9 @@ let mergeAtom = (pieces, atom, atomWidth) => {
|
|
|
95
146
|
font: atom.font,
|
|
96
147
|
size: atom.size,
|
|
97
148
|
color: atom.color,
|
|
149
|
+
bg: atom.bg,
|
|
150
|
+
underline: atom.underline,
|
|
151
|
+
strikethrough: atom.strikethrough,
|
|
98
152
|
w: atomWidth,
|
|
99
153
|
});
|
|
100
154
|
};
|
|
@@ -219,9 +273,9 @@ let placeAtom = (state, atom, avail) => {
|
|
|
219
273
|
};
|
|
220
274
|
|
|
221
275
|
// Greedy wrap against `avail`: spaces never start a line, an over-wide word
|
|
222
|
-
// breaks by character, hard breaks always break.
|
|
223
|
-
//
|
|
224
|
-
// empty-run height — a blank item, a hard-break hole.
|
|
276
|
+
// breaks by character, hard breaks always break. Atoms that draw alike merge
|
|
277
|
+
// into one piece, so a cell with no styled runs is still one piece per line.
|
|
278
|
+
// `size` is the empty-run height — a blank item, a hard-break hole.
|
|
225
279
|
/** @type {(list: Atom[], avail: number, size: number) => Line[]} */
|
|
226
280
|
let wrap = (list, avail, size) => {
|
|
227
281
|
/** @type {Wrap} */
|
|
@@ -231,22 +285,7 @@ let wrap = (list, avail, size) => {
|
|
|
231
285
|
return state.lines;
|
|
232
286
|
};
|
|
233
287
|
|
|
234
|
-
// Stamp cell-level decorations onto every wrapped fragment. Measurement does
|
|
235
|
-
// not need them; drawing does, and wrapping must not lose them.
|
|
236
|
-
/** @type {(line: Line, underline: boolean, strikethrough: boolean) => void} */
|
|
237
|
-
let stamp = (line, underline, strikethrough) => {
|
|
238
|
-
if (underline) line.underline = true;
|
|
239
|
-
if (strikethrough) line.strikethrough = true;
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
/** @type {(lines: Line[], style: any) => Line[]} */
|
|
243
|
-
let dress = (lines, style) => {
|
|
244
|
-
if (!dressed(style)) return lines;
|
|
245
|
-
for (let line of lines) stamp(line, !!style.underline, !!style.strikethrough);
|
|
246
|
-
return lines;
|
|
247
|
-
};
|
|
248
|
-
|
|
249
288
|
/** @type {(lines: Line[]) => number} */
|
|
250
289
|
let heightOf = (lines) => lines.reduce((total, line) => total + line.h, 0);
|
|
251
290
|
|
|
252
|
-
export { atoms,
|
|
291
|
+
export { atoms, heightOf, wrap };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quario/layout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The paged display list for quario — the layout the PDF target writes and the viewer paints — in the makings, not yet released",
|
|
5
5
|
"homepage": "https://getquario.com",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
44
44
|
"@types/fontkit": "^2.0.9",
|
|
45
45
|
"fontkit": "^2.0.4",
|
|
46
|
-
"quario": "^0.
|
|
46
|
+
"quario": "^0.8.0",
|
|
47
47
|
"size-limit": "^13.0.3",
|
|
48
48
|
"typescript": "^7.0.2"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"fontkit": "^2.0.4",
|
|
52
|
-
"quario": "^0.
|
|
52
|
+
"quario": "^0.8.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"fontkit": {
|