@quario/html 0.4.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 CHANGED
@@ -7,6 +7,39 @@ 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
+
10
43
  ## [0.4.0] - 2026-09-03
11
44
 
12
45
  ### Changed
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 = [];
@@ -267,14 +268,34 @@ let slotAttr = (slot) =>
267
268
  esc("display:grid;" + (slot && slot.width != null ? "width:" + slot.width + "%" : "flex:1")) +
268
269
  '"';
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;
293
+
270
294
  // The identity attribute, escaped like every other generated value at this
271
295
  // edge. `mark` is the factory's verdict: a render without `paths` maps every
272
296
  // event to "" through the same function rather than re-deciding per call site.
273
297
  /** @type {(event: { path?: string }) => string} */
274
298
  let pathAttr = ({ path }) => (path ? ' data-q-path="' + esc(path) + '"' : "");
275
- /** @type {() => string} */
276
- let noAttr = () => "";
277
-
278
299
  /** @type {(list: any[], tag: string, attrs: (event: any) => string, intl: any) => string} */
279
300
  let cells = (list, tag, attrs, intl) =>
280
301
  list
@@ -313,12 +334,24 @@ let cells = (list, tag, attrs, intl) =>
313
334
  * The target (see SCHEMA.md, "Instances and targets").
314
335
  */
315
336
  export function html(options) {
316
- let mark = options?.paths ? pathAttr : noAttr;
337
+ let mark = options?.paths ? pathAttr : () => "";
317
338
  // The factory's verdict, like `mark`: the mapping is validated once, here.
318
339
  let styleAttr = styling(options?.fonts);
319
340
  // The pair every cell carries, bound once rather than threaded as two.
320
341
  /** @type {(cell: any) => string} */
321
- let attrs = (cell) => styleAttr(cell) + mark(cell);
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>";
322
355
  return {
323
356
  name: "html",
324
357
  compile: (stream) => async (data) => {
@@ -425,12 +458,13 @@ export function html(options) {
425
458
  // The stable `q-item q-<role>` classes are the whole class attribute;
426
459
  // print CSS targets the band-role class for styling and breaks.
427
460
  item: (event) => {
461
+ let slot = placed(event);
428
462
  emit(
429
463
  event,
430
464
  '<div class="q-item q-' +
431
465
  event.role +
432
466
  '"' +
433
- styleAttr(event) +
467
+ styleAttr(slot, slot.own) +
434
468
  mark(event) +
435
469
  ">" +
436
470
  markup(event.tokens, event.style, intl) +
@@ -461,6 +495,7 @@ export function html(options) {
461
495
  // never the markup-passing join a cell body gets; an image without one
462
496
  // carries `alt=""`, the decorative-image convention.
463
497
  image: (event) => {
498
+ let slot = placed(event);
464
499
  let source = sources.get(event.bytes);
465
500
  if (source === undefined)
466
501
  sources.set(
@@ -472,7 +507,7 @@ export function html(options) {
472
507
  '<div class="q-item q-image q-' +
473
508
  event.role +
474
509
  '"' +
475
- styleAttr(event) +
510
+ styleAttr(slot, justified(slot.own, event.style)) +
476
511
  mark(event) +
477
512
  '><img src="' +
478
513
  source +
@@ -522,9 +557,13 @@ export function html(options) {
522
557
  for (let column of /** @type {any[]} */ (event.columns))
523
558
  cols +=
524
559
  "<col" + (column.width ? ' style="width:' + esc(column.width) + '%"' : "") + ">";
525
- let headers = event.columns.map((/** @type {any} */ column) =>
526
- Object.assign({}, column.header, { path: column.path }),
527
- );
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
+ );
528
567
  out +=
529
568
  '<table class="q-table"' +
530
569
  mark(event) +
@@ -532,31 +571,19 @@ export function html(options) {
532
571
  "<colgroup>" +
533
572
  cols +
534
573
  "</colgroup>" +
535
- "<thead><tr" +
536
- styleAttr({ style: unbox(event.style) }) +
537
- ">" +
538
- cells(headers, "th", attrs, intl) +
539
- "</tr></thead><tbody>";
574
+ "<thead>" +
575
+ tr(event, headers, "th", intl) +
576
+ "</thead><tbody>";
540
577
  },
541
578
  row: (event) => {
542
- out +=
543
- "<tr" +
544
- styleAttr({ style: unbox(event.style) }) +
545
- ">" +
546
- cells(event.cells, "td", attrs, intl) +
547
- "</tr>";
579
+ out += tr(event, event.cells, "td", intl);
548
580
  },
549
581
  "total-row": (event) => {
550
582
  if (!tfoot) {
551
583
  out += "</tbody><tfoot>";
552
584
  tfoot = true;
553
585
  }
554
- out +=
555
- "<tr" +
556
- styleAttr({ style: unbox(event.style) }) +
557
- ">" +
558
- cells(event.cells, "td", attrs, intl) +
559
- "</tr>";
586
+ out += tr(event, event.cells, "td", intl);
560
587
  },
561
588
  "table-end": () => {
562
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 byte-identical to the one
19
- * in `packages/viewer/lib/style.js`, which the viewer adopts into its shadow
20
- * root. The two cannot be single-sourced -- the viewer imports no target
21
- * package -- so `test/stylesheet.test.js` at the repo root fails when they
22
- * drift. Edit one, edit the other.
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.4.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.4.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.4.0"
47
+ "quario": "^0.5.0"
48
48
  },
49
49
  "size-limit": [
50
50
  {
51
51
  "path": "lib/index.js",
52
- "limit": "2.5 kB",
52
+ "limit": "3 kB",
53
53
  "ignore": [
54
54
  "quario"
55
55
  ]