@quario/html 0.9.0 → 0.10.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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # @quario/html
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **A group's `break` names a position, and `break: "page"` is retired.** Its values are `"before"`, `"between"`, `"after"` and `"around"`. Write `break: "before"` where you wrote `break: "page"`. That is the whole of the migration, and it changes nothing the report produces.
8
+
9
+ The boundary between two consecutive instances always turns a page. `before` adds the leading edge of the run, so the first instance opens a page of its own. `between` adds neither edge, which keeps the first instance on the page the report header opened — the case the old vocabulary could not express, and the reason for the change. `after` adds the trailing edge, and `around` adds both. A trailing edge turns the page for what follows the run, so a report whose last band is that group reads `after` as `between` and `around` as `before`. A nested group takes its two edges from each instance of the group above it, rather than from the document. `"page"` said which unit a break used, where the four say where it falls; one set cannot say both and still read at a glance.
10
+
11
+ **`reset: "page"` turns no page of its own, and now needs a `break` beside it.** It says only that a new `page.number` / `page.total` sequence starts at this instance. A sequence owns whole pages, so `reset` requires `break` to be `"before"` or `"around"`. Any other `break`, and `reset` with no `break` at all, is a definition error the compile reports. Add `break: "before"` to a group that declares `reset` alone today.
12
+
13
+ The HTML target adds `q-break` to an instance whose leading edge turns, as before, and the new `q-break-after` to one whose trailing edge turns. `@quario/html/style.css` gains `.q-break-after { break-after: page }` beside the rule it already shipped for `.q-break`.
14
+
15
+ The Word target also stops losing a page break a table would swallow. A table carries no paragraph properties, so a break owed where one starts had nowhere to sit and reached the next paragraph instead, on the wrong page or on none. It now gets a paragraph of its own, the same carrier a section break already took.
16
+
17
+ The render-event stream states the two edges rather than the four positions. `group-start` carries `break` where a page turns before the instance, and the new `breakAfter` where one turns after it, so a target reads boundaries and never the position that asked for them.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies
22
+ - quario@0.10.0
23
+
3
24
  ## 0.9.0
4
25
 
5
26
  ### Minor Changes
package/README.md CHANGED
@@ -4,6 +4,18 @@
4
4
  definition to an HTML fragment with semantic tables, a stable class contract, and every
5
5
  interpolated value escaped.
6
6
 
