rapier-markdown-kit 1.1.14 → 1.1.21

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/README.md CHANGED
@@ -18,21 +18,6 @@ import {parseWill} from 'rapier-markdown-kit/will'; // Will/1 markers: kee
18
18
  import {parseAssets} from 'rapier-markdown-kit/assets'; // the picture appendix
19
19
  ```
20
20
 
21
- **What it does**
22
- - Parses, validates and writes back the `md-layout:v1` comment that places a picture: width, wrap,
23
- position.
24
- - Lays text out around a picture's shape exactly as Rapier does, given the host's font metrics.
25
- - Reads and writes colour and page-break marks.
26
- - Reads Will/1 markers (a separate, optional standard) and says what a region's law allows.
27
- - Reads the picture appendix, the reference definitions a document's pictures live in, without decoding
28
- a picture.
29
- - Styles rendered Markdown with the same self-contained sheet Rapier and its exported pages use.
30
-
31
- **What it is not**
32
- - Not the editor, not Draw, not the image decoders. Every convention is plain Markdown or an HTML
33
- comment other readers ignore. The text layout engine it uses, Pretext, is vendored with its own MIT
34
- licence.
35
-
36
21
  ## What it gives you
37
22
 
38
23
  | Import | What it is |
@@ -45,8 +30,8 @@ import {parseAssets} from 'rapier-markdown-kit/assets'; // the picture appendi
45
30
  | `rapier-markdown-kit/style.css` | The reference style: type, lists, tables, tasks, pictures and the document's other markup, scoped to `.md-render`. |
46
31
  | `rapier-markdown-kit` | All five in one import. |
47
32
 
48
- Every convention here is plain Markdown or an HTML comment that other readers ignore. Laying lines
49
- out exactly as Rapier does also takes the same font metrics, which the host supplies (below).
33
+ Every convention is plain Markdown or an HTML comment other readers ignore. Laying lines out as Rapier does takes
34
+ the host's font metrics (below).
50
35
 
51
36
  ## Render with the reference style
52
37
 
@@ -56,17 +41,11 @@ link it with `<link rel="stylesheet" href="style.css">`. Put rendered content in
56
41
  on that root chooses one. Its `--md-*` properties are defined by the sheet itself. Geist and Geist Mono
57
42
  are named font families with system fallbacks; no font or external asset is fetched by the sheet.
58
43
 
59
- This runnable example supplies `markdown-it` as the renderer and the kit as the convention readers.
60
- Save the following as `render.mjs`, run `npm install rapier-markdown-kit markdown-it@15`, then
61
- `node render.mjs`. Open `styled.html`; it uses only `style.css` for its document styles. The function
62
- below belongs to the example, not to the package API.
63
-
64
- The example renders trusted Markdown, including its HTML. It demonstrates core Markdown, GFM tables,
65
- an aligned paragraph, a sized picture, a named text colour and a page break. Tasks,
66
- footnotes and other extensions use the caller's Markdown plugins; details use ordinary HTML.
67
- In a Rapier page, supported Mermaid flowchart fences also draw offline in Rapier's look, so an agent can write a fence or use figures. The kit itself does not render diagrams.
68
- The complete markup contract, including callouts, diagrams, math and image wrapping, is
69
- [Reference style](https://github.com/jackskip22/rapier/blob/main/docs/markdown-standard.md#reference-style).
44
+ The example below renders trusted Markdown with `markdown-it` and the kit's readers: run
45
+ `npm install rapier-markdown-kit markdown-it@15`, save it as `render.mjs`, run `node render.mjs` and open
46
+ `styled.html`, which uses only `style.css`. It shows core Markdown, GFM tables, an aligned paragraph, a sized
47
+ picture, a named colour and a page break; tasks, footnotes and diagrams are the caller's plugins. The full markup
48
+ contract is [Reference style](https://github.com/jackskip22/rapier/blob/main/docs/markdown-standard.md#reference-style).
70
49
 
71
50
  <!-- reference-style-example -->
72
51
  ```js
@@ -236,12 +215,11 @@ The appendix parser takes a markdown-it-compatible parser factory from the calle
236
215
  node node_modules/rapier-markdown-kit/dist/kit/conformance/run.mjs
