@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/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. `fonts` is the host's font mapping, the same
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 is
243
- * filled at that call's own `scale`, not the size it now has. A caller that
244
- * repaints one canvas at changing scales owns that, by not letting a
245
- * superseded call reach a canvas still on screen — which is what the viewer's
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 the layout
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
- ctx.setTransform(scale, 0, 0, scale, 0, 0);
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, page.width, page.height);
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; the XLSX target carries the same two,
79
- // byte-identical, in `packages/xlsx/lib/index.js`, and they move together
80
- // (SCHEMA.md states the pair for both). A target can only import public engine helpers, so there is
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. `@quario/html/style.css` is what supplies them there; keep the three in
88
- // agreement.
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
- // Whether a resolved style asks for either text decoration -- one reading for
101
- // the wrapper that stamps it on a line and the canvas that draws it.
102
- /** @type {(style: any) => boolean} */
103
- let dressed = (style) => !!(style && (style.underline || style.strikethrough));
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
- dressed,
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, dressed, sizeOf, upper } from "./style.js";
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 {{ text: string, font: any, size: number, color: any,
19
- * space: boolean, hard: boolean }} Atom
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: { text: string, font: any, size: number, color: any,
23
- * w: number }[], w: number, h: number, size: number, asc: number,
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[], font: any, size: number, color: any, line: string) => void} */
44
- let pushParts = (out, font, size, color, line) => {
45
- for (let part of printable(font, line).split(/( +)/).filter(Boolean))
46
- out.push({ text: part, font, size, color, space: part[0] === " ", hard: false });
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[], font: any, size: number, color: any, i: number, line: string) => void} */
50
- let pushLine = (out, font, size, color, i, line) => {
51
- if (i) out.push({ text: "", font, size, color, space: false, hard: true });
52
- pushParts(out, font, size, color, line);
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) => { font: any, size: number, color: 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
- // Flatten a cell's tokens to word/space atoms carrying the cell's resolved
66
- // typography one face, size and colour for the whole cell. CR, LF, and
67
- // CRLF are one hard break each (SCHEMA.md, Cell values).
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 { font, size, color } = look(settings, style);
72
- for (let token of tokens)
73
- for (let [i, line] of cased(rawOf(token, style, settings), style)
74
- .split(/\r\n|\r|\n/)
75
- .entries())
76
- pushLine(out, font, size, color, i, line);
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
- /** @type {(pieces: Line['pieces'], atom: Atom, atomWidth: number) => void} */
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. A cell's atoms share one
223
- // typography, so a line's atoms merge into a single draw piece. `size` is the
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, dress, heightOf, wrap };
291
+ export { atoms, heightOf, wrap };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/layout",
3
- "version": "0.3.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.6.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.6.0"
52
+ "quario": "^0.8.0"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "fontkit": {