@quario/html 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +7 -7
- package/lib/index.js +73 -39
- package/lib/style.css +15 -5
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A spanning cell emits `colspan`**, ahead of its `style`; a span of one
|
|
15
|
+
emits no attribute. The `<colgroup>` still names every column, so the
|
|
16
|
+
geometry is the document's and only the cells merge — which means the header
|
|
17
|
+
row can carry fewer `<th>` than there are columns.
|
|
18
|
+
|
|
19
|
+
- **`valign`** renders as `vertical-align`, on a cell or on the row's `<tr>`,
|
|
20
|
+
which the browser's own `inherit` on cells carries down. On a split slot the
|
|
21
|
+
item itself becomes a grid with `align-content`, so the content moves inside
|
|
22
|
+
the box the slot wrapper already stretches.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- **A row's box now reaches the cells** instead of being dropped. Padding and
|
|
27
|
+
border on `detail.header`, `detail.row` or a total row's `style` are emitted
|
|
28
|
+
inline on each `<th>`/`<td>`, where they also beat the reference
|
|
29
|
+
stylesheet's own cell padding. The `<tr>` still carries the row's other
|
|
30
|
+
declarations and still never carries a box, which it could not honour.
|
|
31
|
+
Fragments of documents that declared a box on a row gain borders and padding
|
|
32
|
+
they previously rendered without.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **Unstyled table cells sit at the top of their row.** The reference
|
|
37
|
+
stylesheet now pins `vertical-align: top` on `.q-table tr`, which the cells
|
|
38
|
+
inherit.
|
|
39
|
+
Before, a browser centred the shorter cells of a wrapping row while the PDF
|
|
40
|
+
painted them from the top, so the same report read differently in the two.
|
|
41
|
+
A host stylesheet overrides it the ordinary way.
|
|
42
|
+
|
|
43
|
+
## [0.4.0] - 2026-09-03
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **A split slot's box now fills the split's height.** Before, a slot's
|
|
48
|
+
background and border stopped at that slot's own content, leaving a gap under
|
|
49
|
+
any slot shorter than its neighbours. The slot wrapper now carries inline
|
|
50
|
+
`display:grid`, so the item inside it fills the row; a slot's item markup is
|
|
51
|
+
unchanged. See the `@quario/layout` changelog for the rule and for what it
|
|
52
|
+
costs a report that relied on the short box.
|
|
53
|
+
|
|
54
|
+
- **A date string under `format: "date"` now presents as a date.** The
|
|
55
|
+
engine's `format()` helper revives the two read forms, so a cell that
|
|
56
|
+
rendered `2026-08-14` verbatim now renders the presented date. See the
|
|
57
|
+
`quario` changelog for the forms and the timezone rule.
|
|
58
|
+
|
|
10
59
|
## [0.3.0] - 2026-09-02
|
|
11
60
|
|
|
12
61
|
### Added
|
package/README.md
CHANGED
|
@@ -97,13 +97,13 @@ host. Compilation stays synchronous; render-time failures reject with the same l
|
|
|
97
97
|
These classes are the contract host CSS targets. They are stable, and changing them is a breaking
|
|
98
98
|
change.
|
|
99
99
|
|
|
100
|
-
| Emits | For
|
|
101
|
-
| ----------------------------------------- |
|
|
102
|
-
| `<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`
|
|
103
|
-
| `<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"`
|
|
104
|
-
| `<table class="q-table">` | Each table, with a real `colgroup`, `thead`, `tbody`, and a `tfoot` when totals are declared
|
|
105
|
-
| `<div class="q-item q-image q-<role>">` | Each image item, holding one `<img>` whose `src` is a base64 `data:` URI of the event's bytes; the rendered `alt` is escaped
|
|
106
|
-
| `<div class="q-split q-<role>">` | Each split, carrying inline `display:flex`; its slots are `<div class="q-slot">`
|
|
100
|
+
| Emits | For |
|
|
101
|
+
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
102
|
+
| `<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` |
|
|
103
|
+
| `<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"` |
|
|
104
|
+
| `<table class="q-table">` | Each table, with a real `colgroup`, `thead`, `tbody`, and a `tfoot` when totals are declared |
|
|
105
|
+
| `<div class="q-item q-image q-<role>">` | Each image item, holding one `<img>` whose `src` is a base64 `data:` URI of the event's bytes; the rendered `alt` is escaped |
|
|
106
|
+
| `<div class="q-split q-<role>">` | Each split, carrying inline `display:flex`; its slots are `<div class="q-slot">` carrying inline `display:grid` and their share, each holding the slot item's ordinary container |
|
|
107
107
|
|
|
108
108
|
A column `width` becomes an inline `width:<n>%` on its `<col>`. The classes above belong to this
|
|
109
109
|
target. The schema never speaks in CSS. Displaying a fragment that contains images under a Content
|
package/lib/index.js
CHANGED
|
@@ -63,6 +63,13 @@ let UNSAFE = /[;}]/;
|
|
|
63
63
|
/** @type {(value: any) => boolean} */
|
|
64
64
|
let isHex = (value) => typeof value === "string" && HEX.test(value);
|
|
65
65
|
let ALIGNMENTS = ["left", "center", "right"];
|
|
66
|
+
// A slot item's `valign` and `align` as grid placement (see `placed`); the
|
|
67
|
+
// vertical vocabulary is these keys, so the three readers of it agree.
|
|
68
|
+
/** @type {Record<string, string>} */
|
|
69
|
+
let ALIGN_CONTENT = { top: "start", middle: "center", bottom: "end" };
|
|
70
|
+
/** @type {Record<string, string>} */
|
|
71
|
+
let JUSTIFY_ITEMS = { left: "start", center: "center", right: "end" };
|
|
72
|
+
let VALIGNMENTS = Object.keys(ALIGN_CONTENT);
|
|
66
73
|
|
|
67
74
|
// A group instance's own bands. They sit outside a columned container only when
|
|
68
75
|
// that instance is the node which declared the count — an inner instance's are
|
|
@@ -123,6 +130,11 @@ let CSS = {
|
|
|
123
130
|
color: (value) => (isHex(value) ? "color:" + value : ""),
|
|
124
131
|
background: (value) => (isHex(value) ? "background-color:" + value : ""),
|
|
125
132
|
align: (value) => (ALIGNMENTS.includes(value) ? "text-align:" + value : ""),
|
|
133
|
+
// The table reading, on a `<tr>` or a cell: the browser's own sheet gives
|
|
134
|
+
// cells `vertical-align: inherit`, so a row's reaches them like `bold`
|
|
135
|
+
// does. A slot item's is placement, and `placed` below lifts the name out
|
|
136
|
+
// before this map sees it.
|
|
137
|
+
valign: (value) => (VALIGNMENTS.includes(value) ? "vertical-align:" + value : ""),
|
|
126
138
|
// The text itself is untouched, so selection and screen readers get what
|
|
127
139
|
// the author wrote; only its rendering is capitalised.
|
|
128
140
|
uppercase: flag("text-transform:uppercase", "text-transform:none"),
|
|
@@ -156,17 +168,6 @@ let borderSide = (style, side) => {
|
|
|
156
168
|
];
|
|
157
169
|
};
|
|
158
170
|
|
|
159
|
-
/** @type {(name: string) => boolean} */
|
|
160
|
-
let isBox = (name) => name.startsWith("padding") || name.startsWith("border");
|
|
161
|
-
|
|
162
|
-
/** @type {(style: any) => any} */
|
|
163
|
-
let unbox = (style) => {
|
|
164
|
-
if (!style) return;
|
|
165
|
-
let names = Object.keys(style).filter((name) => !isBox(name));
|
|
166
|
-
if (!names.length) return;
|
|
167
|
-
return Object.fromEntries(names.map((name) => [name, style[name]]));
|
|
168
|
-
};
|
|
169
|
-
|
|
170
171
|
/** @type {(style: any) => string[]} */
|
|
171
172
|
let decoLine = (style) => {
|
|
172
173
|
let parts = [];
|
|
@@ -256,18 +257,45 @@ let styling = (fonts) => {
|
|
|
256
257
|
|
|
257
258
|
// One slot's wrapper. An authored share is that width; a width-less slot
|
|
258
259
|
// divides what the sized ones leave, exactly as a width-less column does.
|
|
260
|
+
// `display:grid` is what gives a slot's box the split's height rather than its
|
|
261
|
+
// own content's: the wrapper already stretches as a flex item, and a grid makes
|
|
262
|
+
// its single child fill it in both axes. It belongs here and not on the
|
|
263
|
+
// fragment, because a slot's item is the same `q-item` div it would be
|
|
264
|
+
// anywhere else -- nothing about an item's markup depends on where it sits.
|
|
259
265
|
/** @type {(slot: { width?: number } | undefined) => string} */
|
|
260
266
|
let slotAttr = (slot) =>
|
|
261
|
-
' style="' +
|
|
267
|
+
' style="' +
|
|
268
|
+
esc("display:grid;" + (slot && slot.width != null ? "width:" + slot.width + "%" : "flex:1")) +
|
|
269
|
+
'"';
|
|
270
|
+
|
|
271
|
+
// A slot item's `valign`. The slot wrapper's grid stretches the item to the
|
|
272
|
+
// split's height, so the item is the box and its content has to move inside
|
|
273
|
+
// it: the item becomes a grid of its own and `align-content` places the
|
|
274
|
+
// content. Not on the wrapper -- `align-items` there would shrink the item
|
|
275
|
+
// back to its content and reopen the gap the grid closed. Lifted out of the
|
|
276
|
+
// style so the cell mapping (`vertical-align`) does not also emit on a div.
|
|
277
|
+
// The result is what `styleAttr` takes: a style and the structural prefix.
|
|
278
|
+
/** @type {(event: { style?: any }) => { style?: any, own: string }} */
|
|
279
|
+
let placed = (event) => {
|
|
280
|
+
let valign = event.style?.valign;
|
|
281
|
+
if (!Object.hasOwn(ALIGN_CONTENT, valign)) return { style: event.style, own: "" };
|
|
282
|
+
let { valign: _, ...style } = event.style;
|
|
283
|
+
return { style, own: "display:grid;align-content:" + ALIGN_CONTENT[valign] };
|
|
284
|
+
};
|
|
285
|
+
// A picture is a replaced element, which a grid places at the start rather
|
|
286
|
+
// than stretching, so the container's `text-align` no longer reaches it: the
|
|
287
|
+
// same `align` word rides `justify-items` while the item is a grid.
|
|
288
|
+
/** @type {(own: string, style: any) => string} */
|
|
289
|
+
let justified = (own, style) =>
|
|
290
|
+
own && Object.hasOwn(JUSTIFY_ITEMS, style?.align)
|
|
291
|
+
? own + ";justify-items:" + JUSTIFY_ITEMS[style.align]
|
|
292
|
+
: own;
|
|
262
293
|
|
|
263
294
|
// The identity attribute, escaped like every other generated value at this
|
|
264
295
|
// edge. `mark` is the factory's verdict: a render without `paths` maps every
|
|
265
296
|
// event to "" through the same function rather than re-deciding per call site.
|
|
266
297
|
/** @type {(event: { path?: string }) => string} */
|
|
267
298
|
let pathAttr = ({ path }) => (path ? ' data-q-path="' + esc(path) + '"' : "");
|
|
268
|
-
/** @type {() => string} */
|
|
269
|
-
let noAttr = () => "";
|
|
270
|
-
|
|
271
299
|
/** @type {(list: any[], tag: string, attrs: (event: any) => string, intl: any) => string} */
|
|
272
300
|
let cells = (list, tag, attrs, intl) =>
|
|
273
301
|
list
|
|
@@ -306,12 +334,24 @@ let cells = (list, tag, attrs, intl) =>
|
|
|
306
334
|
* The target (see SCHEMA.md, "Instances and targets").
|
|
307
335
|
*/
|
|
308
336
|
export function html(options) {
|
|
309
|
-
let mark = options?.paths ? pathAttr :
|
|
337
|
+
let mark = options?.paths ? pathAttr : () => "";
|
|
310
338
|
// The factory's verdict, like `mark`: the mapping is validated once, here.
|
|
311
339
|
let styleAttr = styling(options?.fonts);
|
|
312
340
|
// The pair every cell carries, bound once rather than threaded as two.
|
|
313
341
|
/** @type {(cell: any) => string} */
|
|
314
|
-
|
|
342
|
+
// A span covers several columns; `colspan` is what that is on this surface.
|
|
343
|
+
// Escaped like every other attribute value this target writes, though the
|
|
344
|
+
// engine has already vouched the span is a positive integer.
|
|
345
|
+
/** @type {(cell: any) => string} */
|
|
346
|
+
let attrs = (cell) =>
|
|
347
|
+
(cell.span > 1 ? ' colspan="' + esc(cell.span) + '"' : "") + styleAttr(cell) + mark(cell);
|
|
348
|
+
// One table row: the row style on the `<tr>`, from which it inherits into
|
|
349
|
+
// the cells, `valign` included. The box is not among these names — a `<tr>`
|
|
350
|
+
// could not honour one, and the engine has already resolved that half onto
|
|
351
|
+
// the cells, where it arrives as ordinary inline CSS.
|
|
352
|
+
/** @type {(event: any, list: any[], tag: string, intl: any) => string} */
|
|
353
|
+
let tr = (event, list, tag, intl) =>
|
|
354
|
+
"<tr" + styleAttr(event) + ">" + cells(list, tag, attrs, intl) + "</tr>";
|
|
315
355
|
return {
|
|
316
356
|
name: "html",
|
|
317
357
|
compile: (stream) => async (data) => {
|
|
@@ -418,12 +458,13 @@ export function html(options) {
|
|
|
418
458
|
// The stable `q-item q-<role>` classes are the whole class attribute;
|
|
419
459
|
// print CSS targets the band-role class for styling and breaks.
|
|
420
460
|
item: (event) => {
|
|
461
|
+
let slot = placed(event);
|
|
421
462
|
emit(
|
|
422
463
|
event,
|
|
423
464
|
'<div class="q-item q-' +
|
|
424
465
|
event.role +
|
|
425
466
|
'"' +
|
|
426
|
-
styleAttr(
|
|
467
|
+
styleAttr(slot, slot.own) +
|
|
427
468
|
mark(event) +
|
|
428
469
|
">" +
|
|
429
470
|
markup(event.tokens, event.style, intl) +
|
|
@@ -454,6 +495,7 @@ export function html(options) {
|
|
|
454
495
|
// never the markup-passing join a cell body gets; an image without one
|
|
455
496
|
// carries `alt=""`, the decorative-image convention.
|
|
456
497
|
image: (event) => {
|
|
498
|
+
let slot = placed(event);
|
|
457
499
|
let source = sources.get(event.bytes);
|
|
458
500
|
if (source === undefined)
|
|
459
501
|
sources.set(
|
|
@@ -465,7 +507,7 @@ export function html(options) {
|
|
|
465
507
|
'<div class="q-item q-image q-' +
|
|
466
508
|
event.role +
|
|
467
509
|
'"' +
|
|
468
|
-
styleAttr(event) +
|
|
510
|
+
styleAttr(slot, justified(slot.own, event.style)) +
|
|
469
511
|
mark(event) +
|
|
470
512
|
'><img src="' +
|
|
471
513
|
source +
|
|
@@ -515,9 +557,13 @@ export function html(options) {
|
|
|
515
557
|
for (let column of /** @type {any[]} */ (event.columns))
|
|
516
558
|
cols +=
|
|
517
559
|
"<col" + (column.width ? ' style="width:' + esc(column.width) + '%"' : "") + ">";
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
560
|
+
// The header row is the cells the columns declared: a column a
|
|
561
|
+
// neighbour's span covers has none, and contributes only its `<col>`.
|
|
562
|
+
let headers = event.columns
|
|
563
|
+
.filter((/** @type {any} */ column) => column.header)
|
|
564
|
+
.map((/** @type {any} */ column) =>
|
|
565
|
+
Object.assign({}, column.header, { path: column.path }),
|
|
566
|
+
);
|
|
521
567
|
out +=
|
|
522
568
|
'<table class="q-table"' +
|
|
523
569
|
mark(event) +
|
|
@@ -525,31 +571,19 @@ export function html(options) {
|
|
|
525
571
|
"<colgroup>" +
|
|
526
572
|
cols +
|
|
527
573
|
"</colgroup>" +
|
|
528
|
-
"<thead
|
|
529
|
-
|
|
530
|
-
">"
|
|
531
|
-
cells(headers, "th", attrs, intl) +
|
|
532
|
-
"</tr></thead><tbody>";
|
|
574
|
+
"<thead>" +
|
|
575
|
+
tr(event, headers, "th", intl) +
|
|
576
|
+
"</thead><tbody>";
|
|
533
577
|
},
|
|
534
578
|
row: (event) => {
|
|
535
|
-
out +=
|
|
536
|
-
"<tr" +
|
|
537
|
-
styleAttr({ style: unbox(event.style) }) +
|
|
538
|
-
">" +
|
|
539
|
-
cells(event.cells, "td", attrs, intl) +
|
|
540
|
-
"</tr>";
|
|
579
|
+
out += tr(event, event.cells, "td", intl);
|
|
541
580
|
},
|
|
542
581
|
"total-row": (event) => {
|
|
543
582
|
if (!tfoot) {
|
|
544
583
|
out += "</tbody><tfoot>";
|
|
545
584
|
tfoot = true;
|
|
546
585
|
}
|
|
547
|
-
out +=
|
|
548
|
-
"<tr" +
|
|
549
|
-
styleAttr({ style: unbox(event.style) }) +
|
|
550
|
-
">" +
|
|
551
|
-
cells(event.cells, "td", attrs, intl) +
|
|
552
|
-
"</tr>";
|
|
586
|
+
out += tr(event, event.cells, "td", intl);
|
|
553
587
|
},
|
|
554
588
|
"table-end": () => {
|
|
555
589
|
out += tfoot ? "</tfoot></table>" : "</tbody></table>";
|
package/lib/style.css
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
* rules are not. They are a reference default -- adjust freely, or never link
|
|
16
16
|
* this file at all.
|
|
17
17
|
*
|
|
18
|
-
* The block between the `shared:` markers below is
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* The block between the `shared:` markers below is the one shipped look.
|
|
19
|
+
* The viewer and the editor used to adopt a copy of it into their shadow
|
|
20
|
+
* roots; both now paint the layout list on a canvas and carry no report
|
|
21
|
+
* stylesheet (docs/adr/0039), so `test/stylesheet.test.js` at the repo root
|
|
22
|
+
* gates this block against `example/print.css` growing a copy back.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
/* shared:start */
|
|
@@ -56,6 +56,16 @@
|
|
|
56
56
|
white-space: pre-line;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/* A wrapping row leaves its shorter cells slack, and a browser fills that
|
|
60
|
+
from the middle where the PDF paints from the top. Pin the top so an
|
|
61
|
+
unstyled table reads the same in both; an author who wants otherwise
|
|
62
|
+
declares `valign`. On the row, not the cells: the browser's own sheet gives
|
|
63
|
+
cells `vertical-align: inherit`, which is what lets a row's declaration
|
|
64
|
+
reach them, and a rule on the cells would cut that off. */
|
|
65
|
+
.q-table tr {
|
|
66
|
+
vertical-align: top;
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
.q-table th,
|
|
60
70
|
.q-table td {
|
|
61
71
|
padding: 2pt 6pt;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quario/html",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The HTML render target for quario — in the makings, not yet released",
|
|
5
5
|
"homepage": "https://getquario.com",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -39,17 +39,17 @@
|
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@arethetypeswrong/cli": "^0.18.3",
|
|
41
41
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
42
|
-
"quario": "^0.
|
|
42
|
+
"quario": "^0.5.0",
|
|
43
43
|
"size-limit": "^13.0.3",
|
|
44
44
|
"typescript": "^7.0.2"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
|
-
"quario": "^0.
|
|
47
|
+
"quario": "^0.5.0"
|
|
48
48
|
},
|
|
49
49
|
"size-limit": [
|
|
50
50
|
{
|
|
51
51
|
"path": "lib/index.js",
|
|
52
|
-
"limit": "
|
|
52
|
+
"limit": "3 kB",
|
|
53
53
|
"ignore": [
|
|
54
54
|
"quario"
|
|
55
55
|
]
|