237
216
  ```
238
217
 
239
- Thirteen small documents, each with the line boxes Rapier's own export produced for it. The runner
240
- reports two things separately. **Preservation**: every layout comment round-trips exactly (12 of
241
- the 12 documents that carry one). **Presentation**: how closely the planned lines match (4 agree
242
- within 2px, 9 are close, none differ); each close row has a named cause, given in the runner's
243
- output and in `dist/kit/conformance/README.md`. `--impl path/to/module.mjs` runs another
244
- implementation against the same documents; `--tolerance 2` sets the pixel tolerance.
218
+ Thirteen small documents, each with the line boxes Rapier's own export produced for it. **Preservation**: every
219
+ layout comment round-trips exactly (12 of 12 that carry one). **Presentation**: how closely the planned lines match
220
+ (4 agree within 2px, 9 are close with a named cause, none differ; `dist/kit/conformance/README.md`).
221
+ `--impl path/to/module.mjs` runs another implementation against the same documents; `--tolerance 2` sets the pixel
222
+ tolerance.
245
223
 
246
224
  ## Licence
247
225
 
@@ -252,4 +230,5 @@ value the editor's release reads.
252
230
 
253
231
  ## Where the modules live
254
232
 
255
- Each module of the standard has one home in the Rapier source (`spec/`, `layout/`, `agent/`), where Rapier itself reads it; `dist/` is that closure copied byte for byte, so an installed copy needs nothing beside it. `style.css` is copied byte for byte from the same `spec/markdown-style.css` that Rapier bundles.
233
+ Each module has one home in the Rapier source (`spec/`, `layout/`, `agent/`), where Rapier itself reads it; `dist/`
234
+ is that closure copied byte for byte, and `style.css` is the same `spec/markdown-style.css` Rapier bundles.
@@ -28,11 +28,13 @@ function _rapierWillMarkerOf(content) {
28
28
  return null;
29
29
  }
30
30
  const near = fault => ({ kind: 'near', fault, law: '', intent: null, content: line });
31
+ const afterPrefix = line.slice(_RAPIER_WILL_OPENER_PREFIX.length);
32
+ const versionGap = afterPrefix.indexOf(' ');
33
+ if ((versionGap < 0 ? afterPrefix : afterPrefix.slice(0, versionGap)) !== '1') return near('unknown_version');
31
34
  if (!line.endsWith(_RAPIER_WILL_CLOSE_SUFFIX)) return near('malformed_marker');
32
35
  const middle = line.slice(_RAPIER_WILL_OPENER_PREFIX.length,
33
36
  line.length - _RAPIER_WILL_CLOSE_SUFFIX.length);
34
37
  const gap = middle.indexOf(' ');
35
- if ((gap < 0 ? middle : middle.slice(0, gap)) !== '1') return near('unknown_version');
36
38
  if (gap < 0) return near('malformed_marker');
37
39
  const rest = middle.slice(gap + 1);
38
40
  const word = (/^[^:\s]*/.exec(rest))[0];
@@ -5,7 +5,10 @@ standard](../../docs/markdown-standard.md): inline (no layout comment), width/x/
5
5
  with a real alpha silhouette, `wrap=box`, `behind`, `front`, `rotate=` on a plain raster, a foreign
6
6
  SVG "drawing" already turned in its own bytes, a ring drawing whose unfilled interior takes a line of
7
7
  text, both-sides wrapping around a centred picture, the neighbour search skipping a second, unrelated
8
- picture, the neighbour search stopping at a heading, and RTL (Arabic) text.
8
+ picture, a heading after a picture, and RTL (Arabic) text.
9
+
10
+ Headings can anchor images. `12-heading-barrier.expected.json` records an earlier paragraph
11
+ anchor; that historical measurement is not a normative neighbour-selection expectation.
9
12
 
10
13
  ## What each document's JSON carries
11
14
 
@@ -78,12 +81,17 @@ Prints two separate tables (preservation and presentation are different claims):
78
81
  either way), **9 CLOSE, 0 DIFFERS**, each CLOSE row's cause named rather than left as "close
79
82
  enough": `01-inline`/`02-width-x-align`/`05-behind`/`06-front` have no real trace at all (see
80
83
  `measurements` above) so still rest on the character-sum fallback; `03-wrap-around-silhouette`,
81
- `09-drawing-ring-interior`, `11-neighbour-skips-image` and `13-rtl-text` run Pretext for real during
82
- export (`13-rtl-text` at 100% — every query of its own answered from the real trace) yet still
83
- carry a small, constant left-edge delta (5.18px for 03/11/13, 6.63px for 09) that is a
84
- picture-obstacle *geometry* fact, not a text one: their `profileHint` is a hand-computed, quantized
85
- alpha-band descriptor for a picture Rapier's own export never serializes an occupancy descriptor
86
- for, and that descriptor's own precision — not the measurer — is the residual gap. Before this
84
+ `11-neighbour-skips-image` and `13-rtl-text` run Pretext for real during export (`13-rtl-text` at
85
+ 100% — every query of its own answered from the real trace) yet still carry a small, constant
86
+ left-edge delta (5.18px) that is a picture-obstacle *geometry* fact, not a text one: their
87
+ `profileHint` is a hand-computed, quantized alpha-band descriptor for a picture Rapier's own export
88
+ never serializes an occupancy descriptor for, and that descriptor's own precision — not the
89
+ measurer — is the residual gap. `09-drawing-ring-interior` plans the recorded 14 lines, but one row
90
+ differs by a whole slot (115.08px left, 292.09px right): its recorded trace, taken before a word
91
+ that must break inside itself was given the widest slot of its row, cuts "ordinary" into "ordinar"
92
+ and "y" in the ring's narrow left slot, where the planner now leaves that slot empty and sets the
93
+ word whole in the wide one (`layout/line-plan.mjs`). Its `expected.json` still holds the earlier cut;
94
+ it is regenerated from a browser export, not by hand. Before this
87
95
  measurer existed (a per-character glyph-sum with no real trace at all), the same run reported 4
88
96
  AGREE / 8 CLOSE / 1 DIFFERS — `13-rtl-text` disagreed by a whole line (7 expected, 8 planned): real
89
97
  Arabic shaping (ligatures, diacritic combination) changes a run's total measured width in a way an
@@ -109,14 +109,16 @@ console.log(`\n${agree}/${rows.length} documents AGREE within ${args.tolerance}p
109
109
  console.log(`Measurer: ${measurer.stats.hits} queries answered from Rapier's own real browser trace, ${measurer.stats.misses} fell back to a character-glyph sum (no kerning/shaping).`);
110
110
  console.log(`A row's own hits/misses can undercount its real coverage: several fixtures share one identical anchor sentence, and Pretext's own per-process`);
111
111
  console.log(`segment cache (agent/vendor/pretext/measurement.js) serves a repeated (font, segment) query straight from that cache without calling`);
112
- console.log(`measureText again at all -- real data, just not counted against the SECOND document to ask for it. Two named, separate reasons a row still`);
113
- console.log(`reads CLOSE rather than AGREE, neither of them "close enough": (a) no real trace exists at all for 01-inline/02-width-x-align/05-behind/06-front`);
112
+ console.log(`measureText again at all -- real data, just not counted against the SECOND document to ask for it. Three named, separate reasons a row still`);
113
+ console.log(`reads CLOSE rather than AGREE, none of them "close enough": (a) no real trace exists at all for 01-inline/02-width-x-align/05-behind/06-front`);
114
114
  console.log(`-- their picture pushes no flow obstacle, so layout/interchange.js's own prepare() never calls Pretext for this paragraph during export (see`);
115
- console.log(`measurer.mjs) -- the from-scratch character-sum approximation disagrees with real shaping by a bounded, named amount; (b) 03/09/11/13 DO run`);
115
+ console.log(`measurer.mjs) -- the from-scratch character-sum approximation disagrees with real shaping by a bounded, named amount; (b) 03/11/13 DO run`);
116
116
  console.log(`Pretext for real (hits > 0, 13-rtl-text at 100% -- every query answered from the real trace) yet still carry a small, constant left-edge delta`);
117
- console.log(`(5.18px for 03/11/13, 6.63px for 09, unchanged from before this measurer existed) -- a picture-obstacle geometry fact, not a text one: these`);
117
+ console.log(`(5.18px, unchanged from before this measurer existed) -- a picture-obstacle geometry fact, not a text one: these`);
118
118
  console.log(`fixtures' profileHint is a hand-computed, quantized alpha band descriptor for a raster Rapier's own export never serialized an occupancy`);