7
+ ## Contents
8
+
9
+ - [Install](#install)
10
+ - [Quick start](#quick-start)
11
+ - [API](#api)
12
+ - [Output contract](#output-contract)
13
+ - [Unlicensed marking](#unlicensed-marking)
14
+ - [Escaping](#escaping)
15
+ - [Printing to PDF](#printing-to-pdf)
16
+ - [Documentation](#documentation)
17
+ - [License](#license)
18
+
7
19
  ## Install
8
20
 
9
21
  ```bash
@@ -110,7 +122,7 @@ change.
110
122
  | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
111
123
  | `<div class="q-report">` | The fragment's root, holding every band |
112
124
  | `<div class="q-item q-<role>">` | Every item. Roles: `q-report-header`, `q-empty`, `q-group-header`, `q-detail`, `q-group-footer`, `q-report-footer` |
113
- | `<div class="q-group" data-group="name">` | Each group instance, wrapping its header, nested content, and footer. Adds `q-break` when the group declares `break: "page"` or `reset: "page"` |
125
+ | `<div class="q-group" data-group="name">` | Each group instance, wrapping its header, nested content, and footer. Adds `q-break` where the group's `break` turns a page before the instance, and `q-break-after` where one turns after it |
114
126
  | `<table class="q-table">` | Each table, with a real `colgroup`, `thead`, `tbody`, and a `tfoot` around the total rows the walk emits. No `tfoot` when `total` is absent or every row of it is hidden. A spanning cell carries `colspan` |
115
127
  | `<div class="q-item q-image q-<role>">` | Each image item, holding one `<img>`. Its `src` is a base64 `data:` URI of the event's bytes, and its `width`/`height` are the picture's natural size. `fit` sets `max-width:100%` or `width:100%` on it, paired with `height:auto`. The target escapes the rendered `alt` |
116
128
  | `<div class="q-columns" style="column-count:<n>">` | The content between a node's own bands when it declares `columns`. On the root, that is the body between report header and footer. On a group, it is inside that instance's `q-group` |
@@ -124,7 +136,7 @@ Security Policy needs `img-src data:` (see
124
136
  Style declarations map to inline CSS (`bold` → `font-weight:bold` or `font-weight:normal`, `size` → `font-size:<n>pt`,
125
137
  `family: "mono"` → `font-family:monospace`, …). Occupy-a-line and newline-as-break are **not** inline: they hang off
126
138
  `.q-item` and `.q-table th, td` in [`@quario/html/style.css`](#the-reference-stylesheet), the same way `.q-break`
127
- honors `break: "page"`. The class is the hook. The rule is a reference default a host overrides on source order.
139
+ honors a leading page break. The class is the hook. The rule is a reference default a host overrides on source order.
128
140
  A fragment without that sheet still carries the classes and the text, including newlines. It does not occupy or
129
141
  break until some stylesheet says so. This target supplies no defaults in the markup: no weight or size per band
130
142
  role, no leading, no padding, no spacing between bands, no borders. An inline `style` attribute would beat yours
@@ -133,7 +145,7 @@ instead. The PDF and XLSX targets carry theirs built in, because their consumers
133
145
  row set still emits the table, its header, and an empty `<tbody>`. A hidden table cell keeps its `<td>`, empty.
134
146
 
135
147
  This target paginates nothing: it ignores schema page bands (page furniture belongs to your print CSS)
136
- and maps `break: "page"` and `reset: "page"` to the `q-break` class. Page columns are laid out:
148
+ and maps each edge a group's `break` turns to its own class. Page columns are laid out:
137
149
  a node declaring `columns` wraps the content between its own bands in the `q-columns` container
138
150
  above, and the browser flows it.
139
151
 
@@ -172,8 +184,8 @@ Host CSS targets these classes:
172
184
  `.q-item { min-height: 1lh; white-space: pre-line }` occupies a line and breaks on newlines,
173
185
  `.q-group { break-inside: avoid }` keeps a group header with its rows,
174
186
  `.q-table tr { break-inside: avoid }` keeps a row whole,
175
- `.q-break { break-before: page }` honors the schema's own break hint, and `thead` repeats per
176
- printed page. Yours adds the page geometry in `@page`, which only you can decide.
187
+ `.q-break { break-before: page }` and `.q-break-after { break-after: page }` honor the schema's own
188
+ break hints, and `thead` repeats per printed page. Yours adds the page geometry in `@page`, which only you can decide.
177
189
  3. Print with a headless browser (`page.pdf()` in Playwright or Puppeteer, page numbers via the
178
190
  footer template) or a Paged-CSS engine (WeasyPrint or Prince, with page numbers via `@page` margin
179
191
  boxes).
package/lib/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * never carries markup-escaped text.
10
10
  */
11
11
 
12
- import { display, format, hostOptions, isReportBand, styledRuns, text, walk } from "quario";
12
+ import { display, format, hostOptions, isReportBand, splits, styledRuns, text, walk } from "quario";
13
13
 
14
14
  /** @type {Record<string, string>} */
15
15
  let ESC = {
@@ -24,9 +24,8 @@ let esc = (value) => display(value).replace(/[&<>"']/g, (char) => ESC[char]);
24
24
 
25
25
  // Join a cell's tokens to HTML: literal template text is author-controlled
26
26
  // markup and passes verbatim; every interpolated value is escaped, no
27
- // exceptions. The engine's own `text` join is the other one -- display text,
28
- // where a literal is words rather than markup -- and an attribute value takes
29
- // that one, escaped whole.
27
+ // exceptions. An attribute value takes the engine's `text` join instead,
28
+ // escaped whole.
30
29
  /** @type {(tokens: any[], style: any, intl: any) => string} */
31
30
  let joined = (tokens, style, intl) => {
32
31
  let out = "";
@@ -41,10 +40,8 @@ let shown = (value, style, intl) => {
41
40
  };
42
41
 
43
42
  // One styled run: a `<span>` carrying that run's whole resolved inline style,
44
- // rather than a difference against the cell's -- the engine already composed
45
- // the two, and a delta would have this target merge them back. An unstyled run
46
- // stays bare, so a cell with no authored runs emits exactly the markup it
47
- // always did. The span's own CSS is `esc()`d like any other attribute value.
43
+ // not a difference against the cell's -- the engine already composed the two.
44
+ // An unstyled run stays bare.
48
45
  /** @type {(styled: { style: any, tokens: any[] }, style: any, intl: any, map: any) => string} */
49
46
  let piece = (styled, style, intl, map) => {
50
47
  let body = joined(styled.tokens, styled.style ?? style, intl);
@@ -54,8 +51,7 @@ let piece = (styled, style, intl, map) => {
54
51
  };
55
52
 
56
53
  // A cell's markup: its styled runs in order. The grouping is the engine's
57
- // `styledRuns`, so this target cannot drift from the others (SCHEMA.md, "Event
58
- // stream").
54
+ // `styledRuns`, so this target cannot drift from the others.
59
55
  /** @type {(map: any) => (tokens: any[], style?: any, intl?: any) => string} */
60
56
  let cellMarkup = (map) => (tokens, style, intl) => {
61
57
  let out = "";
@@ -63,10 +59,9 @@ let cellMarkup = (map) => (tokens, style, intl) => {
63
59
  return out;
64
60
  };
65
61
 
66
- // base64 for an image's `data:` URI, without Buffer: this package runs in Node
67
- // and in ES2024 browsers off the same `lib/`, and `btoa` is a standard global
68
- // in both. The walk is chunked because `fromCharCode(...bytes)` spreads one
69
- // argument per byte, which a real image exhausts.
62
+ // base64 for an image's `data:` URI, without Buffer: the same `lib/` runs in
63
+ // Node and in browsers, and `btoa` is standard in both. Chunked because
64
+ // `fromCharCode(...bytes)` spreads one argument per byte.
70
65
  let CHUNK = 0x8000;
71
66
  /** @type {(bytes: Uint8Array) => string} */
72
67
  let base64 = (bytes) => {
@@ -105,11 +100,10 @@ let VALIGNMENTS = Object.keys(ALIGN_CONTENT);
105
100
  // content like anything else. The report's own bands are the engine's to name.
106
101
  let OWN = new Set(["group-header", "group-footer"]);
107
102
  // A host font mapping (`html({ fonts })`), lowercased once so lookup matches
108
- // `pdf({ fonts })`'s. Validated here rather than at render, because a
109
- // malformed one is host configuration and belongs where the host wrote it --
110
- // the value itself is trusted and emitted verbatim, as that target's font
111
- // bytes are. `Error` and the `options.fonts.<name>:` prefix are that target's
112
- // too: one option, one message shape.
103
+ // `pdf({ fonts })`'s. Validated here rather than at render: a malformed one is
104
+ // host configuration and belongs where the host wrote it. The value itself is
105
+ // trusted and emitted verbatim. `Error` and the `options.fonts.<name>:` prefix
106
+ // match the pdf target's: one option, one message shape.
113
107
  /** @type {(path: string, value: any) => string} */
114
108
  let cssValue = (path, value) => {
115
109
  if (typeof value !== "string" || !value)
@@ -144,11 +138,10 @@ let mapping = (fonts) => {
144
138
  );
145
139
  };
146
140
 
147
- // Resolve a declared family name against the table the factory built -- the
148
- // host's mapping laid over the three generics -- then the name itself. A name
149
- // that is not nameable as it stands resolves to nothing rather than to a
150
- // rewritten near-miss, so the element falls through to the layer below instead
151
- // of naming a face that cannot exist (docs/adr/0034).
141
+ // Resolve a declared family name against the table the factory built, then
142
+ // the name itself. A name that is not nameable as it stands resolves to
143
+ // nothing rather than a rewritten near-miss, so the element falls through to
144
+ // the layer below (docs/adr/0034).
152
145
  /** @type {(map: Record<string, string>, value: string) => string} */
153
146
  let named = (map, value) => {
154
147
  // Own-key lookup: `constructor` must not resolve an inherited member.
@@ -174,9 +167,8 @@ let CSS = {
174
167
  background: (value) => (isHex(value) ? "background-color:" + value : ""),
175
168
  align: (value) => (ALIGNMENTS.includes(value) ? "text-align:" + value : ""),
176
169
  // The table reading, on a `<tr>` or a cell: the browser's own sheet gives
177
- // cells `vertical-align: inherit`, so a row's reaches them like `bold`
178
- // does. A slot item's is placement, and `placed` below lifts the name out
179
- // before this map sees it.
170
+ // cells `vertical-align: inherit`, so a row's reaches them like `bold` does.
171
+ // `placed` below lifts the name out before this map sees a slot item's.
180
172
  valign: (value) => (VALIGNMENTS.includes(value) ? "vertical-align:" + value : ""),
181
173
  // The text itself is untouched, so selection and screen readers get what
182
174
  // the author wrote; only its rendering is capitalised.
@@ -282,17 +274,15 @@ let css = (style, map) => {
282
274
  };
283
275
 
284
276
  // Compose a resolved style into one escaped inline style attribute. `own` is
285
- // this target's own structural CSS, which goes first and always emits: page
277
+ // this target's structural CSS, which goes first and always emits: page
286
278
  // columns and splits are placement, not the visual defaults this target
287
- // refuses to supply, so they ride the same escaped attribute path as
288
- // everything else rather than a second one (CLAUDE.md, the markup edge).
279
+ // refuses to supply.
289
280
  /** @type {(fonts: any) => { attr: (event: { style?: any }, own?: string) => string, map: Record<string, string> }} */
290
281
  let styling = (fonts) => {
291
282
  // The spread is the precedence the option promises: a host mapping is
292
- // consulted before the three generics, and may replace one. `mapping`
293
- // lowercased its keys to match the ones already here. The table is handed
294
- // back beside the attribute writer, because a styled run's span resolves a
295
- // `family` against the same one.
283
+ // consulted before the three generics, and may replace one. Handed back
284
+ // beside the attribute writer, because a styled run's span resolves a
285
+ // `family` against the same table.
296
286
  let map = { ...FAMILY, ...mapping(fonts) };
297
287
  return {
298
288
  map,
@@ -304,25 +294,21 @@ let styling = (fonts) => {
304
294
  };
305
295
 
306
296
  // One slot's wrapper. An authored share is that width; a width-less slot
307
- // divides what the sized ones leave, exactly as a width-less column does.
297
+ // divides what the sized ones leave, as a width-less column does.
308
298
  // `display:grid` is what gives a slot's box the split's height rather than its
309
- // own content's: the wrapper already stretches as a flex item, and a grid makes
310
- // its single child fill it in both axes. It belongs here and not on the
311
- // fragment, because a slot's item is the same `q-item` div it would be
312
- // anywhere else -- nothing about an item's markup depends on where it sits.
299
+ // own content's. It belongs here and not on the fragment: a slot's item is the
300
+ // same `q-item` div it would be anywhere else.
313
301
  /** @type {(slot: { width?: number } | undefined) => string} */
314
302
  let slotAttr = (slot) =>
315
303
  ' style="' +
316
304
  esc("display:grid;" + (slot && slot.width != null ? "width:" + slot.width + "%" : "flex:1")) +
317
305
  '"';
318
306
 
319
- // A slot item's `valign`. The slot wrapper's grid stretches the item to the
320
- // split's height, so the item is the box and its content has to move inside
321
- // it: the item becomes a grid of its own and `align-content` places the
322
- // content. Not on the wrapper -- `align-items` there would shrink the item
323
- // back to its content and reopen the gap the grid closed. Lifted out of the
324
- // style so the cell mapping (`vertical-align`) does not also emit on a div.
325
- // The result is what `styleAttr` takes: a style and the structural prefix.
307
+ // A slot item's `valign`. The wrapper's grid stretches the item to the split's
308
+ // height, so the item is the box: it becomes a grid of its own and
309
+ // `align-content` places the content. Not on the wrapper -- `align-items`
310
+ // there would shrink the item back and reopen the gap the grid closed. Lifted
311
+ // out of the style so the cell mapping (`vertical-align`) does not also emit.
326
312
  /** @type {(event: { style?: any }) => { style?: any, own: string }} */
327
313
  let placed = (event) => {
328
314
  let valign = event.style?.valign;
@@ -339,9 +325,9 @@ let justified = (own, style) =>
339
325
  ? own + ";justify-items:" + JUSTIFY_ITEMS[style.align]
340
326
  : own;
341
327
 
342
- // The identity attribute, escaped like every other generated value at this
343
- // edge. `mark` is the factory's verdict: a render without `paths` maps every
344
- // event to "" through the same function rather than re-deciding per call site.
328
+ // The identity attribute. `mark` is the factory's verdict: a render without
329
+ // `paths` maps every event to "" through the same function rather than
330
+ // re-deciding per call site.
345
331
  /** @type {(event: { path?: string }) => string} */
346
332
  let pathAttr = ({ path }) => (path ? ' data-q-path="' + esc(path) + '"' : "");
347
333
  /** @type {(list: any[], tag: string, attrs: (event: any) => string, intl: any, markup: any) => string} */
@@ -365,9 +351,7 @@ let cells = (list, tag, attrs, intl, markup) =>
365
351
  *
366
352
  * `paths` opts the fragment into identity: each element whose event carries a
367
353
  * schema `path` gains `data-q-path`, so a consumer can map rendered output
368
- * back to the definition behind it — the editor's selection seam. Off by
369
- * default, because the attribute is weight every plain display pays for
370
- * nothing.
354
+ * back to the definition behind it. Off by default.
371
355
  *
372
356
  * `fonts` is this target's font mapping: a declared `family` name to a CSS
373
357
  * value, so a host says what the name means on this page. Malformed values
@@ -391,15 +375,12 @@ export function html(options) {
391
375
  // The pair every cell carries, bound once rather than threaded as two.
392
376
  /** @type {(cell: any) => string} */
393
377
  // A span covers several columns; `colspan` is what that is on this surface.
394
- // Escaped like every other attribute value this target writes, though the
395
- // engine has already vouched the span is a positive integer.
396
378
  /** @type {(cell: any) => string} */
397
379
  let attrs = (cell) =>
398
380
  (cell.span > 1 ? ' colspan="' + esc(cell.span) + '"' : "") + styleAttr(cell) + mark(cell);
399
381
  // One table row: the row style on the `<tr>`, from which it inherits into
400
382
  // the cells, `valign` included. The box is not among these names — a `<tr>`
401
- // could not honour one, and the engine has already resolved that half onto
402
- // the cells, where it arrives as ordinary inline CSS.
383
+ // could not honour one, and the engine resolved that half onto the cells.
403
384
  /** @type {(event: any, list: any[], tag: string, intl: any) => string} */
404
385
  let tr = (event, list, tag, intl) =>
405
386
  "<tr" + styleAttr(event) + ">" + cells(list, tag, attrs, intl, markup) + "</tr>";
@@ -409,8 +390,7 @@ export function html(options) {
409
390
  let out = "";
410
391
  // The finished `src` of every image this render has already encoded,
411
392
  // keyed by the array the source expression yielded. A logo down a
412
- // thousand rows is one base64 walk rather than a thousand, and the map
413
- // goes with the render — nothing here outlives it.
393
+ // thousand rows is one base64 walk. Nothing here outlives the render.
414
394
  /** @type {Map<Uint8Array, string>} */
415
395
  let sources = new Map();
416
396
  // Whether a `total-row` already opened `<tfoot>` — markup
@@ -422,35 +402,16 @@ export function html(options) {
422
402
  let rooted = false;
423
403
  // The page columns a node declared: its `count`, the `owner` depth that
424
404
  // declared it (-1 for the report root), and whether the container is
425
- // emitted and still to be closed. Null when no node owes one. Never more
426
- // than one, because nesting a columned region is a definition error.
405
+ // emitted and still to be closed. Never more than one, because nesting a
406
+ // columned region is a definition error.
427
407
  /** @type {{ count: number, owner: number, open: boolean } | null} */
428
408
  let region = null;
429
409
  // How many group instances are open, so the declaring node's own bands
430
- // can be told from the content they bracket: a `group-header` or
431
- // `group-footer` belongs to the innermost instance, which is the node
432
- // that may own the region. Taken from each event rather than counted, so
433
- // there is one spelling of a depth the stream already carries.
410
+ // can be told from the content they bracket. Taken from each event
411
+ // rather than counted, so one spelling of a depth the stream carries.
434
412
  let depth = 0;
435
- // The split being filled, if any: the slot geometry the bracket carried
436
- // and how many slots have been placed. Splits never nest, so one is
437
- // enough. Null whenever the walk is not inside a bracket.
438
- /** @type {{ slots: any[], at: number } | null} */
439
- let split = null;
440
413
  /** @type {{ locale?: string, currency?: string, timeZone?: string } | null} */
441
414
  let intl = null;
442
- // Place one item's markup: inside a bracket it goes in the next slot,
443
- // and everywhere else it is a band item like any other.
444
- /** @type {(event: any, fragment: string) => void} */
445
- let emit = (event, fragment) => {
446
- if (!split) {
447
- content(event);
448
- out += fragment;
449
- return;
450
- }
451
- out +=
452
- '<div class="q-slot"' + slotAttr(split.slots[split.at++]) + ">" + fragment + "</div>";
453
- };
454
415
  // Does this event stay full-width, outside the columns? The declaring
455
416
  // node's own bands do.
456
417
  /** @type {(event: any) => boolean} */
@@ -483,14 +444,61 @@ export function html(options) {
483
444
  if (fullWidth(event)) close();
484
445
  else openColumns();
485
446
  };
447
+ // One item's or picture's own markup, by event type: the stable
448
+ // `q-item q-<role>` classes are the whole class attribute (print CSS
449
+ // targets the band-role class for styling and breaks), and a picture is
450
+ // an item container of its own holding one `<img>`.
451
+ /** @type {Record<string, (event: any) => string>} */
452
+ let fragment = {
453
+ item: (event) => {
454
+ let slot = placed(event);
455
+ return (
456
+ '<div class="q-item q-' +
457
+ event.role +
458
+ '"' +
459
+ styleAttr(slot, slot.own) +
460
+ mark(event) +
461
+ ">" +
462
+ markup(event.tokens, event.style, intl) +
463
+ "</div>"
464
+ );
465
+ },
466
+ image: (event) => {
467
+ let slot = placed(event);
468
+ let source = sources.get(event.bytes);
469
+ if (source === undefined)
470
+ sources.set(
471
+ event.bytes,
472
+ (source = esc("data:image/" + event.format + ";base64," + base64(event.bytes))),
473
+ );
474
+ return (
475
+ '<div class="q-item q-image q-' +
476
+ event.role +
477
+ '"' +
478
+ styleAttr(slot, justified(slot.own, event.style)) +
479
+ mark(event) +
480
+ '><img src="' +
481
+ source +
482
+ '" width="' +
483
+ esc(event.width) +
484
+ '" height="' +
485
+ esc(event.height) +
486
+ '" alt="' +
487
+ esc(text(event.alt || [])) +
488
+ '" style="' +
489
+ (event.fit === "width" ? "" : "max-") +
490
+ 'width:100%;height:auto">' +
491
+ "</div>"
492
+ );
493
+ },
494
+ };
486
495
  // Hidden items never reach the stream and a hidden cell arrives with no
487
496
  // tokens, so no visibility checks are needed in any handler below.
488
- await walk(stream(data), {
497
+ await walk(splits(stream(data)), {
489
498
  // Nothing unless the render is unlicensed — then the fragment opens
490
- // with the marking badge (LICENSE section 6): the engine's wording in
491
- // this target's element, escaped like every other value crossing this
492
- // edge, so the badge stays childless whatever stream a host compiles
493
- // against.
499
+ // with the marking badge: the engine's wording in this target's
500
+ // element, escaped, so the badge stays childless whatever stream a
501
+ // host compiles against.
494
502
  "report-start": (event) => {
495
503
  intl = {
496
504
  locale: event.locale,
@@ -500,12 +508,10 @@ export function html(options) {
500
508
  if (event.marking) out += '<div class="q-unlicensed">' + esc(event.marking) + "</div>";
501
509
  if (event.margin != null)
502
510
  out += "<style>@page{margin:" + esc(event.margin) + "pt}</style>";
503
- // The report root. Always emitted, because the fragment otherwise
504
- // has no element for a document-wide declaration to be inherited
505
- // from, and the shipped stylesheet's own baseline now hangs off it.
506
- // The marking stays outside: inside, an author's `style` would
507
- // reach it, and a marking an author can shrink is one ADR 0002
508
- // does not allow.
511
+ // The report root. Always emitted: the fragment otherwise has no
512
+ // element for a document-wide declaration to inherit from, and the
513
+ // shipped stylesheet's baseline hangs off it. The marking stays
514
+ // outside, where an author's `style` cannot reach it (ADR 0002).
509
515
  out += '<div class="q-report"' + styleAttr(event) + ">";
510
516
  rooted = true;
511
517
  if (event.columns) region = { count: event.columns, owner: -1, open: false };
@@ -513,90 +519,54 @@ export function html(options) {
513
519
  // The stable `q-item q-<role>` classes are the whole class attribute;
514
520
  // print CSS targets the band-role class for styling and breaks.
515
521
  item: (event) => {
516
- let slot = placed(event);
517
- emit(
518
- event,
519
- '<div class="q-item q-' +
520
- event.role +
521
- '"' +
522
- styleAttr(slot, slot.own) +
523
- mark(event) +
524
- ">" +
525
- markup(event.tokens, event.style, intl) +
526
- "</div>",
527
- );
522
+ content(event);
523
+ out += fragment.item(event);
528
524
  },
529
525
  // A split places its slots across the content width instead of down
530
- // the band. The slot items inside keep their ordinary markup, so a
531
- // consumer of this fragment reads the same item container wherever it
532
- // sits; only the wrapper around it is new.
533
- "split-start": (event) => {
526
+ // the band. The slot items keep their ordinary markup; only the
527
+ // wrapper around them is new.
528
+ split: (event) => {
534
529
  content(event);
535
530
  out +=
536
531
  '<div class="q-split q-' + event.role + '"' + styleAttr(event, "display:flex") + ">";
537
- split = { slots: event.slots, at: 0 };
538
- },
539
- "split-end": () => {
540
- split = null;
532
+ event.items.forEach((/** @type {any} */ slot, /** @type {number} */ at) => {
533
+ out +=
534
+ '<div class="q-slot"' +
535
+ slotAttr(event.slots[at]) +
536
+ ">" +
537
+ fragment[slot.type](slot) +
538
+ "</div>";
539
+ });
541
540
  out += "</div>";
542
541
  },
543
542
  // An image is an item container of its own holding one `<img>`: the
544
543
  // band-role class joins `q-item q-image`, `align`/`background` dress
545
- // the container, and `fit` sizes the picture inside it. The `src` is
546
- // this target's own base64 of the event's bytes under the MIME type
547
- // the engine already sniffed -- generated, but emitted through the
548
- // same escaped attribute path as everything else rather than a second
549
- // one, and escaped when it is encoded rather than per occurrence. `alt` is report data, so it is the display-text join escaped,
550
- // never the markup-passing join a cell body gets; an image without one
551
- // carries `alt=""`, the decorative-image convention.
544
+ // the container, and `fit` sizes the picture inside it. `alt` is report
545
+ // data, so it takes the display-text join escaped, never the
546
+ // markup-passing join a cell body gets; without one it is `alt=""`.
552
547
  //
553
- // `width`/`height` are the event's natural size, which the engine has
554
- // already read and vouched for, so a picture reserves its aspect ratio
555
- // before it decodes rather than reflowing the page around it. The pair
556
- // is a presentational hint, not a ratio on its own, which is why the
557
- // `fit` style carries `height:auto`: without it the stated height is a
558
- // used height and a picture capped narrower than its file renders
559
- // squashed. SCHEMA.md, "The HTML target", states that normatively.
548
+ // `width`/`height` are the event's natural size, so a picture reserves
549
+ // its aspect ratio before it decodes. The pair is a presentational
550
+ // hint rather than a ratio, which is why the `fit` style carries
551
+ // `height:auto`: without it a picture capped narrower than its file
552
+ // renders squashed.
560
553
  image: (event) => {
561
- let slot = placed(event);
562
- let source = sources.get(event.bytes);
563
- if (source === undefined)
564
- sources.set(
565
- event.bytes,
566
- (source = esc("data:image/" + event.format + ";base64," + base64(event.bytes))),
567
- );
568
- emit(
569
- event,
570
- '<div class="q-item q-image q-' +
571
- event.role +
572
- '"' +
573
- styleAttr(slot, justified(slot.own, event.style)) +
574
- mark(event) +
575
- '><img src="' +
576
- source +
577
- '" width="' +
578
- esc(event.width) +
579
- '" height="' +
580
- esc(event.height) +
581
- '" alt="' +
582
- esc(text(event.alt || [])) +
583
- '" style="' +
584
- (event.fit === "width" ? "" : "max-") +
585
- 'width:100%;height:auto">' +
586
- "</div>",
587
- );
554
+ content(event);
555
+ out += fragment.image(event);
588
556
  },
589
- // Each group instance is wrapped in a `q-group` container carrying
590
- // the group name, so print CSS can keep a header with its rows
591
- // (`break-inside`) or force a break per group (`break-before`). A
592
- // declared `break: 'page'` or `reset: 'page'` adds the `q-break` class
593
- // for print CSS to map (`.q-break { break-before: page }`).
557
+ // Each group instance is wrapped in a `q-group` container carrying the
558
+ // group name, so print CSS can keep a header with its rows or force a
559
+ // break per group. The stream resolved the group's `break` position
560
+ // into this instance's two edges, each mapping to its class. Both ride
561
+ // the opening event: the container is written here and a class cannot
562
+ // be added to it later.
594
563
  "group-start": (event) => {
595
564
  content(event);
596
565
  depth = event.depth + 1;
597
566
  out +=
598
567
  '<div class="q-group' +
599
- (event.break || event.reset ? " q-break" : "") +
568
+ (event.break ? " q-break" : "") +
569
+ (event.breakAfter ? " q-break-after" : "") +
600
570
  '"' +
601
571
  mark(event) +
602
572
  ' data-group="' +
package/lib/style.css CHANGED
@@ -105,7 +105,9 @@
105
105
  * Fragmentation. SCHEMA.md calls these the print-CSS contract, so they ship
106
106
  * with the look rather than being left to the host to rediscover: `thead`
107
107
  * repeats per printed page, a table row stays whole, a group header travels
108
- * with its rows, and a group declaring `break: "page"` gets one.
108
+ * with its rows, and each edge a group's `break` position turns gets one. The
109
+ * trailing edge needs its own rule: `break-before` cannot say what falls after
110
+ * the last instance of a run.
109
111
  *
110
112
  * Pages are not the only fragmentation context: the columns case lives in the
111
113
  * shared block above, because a sheet with no pages can still have columns.
@@ -132,3 +134,7 @@
132
134
  .q-break {
133
135
  break-before: page;
134
136
  }
137
+
138
+ .q-break-after {
139
+ break-after: page;
140
+ }
package/package.json CHANGED
@@ -1,7 +1,14 @@
1
1
  {
2
2
  "name": "@quario/html",
3
- "version": "0.9.0",
4
- "description": "The HTML render target for quario in the makings, not yet released",
3
+ "version": "0.10.0",
4
+ "description": "Tiny, escape-by-default HTML render target for quario. Semantic tables and a stable class contract.",
5
+ "keywords": [
6
+ "csp",
7
+ "escaping",
8
+ "html",
9
+ "quario",
10
+ "report"
11
+ ],
5
12
  "homepage": "https://getquario.com",
6
13
  "license": "SEE LICENSE IN LICENSE",
7
14
  "repository": {
@@ -39,12 +46,12 @@
39
46
  "devDependencies": {
40
47
  "@arethetypeswrong/cli": "^0.18.3",
41
48
  "@size-limit/preset-small-lib": "^13.0.3",
42
- "quario": "^0.9.0",
49
+ "quario": "^0.10.0",
43
50
  "size-limit": "^13.0.3",
44
51
  "typescript": "^7.0.2"
45
52
  },
46
53
  "peerDependencies": {
47
- "quario": "^0.9.0"
54
+ "quario": "^0.10.0"
48
55
  },
49
56
  "size-limit": [
50
57
  {