@quario/layout 0.3.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 +25 -0
- package/lib/canvas.js +45 -16
- package/lib/fonts.js +19 -9
- package/lib/index.d.ts +10 -0
- package/lib/index.js +1 -0
- package/lib/layout.js +56 -264
- package/lib/measure.js +263 -0
- package/lib/style.js +28 -11
- package/lib/text.js +85 -46
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Per-run typography, decoration and highlights.** A styled run resolves its
|
|
15
|
+
own face, size and colour, and a line takes the largest size among its runs.
|
|
16
|
+
`underline` and `strikethrough` are drawn per run — over that run's width, in
|
|
17
|
+
that run's colour — where they used to stroke the whole line in the first
|
|
18
|
+
piece's colour. A run's `background` paints a highlight rectangle behind its
|
|
19
|
+
text; a cell's own background is still painted once, at cell scope.
|
|
20
|
+
|
|
21
|
+
- **`checkFonts` is public.** It checks the shape of a `fonts` mapping without
|
|
22
|
+
loading a parser, and takes an optional name to prefix a failure with, the
|
|
23
|
+
way `pageBox` does — so a surface validating its own `fonts` property can
|
|
24
|
+
report the mistake against that property rather than against
|
|
25
|
+
`options.fonts`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **An outline `Mark` carries only what it declares.** Every mark shipped an
|
|
30
|
+
extra `titled` boolean — bookkeeping for whether a header had claimed the
|
|
31
|
+
entry — which was never part of the `Mark` interface and which nothing
|
|
32
|
+
reads. It is the open group instance's own state now, and no longer travels
|
|
33
|
+
on the object a consumer receives.
|
|
34
|
+
|
|
10
35
|
## [0.3.0] - 2026-09-07
|
|
11
36
|
|
|
12
37
|
### Added
|
package/lib/canvas.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* `y` descending, the way a screen reads. Points throughout.
|
|
25
25
|
*/
|
|
26
26
|
import { baseSans } from "./fonts.js";
|
|
27
|
-
import { BLACK,
|
|
27
|
+
import { BLACK, shift } from "./style.js";
|
|
28
28
|
|
|
29
29
|
/** @typedef {import('./page.js').Frame} Frame */
|
|
30
30
|
/** @typedef {import('./text.js').Line} Line */
|
|
@@ -71,27 +71,55 @@ import { BLACK, dressed, shift } from "./style.js";
|
|
|
71
71
|
*/
|
|
72
72
|
let blank = (box, render) => ({ ...box, settings: render, y: 0, fresh: true });
|
|
73
73
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
// A styled run's `background`, painted behind its text as a highlight over
|
|
75
|
+
// that run's width and the line's full height -- the same rectangle a cell's
|
|
76
|
+
// own background is, one stretch narrower. All of a line's highlights are
|
|
77
|
+
// painted before any of its glyphs, so a rectangle behind one run can never
|
|
78
|
+
// land on top of the run before it. Shared with the layout suite's recorder
|
|
79
|
+
// for the reason `decorateLine` is.
|
|
80
|
+
/**
|
|
81
|
+
* @type {(rect: (color: Color, x: number, y: number, w: number, h: number) => void,
|
|
82
|
+
* line: Line, left: number, yTop: number) => void}
|
|
83
|
+
*/
|
|
84
|
+
let highlightLine = (rect, line, left, yTop) => {
|
|
85
|
+
let cursor = left;
|
|
86
|
+
for (let piece of line.pieces) {
|
|
87
|
+
if (piece.bg) rect(piece.bg, cursor, yTop - line.h, piece.w, line.h);
|
|
88
|
+
cursor += piece.w;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
80
91
|
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
// Text decoration, drawn per piece: over that piece's width, in that piece's
|
|
93
|
+
// colour. A rule across the whole line in the first piece's colour is what
|
|
94
|
+
// this used to be, and it is exactly what "underline a word" cannot mean
|
|
95
|
+
// (ADR 0061). Thickness and offset stay the line's, from the ascender already
|
|
96
|
+
// measured for baseline placement, so one line's rules sit at one height
|
|
97
|
+
// whatever sizes it mixes. Empty lines (no width) draw nothing. Shared by the
|
|
98
|
+
// listing adapter and the layout suite's recorder so both exercise the same
|
|
99
|
+
// path.
|
|
100
|
+
/**
|
|
101
|
+
* @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: Color) => void,
|
|
102
|
+
* piece: Line['pieces'][number], line: Line, left: number, baseline: number) => void}
|
|
103
|
+
*/
|
|
104
|
+
let decoratePiece = (stroke, piece, line, left, baseline) => {
|
|
105
|
+
let thickness = Math.max(line.asc / 12, 0.5);
|
|
106
|
+
let right = left + piece.w;
|
|
107
|
+
let color = piece.color || BLACK;
|
|
108
|
+
if (piece.underline) stroke(left, right, baseline - line.asc * 0.12, thickness, color);
|
|
109
|
+
if (piece.strikethrough) stroke(left, right, baseline + line.asc * 0.35, thickness, color);
|
|
110
|
+
};
|
|
83
111
|
|
|
84
112
|
/**
|
|
85
113
|
* @type {(stroke: (x1: number, x2: number, y: number, thickness: number, color: Color) => void,
|
|
86
114
|
* line: Line, left: number, baseline: number) => void}
|
|
87
115
|
*/
|
|
88
116
|
let decorateLine = (stroke, line, left, baseline) => {
|
|
89
|
-
if (!
|
|
90
|
-
let
|
|
91
|
-
let
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
if (!line.w) return;
|
|
118
|
+
let cursor = left;
|
|
119
|
+
for (let piece of line.pieces) {
|
|
120
|
+
decoratePiece(stroke, piece, line, cursor, baseline);
|
|
121
|
+
cursor += piece.w;
|
|
122
|
+
}
|
|
95
123
|
};
|
|
96
124
|
|
|
97
125
|
/**
|
|
@@ -172,6 +200,7 @@ let listing = (box, render) => {
|
|
|
172
200
|
/** @type {Canvas['drawLine']} */
|
|
173
201
|
let drawLine = (line, x, yTop, avail, align) => {
|
|
174
202
|
let left = x + shift(align, avail - line.w);
|
|
203
|
+
highlightLine(rect, line, left, yTop);
|
|
175
204
|
let cursor = left;
|
|
176
205
|
for (let piece of line.pieces) {
|
|
177
206
|
writePiece(piece, line, cursor, yTop);
|
|
@@ -307,4 +336,4 @@ let stamp = (canvas, text) => {
|
|
|
307
336
|
};
|
|
308
337
|
};
|
|
309
338
|
|
|
310
|
-
export { adopt, decorateLine, listing, measuring, stamp };
|
|
339
|
+
export { adopt, decorateLine, highlightLine, listing, measuring, stamp };
|
package/lib/fonts.js
CHANGED
|
@@ -161,9 +161,9 @@ let useFontkit = async () => {
|
|
|
161
161
|
}
|
|
162
162
|
};
|
|
163
163
|
|
|
164
|
-
/** @type {(name: string, def: any) => string} */
|
|
165
|
-
let asFamily = (name, def) => {
|
|
166
|
-
let path = "
|
|
164
|
+
/** @type {(name: string, def: any, at: string) => string} */
|
|
165
|
+
let asFamily = (name, def, at) => {
|
|
166
|
+
let path = at + "." + name;
|
|
167
167
|
if (!def || typeof def !== "object")
|
|
168
168
|
throw Error(path + ": expected { regular, bold?, italic?, boldItalic? }");
|
|
169
169
|
if (def.regular == null) throw Error(path + ".regular: required");
|
|
@@ -241,7 +241,9 @@ let remembered = (source, key, make) => {
|
|
|
241
241
|
// resolvable once the family is named.
|
|
242
242
|
/** @type {(fontkit: any, name: string, def: any) => Face[]} */
|
|
243
243
|
let familyOf = (fontkit, name, def) => {
|
|
244
|
-
|
|
244
|
+
// Always the factory's own option here: loading happens behind `layout()`,
|
|
245
|
+
// and a surface that named it something else has already validated it.
|
|
246
|
+
let path = asFamily(name, def, "options.fonts");
|
|
245
247
|
let family = name.toLowerCase();
|
|
246
248
|
/** @type {Face[]} */
|
|
247
249
|
let faces = [];
|
|
@@ -278,15 +280,23 @@ export async function loadFonts(custom) {
|
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/**
|
|
281
|
-
* Check the shape of a host's font mapping without loading a parser
|
|
282
|
-
*
|
|
283
|
+
* Check the shape of a host's font mapping without loading a parser.
|
|
284
|
+
*
|
|
285
|
+
* `at` names the property in the message, the way `pageBox` takes one: the
|
|
286
|
+
* factory call reports `options.fonts`, but a surface element validating its
|
|
287
|
+
* own `fonts` property before any render has a different name for the same
|
|
288
|
+
* value, and a host mistake is named on the property that carries it.
|
|
289
|
+
*
|
|
290
|
+
* Only the shape. A face that will not parse, or a missing parser, is found
|
|
291
|
+
* while the report is measured and is a render failure there.
|
|
283
292
|
*
|
|
284
293
|
* @param {any} custom `options.fonts`, or null.
|
|
294
|
+
* @param {string} [at] What to call it in the message.
|
|
285
295
|
*/
|
|
286
|
-
export let checkFonts = (custom) => {
|
|
296
|
+
export let checkFonts = (custom, at = "options.fonts") => {
|
|
287
297
|
if (custom == null) return;
|
|
288
|
-
if (typeof custom !== "object") throw Error("
|
|
289
|
-
for (let [name, def] of Object.entries(custom)) asFamily(name, def);
|
|
298
|
+
if (typeof custom !== "object") throw Error(at + ": expected a record of families");
|
|
299
|
+
for (let [name, def] of Object.entries(custom)) asFamily(name, def, at);
|
|
290
300
|
};
|
|
291
301
|
|
|
292
302
|
// What a declared `family` normalises to before it is looked up: lower case,
|
package/lib/index.d.ts
CHANGED
|
@@ -232,3 +232,13 @@ export function pageBox(
|
|
|
232
232
|
page?: LayoutPage,
|
|
233
233
|
at?: string,
|
|
234
234
|
): { width: number; height: number; margin: number };
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Check the shape of a `fonts` mapping, throwing where it is malformed. `at`
|
|
238
|
+
* prefixes a failure with the option's name, so a surface validating its own
|
|
239
|
+
* `fonts` property names that rather than `options.fonts`.
|
|
240
|
+
*
|
|
241
|
+
* Only the shape: a face that will not parse, or a missing parser, is found
|
|
242
|
+
* while the report is measured and is a render failure there.
|
|
243
|
+
*/
|
|
244
|
+
export function checkFonts(fonts?: LayoutFonts | null, at?: string): void;
|
package/lib/index.js
CHANGED
|
@@ -29,6 +29,7 @@ import { settings } from "./settings.js";
|
|
|
29
29
|
|
|
30
30
|
export { PX_PER_POINT, hit, paint } from "./paint.js";
|
|
31
31
|
export { pageBox } from "./page.js";
|
|
32
|
+
export { checkFonts } from "./fonts.js";
|
|
32
33
|
|
|
33
34
|
// The options are described once, in the hand-written public declarations, and
|
|
34
35
|
// read back here — a second copy in JSDoc is a copy that drifts.
|
package/lib/layout.js
CHANGED
|
@@ -23,12 +23,33 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { display, imageError, isReportBand, text } from "quario";
|
|
25
25
|
import { balance } from "./balance.js";
|
|
26
|
+
/** `Measured` is the measured table's own type, and the flow passes one
|
|
27
|
+
* through: the region buffer measures early and the replay hands it back.
|
|
28
|
+
* @import { Measured, TableRow } from './measure.js' */
|
|
29
|
+
|
|
30
|
+
/** One table being emitted: what `carryOver`, `sliced` and `put` all need. It
|
|
31
|
+
* holds the flow, so it is the band flow's own — the measured half never sees
|
|
32
|
+
* a cursor.
|
|
33
|
+
* @typedef {{ state: Flow, head: TableRow,
|
|
34
|
+
* xOffsets: number[], widths: number[], x: number, avail: number }} Grid */
|
|
35
|
+
import { gridOf, measureTable, sum, tableOf } from "./measure.js";
|
|
26
36
|
import { adopt, measuring } from "./canvas.js";
|
|
27
37
|
import { frame } from "./page.js";
|
|
28
38
|
import { intrinsic } from "./image.js";
|
|
29
39
|
import { CELL_PAD, NO_PAD, WHOLE, insetOf, isWhole, paintBox, sliceInset, unbox } from "./box.js";
|
|
30
|
-
import {
|
|
31
|
-
|
|
40
|
+
import {
|
|
41
|
+
BAND,
|
|
42
|
+
col,
|
|
43
|
+
GUTTER,
|
|
44
|
+
instanceGap,
|
|
45
|
+
merge,
|
|
46
|
+
roled,
|
|
47
|
+
rowHeight,
|
|
48
|
+
shift,
|
|
49
|
+
sizeOf,
|
|
50
|
+
vshift,
|
|
51
|
+
} from "./style.js";
|
|
52
|
+
import { atoms, heightOf, wrap } from "./text.js";
|
|
32
53
|
|
|
33
54
|
/** @typedef {import('./balance.js').Unit} Unit */
|
|
34
55
|
/** @typedef {import('./canvas.js').Canvas} Canvas */
|
|
@@ -45,7 +66,7 @@ import { atoms, dress, heightOf, wrap } from "./text.js";
|
|
|
45
66
|
// indexes where a `reset: "page"` sequence begins (the document itself is the
|
|
46
67
|
// sequence that starts at page 0).
|
|
47
68
|
/**
|
|
48
|
-
* @typedef {{
|
|
69
|
+
* @typedef {{ pending: any[], blocks: Block[], gap: number, skip: boolean, titled: boolean }} Group
|
|
49
70
|
*/
|
|
50
71
|
/**
|
|
51
72
|
* @typedef {{ canvas: Canvas, open: Group[], gap: number, marks: any[],
|
|
@@ -149,7 +170,7 @@ let blockOf = (canvas, event, avail, under = null) => {
|
|
|
149
170
|
};
|
|
150
171
|
}
|
|
151
172
|
let size = sizeOf(text, canvas.settings.base);
|
|
152
|
-
let lines =
|
|
173
|
+
let lines = wrap(atoms(canvas.settings, event.tokens, text), inner, size);
|
|
153
174
|
return {
|
|
154
175
|
lines,
|
|
155
176
|
bg,
|
|
@@ -686,7 +707,7 @@ let stripWidth = (state, count) => (state.canvas.content - GUTTER * (count - 1))
|
|
|
686
707
|
//
|
|
687
708
|
// It opens buffering: what the strips do with the content depends on how much
|
|
688
709
|
// of it there is, and that is not known until the region ends or outgrows a
|
|
689
|
-
// page. `held` is that buffer; `null` means the region has
|
|
710
|
+
// page. `held` is that buffer; `null` means the region has decided.
|
|
690
711
|
/** @type {(state: Flow) => void} */
|
|
691
712
|
let openRegion = (state) => {
|
|
692
713
|
let owed = state.pending;
|
|
@@ -808,13 +829,15 @@ let stripHeight = (state, region, own) =>
|
|
|
808
829
|
? null
|
|
809
830
|
: balance(unitsOf(state, own), region.count, roomOf(state, region), rowHeight(state.canvas));
|
|
810
831
|
|
|
811
|
-
//
|
|
832
|
+
// Decide the region: settle the strip height, stop holding, and hand the held
|
|
812
833
|
// entries back in the order they arrived, for `flow`'s `replay` to walk.
|
|
834
|
+
// Not `commit`: the editor's session already spends that word on a gesture
|
|
835
|
+
// ending, and git spends it on a commit.
|
|
813
836
|
//
|
|
814
837
|
// Marking the spans is also what picks out the region's own children, and the
|
|
815
838
|
// height is decided from those — so it runs before the replay, not with it.
|
|
816
839
|
/** @type {(state: Flow) => any[]} */
|
|
817
|
-
let
|
|
840
|
+
let decide = (state) => {
|
|
818
841
|
let region = /** @type {Region} */ (state.region);
|
|
819
842
|
let held = /** @type {any[]} */ (region.held);
|
|
820
843
|
region.height = stripHeight(state, region, spans(held));
|
|
@@ -1031,7 +1054,7 @@ let anchor = (state) => {
|
|
|
1031
1054
|
};
|
|
1032
1055
|
|
|
1033
1056
|
/** @type {(state: Flow) => Group[]} */
|
|
1034
|
-
let pending = (state) => state.open.filter((group) => group.
|
|
1057
|
+
let pending = (state) => state.open.filter((group) => group.pending.length);
|
|
1035
1058
|
|
|
1036
1059
|
/** @type {(state: Flow, extra: number, runs: Block[][]) => number} */
|
|
1037
1060
|
let needed = (state, extra, runs) =>
|
|
@@ -1042,12 +1065,12 @@ let needed = (state, extra, runs) =>
|
|
|
1042
1065
|
// for good: every other path drew its headers inside the room a page still
|
|
1043
1066
|
// had, so a run always leaves space for the content it introduces.
|
|
1044
1067
|
/** @type {(state: Flow, holding: Group[]) => void} */
|
|
1045
|
-
let
|
|
1046
|
-
let
|
|
1047
|
-
for (let group of holding) group.
|
|
1068
|
+
let dropPending = (state, holding) => {
|
|
1069
|
+
let waiting = holding.flatMap((group) => group.pending);
|
|
1070
|
+
for (let group of holding) group.pending = [];
|
|
1048
1071
|
state.gap = 0;
|
|
1049
1072
|
anchor(state);
|
|
1050
|
-
for (let entry of
|
|
1073
|
+
for (let entry of waiting) item(state, entry.event, entry.block);
|
|
1051
1074
|
};
|
|
1052
1075
|
|
|
1053
1076
|
/** @type {(state: Flow, need: number) => void} */
|
|
@@ -1065,12 +1088,12 @@ let settle = (state, need) => {
|
|
|
1065
1088
|
// — the turn above already did — so an instance taking its blocks the moment
|
|
1066
1089
|
// they are drawn can never have them replayed on top of themselves.
|
|
1067
1090
|
/** @type {(state: Flow, holding: Group[], runs: Block[][], avail: number) => void} */
|
|
1068
|
-
let
|
|
1091
|
+
let drawPending = (state, holding, runs, avail) => {
|
|
1069
1092
|
let x = originOf(state);
|
|
1070
1093
|
for (let [i, group] of holding.entries()) {
|
|
1071
1094
|
for (let block of runs[i]) drawBlock(state.canvas, block, x, avail);
|
|
1072
1095
|
group.blocks.push(...runs[i]);
|
|
1073
|
-
group.
|
|
1096
|
+
group.pending = [];
|
|
1074
1097
|
}
|
|
1075
1098
|
};
|
|
1076
1099
|
|
|
@@ -1086,14 +1109,14 @@ let flush = (state, extra) => {
|
|
|
1086
1109
|
// at this same width, so a replayed run reuses those rather than wrapping
|
|
1087
1110
|
// the same text a second time (ADR 0027).
|
|
1088
1111
|
let runs = runsFor.map((group) =>
|
|
1089
|
-
group.
|
|
1112
|
+
group.pending.map((entry) => entry.block || blockOf(canvas, entry.event, avail)),
|
|
1090
1113
|
);
|
|
1091
1114
|
let need = needed(state, extra, runs);
|
|
1092
1115
|
// Against what a fresh column has left once the page has replayed what it
|
|
1093
1116
|
// carries. Inside a region that is a strip, and the replay sits above it.
|
|
1094
|
-
if (need > ceilOf(state) - carried(state) - floorOf(state)) return
|
|
1117
|
+
if (need > ceilOf(state) - carried(state) - floorOf(state)) return dropPending(state, runsFor);
|
|
1095
1118
|
settle(state, need);
|
|
1096
|
-
|
|
1119
|
+
drawPending(state, runsFor, runs, avail);
|
|
1097
1120
|
};
|
|
1098
1121
|
|
|
1099
1122
|
// Enough of an item that its group header is never left introducing nothing.
|
|
@@ -1126,7 +1149,7 @@ let keepBlock = (state, event, width) =>
|
|
|
1126
1149
|
// Is anything waiting on this item's arrival — a header run, or the gap an
|
|
1127
1150
|
// instance opens with?
|
|
1128
1151
|
/** @type {(state: Flow) => boolean} */
|
|
1129
|
-
let holding = (state) => state.open.some((group) => group.
|
|
1152
|
+
let holding = (state) => state.open.some((group) => group.pending.length) || state.gap > 0;
|
|
1130
1153
|
|
|
1131
1154
|
// A half-line gap before each instance (dropped at a page top by `flush`)
|
|
1132
1155
|
// keeps groups reading as blocks. Every instance becomes an outline entry.
|
|
@@ -1159,7 +1182,7 @@ let openGroup = (state, event) => {
|
|
|
1159
1182
|
let skip = state.skipGap;
|
|
1160
1183
|
state.gap = skip ? 0 : Math.max(state.gap, instanceGap(state.canvas));
|
|
1161
1184
|
state.skipGap = false;
|
|
1162
|
-
state.open.push({
|
|
1185
|
+
state.open.push({ pending: [], blocks: [], gap, skip, titled: false });
|
|
1163
1186
|
state.marks.push(markFor(event));
|
|
1164
1187
|
};
|
|
1165
1188
|
|
|
@@ -1195,7 +1218,6 @@ let markFor = (event) => ({
|
|
|
1195
1218
|
// display(), not String(): a Date group key must title its outline bookmark
|
|
1196
1219
|
// with the same ISO 8601 UTC text its cells render, on every machine.
|
|
1197
1220
|
title: event.name + ": " + display(event.key),
|
|
1198
|
-
titled: false,
|
|
1199
1221
|
depth: event.depth,
|
|
1200
1222
|
page: -1,
|
|
1201
1223
|
y: 0,
|
|
@@ -1205,24 +1227,24 @@ let markFor = (event) => ({
|
|
|
1205
1227
|
// page bottom. The instance's first header titles its outline entry.
|
|
1206
1228
|
/** @type {(state: Flow, event: any, title: string | null, block?: Block | null) => void} */
|
|
1207
1229
|
let holdHeader = (state, event, title, block = null) => {
|
|
1230
|
+
let group = state.open[state.open.length - 1];
|
|
1208
1231
|
let mark = state.marks[state.marks.length - 1];
|
|
1232
|
+
// Whether a header has claimed this entry is the open instance's own fact,
|
|
1233
|
+
// not the outline entry's: a Mark is what a consumer receives, and it
|
|
1234
|
+
// carries only what `index.d.ts` declares. The two records correspond here
|
|
1235
|
+
// because `openGroup` pushes them together and this only ever runs inside
|
|
1236
|
+
// that instance's own header band, before any nested group opens -- groups
|
|
1237
|
+
// pop from `open` while their marks stay, so nowhere else may assume it.
|
|
1238
|
+
//
|
|
1209
1239
|
// A picture has no words to title an outline entry with, so it holds its
|
|
1210
1240
|
// place in the run and leaves the title to the next header that has some.
|
|
1211
|
-
if (mark && !
|
|
1212
|
-
|
|
1241
|
+
if (mark && !group.titled && title != null) {
|
|
1242
|
+
group.titled = true;
|
|
1213
1243
|
mark.title = title;
|
|
1214
1244
|
}
|
|
1215
|
-
|
|
1245
|
+
group.pending.push({ event, block });
|
|
1216
1246
|
};
|
|
1217
1247
|
|
|
1218
|
-
// A table row's floor height and the gap a group instance opens with: named
|
|
1219
|
-
// because the region's estimator reads both, and a constant with two readers
|
|
1220
|
-
// and no name drifts between them.
|
|
1221
|
-
/** @type {(canvas: Canvas) => number} */
|
|
1222
|
-
let rowHeight = (canvas) => LEAD * canvas.settings.base;
|
|
1223
|
-
/** @type {(canvas: Canvas) => number} */
|
|
1224
|
-
let instanceGap = (canvas) => 0.5 * LEAD * canvas.settings.base;
|
|
1225
|
-
|
|
1226
1248
|
// What a fresh strip has to hold to take an instance: the span less the
|
|
1227
1249
|
// opening gap, which `settle` drops at a strip head. This is the one fact the
|
|
1228
1250
|
// balance model and the placement rule have to agree on, so both read it here
|
|
@@ -1232,60 +1254,6 @@ let headOf = (canvas, span) => span - instanceGap(canvas);
|
|
|
1232
1254
|
|
|
1233
1255
|
// --- tables -----------------------------------------------------------------
|
|
1234
1256
|
|
|
1235
|
-
// Pre-measure one cell: wrapped lines per column width come later; natural
|
|
1236
|
-
// width first (no wrapping except hard breaks). `mt`/`mb` go unread: SCHEMA.md
|
|
1237
|
-
// gives flow spacing no meaning inside a table row, in either target.
|
|
1238
|
-
/** @type {(canvas: Canvas, cell: any, rowStyle: any) => any} */
|
|
1239
|
-
let cellOf = (canvas, cell, rowStyle) => {
|
|
1240
|
-
// A row's block reaches its cells with no box in it: the engine resolved
|
|
1241
|
-
// that half onto the cells themselves before the event was emitted
|
|
1242
|
-
// (SCHEMA.md, "Style declarations"), which is why the inset and the ink
|
|
1243
|
-
// both read the cell and a row is never asked for a box it cannot have.
|
|
1244
|
-
let style = merge(rowStyle, unbox(cell.style));
|
|
1245
|
-
let inset = insetOf(cell.style, CELL_PAD);
|
|
1246
|
-
let list = atoms(canvas.settings, cell.tokens, style);
|
|
1247
|
-
let natural = wrap(list, Infinity, sizeOf(style, canvas.settings.base)).reduce(
|
|
1248
|
-
(widest, line) => Math.max(widest, line.w),
|
|
1249
|
-
0,
|
|
1250
|
-
);
|
|
1251
|
-
return {
|
|
1252
|
-
list,
|
|
1253
|
-
natural: natural + inset.l + inset.r,
|
|
1254
|
-
align: style.align,
|
|
1255
|
-
valign: style.valign,
|
|
1256
|
-
bg: col(style.background),
|
|
1257
|
-
underline: !!style.underline,
|
|
1258
|
-
strikethrough: !!style.strikethrough,
|
|
1259
|
-
inset,
|
|
1260
|
-
style: cell.style,
|
|
1261
|
-
path: cell.path,
|
|
1262
|
-
};
|
|
1263
|
-
};
|
|
1264
|
-
|
|
1265
|
-
/** @type {(canvas: Canvas, cells: any[], widths: number[]) => { cells: any[], h: number }} */
|
|
1266
|
-
let rowOf = (canvas, cells, widths) => {
|
|
1267
|
-
let h = rowHeight(canvas);
|
|
1268
|
-
/** @type {number[]} */
|
|
1269
|
-
let owns = [];
|
|
1270
|
-
let out = cells.map((cell, i) => {
|
|
1271
|
-
let inner = Math.max(widths[i] - cell.inset.l - cell.inset.r, 1);
|
|
1272
|
-
// A glyph or a hard break occupies; spaces alone are an empty cell.
|
|
1273
|
-
let lines = cell.list.some(
|
|
1274
|
-
(/** @type {{ hard: boolean, space: boolean }} */ atom) => atom.hard || !atom.space,
|
|
1275
|
-
)
|
|
1276
|
-
? dress(wrap(cell.list, inner, cell.list[0].size), cell)
|
|
1277
|
-
: [];
|
|
1278
|
-
let own = heightOf(lines) + cell.inset.t + cell.inset.b;
|
|
1279
|
-
if (own > h) h = own;
|
|
1280
|
-
owns.push(own);
|
|
1281
|
-
return { ...cell, lines };
|
|
1282
|
-
});
|
|
1283
|
-
// The row's height is known only now, so the slack each cell's `valign`
|
|
1284
|
-
// reads is measured here, as `splitBlock` measures a slot's.
|
|
1285
|
-
for (let [i, cell] of out.entries()) cell.drop = vshift(cell.valign, h - owns[i]);
|
|
1286
|
-
return { cells: out, h };
|
|
1287
|
-
};
|
|
1288
|
-
|
|
1289
1257
|
/** @type {(canvas: Canvas, cell: any, bg: any, x: number, y: number, w: number, h: number) => void} */
|
|
1290
1258
|
let fillCell = (canvas, cell, bg, x, y, w, h) => {
|
|
1291
1259
|
if (cell.bg && cell.bg !== bg) canvas.rect(cell.bg, x, y, w, h);
|
|
@@ -1345,107 +1313,7 @@ let drawRow = (canvas, row, xOffsets, widths, bg) => {
|
|
|
1345
1313
|
);
|
|
1346
1314
|
};
|
|
1347
1315
|
|
|
1348
|
-
// The column geometry one row sees. A cell covering several columns starts
|
|
1349
|
-
// where the first of them starts and is as wide as all of them together; a row
|
|
1350
|
-
// that spans nothing sees the columns themselves, and pays nothing for the
|
|
1351
|
-
// feature. `null` spans is that row -- every data row, and every total row
|
|
1352
|
-
// whose cells each cover one column.
|
|
1353
|
-
/** @type {(cells: any[]) => number[] | null} */
|
|
1354
|
-
let spansOf = (cells) =>
|
|
1355
|
-
cells.some((cell) => cell.span > 1) ? cells.map((cell) => cell.span || 1) : null;
|
|
1356
|
-
// One value per cell, walking the columns each of them covers. Reached only
|
|
1357
|
-
// for a row that spans, so the closure it takes costs nothing per data row.
|
|
1358
|
-
/** @type {(spans: number[], pick: (at: number, span: number) => number) => number[]} */
|
|
1359
|
-
let overSpans = (spans, pick) => {
|
|
1360
|
-
/** @type {number[]} */
|
|
1361
|
-
let out = [];
|
|
1362
|
-
let at = 0;
|
|
1363
|
-
for (let span of spans) {
|
|
1364
|
-
out.push(pick(at, span));
|
|
1365
|
-
at += span;
|
|
1366
|
-
}
|
|
1367
|
-
return out;
|
|
1368
|
-
};
|
|
1369
|
-
/** @type {(widths: number[], spans: number[] | null) => number[]} */
|
|
1370
|
-
let spanWidths = (widths, spans) =>
|
|
1371
|
-
spans ? overSpans(spans, (at, span) => sum(widths.slice(at, at + span))) : widths;
|
|
1372
1316
|
/** @type {(xOffsets: number[], spans: number[] | null) => number[]} */
|
|
1373
|
-
let spanOffsets = (xOffsets, spans) => (spans ? overSpans(spans, (at) => xOffsets[at]) : xOffsets);
|
|
1374
|
-
|
|
1375
|
-
// The widest of each column's header, rows and totals, padding included.
|
|
1376
|
-
//
|
|
1377
|
-
// A cell covering more than one column has no say in their widths (SCHEMA.md):
|
|
1378
|
-
// what a span states is which columns a cell reaches across, never how wide
|
|
1379
|
-
// they are. So a column can end up with no voter at all -- every cell above it
|
|
1380
|
-
// spans over it -- which only an empty table reaches, since a data row never
|
|
1381
|
-
// spans. It opens at the padding floor rather than at nothing, so an empty
|
|
1382
|
-
// table still shows the geometry it promises.
|
|
1383
|
-
/**
|
|
1384
|
-
* @typedef {{ cells: any[], spans: number[] | null }} Voting
|
|
1385
|
-
*/
|
|
1386
|
-
/** @type {(cols: any[], header: Voting, rows: Voting[], totals: Voting[]) => number[]} */
|
|
1387
|
-
let naturalWidths = (cols, header, rows, totals) => {
|
|
1388
|
-
/** @type {(number | null)[]} */
|
|
1389
|
-
let widest = cols.map(() => null);
|
|
1390
|
-
/** @type {(at: number, natural: number) => void} */
|
|
1391
|
-
let widen = (at, natural) => {
|
|
1392
|
-
let held = widest[at];
|
|
1393
|
-
if (held === null || natural > held) widest[at] = natural;
|
|
1394
|
-
};
|
|
1395
|
-
/** @type {(row: Voting) => void} */
|
|
1396
|
-
let vote = (row) => {
|
|
1397
|
-
let at = 0;
|
|
1398
|
-
for (let [i, cell] of row.cells.entries()) {
|
|
1399
|
-
let span = row.spans ? row.spans[i] : 1;
|
|
1400
|
-
if (span === 1) widen(at, cell.natural);
|
|
1401
|
-
at += span;
|
|
1402
|
-
}
|
|
1403
|
-
};
|
|
1404
|
-
vote(header);
|
|
1405
|
-
for (let row of rows) vote(row);
|
|
1406
|
-
for (let row of totals) vote(row);
|
|
1407
|
-
return widest.map((width) => (width === null ? CELL_PAD.l + CELL_PAD.r : width));
|
|
1408
|
-
};
|
|
1409
|
-
|
|
1410
|
-
/** @type {(values: number[]) => number} */
|
|
1411
|
-
let sum = (values) => values.reduce((total, value) => total + value, 0);
|
|
1412
|
-
|
|
1413
|
-
// An authored `width` percentage fixes its column; the rest share what is left
|
|
1414
|
-
// in proportion to their natural widths. Three cases, one return each — the
|
|
1415
|
-
// fall-through is all-authored within budget, honoured exactly.
|
|
1416
|
-
//
|
|
1417
|
-
// There is no over-commitment case: the engine rejects a document whose fixed
|
|
1418
|
-
// shares leave the width-less columns nothing, so `left` is positive whenever
|
|
1419
|
-
// an auto column exists and the fixed shares never exceed the content width.
|
|
1420
|
-
// That invariant is asserted rather than trusted — falling through with auto
|
|
1421
|
-
// columns unplaced would draw them at zero width, an invisible failure — the
|
|
1422
|
-
// same fail-loud posture as the measuring canvas's `newPage`.
|
|
1423
|
-
/** @type {(cols: any[], natural: number[], avail: number) => number[]} */
|
|
1424
|
-
let columnWidths = (cols, natural, avail) => {
|
|
1425
|
-
let fixed = cols.map((column) => Number.isFinite(column.width));
|
|
1426
|
-
if (!fixed.some(Boolean)) {
|
|
1427
|
-
// Natural widths always carry the cell padding, so the sum is never zero.
|
|
1428
|
-
let wanted = sum(natural);
|
|
1429
|
-
return natural.map((width) => (width * avail) / wanted);
|
|
1430
|
-
}
|
|
1431
|
-
let widths = cols.map((col, i) => (fixed[i] ? (col.width / 100) * avail : 0));
|
|
1432
|
-
let auto = natural.map((width, i) => (fixed[i] ? 0 : width));
|
|
1433
|
-
let left = avail - sum(widths);
|
|
1434
|
-
let wanted = sum(auto);
|
|
1435
|
-
// Room for the auto columns: they share what the fixed ones left.
|
|
1436
|
-
if (wanted > 0) {
|
|
1437
|
-
if (left <= 0) throw new Error("over-committed column widths reached the layout");
|
|
1438
|
-
return widths.map((width, i) => (fixed[i] ? width : (auto[i] * left) / wanted));
|
|
1439
|
-
}
|
|
1440
|
-
return widths;
|
|
1441
|
-
};
|
|
1442
|
-
|
|
1443
|
-
// One table being emitted: what `carryOver`, `sliced` and `put` all need.
|
|
1444
|
-
/**
|
|
1445
|
-
* @typedef {{ cells: any[], h: number, style?: any, spans?: number[] | null }} TableRow
|
|
1446
|
-
* @typedef {{ state: Flow, head: TableRow,
|
|
1447
|
-
* xOffsets: number[], widths: number[], x: number, avail: number }} Grid
|
|
1448
|
-
*/
|
|
1449
1317
|
|
|
1450
1318
|
// Continue in a fresh column: a table that spans columns or pages restates its
|
|
1451
1319
|
// headings, under whatever headers the open groups replay above them. Both
|
|
@@ -1458,18 +1326,6 @@ let columnWidths = (cols, natural, avail) => {
|
|
|
1458
1326
|
/** @type {(row: { style?: any }) => any} */
|
|
1459
1327
|
let rowFill = (row) => col((row.style || {}).background);
|
|
1460
1328
|
|
|
1461
|
-
// What this row draws against: the grid's own columns, or the merged geometry
|
|
1462
|
-
// a spanning row sees. Read at draw time rather than kept on the row, because
|
|
1463
|
-
// `rebase` moves the offsets under it every time the table crosses a strip.
|
|
1464
|
-
/** @type {(grid: Grid, row: TableRow) => { xOffsets: number[], widths: number[] }} */
|
|
1465
|
-
let gridOf = (grid, row) =>
|
|
1466
|
-
row.spans
|
|
1467
|
-
? {
|
|
1468
|
-
xOffsets: spanOffsets(grid.xOffsets, row.spans),
|
|
1469
|
-
widths: spanWidths(grid.widths, row.spans),
|
|
1470
|
-
}
|
|
1471
|
-
: grid;
|
|
1472
|
-
|
|
1473
1329
|
/** @type {(grid: Grid) => void} */
|
|
1474
1330
|
let carryOver = (grid) => {
|
|
1475
1331
|
advance(grid.state);
|
|
@@ -1611,70 +1467,6 @@ let emitRows = (grid, laid, totalRows) => {
|
|
|
1611
1467
|
for (let row of totalRows) put(grid, row, rowFill(row), 0);
|
|
1612
1468
|
};
|
|
1613
1469
|
|
|
1614
|
-
// A buffered table, in the one shape `table` lays out: its columns come from
|
|
1615
|
-
// the opening event, its rows are the row events themselves, and its total is
|
|
1616
|
-
// the total row's cells. Both readers build it here — the replay, collecting
|
|
1617
|
-
// events as they arrive, and the region buffer, reading them back off what it
|
|
1618
|
-
// held — so a new table event kind cannot reach one and miss the other.
|
|
1619
|
-
/** @type {(events: any[]) => any} */
|
|
1620
|
-
let tableOf = (events) => {
|
|
1621
|
-
let opening = events[0];
|
|
1622
|
-
return {
|
|
1623
|
-
columns: opening.columns.map((/** @type {any} */ col) => ({ ...col })),
|
|
1624
|
-
// The header row arrives whole (docs/adr/0053), so both readers -- the
|
|
1625
|
-
// replay and the region buffer -- take the same list rather than each
|
|
1626
|
-
// rebuilding it from the columns.
|
|
1627
|
-
header: opening.header.cells,
|
|
1628
|
-
headerStyle: opening.header.style,
|
|
1629
|
-
rows: events.filter((event) => event.type === "row"),
|
|
1630
|
-
// The events themselves, as `rows` are: a total row is a row, and the
|
|
1631
|
-
// buffer files each corrected height under the event it was measured from.
|
|
1632
|
-
totals: events.filter((event) => event.type === "total-row"),
|
|
1633
|
-
};
|
|
1634
|
-
};
|
|
1635
|
-
|
|
1636
|
-
/**
|
|
1637
|
-
* @typedef {{ widths: number[], head: any, laid: any[], totalRows: any[] }} Measured
|
|
1638
|
-
*/
|
|
1639
|
-
// Measure a buffered table at a given width: the column widths every cell has
|
|
1640
|
-
// a say in, then each row laid out against them. Everything here is
|
|
1641
|
-
// arithmetic over the cells, so the region's buffer can ask for it before
|
|
1642
|
-
// anything is drawn — which is the only way a row's real height is known
|
|
1643
|
-
// early enough to balance on (bead `quario-cgk`).
|
|
1644
|
-
/** @type {(canvas: Canvas, buffered: any, avail: number) => Measured} */
|
|
1645
|
-
let measureTable = (canvas, buffered, avail) => {
|
|
1646
|
-
let cols = /** @type {any[]} */ (buffered.columns);
|
|
1647
|
-
/** @type {(cells: any[], style: any, spans: number[] | null) => Voting & { style: any }} */
|
|
1648
|
-
let measured = (cells, style, spans) => ({
|
|
1649
|
-
cells: cells.map((/** @type {any} */ cell) => cellOf(canvas, cell, style)),
|
|
1650
|
-
spans,
|
|
1651
|
-
style,
|
|
1652
|
-
});
|
|
1653
|
-
let header = measured(buffered.header, buffered.headerStyle, spansOf(buffered.header));
|
|
1654
|
-
// A data row's cells are the columns' own, so nothing in one can span
|
|
1655
|
-
// (SCHEMA.md, "Span"). Asking each row anyway would scan every cell of the
|
|
1656
|
-
// table to rediscover what the schema already guarantees.
|
|
1657
|
-
let rows = buffered.rows.map((/** @type {any} */ row) => measured(row.cells, row.style, null));
|
|
1658
|
-
let totals = buffered.totals.map((/** @type {any} */ row) =>
|
|
1659
|
-
measured(row.cells, row.style, spansOf(row.cells)),
|
|
1660
|
-
);
|
|
1661
|
-
let widths = columnWidths(cols, naturalWidths(cols, header, rows, totals), avail);
|
|
1662
|
-
// Each row wraps against the geometry it sees, which is the columns' own
|
|
1663
|
-
// unless one of its cells spans.
|
|
1664
|
-
/** @type {(row: Voting & { style: any }) => any} */
|
|
1665
|
-
let laidOut = (row) => ({
|
|
1666
|
-
...rowOf(canvas, row.cells, spanWidths(widths, row.spans)),
|
|
1667
|
-
spans: row.spans,
|
|
1668
|
-
style: row.style,
|
|
1669
|
-
});
|
|
1670
|
-
return {
|
|
1671
|
-
widths,
|
|
1672
|
-
head: laidOut(header),
|
|
1673
|
-
laid: rows.map(laidOut),
|
|
1674
|
-
totalRows: totals.map(laidOut),
|
|
1675
|
-
};
|
|
1676
|
-
};
|
|
1677
|
-
|
|
1678
1470
|
// Lay out one table from the events it arrived as: measure every cell,
|
|
1679
1471
|
// allocate the columns, then emit the header, the rows and the total,
|
|
1680
1472
|
// breaking pages as needed.
|
|
@@ -1943,7 +1735,7 @@ let flow = (canvas) => {
|
|
|
1943
1735
|
// The nesting the router is at. While the buffer holds, routing runs ahead of
|
|
1944
1736
|
// placement: nothing has been placed, so `state.open` is frozen at the depth
|
|
1945
1737
|
// the region opened at and only the buffer knows where the walk really is.
|
|
1946
|
-
// Once `
|
|
1738
|
+
// Once `decide` nulls `held`, the replay *is* the placement timeline and the
|
|
1947
1739
|
// open stack is exact again.
|
|
1948
1740
|
let nesting = () =>
|
|
1949
1741
|
state.region && state.region.held ? state.region.nesting : state.open.length - 1;
|
|
@@ -1993,14 +1785,14 @@ let flow = (canvas) => {
|
|
|
1993
1785
|
if (buffer(state, event, payload, hollows.has(event))) replay();
|
|
1994
1786
|
};
|
|
1995
1787
|
|
|
1996
|
-
// Lay the buffer out: `
|
|
1788
|
+
// Lay the buffer out: `decide` settles the strip height and hands the held
|
|
1997
1789
|
// entries back in order, and this walks them through `route`. The walk lives
|
|
1998
1790
|
// here rather than under the region section marker for the reason everything
|
|
1999
1791
|
// there is module-level — that section is reachable without the router, and
|
|
2000
1792
|
// a walk through `route` is not.
|
|
2001
1793
|
let replay = () => {
|
|
2002
1794
|
let region = /** @type {Region} */ (state.region);
|
|
2003
|
-
for (let entry of
|
|
1795
|
+
for (let entry of decide(state)) {
|
|
2004
1796
|
if (breaksFor(state, region, entry.span)) advance(state);
|
|
2005
1797
|
route(entry.event, entry.payload);
|
|
2006
1798
|
}
|
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/style.js
CHANGED
|
@@ -75,17 +75,20 @@ let merge = (under, over) => (under ? (over ? { ...under, ...over } : under) : o
|
|
|
75
75
|
|
|
76
76
|
// Band-role omakase defaults — the third of those four layers, over the report
|
|
77
77
|
// 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.
|
|
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.
|
|
82
81
|
//
|
|
83
82
|
// `@quario/html` deliberately carries none of this, nor the leading, padding
|
|
84
83
|
// and band gap below: its consumer has a stylesheet and the `q-*` classes are
|
|
85
84
|
// the seam. The rule is docs/adr/0014-a-target-supplies-defaults-only-where-its-consumer-has-no-seam.md —
|
|
86
85
|
// a target supplies defaults only where its consumer has no seam to supply
|
|
87
|
-
// them.
|
|
88
|
-
//
|
|
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.
|
|
89
92
|
/** @type {Record<string, any>} */
|
|
90
93
|
let ROLES = { "report-header": { bold: true, size: 14 }, "group-header": { bold: true } };
|
|
91
94
|
|
|
@@ -97,10 +100,13 @@ let roled = (event) =>
|
|
|
97
100
|
? { ...event, style: merge(ROLES[event.role], event.style) }
|
|
98
101
|
: event;
|
|
99
102
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
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);
|
|
104
110
|
|
|
105
111
|
// Whether a resolved style asks for capitals. This target has no
|
|
106
112
|
// text-transform to defer to, so the reading is here beside the rest of the
|
|
@@ -108,6 +114,15 @@ let dressed = (style) => !!(style && (style.underline || style.strikethrough));
|
|
|
108
114
|
/** @type {(style: any) => boolean} */
|
|
109
115
|
let upper = (style) => !!(style && style.uppercase);
|
|
110
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
|
+
|
|
111
126
|
export {
|
|
112
127
|
BAND,
|
|
113
128
|
BLACK,
|
|
@@ -116,9 +131,11 @@ export {
|
|
|
116
131
|
PADX,
|
|
117
132
|
PADY,
|
|
118
133
|
col,
|
|
119
|
-
|
|
134
|
+
instanceGap,
|
|
120
135
|
merge,
|
|
121
136
|
roled,
|
|
137
|
+
rowHeight,
|
|
138
|
+
sameCol,
|
|
122
139
|
shift,
|
|
123
140
|
sizeOf,
|
|
124
141
|
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.4.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.7.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.7.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"fontkit": {
|