119
- console.log(`descriptor for (conformance/README.md, "profileHint"), and that descriptor's own precision -- not this lane's measurer -- is the residual gap.`);
119
+ console.log(`descriptor for (conformance/README.md, "profileHint"), and that descriptor's own precision -- not this lane's measurer -- is the residual gap; (c) 09 plans the recorded 14 lines but one row`);
120
+ console.log(`differs by a whole slot: its recorded trace cuts a word in the ring's narrow left slot, and the planner now gives a word that must break inside itself`);
121
+ console.log(`the widest slot of its row (layout/line-plan.mjs) -- the recorded lines are the earlier planner's.`);
120
122
  if (measurer.stats.missedQueries.length) {
121
123
  console.log(`\nFirst ${Math.min(10, measurer.stats.missedQueries.length)} of ${measurer.stats.misses} missed (font, text) queries (fell back to the character sum):`);
122
124
  for (const {font, text} of measurer.stats.missedQueries.slice(0, 10)) console.log(` ${JSON.stringify(text)} (${font})`);
@@ -1,4 +1,89 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ // A word or an address wider than its slot has to break inside itself; the browser would let it run out of the column, a
3
+ // projection cannot. Pretext breaks it after the last hyphen that fits, else after the last letter that fits. A person reads an
4
+ // address better broken where it has its own punctuation, so the break moves back to the last of these that fits.
5
+ const naturalBreak = /[/._?&=]/, hyphen = /[-\u058a\u2010\u2012\u2013\u2014]/;
6
+ let clusters;
7
+ const lettersOf = new WeakMap();
8
+
9
+ function breakAtPunctuation(flow, range, cursor, layoutNextRichInlineLineRange) {
10
+ const last = range.fragments.at(-1), end = last?.end;
11
+ if (!end || end.graphemeIndex === 0) return range;
12
+ const prepared = flow.items[last.itemIndex]?.prepared, segment = prepared?.segments?.[end.segmentIndex];
13
+ if (typeof segment !== 'string') return range;
14
+ // A long address is cut once per line; its letters are counted once.
15
+ let byIndex = lettersOf.get(prepared);
16
+ if (!byIndex) lettersOf.set(prepared, byIndex = new Map());
17
+ let letters = byIndex.get(end.segmentIndex);
18
+ if (!letters) {
19
+ clusters ||= new Intl.Segmenter(undefined, {granularity: 'grapheme'});
20
+ byIndex.set(end.segmentIndex, letters = [...clusters.segment(segment)].map(part => part.segment));
21
+ }
22
+ const from = last.start.segmentIndex === end.segmentIndex ? last.start.graphemeIndex : 0;
23
+ if (end.graphemeIndex > letters.length || hyphen.test(letters[end.graphemeIndex - 1]) || naturalBreak.test(letters[end.graphemeIndex - 1])) return range;
24
+ let target = end.graphemeIndex - 1;
25
+ while (target > from && !naturalBreak.test(letters[target - 1])) target--;
26
+ if (target <= from) return range;
27
+ // Each narrower limit yields the break one letter back (`balanced` walks the breaks of a line the same way).
28
+ for (let limit = range.width - .05, at = end.graphemeIndex; at > target;) {
29
+ const shorter = layoutNextRichInlineLineRange(flow, limit, cursor), tail = shorter?.fragments.at(-1);
30
+ if (!tail || tail.itemIndex !== last.itemIndex || tail.end.segmentIndex !== end.segmentIndex || tail.end.graphemeIndex >= at) return range;
31
+ if (tail.end.graphemeIndex === target) return shorter;
32
+ at = tail.end.graphemeIndex;
33
+ limit = shorter.width - .05;
34
+ }
35
+ return range;
36
+ }
37
+
38
+ // Pretext lets a line end wherever one element ends and the next begins. The browser allows no break there unless white space
39
+ // stands between them, so a comma, a colon or a closing bracket straight after `agents.md` (a chip, a link, bold words) would
40
+ // start the next line alone. The line ends one break earlier instead, and the punctuation keeps its word.
41
+ const closing = /^[,.:;!?)\]}%\u2019\u201d\u00bb\u203a\u2026\u060c\u061b\u061f\u3001\u3002\uff0c\uff0e\uff1a\uff1b\uff01\uff1f\uff09\u300d\u300f]/;
42
+
43
+ function stranding(flow, range) {
44
+ const {itemIndex, segmentIndex, graphemeIndex} = range.end;
45
+ if (segmentIndex !== 0 || graphemeIndex !== 0) return false;
46
+ for (let index = itemIndex; index < flow.items.length; index++) {
47
+ const item = flow.items[index];
48
+ if (!item?.establishesLine) continue;
49
+ return !item.breakBefore && closing.test(item.prepared?.segments?.[0] ?? '');
50
+ }
51
+ return false;
52
+ }
53
+
54
+ function keepWithWord(flow, range, cursor, layoutNextRichInlineLineRange) {
55
+ if (!stranding(flow, range)) return range;
56
+ for (let limit = range.width - .05, tries = 64; tries > 0; tries--) {
57
+ const shorter = layoutNextRichInlineLineRange(flow, limit, cursor);
58
+ if (!shorter || shorter.width > limit + .05) return range;
59
+ if (!stranding(flow, shorter)) return shorter;
60
+ limit = shorter.width - .05;
61
+ }
62
+ return range;
63
+ }
64
+
65
+ // A chip (inline code, a box each piece of it repeats) breaks at its own spaces and hyphens like any words, but never across a
66
+ // picture: "npx rapier-" left of the picture and "html" right of it read as two things. When a line would end inside a chip
67
+ // with another slot of the row still to come, the line ends before the chip instead and the chip goes whole to the next slot;
68
+ // when nothing but the chip stands in the slot, the row's remaining slots are given up and the chip's pieces stack in this one.
69
+ function insideChip(flow, range) {
70
+ const {itemIndex, segmentIndex, graphemeIndex} = range.end;
71
+ return (segmentIndex > 0 || graphemeIndex > 0) && flow.items[itemIndex]?.extraWidth > 0;
72
+ }
73
+ function beforeChip(flow, range, cursor, layoutNextRichInlineLineRange) {
74
+ let first = range.end.itemIndex;
75
+ while (first > 0 && flow.items[first - 1]?.extraWidth > 0) first--;
76
+ if (first === 0 || first <= cursor.itemIndex && (cursor.segmentIndex > 0 || cursor.graphemeIndex > 0 || first < cursor.itemIndex)) return null;
77
+ const before = shorter => shorter.end.itemIndex < first || shorter.end.itemIndex === first && shorter.end.segmentIndex === 0 && shorter.end.graphemeIndex === 0;
78
+ for (let limit = range.width - .05, tries = 64; tries > 0; tries--) {
79
+ const shorter = layoutNextRichInlineLineRange(flow, limit, cursor);
80
+ if (!shorter || shorter.width > limit + .05) return null;
81
+ if (before(shorter)) return shorter.end.itemIndex === cursor.itemIndex && shorter.end.segmentIndex === cursor.segmentIndex && shorter.end.graphemeIndex === cursor.graphemeIndex ? null : shorter;
82
+ limit = shorter.width - .05;
83
+ }
84
+ return null;
85
+ }
86
+
2
87
  export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance, slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange) {
3
88
  const lines = [], ranges = [];
4
89
  let cursor = {itemIndex: 0, segmentIndex: 0, graphemeIndex: 0}, y = 0, bottom = 0, rows = 0;
@@ -6,16 +91,30 @@ export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, dire
6
91
  const bands = slotsForBand(width, obstacles, top + y, lineHeight, Math.min(minWidth, width));
7
92
  if (!bands) return null;
8
93
  if (direction === 'rtl') bands.reverse();
94
+ const widest = Math.max(0, ...bands.map(band => band.width));
9
95
  let advanced = false;
10
- for (const band of bands) {
11
- const range = layoutNextRichInlineLineRange(flow, band.width, cursor);
96
+ for (let slot = 0; slot < bands.length; slot++) {
97
+ const band = bands[slot];
98
+ let range = layoutNextRichInlineLineRange(flow, band.width, cursor);
12
99
  if (!range) return lines.length ? {lines: balance ? balanced(flow, width, balance, lines, ranges, lineHeight, layoutNextRichInlineLineRange, materializeRichInlineLineRange) : lines, height: Math.max(lineHeight, bottom)} : null;
13
100
  if (range.width > width + .5 || range.end.itemIndex === cursor.itemIndex &&
14
101
  range.end.segmentIndex === cursor.segmentIndex && range.end.graphemeIndex === cursor.graphemeIndex) return null;
15
102
  if (range.width > band.width + .5) continue;
103
+ range = breakAtPunctuation(flow, range, cursor, layoutNextRichInlineLineRange);
104
+ // Ending a line earlier for a comma can cut a word too, so that cut is settled as well.
105
+ if (!balance && stranding(flow, range)) range = breakAtPunctuation(flow, keepWithWord(flow, range, cursor, layoutNextRichInlineLineRange), cursor, layoutNextRichInlineLineRange);
106
+ // A word or address that must break inside itself does it in the widest slot of the row. A narrower one, a sliver beside the
107
+ // picture, waits and the widest takes it, so the head of an address, or "passage" and "s,", is never left alone in a corner.
108
+ if (range.end.graphemeIndex > 0 && band.width < widest - .5) continue;
109
+ let column = false;
110
+ if (!balance && slot + 1 < bands.length && insideChip(flow, range)) {
111
+ const whole = beforeChip(flow, range, cursor, layoutNextRichInlineLineRange);
112
+ if (whole) range = whole; else column = true;
113
+ }
16
114
  if (lines.length === 4096) return null;
17
115
  lines.push({...materializeRichInlineLineRange(flow, range), ...band, y}); ranges.push(range);
18
116
  cursor = range.end; advanced = true; bottom = y + lineHeight;
117
+ if (column) break;
19
118
  }
20
119
  if (advanced) { y += lineHeight; rows++; continue; }
21
120
  let next = Infinity;
@@ -110,36 +110,45 @@ export function rasterReserved(columnWidth, unrotated, rad) {
110
110
  }
111
111
 
112
112
 
113
- const shapeGrid = 48, alphaThreshold = .1, profileBands = new WeakMap();
113
+ const shapeGrid = 48, alphaThreshold = .1, alphaOversample = 4, profileBands = new WeakMap();
114
+ // Slices are cut at up to this many bands: a picture shown 6 px a band or finer keeps every row the profile holds.
115
+ const sliceGridMax = shapeGrid * alphaOversample, slicePixelsPerBand = 6;
114
116
 
117
+ // The picture's silhouette as 192 bands of horizontal runs, read from its alpha at that resolution both ways: a band holds the
118
+ // ink of one sampled row, and a run's ends fall on a 192nd of the picture's width, so the standoff a slice adds (`pictureSlices`'
119
+ // `gap`) is the least distance from the words to the paint however large the picture is drawn. At 48 rows and columns a run's
120
+ // end drifted by a 48th of the width (9 px at 430), a thin stroke fell between two samples, and a tall picture's bands were
121
+ // 20 px high. The descriptor an exported page carries (`serializeProfile`) still writes 48 bands.
115
122
  export function alphaProfile(image) {
116
123
  const src = image.currentSrc || image.getAttribute('src') || '';
117
124
  if (!image.complete || !image.naturalWidth || !image.naturalHeight || !src || /^data:image\/jpe?g[;,]/i.test(src)) return null;
125
+ const fine = shapeGrid * alphaOversample;
118
126
  const canvas = image.ownerDocument.createElement('canvas');
119
- canvas.width = canvas.height = shapeGrid;
127
+ canvas.width = canvas.height = fine;
120
128
  let data;
121
129
  try {
122
130
  const context = canvas.getContext('2d', {willReadFrequently: true});
123
131
  if (!context) return null;
124
- context.drawImage(image, 0, 0, shapeGrid, shapeGrid);
125
- data = context.getImageData(0, 0, shapeGrid, shapeGrid).data;
132
+ context.imageSmoothingQuality = 'high';
133
+ context.drawImage(image, 0, 0, fine, fine);
134
+ data = context.getImageData(0, 0, fine, fine).data;
126
135
  } catch (_) { return null; }
127
136
  finally { canvas.width = canvas.height = 0; }
128
137
  const bands = [];
129
138
  let opaque = true;
130
- for (let y = 0; y < shapeGrid; y++) {
139
+ for (let y = 0; y < fine; y++) {
131
140
  const runs = [];
132
141
  let start = -1;
133
- for (let x = 0; x <= shapeGrid; x++) {
134
- const alpha = x < shapeGrid ? data[(y * shapeGrid + x) * 4 + 3] : 0;
135
- if (x < shapeGrid && alpha < 250) opaque = false;
136
- if (alpha > alphaThreshold * 255) { if (start < 0) start = x; }
137
- else if (start >= 0) { runs.push([start / shapeGrid, x / shapeGrid]); start = -1; }
142
+ for (let x = 0; x <= fine; x++) {
143
+ const alpha = x < fine ? data[(y * fine + x) * 4 + 3] : 0;
144
+ if (x < fine && alpha < 250) opaque = false;
145
+ if (x < fine && alpha > alphaThreshold * 255) { if (start < 0) start = x; }
146
+ else if (start >= 0) { runs.push([start / fine, x / fine]); start = -1; }
138
147
  }
139
148
  bands.push(runs);
140
149
  }
141
150
  if (opaque) return null;
142
- const runsAt = t => bands[Math.max(0, Math.min(shapeGrid - 1, Math.floor(t * shapeGrid)))];
151
+ const runsAt = t => bands[Math.max(0, Math.min(fine - 1, Math.floor(t * fine)))];
143
152
  return {runsAt,
144
153
  spanAt(t) { const runs = runsAt(t); return runs.length ? [runs[0][0], runs.at(-1)[1]] : null; }};
145
154
  }
@@ -234,15 +243,25 @@ export function parseProfile(text) {
234
243
  return bandsProfile(bands);
235
244
  }
236
245
 
246
+ // The standoff is round: a band's own rows are pushed out by the whole gap, and the rows above and below it, out to a gap away,
247
+ // by what a circle of that radius allows at their distance, in three steps. A square standoff stood up to 14 px off a slanted
248
+ // stroke and 10 off a level one; this stands 10 off both, within a step.
249
+ const shoulderSteps = 3, shoulders = Array.from({length: shoulderSteps}, (_, k) => ({from: k / shoulderSteps, to: (k + 1) / shoulderSteps, reach: Math.sqrt(1 - ((k + .5) / shoulderSteps) ** 2)}));
250
+
237
251
  export function pictureSlices(profile, x, y, width, height, gap = 10) {
238
252
  if (!profile) return [{x: x - gap, y: y - gap, width: width + gap * 2, height: height + gap * 2}];
239
- let bands = profileBands.get(profile);
253
+ // A band is at most 6 px high at the shown size, never coarser than the descriptor's 48 nor finer than the profile's 192.
254
+ const count = Math.max(shapeGrid, Math.min(sliceGridMax, Math.ceil((finite(height) && height > 0 ? height : 0) / slicePixelsPerBand)));
255
+ let byCount = profileBands.get(profile);
256
+ if (!byCount) profileBands.set(profile, byCount = new Map());
257
+ let bands = byCount.get(count);
240
258
  if (!bands) {
241
259
  bands = [];
242
260
  let previous = new Map();
243
- for (let index = 0; index < shapeGrid; index++) {
244
- const top = index / shapeGrid, bottom = (index + 1) / shapeGrid;
245
- const runs = [top, (top + bottom) / 2, bottom].flatMap(t => {
261
+ for (let index = 0; index < count; index++) {
262
+ const top = index / count, bottom = (index + 1) / count;
263
+ // Sampled just inside the bottom edge: on the edge itself a band profile answers with the next band's row and the slice runs a row long.
264
+ const runs = [top, (top + bottom) / 2, bottom - 1e-9].flatMap(t => {
246
265
  if (profile.runsAt) return profile.runsAt(t) || [];
247
266
  const span = profile.spanAt(t);
248
267
  return span ? [span] : [];
@@ -264,10 +283,19 @@ export function pictureSlices(profile, x, y, width, height, gap = 10) {
264
283
  }
265
284
  previous = current;
266
285
  }
267
- profileBands.set(profile, bands);
286
+ byCount.set(count, bands);
287
+ }
288
+ const slices = [];
289
+ for (const band of bands) {
290
+ const left = x + band.left * width, right = x + band.right * width, top = y + band.top * height, bottom = y + band.bottom * height;
291
+ slices.push({x: left - gap, y: top, width: right - left + gap * 2, height: bottom - top});
292
+ for (const {from, to, reach} of shoulders) {
293
+ const ext = gap * reach, span = gap * (to - from);
294
+ slices.push({x: left - ext, y: top - gap * to, width: right - left + ext * 2, height: span});
295
+ slices.push({x: left - ext, y: bottom + gap * from, width: right - left + ext * 2, height: span});
296
+ }
268
297
  }
269
- return bands.map(band => ({x: x + band.left * width - gap, y: y + band.top * height - gap,
270
- width: (band.right - band.left) * width + gap * 2, height: (band.bottom - band.top) * height + gap * 2}));
298
+ return slices;
271
299
  }
272
300
 
273
301
  export function slotsForBand(width, obstacles, top, height, minWidth = 40) {
@@ -128,6 +128,52 @@ export function parseAssets(source, factory = configured || globalThis.markdowni
128
128
  return index;
129
129
  }
130
130
  export function documentAssets(source, factory) { return parseAssets(source, factory, markdownBodyOffset(source)); }
131
+
132
+ // The same index folded from the document's top-level blocks: `rows` are the blocks as markdown-it's block parse
133
+ // of the body cuts them, each {start, raw} with `start` absolute in `source`, and `parse(raw, row)` is parseAssets
134
+ // over one block (the caller may cache it by the row). A definition the whole parse finds is one a block's own parse
135
+ // finds at the block's place, because a top-level block's lines parse the same alone as in the document; labels,
136
+ // duplicates, the first definition's precedence, the count limit and the appendix fold as parseAssets folds them.
137
+ // Null when the rows are not this source: a block's bytes elsewhere than its start, rows out of order or overlapping,
138
+ // or text that could hold a definition (`]:`) between the body's start, the rows and the end, where no row parses
139
+ // it. Positions are the source's own, never normalized.
140
+ export function blockwiseAssets(source, rows, parse = raw => parseAssets(raw), bodyOffset = markdownBodyOffset(source)) {
141
+ if (typeof source !== 'string' || !Array.isArray(rows)) return null;
142
+ const blocks = [], references = Object.create(null), duplicateLabels = new Set(), seen = new Set(), assets = new Map();
143
+ let cursor = bodyOffset;
144
+ for (const row of rows) {
145
+ const raw = String(row.raw || ''), start = row.start;
146
+ if (!(start >= cursor) || source.slice(cursor, start).includes(']:') || source.slice(start, start + raw.length) !== raw) return null;
147
+ cursor = start + raw.length;
148
+ if (!raw.includes(']:')) continue;
149
+ const index = parse(raw, row);
150
+ // Every label the block defines, picture or not: an earlier definition of a label, of any kind, is the one
151
+ // the document resolves, so a later picture under it is inactive and the label a duplicate.
152
+ const labels = Object.keys(index.references);
153
+ for (const label of index.duplicateLabels) duplicateLabels.add(label);
154
+ for (const label of labels) {
155
+ if (seen.has(label)) duplicateLabels.add(label);
156
+ if (!Object.hasOwn(references, label)) references[label] = index.references[label];
157
+ }
158
+ for (const block of index.blocks) {
159
+ const active = block.active && !seen.has(block.id);
160
+ const shifted = {...block, start: block.start + start, end: block.end + start, status: 'unverified', active};
161
+ for (const key of ['urlStart', 'urlEnd', 'payloadStart', 'payloadEnd']) if (block[key] != null) shifted[key] = block[key] + start;
162
+ blocks.push(shifted);
163
+ if (active) assets.set(block.id, shifted);
164
+ }
165
+ for (const label of labels) seen.add(label);
166
+ }
167
+ if (source.slice(cursor).includes(']:')) return null;
168
+ if (assets.size > IMAGE_LIMITS.assets) for (const asset of assets.values()) asset.status = 'asset_count_limit';
169
+ let appendixStart = source.length;
170
+ for (let index = blocks.length - 1; index >= 0; index--) {
171
+ const block = blocks[index];
172
+ if (!block.active || !block.topLevel || !/^[ \t\r\n]*$/.test(source.slice(block.end, appendixStart))) break;
173
+ appendixStart = block.start;
174
+ }
175
+ return {assets, blocks, duplicateLabels, appendixStart, references};
176
+ }
131
177
  export function imageEnvironment(source, factory) { return {references: Object.assign(Object.create(null), documentAssets(source, factory).references)}; }
132
178
 
133
179
  function imageUses(source, factory, env = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.1.14",
3
+ "version": "1.1.21",
4
4
  "description": "Read, write and style Self-contained Markdown: the universal stylesheet, picture layout, text marks, picture appendix, line planner and Will/1 markers. MIT, no dependencies.",
5
5
  "author": "Jack Skipworth",
6
6
  "license": "MIT",
package/style.css CHANGED
@@ -16,7 +16,8 @@
16
16
  --md-text-base:1rem;
17
17
  --md-fw-semibold:600;
18
18
  --md-fw-regular:400;
19
- --md-leading-tight:1.2;
19
+ --md-text-body:1.1rem;
20
+ --md-line:1.75rem;
20
21
  --md-space-6:24px;
21
22
  --md-space-4:16px;
22
23
  --md-space-3:12px;
@@ -31,11 +32,10 @@
31
32
  --md-color-border-focus:light-dark(#c4c2bc,#2a2a2a);
32
33
  --md-color-text-secondary:light-dark(#444444,#a1a1a1);
33
34
  --md-color-text-muted:light-dark(#606060,#878787);
34
- --md-color-success:light-dark(#1a9148,#45d483);
35
- --md-color-warning:light-dark(#d97706,#f5a623);
35
+ --md-color-success:light-dark(#188844,#45d483);
36
+ --md-color-warning:light-dark(#b26105,#f5a623);
36
37
  --md-color-error:light-dark(#c00,#ec5156);
37
38
  --md-space-8:32px;
38
- --md-space-7:28px;
39
39
  --md-color-accent:#12A594;
40
40
  --md-color-text-subtle:light-dark(#636363,#858585);
41
41
  --md-heading-color:var(--md-color-text);
@@ -117,7 +117,7 @@
117
117
  runs past the column reads as a wide table does -- it scrolls, under the same fading edge. */
118
118
  :where(.md-render) .diagram-block {
119
119
  position: relative;
120
- margin: 1em 0;
120
+ margin: 0 0 var(--md-line);
121
121
  max-width: 100%;
122
122
  overflow: auto;
123
123
  }
@@ -345,25 +345,31 @@
345
345
  opacity: .78;
346
346
  }
347
347
 
348
+ /* The line model (item 2c, the founder's: the way a Word document reads). One line, --md-line, is the body's line
349
+ box: 1.75rem, 1.6 lines of the 1.1rem body, a whole 28 px at the browser's 16 px root so baselines land on pixels.
350
+ Every block ends one line below its last line; a heading ends half a line below. A heading's own box is the whole
351
+ or half lines its size fills: h1 at 2.4 x the body on two lines, h2 and h3 on one and a half, h4 to h6 on one.
352
+ Nothing sets a top margin, so a plain page (sibling margins collapsing) and the editor (a wrapper per block) space
353
+ alike, and the space before any block is the line its predecessor leaves. */
348
354
  .md-render{
349
- font:var(--md-fw-regular) 1.1rem/1.75 var(--md-font-sans);color:var(--md-color-text);text-align:start}
355
+ font:var(--md-fw-regular) var(--md-text-body)/var(--md-line) var(--md-font-sans);color:var(--md-color-text);text-align:start}
350
356
  .md-render>:first-child,.md-render :is(blockquote,li,td,th,dd,.callout,details)>:first-child{margin-top:0}
357
+ /* A block's last line ends the block: the line its last paragraph would leave is the block's own (a quote used to stand a line taller than its words). */
358
+ .md-render :is(blockquote,td,th,dd)>:last-child{margin-bottom:0}
351
359
  .md-render h1,.md-render h2,.md-render h3,.md-render h4,.md-render h5,.md-render h6{
352
360
  font-family:var(--md-font-sans);letter-spacing:-0.01em;
353
- line-height:var(--md-leading-tight);color:var(--md-heading-color);text-wrap:balance}
354
- .md-render h1{font-size:2.8rem;font-weight:700;
355
- margin:var(--md-space-6) 0 var(--md-space-4);
356
- padding-bottom:var(--md-space-3)}
357
- .md-render h2{font-size:2.2rem;font-weight:600;margin:var(--md-space-6) 0 var(--md-space-3)}
358
- .md-render h3{font-size:1.7rem;font-weight:600;margin:var(--md-space-5) 0 var(--md-space-2)}
359
- .md-render h4{font-size:1.3rem;font-weight:500;margin:var(--md-space-4) 0 var(--md-space-2)}
361
+ line-height:var(--md-line);margin:0 0 calc(var(--md-line) / 2);color:var(--md-heading-color);text-wrap:balance}
362
+ .md-render h1{font-size:calc(var(--md-text-body) * 2.4);line-height:calc(var(--md-line) * 2);font-weight:700}
363
+ .md-render h2{font-size:calc(var(--md-text-body) * 1.9);line-height:calc(var(--md-line) * 1.5);font-weight:600}
364
+ .md-render h3{font-size:calc(var(--md-text-body) * 1.5);line-height:calc(var(--md-line) * 1.5);font-weight:600}
365
+ .md-render h4{font-size:calc(var(--md-text-body) * 1.25);font-weight:500}
360
366
  /* h5/h6 complete the six-level hierarchy: modest but owned (explicit size/weight/colour), never
361
- the browser default. Size stays strictly monotonic below h4; h6 firms up weight and dims colour
362
- since its size alone reads weakly small next to body text. */
363
- .md-render h5{font-size:1.1rem;font-weight:600;margin:var(--md-space-4) 0 var(--md-space-2)}
364
- .md-render h6{font-size:0.95rem;font-weight:600;margin:var(--md-space-3) 0 var(--md-space-2);color:var(--md-heading-muted-color)}
365
- .md-render p{margin:0 0 var(--md-space-4)}
366
- .md-render ul,.md-render ol{margin:0 0 var(--md-space-4);padding-inline-start:var(--md-space-5)}
367
+ the browser default. Size stays strictly monotonic below h4; h6 sits at the body's own size and
368
+ firms up weight and dims colour, since its size alone would read as body text. */
369
+ .md-render h5{font-size:calc(var(--md-text-body) * 1.1);font-weight:600}
370
+ .md-render h6{font-size:var(--md-text-body);font-weight:600;color:var(--md-heading-muted-color)}
371
+ .md-render p{margin:0 0 var(--md-line)}
372
+ .md-render ul,.md-render ol{margin:0 0 var(--md-line);padding-inline-start:var(--md-space-5)}
367
373
  .md-render ul{list-style:none}
368
374
  .md-render ul>li{position:relative}
369
375
 
@@ -445,7 +451,7 @@
445
451
  .md-render ol{
446
452
  list-style:none;
447
453
  padding-inline-start:2.75rem;
448
- margin:0 0 var(--md-space-4);
454
+ margin:0 0 var(--md-line);
449
455
  counter-reset:rapier-ol;
450
456
  }
451
457
  .md-render ol>li{
@@ -475,12 +481,13 @@
475
481
 
476
482
  inset-inline-start:calc(-1.75rem - 1.5px);
477
483
  top:2.2rem;
478
- bottom:-0.4rem;
484
+ bottom:calc(var(--md-line) / -4);
479
485
  border-inline-start:3px dotted var(--md-color-line);
480
486
  z-index:0;
481
487
  }
482
- .md-render li{margin:0 0 var(--md-space-3)}
483
- .md-render li>ul,.md-render li>ol{margin:var(--md-space-2) 0 0}
488
+ .md-render li{margin:0 0 calc(var(--md-line) / 4)}
489
+ .md-render li:last-child{margin-bottom:0}
490
+ .md-render li>ul,.md-render li>ol{margin:calc(var(--md-line) / 4) 0 0}
484
491
  .md-render a{color:var(--md-color-accent-text);text-decoration:underline;text-underline-offset:2px}
485
492
  /* The house focus (rapier-app.css) in the document and on the exported page: the underline thickens, no ring. */
486
493
  .md-render a:focus-visible,.md-render ul.rapier-jump-list>li>a:focus-visible,.md-render .footnote-ref a:focus-visible,.md-render summary:focus-visible{outline:none;text-decoration:underline;text-decoration-thickness:3px;text-underline-offset:.2em}
@@ -499,7 +506,7 @@
499
506
  --md-jump-marker-center:1em;
500
507
  list-style:none;
501
508
  padding-inline-start:1.85rem;
502
- margin:0 0 var(--md-space-5);
509
+ margin:0 0 var(--md-line);
503
510
  }
504
511
  .md-render ul.rapier-jump-list>li{
505
512
  position:relative;
@@ -543,13 +550,13 @@
543
550
  .md-render em{font-style:italic}
544
551
  .md-render code{font-family:var(--md-font-mono);font-size:0.85em;
545
552
  background:var(--md-color-inset);border:1px solid var(--md-color-border);padding:1px 5px}
546
- .md-render pre{margin:0 0 var(--md-space-4);padding:var(--md-space-3) var(--md-space-4);
553
+ .md-render pre{margin:0 0 var(--md-line);padding:var(--md-space-3) var(--md-space-4);
547
554
  background:var(--md-color-surface-2);border:1px solid var(--md-color-border);overflow-x:auto}
548
- .md-render pre code{background:none;border:none;padding:0;font-size:0.8125rem;line-height:1.6}
555
+ .md-render pre code{background:none;border:none;padding:0;font-size:0.8125rem;line-height:calc(var(--md-line) * .75)}
549
556
  .md-render .math-display-wrap{
550
557
  display:block;
551
558
  max-width:100%;
552
- margin:0 0 var(--md-space-4);
559
+ margin:0 0 var(--md-line);
553
560
  overflow-x:auto;
554
561
  overflow-y:hidden;
555
562
  -webkit-overflow-scrolling:touch;
@@ -565,14 +572,14 @@
565
572
  text-align:initial;
566
573
  vertical-align:middle;
567
574
  }
568
- .md-render p>.math-display-wrap{margin:var(--md-space-2) 0 var(--md-space-4)}
575
+ .md-render p>.math-display-wrap{margin:calc(var(--md-line) / 4) 0 var(--md-line)}
569
576
  .md-render math[display="block"]{font-size:1.12em}
570
577
  .md-render math:not([display="block"]){font-size:1em}
571
- .md-render blockquote{margin:0 0 var(--md-space-4);
578
+ .md-render blockquote{margin:0 0 var(--md-line);
572
579
  padding-block:var(--md-space-1);padding-inline-start:var(--md-space-4);padding-inline-end:0;
573
580
  border-inline-start:2px solid var(--md-color-border-focus);color:var(--md-color-text-secondary)}
574
581
  .md-render blockquote blockquote{
575
- margin:var(--md-space-3) 0 var(--md-space-2);padding-inline-start:var(--md-space-3);
582
+ margin:calc(var(--md-line) / 2) 0 calc(var(--md-line) / 4);padding-inline-start:var(--md-space-3);
576
583
  border-inline-start-color:color-mix(in srgb,var(--md-color-border-focus) 70%,var(--md-color-text-muted));
577
584
  color:color-mix(in srgb,var(--md-color-text-secondary) 84%,var(--md-color-text-muted))
578
585
  }
@@ -597,7 +604,7 @@
597
604
  .md-render blockquote.callout>:last-child{margin-bottom:0}
598
605
  @media print{.md-render{--md-callout-note:#0969da;--md-callout-important:#8250df}}
599
606
 
600
- .md-render details { border: 1px solid var(--md-color-border); border-radius: 0; padding: var(--md-space-2) var(--md-space-4); margin: 0 0 var(--md-space-4); }
607
+ .md-render details { border: 1px solid var(--md-color-border); border-radius: 0; padding: var(--md-space-2) var(--md-space-4); margin: 0 0 var(--md-line); }
601
608
  .md-render details[open] { padding-bottom: var(--md-space-3); }
602
609
  /* A section with nothing typed into it yet has no paragraph to tap: without this, the only body
603
610
  space below the summary is the plain padding-bottom above (12px), too thin a margin for a real
@@ -615,15 +622,15 @@
615
622
  background:transparent;
616
623
  color:var(--md-color-line);
617
624
  opacity:1;
618
- margin:var(--md-space-7) 0;
625
+ margin:var(--md-line) 0;
619
626
  overflow:visible;
620
627
  }
621
628
  .md-render hr[data-hr-style="dash"]{
622
629
  border-top:2px solid var(--md-color-line);
623
630
  }
624
631
  .md-render hr[data-hr-style="stars"]{
625
- height:18px;
626
- margin:var(--md-space-8) 0;
632
+ height:var(--md-line);
633
+ margin:var(--md-line) 0;
627
634
  }
628
635
  .md-render hr[data-hr-style="stars"]::before{
629
636
  content:"";
@@ -652,7 +659,7 @@
652
659
  height:0;
653
660
  border:none;
654
661
  border-top:1px dashed var(--md-color-text-muted);
655
- margin:var(--md-space-7) 0;
662
+ margin:var(--md-line) 0;
656
663
  opacity:.37;
657
664
  }
658
665
  .md-render [data-md-break]::after{
@@ -671,7 +678,7 @@
671
678
  .md-render table{
672
679
  width:100%;
673
680
  border-collapse:collapse;
674
- margin:0 0 var(--md-space-5);
681
+ margin:0 0 var(--md-line);
675
682
  font-size:var(--md-text-sm);
676
683
  border:2px solid var(--md-color-line);
677
684
  }
@@ -695,7 +702,7 @@
695
702
  adjacent-sibling rule below settles the real "is it truly right after a table" question against
696
703
  the live .block-wrapper DOM, reactively, with no bookkeeping kept in sync on insert/delete/reorder. */
697
704
  :where(.md-render) .rapier-table-caption {
698
- margin-top: calc(-1 * var(--md-space-4));
705
+ margin-top: calc(var(--md-space-1) - var(--md-line));
699
706
  font-style: italic;
700
707
  font-size: var(--md-text-sm);
701
708
  color: var(--md-color-text-secondary);
@@ -714,8 +721,8 @@
714
721
  .md-render :has(>img[data-md-drawing],>a>img[data-md-drawing]){overflow-x:auto}
715
722
  .md-render img:is([data-rapier-asset],[data-rapier-image-url]):not([data-rapier-asset-state="ready"]){background:color-mix(in srgb,var(--md-color-text) 5%,transparent)}
716
723
 
717
- .md-render dl{margin:0 0 var(--md-space-4)}
718
- .md-render dt{font-weight:var(--md-fw-semibold);margin:var(--md-space-2) 0 0}
724
+ .md-render dl{margin:0 0 var(--md-line)}
725
+ .md-render dt{font-weight:var(--md-fw-semibold);margin:calc(var(--md-line) / 4) 0 0}
719
726
  .md-render dt:first-child{margin-top:0}
720
727
  .md-render dd{margin:var(--md-space-1) 0 0;padding-inline-start:var(--md-space-4);color:var(--md-color-text-secondary)}
721
728
 
@@ -752,7 +759,7 @@
752
759
  .md-render del,.md-render s{color:var(--md-color-text-muted);text-decoration:line-through}
753
760
 
754
761
  .md-render .footnotes-sep{display:none}
755
- .md-render .footnotes{margin:var(--md-space-7) 0 0;padding-top:var(--md-space-3);border-top:1px solid var(--md-color-border);font-size:var(--md-text-sm);color:var(--md-color-text-secondary)}
762
+ .md-render .footnotes{margin:var(--md-line) 0 0;padding-top:var(--md-space-3);border-top:1px solid var(--md-color-border);font-size:var(--md-text-sm);color:var(--md-color-text-secondary)}
756
763
  .md-render .footnotes-list{list-style:decimal;padding-inline-start:var(--md-space-5);margin:0;counter-reset:none}
757
764
  .md-render .footnotes-list>li{padding-top:0;min-height:0}
758
765
  .md-render .footnotes-list>li::before,.md-render .footnotes-list>li::after{content:none}
@@ -781,8 +788,9 @@
781
788
  }
782
789
 
783
790
  /* Overflow belongs to the document; the surrounding page owns its measure and padding. */
784
- .md-render .table-scroll-wrap{width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:2px}
785
- .md-render .table-scroll-wrap table{min-width:100%}
791
+ /* A wide table scrolls inside its wrap; the wrap carries the block's line, so a scrollbar sits under the table, not a line below it. */
792
+ .md-render .table-scroll-wrap{width:100%;margin:0 0 var(--md-line);overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:2px}
793
+ .md-render .table-scroll-wrap table{min-width:100%;margin-bottom:0}
786
794
  @media print{
787
795
  .md-render [data-md-break]{border:0!important;height:0!important;margin:0!important;break-before:page;page-break-before:always}
788
796
  .md-render [data-md-break]::after{content:none!important}