rapier-markdown-kit 1.1.65 → 1.1.73

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.
@@ -48,7 +48,7 @@ function _rapierWillMarkerOf(content) {
48
48
  if (!intent.length || intent.indexOf('--') >= 0) return near('malformed_marker');
49
49
  if ([...intent].length > _RAPIER_WILL_INTENT_LIMIT) return near('intent_over_bound');
50
50
  }
51
- // The words after the colon are document data, never permission (R85b); willGovern alone decides.
51
+ // The words after the colon are document data, never permission; willGovern alone decides.
52
52
  return { kind: 'open', law: word, intent, content: line };
53
53
  }
54
54
 
@@ -31,7 +31,7 @@ anchor; that historical measurement is not a normative neighbour-selection expec
31
31
  alpha shape of the fixture's own picture, computed independently in Node from how the picture was
32
32
  built (see the generation notes at the foot of this file) — not read back from Rapier — since these
33
33
  are plain rasters/foreign SVGs and Rapier's export only serializes `data-rapier-occupancy` for its
34
- own drawing recipes (`docs/architecture.md`, "Layout and images").
34
+ own drawing recipes.
35
35
  - `lines` — the anchor's own line boxes (`left`/`top`/`right`/`bottom`), relative to its own content
36
36
  box, read from Rapier's styled export: from the reflow script's own `<span>` boxes when the
37
37
  paragraph is flowed (their `right` is the reserved *slot*'s edge, `x + width` — the script sizes
@@ -40,8 +40,8 @@ anchor; that historical measurement is not a normative neighbour-selection expec
40
40
  *is* the glyphs' own tight extent).
41
41
  - `glyphWidths` — every character the anchor's text uses, with its own measured width in that exact
42
42
  font, from this browser's `measureText`, called in isolation on one character at a time. Always
43
- present; the fallback `measurer.mjs` (R77) reaches for when a query is not in `measurements` below.
44
- - `measurements` (R77) — every `(font, text)` pair Pretext's own `getMeasureContext()` was actually
43
+ present; the fallback `measurer.mjs` reaches for when a query is not in `measurements` below.
44
+ - `measurements` — every `(font, text)` pair Pretext's own `getMeasureContext()` was actually
45
45
  asked to measure while *this fixture's own export* ran its real reflow script, each with the exact
46
46
  width the browser gave it — captured from the exported page's own reflow as it ran.
47
47
  This is Rapier's own measurement, not a stand-in: a caller replaying the same font and text later
@@ -64,7 +64,7 @@ anchor; that historical measurement is not a normative neighbour-selection expec
64
64
  ## Running the comparison
65
65
 
66
66
  ```sh
67
- node run.mjs # the kit itself, driven by measurer.mjs (R77)
67
+ node run.mjs # the kit itself, driven by measurer.mjs
68
68
  node run.mjs --impl path/to/other-kit/index.mjs --tolerance 4
69
69
  ```
70
70
 
@@ -74,8 +74,8 @@ Prints two separate tables (preservation and presentation are different claims):
74
74
  `parseLayout`/`formatLayout`. No measurer, no font: either exactly right, or a real bug. 12/12.
75
75
  - **Presentation** — does the planned line count and each line's left/right agree with the recorded
76
76
  fact, within `--tolerance` pixels (default 2). This depends entirely on the measurer supplied;
77
- `run.mjs`'s own default (`measurer.mjs`, R77) replays Rapier's real Pretext trace wherever one was
78
- captured, falling back to the character-glyph sum only where none exists. Recorded R77 result (not a current-run receipt): **3/13
77
+ `run.mjs`'s own default (`measurer.mjs`) replays Rapier's real Pretext trace wherever one was
78
+ captured, falling back to the character-glyph sum only where none exists. Recorded result (not a current-run receipt): **3/13
79
79
  AGREE outright** (`04-wrap-box`, `10-both-sides`, `12-heading-barrier` — every
80
80
  one a case where the compared edge is pure obstacle-slot geometry, unaffected by text fidelity
81
81
  either way), **10 CLOSE, 0 DIFFERS**, each CLOSE row's cause named rather than left as "close
@@ -45,12 +45,11 @@ function _rapierRecordSplices(entry, ledger) {
45
45
  }
46
46
 
47
47
  // A ledger record as the journal and the Notes return validate it below: plain data, nothing
48
- // undefined anywhere in it. A live record could carry a row whose type or order was not yet known
49
- // (a block just inserted, before its first render: `type: undefined`), and the validator refused
50
- // the whole ledger for that one field -- silently at boot, where a refused ledger is cleared, and on
51
- // the founder's phone as "Rapier could not be reopened: the document history could not be restored"
52
- // on every return from Notes (24 September). The copy is made once per record; records are frozen,
53
- // so it never goes stale.
48
+ // undefined anywhere in it. A live record could carry a row whose type or order is not yet known (a
49
+ // block just inserted, before its first render: `type: undefined`), and the validator would refuse
50
+ // the whole ledger for that one field -- silently at boot, where a refused ledger is cleared, and
51
+ // as "Rapier could not be reopened: the document history could not be restored" on a return from
52
+ // Notes. The copy is made once per record; records are frozen, so it never goes stale.
54
53
  const _rapierJournalRecord = (() => {
55
54
  const copies = new WeakMap();
56
55
  return record => {
@@ -16,6 +16,21 @@ export function transportInterval(start, end, splices) {
16
16
  }
17
17
  return {start: a, end: b};
18
18
  }
19
+ // Follow the current extent of a touched range for disclosure or the person's focus.
20
+ // Unlike transportInterval, this does not prove unchanged source and never grants edit authority.
21
+ export function transportTouchedInterval(start, end, splices) {
22
+ const unchanged = transportInterval(start, end, splices);
23
+ if (unchanged) return unchanged;
24
+ let range = {start, end};
25
+ for (const row of splices) {
26
+ // A split replacement can insert at a boundary before another splice touches the range.
27
+ const boundaryInsertion = !row.removed.length && (row.pos === range.start || row.pos === range.end);
28
+ const exact = boundaryInsertion ? null : transportInterval(range.start, range.end, [row]);
29
+ range = exact || {start: Math.min(range.start, row.pos),
30
+ end: Math.max(row.pos + row.inserted.length, range.end + row.inserted.length - row.removed.length)};
31
+ }
32
+ return range;
33
+ }
19
34
  function over(row, other) {
20
35
  // Equal-position insertions have no ordering evidence. Review, never a lexical winner.
21
36
  if (!row.removed.length && !other.removed.length && row.pos === other.pos) return null;
@@ -3,7 +3,7 @@ import {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, histor
3
3
  export {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, historyEnvelope, agentActorId};
4
4
  import {authorship, readAuthorship, ledgerFromAuthorship} from './ledger/authorship.mjs';
5
5
  export {authorship, readAuthorship, ledgerFromAuthorship};
6
- import {merge, transportInterval} from './ledger/merge.mjs';
7
- export {merge, transportInterval};
6
+ import {merge, transportInterval, transportTouchedInterval} from './ledger/merge.mjs';
7
+ export {merge, transportInterval, transportTouchedInterval};
8
8
  import {_rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord} from './ledger/journal-records.mjs';
9
9
  export {_rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord};
@@ -6,12 +6,12 @@ function createMarkdownRenderer(runtime) {
6
6
  let md = runtime.md;
7
7
  function initMarkdownIt() {
8
8
  if (typeof window.markdownit !== 'function') throw new Error('markdownit');
9
- if (typeof _rapierMarkdownPreview === 'function') globalThis.rapierRenderPreview = _rapierMarkdownPreview; // Notes' cards read through here (R86i law 3); the Node witnesses that run this function alone have no such neighbour
9
+ if (typeof _rapierMarkdownPreview === 'function') globalThis.rapierRenderPreview = _rapierMarkdownPreview; // Notes' cards read through here; the Node witnesses that run this function alone have no such neighbour
10
10
 
11
11
  md = window.markdownit({
12
12
  ...RAPIER_MARKDOWN_SPEC.options,
13
13
  // The first paint is coloured: the CPU reading, synchronously, for every admitted fence.
14
- highlight: (code, lang) => _rapierHighlightAdmitted(code) ? _rapierCodeHtml(code, lang) : '',
14
+ highlight: (code, lang) => lang && _rapierHighlightAdmitted(code) ? _rapierCodeHtml(code, lang) : '',
15
15
  });
16
16
 
17
17
  _rapierApplyMarkdownSpec(md, window);
@@ -51,9 +51,9 @@ function initMarkdownIt() {
51
51
  return '<span data-md-color="' + hex + '" style="--md-color:' + hex + ';--md-color-dark:' + dark + '">';
52
52
  };
53
53
  md.renderer.rules.rapier_color_close = () => '</span>';
54
- // Ink: the span carries its opener's body (the grammar's inkOpenBody: a sanitizer drops an attribute holding the comment's
55
- // close); the ink layer reads it back through the one grammar and draws the stroke over the words' own boxes
56
- // (docs/briefs/ink.md §3). The span itself shows nothing.
54
+ // Ink: the span carries its opener's body (the grammar's inkOpenBody: a sanitizer drops an attribute holding the
55
+ // comment's close); the ink layer reads it back through the one grammar and draws the stroke over the words' own boxes.
56
+ // The span itself shows nothing.
57
57
  md.renderer.rules.rapier_ink_open = (tokens, idx) => {
58
58
  const body = String(tokens[idx].attrGet('data-rapier-ink') || '');
59
59
  // The dark-page colour on the span, as the colour span's: an exported page draws its ink with no engine to ask (the
@@ -111,7 +111,7 @@ function initMarkdownIt() {
111
111
  const alt = suffix && semanticAlt.endsWith(suffix) ? semanticAlt.slice(0, -suffix.length) : semanticAlt;
112
112
  const layout = token.meta?.mdLayout?.imageOnly ? token.meta.mdLayout.marker : '';
113
113
  const asset = globalThis.RapierImageAssets.dataImage(src), reference = token.meta?.mdImage?.reference;
114
- // A connected host's own asset (W/6); a host without one (the server) supplies no port.
114
+ // A connected host's own asset; a host without one (the server) supplies no port.
115
115
  if (_rapierEmbedAssetSource?.(src) || asset && (reference || asset.codec === 'image/jxl' || asset.codec === 'image/svg+xml')) return globalThis.RapierEmbeddedImages.imageHtml(
116
116
  reference, alt, title, size, layout, rawAlt, token.meta?.mdImage?.source, src);
117
117
  if (!_rapierRemoteContent.allowed && _rapierRemoteSubresourceOrigin(src)) {
@@ -301,21 +301,22 @@ function initMarkdownIt() {
301
301
  let _mathPromptedOnce = false;
302
302
  const _renderMath = (src, displayMode) => {
303
303
  const marker = displayMode ? '$$' : '$';
304
+ const source = marker + src + marker, literal = md.utils.escapeHtml(source);
304
305
  const mathProvider = _rapierProviders.math;
305
306
  if (!mathProvider || mathProvider.status !== 'ready' || typeof mathProvider.renderToString !== 'function') {
306
307
  if (!_mathPromptedOnce) {
307
308
  _mathPromptedOnce = true;
308
309
  try { _rapierUiMath.request(); } catch (_) {}
309
310
  }
310
- return '<span class="math-placeholder" title="Install the math plug-in in Settings to show this equation.">'
311
- + md.utils.escapeHtml(marker + src + marker) + '</span>';
311
+ return '<span class="math-placeholder" data-rapier-math-source="" title="Install the math plug-in in Settings to show this equation.">'
312
+ + literal + '</span>';
312
313
  }
313
314
  try {
314
315
  const rendered = mathProvider.renderToString(src, { displayMode });
315
316
  return '<span class="math-rendered" data-math-src="'
316
- + encodeURIComponent(marker + src + marker) + '">' + rendered + '</span>';
317
+ + encodeURIComponent(source) + '">' + rendered + '</span>';
317
318
  }
318
- catch (_) { return md.utils.escapeHtml(marker + src + marker); }
319
+ catch (_) { return '<span data-rapier-math-source="">' + literal + '</span>'; }
319
320
  };
320
321
  md.renderer.rules.math_inline = (tokens, idx) => _renderMath(tokens[idx].content, false);
321
322
  md.renderer.rules.math_block = (tokens, idx) => wrapDisplayMath(_renderMath(tokens[idx].content, true)) + '\n';
@@ -675,9 +676,9 @@ function _rapierDisambiguateRenderedAnchors(root) {
675
676
  }
676
677
 
677
678
  function _rapierDecodeHash(hash) {
678
- // A document's own address may carry a heading after it (`#d/<id>/<heading>`, task #413): the
679
- // heading is the anchor, and an address with no heading names nothing to jump to. A view's
680
- // (`#v/draw`, `#v/notes`) names no heading either (docs/agents.md "The address of a document").
679
+ // A document's own address may carry a heading after it (`#d/<id>/<heading>`): the heading is the
680
+ // anchor, and an address with no heading names nothing to jump to. A view's (`#v/draw`,
681
+ // `#v/notes`) names no heading either (docs/agents.md "The address of a document").
681
682
  let raw = String(hash || '').replace(/^#/, '');
682
683
  const own = /^[dnv]\/[^/]*(?:\/(.*))?$/.exec(raw);
683
684
  if (own) raw = own[1] || '';
@@ -23,6 +23,26 @@ const RAPIER_SANITIZE_OPTIONS = Object.freeze({
23
23
  FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
24
24
  }),
25
25
  });
26
+
27
+ // Snapshot current math text before a Markdown writer collapses whitespace. The parsed copy
28
+ // stays inert; the writer still admits raw source through its shared math grammar.
29
+ function prepareMathSource(input, DOMParser) {
30
+ const html = typeof input === 'string';
31
+ const MARK = 'span[data-rapier-math-source]';
32
+ if (html ? !/data-rapier-math-source/i.test(input) : typeof input?.cloneNode !== 'function' ||
33
+ !(input.matches?.(MARK) || input.querySelector?.(MARK))) return input;
34
+ const root = html ? new DOMParser().parseFromString(
35
+ '<x-turndown id="turndown-root">' + input + '</x-turndown>', 'text/html').getElementById('turndown-root') : input.cloneNode(true);
36
+ const nodes = Array.from(root.querySelectorAll(MARK));
37
+ const sourceRoot = root.nodeName === 'SPAN' && root.hasAttribute('data-rapier-math-source');
38
+ if (sourceRoot) nodes.push(root);
39
+ for (const node of nodes) {
40
+ try { node.setAttribute('data-rapier-math-source', encodeURIComponent(node.textContent || '')); }
41
+ catch (_) { node.removeAttribute('data-rapier-math-source'); }
42
+ }
43
+ return sourceRoot ? root.outerHTML : root;
44
+ }
45
+
26
46
  // The document's renderer. DOM, codecs and host state are explicit inputs.
27
47
  function createRenderSanitizer(runtime) {
28
48
  const {CSSStyleSheet, DOMPurify, RAPIER_RASTER_DATA_URL_RE, URL, _rapierCssDeclaration, _rapierDropRemoteDeclarations, _rapierRemoteContent, _rapierRuleDescriptorIsRemote, _rapierSanitizeRuntime, _rapierVerifyRasterBytes, document, globalThis, location} = runtime;
@@ -39,15 +59,14 @@ function _rapierInstallSanitizeHooks() {
39
59
  && _rapierSafeRasterDataUrl(compact);
40
60
  if (!rasterAllowed) data.keepAttr = false;
41
61
  });
42
- // Paste and render sanitise: a remote <img src> would fetch the instant its markup lands in the
43
- // page, so the src the hook below is about to strip never survives. Move it to a data-* attribute
44
- // first (DOMPurify already leaves data-* alone, and the sanitizer's own inert document never
45
- // fetches it either), so the picture and its alt text still reach Turndown's rapierImage rule
46
- // as a live pasted-page picture and write out `![alt](url)` -- not vanish (task #336) -- and an
47
- // <img> in a document's own HTML (a table kept as HTML) is written again with its URL: the raw
48
- // profile admits no data-*, so only the three this hook sets on the node it holds back stay. A picture
49
- // inside a link is the same node with the same fix: the outer <a> rule reads this img's own
50
- // converted markdown as its content.
62
+ // Paste and render sanitise: a remote <img src> would fetch the instant its markup lands in the page,
63
+ // so the src the hook below is about to strip never survives. Move it to a data-* attribute first
64
+ // (DOMPurify already leaves data-* alone, and the sanitizer's own inert document never fetches it
65
+ // either), so the picture and its alt text still reach Turndown's rapierImage rule as a live
66
+ // pasted-page picture and write out `![alt](url)` -- not vanish -- and an <img> in a document's own
67
+ // HTML (a table kept as HTML) is written again with its URL: the raw profile admits no data-*, so
68
+ // only the three this hook sets on the node it holds back stay. A picture inside a link is the same
69
+ // node with the same fix: the outer <a> rule reads this img's own converted markdown as its content.
51
70
  DOMPurify.addHook('uponSanitizeElement', node => {
52
71
  if (_rapierRemoteContent.allowed || !/^(?:paste|render|raw)$/.test(_rapierSanitizeRuntime.context)) return;
53
72
  if (!node || node.nodeName !== 'IMG' || node.hasAttribute('data-rapier-remote-src')) return;
@@ -105,9 +124,9 @@ function _rapierInstallSanitizeHooks() {
105
124
  data.forceKeepAttr = true;
106
125
  }
107
126
  });
108
- // A link that opens a new tab never hands that tab a window.opener or a Referer: the editor's
109
- // own click router already opens with noopener,noreferrer; the sanitized markup that Share and
110
- // the standalone export carry gets the same law in its bytes (red-team R65-24).
127
+ // A link that opens a new tab never hands that tab a window.opener or a Referer: the editor's own
128
+ // click router already opens with noopener,noreferrer; the sanitized markup that Share and the
129
+ // standalone export carry gets the same rule in its bytes.
111
130
  DOMPurify.addHook('afterSanitizeAttributes', node => {
112
131
  if (!node || !/^(?:A|AREA)$/i.test(node.nodeName) || !node.hasAttribute('target')) return;
113
132
  if (String(node.getAttribute('target') || '').trim().toLowerCase() !== '_blank') return;
@@ -220,4 +239,4 @@ function _rapierDropRemoteRules(rules, owner) {
220
239
 
221
240
  return {_rapierInstallSanitizeHooks, sanitizeRapierHtml, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
222
241
  }
223
- export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, RAPIER_SANITIZE_OPTIONS as options};
242
+ export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, RAPIER_SANITIZE_OPTIONS as options, prepareMathSource};
@@ -18,7 +18,7 @@ body{
18
18
  --color-text-subtle:var(--md-color-text-subtle);--color-accent-text:var(--md-color-accent-text);
19
19
  margin:0;background:var(--md-color-bg);color:var(--md-color-text);font-family:var(--md-font-sans);-webkit-font-smoothing:antialiased
20
20
  }
21
- .rapier-page{width:min(100%,768px);margin:0 auto;padding:48px 24px 72px}
21
+ .rapier-page{width:min(100%,calc(var(--md-measure,39.6rem) + 48px));margin:0 auto;padding:48px 24px 72px}
22
22
  .rapier-page [id]{scroll-margin-top:var(--md-space-8)}
23
23
  .artifact-flat{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2}
24
24
  .artifact-flat--text{font:var(--md-fw-regular) 1rem/1.7 var(--md-font-sans)}
@@ -87,11 +87,9 @@ async function _rapierBuildArtifact(options, providedContext) {
87
87
  // styles and other page resources get no network authority. Following an ordinary link
88
88
  // is the reader's navigation, with no Referer, not a background resource request.
89
89
  const nonce = _rapierArtifactNonce();
90
- // The reflow script is written first, because whether it exists decides the policy. A page that
91
- // carries no script says so: script-src 'none' refuses every script outright. Declaring a nonce a page has no use for is a widening, small but real -- anything
92
- // that could inject markup into the file could read the nonce out of it and be admitted. G3's
93
- // return caught this against the R86k fold, which always emitted the directive; the rule here
94
- // is tighter than either that fold or G3's own writer, which could not carry a script at all.
90
+ // The reflow script is written first, because whether it exists decides the policy. A page that carries no script says so: script-src 'none' refuses every
91
+ // script outright. Declaring a nonce a page has no use for is a widening, small but real -- anything that could inject markup into the file could read the
92
+ // nonce out of it and be admitted.
95
93
  const layoutScript = !opts.print ? _rapierArtifactLayoutScript(styledRoot, nonce) : '';
96
94
  // The lexer for the page's code, under the same nonce, only when a block earned it: the CPU
97
95
  // spans are the first paint, and where the reader's browser has WebGPU the lexer repaints them.
@@ -101,10 +99,10 @@ async function _rapierBuildArtifact(options, providedContext) {
101
99
  + '<html lang="en" data-rapier-theme="' + theme + '" data-highlights="' + highlights + '">\n'
102
100
  + '<head>\n<meta charset="UTF-8">\n' + (carrier ? RapierPageReturnAddress.PAGE_SEED + '\n' : '')
103
101
  + '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
104
- // Chrome 154's responsively sized frames (the founder's link, 24 September 2026): a host frame styled
105
- // `frame-sizing: content-height` sizes itself to a page that opts in with this meta, so a document
106
- // exported from Rapier and carried in someone's page shows whole, with no inner scroll. Any origin may
107
- // read the height: the page is the document, published by the person who exported it.
102
+ // Chrome 154's responsively sized frames: a host frame styled `frame-sizing: content-height` sizes
103
+ // itself to a page that opts in with this meta, so a document exported from Rapier and carried in
104
+ // someone's page shows whole, with no inner scroll. Any origin may read the height: the page is the
105
+ // document, published by the person who exported it.
108
106
  + '<meta name="responsive-embedded-sizing" content="allow-origins=*">\n'
109
107
  + '<meta http-equiv="Content-Security-Policy" content="' + _rapierExportedPageCsp(layoutScript || lexerScript || inkScript ? nonce : '', !!opts.print) + '">\n'
110
108
  + '<meta name="referrer" content="no-referrer">\n'
@@ -145,8 +143,9 @@ function _rapierProjectStyledRoot(semanticRoot, options) {
145
143
  }
146
144
  } else {
147
145
  root.querySelectorAll('pre > code').forEach(code => {
148
- _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', _rapierLanguageClass(code)));
149
- _rapierArtifactMarkLexed(code, _rapierLanguageClass(code));
146
+ const lang = _rapierLanguageClass(code) || 'text';
147
+ _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang));
148
+ _rapierArtifactMarkLexed(code, lang);
150
149
  });
151
150
 
152
151
  root.querySelectorAll('table').forEach(table => {
@@ -204,9 +203,8 @@ function _rapierPrefixPortableAnchors(root, baseName) {
204
203
  const scopeOf = element => {
205
204
  // querySelectorAll('*') hands back whatever the host document holds, and not every host gives
206
205
  // each node Element.closest -- the export writer runs against parsed and stubbed documents as
207
- // well as a browser's. Without this guard the whole anchor pass threw TypeError and took 107
208
- // export corpus cases with it. No scope simply means the pre-R87M behaviour for that node:
209
- // its references resolve document-wide, exactly as they did before drawings were scoped.
206
+ // well as a browser's. Without this guard the whole anchor pass would throw TypeError. No scope
207
+ // simply means that node's references resolve document-wide, as an unscoped drawing's do.
210
208
  if (typeof element?.closest !== 'function') return null;
211
209
  let svg = element.closest('svg');
212
210
  while (typeof svg?.parentElement?.closest === 'function' && svg.parentElement.closest('svg')) svg = svg.parentElement.closest('svg');
@@ -322,12 +320,12 @@ function _rapierAnnotateExportBoxPolygons(root) {
322
320
  if (!recipe) continue;
323
321
  const view = recipe.view && recipe.view.w > 0 && recipe.view.h > 0 ? recipe.view : { x: 0, y: 0, w: recipe.canvas.w, h: recipe.canvas.h };
324
322
  if (layout.wrap === 'around') {
325
- // One occupancy for live view and export (Astra R74 I02): the drawing's own profile --
326
- // closed faces reserved, open strokes as ribbons, labels as boxes -- computed from the same
327
- // recipe the live view reads, sampled into bands the exported page reads back
328
- // (layout/model.mjs parseProfile). Text and paint shapes need rendered pixels for their
329
- // glyph silhouettes, so the live editor's own <img> supplies those when it can be found; a
330
- // raster or a foreign SVG writes nothing and the export measures alpha, as the live view does.
323
+ // One occupancy for live view and export: the drawing's own profile -- closed faces reserved,
324
+ // open strokes as ribbons, labels as boxes -- computed from the same recipe the live view
325
+ // reads, sampled into bands the exported page reads back (layout/model.mjs parseProfile). Text
326
+ // and paint shapes need rendered pixels for their glyph silhouettes, so the live editor's own
327
+ // <img> supplies those when it can be found; a raster or a foreign SVG writes nothing and the
328
+ // export measures alpha, as the live view does.
331
329
  try {
332
330
  let glyphs = null;
333
331
  if (recipe.shapes.some(shape => shape.recognized === 'text' || shape.recognized === 'paint' || shape.label)) {
@@ -405,7 +403,7 @@ async function _rapierSharedSourceCarrier(context, root) {
405
403
  '"' + (forms.ids.length ? ' data-images="' + forms.ids.join(' ') + '"' : '') +
406
404
  (forms.definitions.length ? ' data-image-definitions="' + forms.definitions.map(encodeURIComponent).join(' ') + '"' : '') + '>\n' +
407
405
  forms.carried + '\n</script>\n' +
408
- // The optional edit ledger (W/4): carried only when the host binds its owner; a host without one writes none.
406
+ // The optional edit ledger: carried only when the host binds its owner; a host without one writes none.
409
407
  (RapierLedgerCarried ? RapierLedgerCarried.writeParts(forms.resolved, _rapierLedgerParts(forms.resolved, context.ledger, context.carried)) +
410
408
  RapierLedgerCarried.writeBase(forms.resolved, context.proposalBase) : '');
411
409
  }
@@ -481,7 +479,7 @@ function _rapierShareWrapKind(element) {
481
479
  if (globalThis.RapierMarkdownLayout.wrapTextBlock(element)) return 'prose';
482
480
  const image = element.tagName === 'P' ? element.querySelector('img') : null;
483
481
  if (image && globalThis.RapierImageLayout.imageOnly(element, image)) return 'picture';
484
- // An empty paragraph is transparent to the owner search (R75), as in the editor and the export.
482
+ // An empty paragraph is transparent to the owner search, as in the editor and the export.
485
483
  if (element.tagName === 'P' && !element.textContent.trim() && !image) return 'metadata';
486
484
  return null;
487
485
  }
@@ -530,22 +528,21 @@ function _rapierSharedPageLayout(root) {
530
528
  paragraph.style.setProperty('shape-outside', 'inset(' + layout.y + 'em 0 0)');
531
529
  }
532
530
  image.style.margin = '0';
533
- // Astra-R75 X03: the reserved box comes from the same rotated-bounds geometry every other
534
- // consumer uses (layout/browser.js, layout/interchange.js's `geometry.rotatedBoundsRad`,
535
- // layout/model.mjs's one owner), expressed as a percentage of the column the way the
536
- // unrotated branch below already is -- never the picture's own pixel dimensions, which
537
- // have no relation to a requested percentage width (a 4000px-wide source at width=40%
538
- // used to float 1788px wide in a 600px column). Intrinsic width/height supply only the
539
- // aspect ratio; a percentage padding-top/bottom resolves against the same containing-block
540
- // width CSS already resolves the float's own percentage width against, so one basis serves
541
- // both the box and its vertical reservation, and the box stays exactly as wide as the
542
- // column at every viewport width. The turned box is centred, horizontally and vertically,
543
- // in the reserved (wider, taller) box rather than left-flush inside it, so the space this
544
- // paragraph clears for it (the float's own width) is evenly split around the picture on
545
- // every side, not just above and below. A missing `width` falls back to the picture's own
546
- // natural pixel size in pixels, the same fallback the unrotated branch below uses; without
547
- // either width/height attribute the picture still turns, just without the extra
548
- // reservation (the same graceful narrowing Share already accepts elsewhere).
531
+ // The reserved box comes from the same rotated-bounds geometry every other consumer uses
532
+ // (layout/browser.js, layout/interchange.js's `geometry.rotatedBoundsRad`, layout/model.mjs's
533
+ // one owner), expressed as a percentage of the column the way the unrotated branch below already
534
+ // is -- never the picture's own pixel dimensions, which have no relation to a requested
535
+ // percentage width (a 4000px-wide source at width=40% would float 1788px wide in a 600px
536
+ // column). Intrinsic width/height supply only the aspect ratio; a percentage padding-top/bottom
537
+ // resolves against the same containing-block width CSS already resolves the float's own
538
+ // percentage width against, so one basis serves both the box and its vertical reservation, and
539
+ // the box stays exactly as wide as the column at every viewport width. The turned box is
540
+ // centred, horizontally and vertically, in the reserved (wider, taller) box rather than
541
+ // left-flush inside it, so the space this paragraph clears for it (the float's own width) is
542
+ // evenly split around the picture on every side, not just above and below. A missing `width`
543
+ // falls back to the picture's own natural pixel size in pixels, the same fallback the unrotated
544
+ // branch below uses; without either width/height attribute the picture still turns, just without
545
+ // the extra reservation (the same graceful narrowing Share already accepts elsewhere).
549
546
  const naturalWidth = Number(image.getAttribute('width')) || 0, naturalHeight = Number(image.getAttribute('height')) || 0;
550
547
  if (layout.rotate && naturalWidth > 0 && naturalHeight > 0) {
551
548
  const rad = layout.rotate * Math.PI / 180, aspect = naturalHeight / naturalWidth;
@@ -589,16 +586,15 @@ function _rapierSharedPageLayout(root) {
589
586
  if (layout.width != null || image.getAttribute('width')) image.style.width = '100%';
590
587
  }
591
588
  if (layout.rotate) { image.style.transform = 'rotate(' + layout.rotate + 'deg)'; image.style.transformOrigin = '50% 50%'; }
592
- // Astra-R75 X04: `wrap=around` wraps to the picture's own alpha silhouette the way
593
- // standard-adoption.md's rung-2 paragraph already promises -- `shape-outside:url(<the same
594
- // picture>)`, which every engine that reads shape-outside re-samples from the referenced
595
- // image's own pixels, no script required. A CSS transform does not turn the shape a
596
- // `shape-outside` url() samples (it stays the picture's unturned alpha), so a rotated
597
- // `around` picture keeps the plain rectangular reservation above instead -- an honest
598
- // conservative box rather than a silhouette shaped for the wrong angle. `layout.y` already
599
- // owns `shape-outside` for its own inset reservation (below); the two are not combinable
600
- // (a float has exactly one `shape-outside` value), so a `y`-offset `around` picture keeps
601
- // that inset instead of silhouette wrapping -- a documented, narrow degradation.
589
+ // `wrap=around` wraps to the picture's own alpha silhouette the way standard-adoption.md's
590
+ // rung-2 paragraph promises -- `shape-outside:url(<the same picture>)`, which every engine that
591
+ // reads shape-outside re-samples from the referenced image's own pixels, no script required. A
592
+ // CSS transform does not turn the shape a `shape-outside` url() samples (it stays the picture's
593
+ // unturned alpha), so a rotated `around` picture keeps the plain rectangular reservation above
594
+ // instead -- an honest conservative box rather than a silhouette shaped for the wrong angle.
595
+ // `layout.y` already owns `shape-outside` for its own inset reservation (below); the two are not
596
+ // combinable (a float has exactly one `shape-outside` value), so a `y`-offset `around` picture
597
+ // keeps that inset instead of silhouette wrapping -- a documented, narrow degradation.
602
598
  if (layout.wrap === 'around' && !layout.rotate && !layout.y) {
603
599
  const src = image.getAttribute('src');
604
600
  if (src) {
@@ -610,12 +606,12 @@ function _rapierSharedPageLayout(root) {
610
606
  paragraph.classList.add('rapier-wrap-picture');
611
607
  owner.before(paragraph);
612
608
  }
613
- // Astra-R75 X04: `behind`/`front` position the picture exactly where `x`/`y`/`width` say, out
614
- // of the words' flow entirely (z-index below or above them) -- the absolutely positioned
615
- // picture standard-adoption.md's rung-2 paragraph already promises for these two placements,
616
- // no obstacle avoidance, no script. The picture's own paragraph collapses to zero height in
617
- // place (the same collapse-in-place anchor layout/interchange.js's own reflow uses for every
618
- // positioned kind) so removing the picture from flow costs the surrounding text nothing.
609
+ // `behind`/`front` position the picture exactly where `x`/`y`/`width` say, out of the words' flow
610
+ // entirely (z-index below or above them) -- the absolutely positioned picture
611
+ // standard-adoption.md's rung-2 paragraph promises for these two placements, no obstacle
612
+ // avoidance, no script. The picture's own paragraph collapses to zero height in place (the same
613
+ // collapse-in-place anchor layout/interchange.js's own reflow uses for every positioned kind) so
614
+ // removing the picture from flow costs the surrounding text nothing.
619
615
  const positionedAssignments = [];
620
616
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
621
617
  const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
@@ -644,9 +640,9 @@ function _rapierSharedPageLayout(root) {
644
640
  if (layout.wrap === 'behind') image.style.zIndex = '-1';
645
641
  paragraph.classList.add('rapier-positioned-picture');
646
642
  }
647
- // F75-11: a turned picture Share does not otherwise position (inline, which this static page
648
- // does not float at all) still turns -- the same picture, painted in place, never upright
649
- // just because Share's own float model has nothing else to say about it.
643
+ // A turned picture Share does not otherwise position (inline, which this static page does not
644
+ // float at all) still turns -- the same picture, painted in place, never upright just because
645
+ // Share's own float model has nothing else to say about it.
650
646
  const positioned = new Set([...assignments, ...positionedAssignments].map(row => row.image));
651
647
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
652
648
  if (positioned.has(image)) continue;
@@ -723,8 +719,8 @@ async function _rapierBuildSharedPage(captured) {
723
719
  _rapierShareCapLongCodeBlocks(root);
724
720
  },
725
721
  }, context);
726
- // No cap and no warning (docs/intent.md, picture format law): the page is as large as the
727
- // document is. A page over the editor's own 25 MiB open limit still opens in any browser.
722
+ // No cap and no warning: the page is as large as the document is. A page over the editor's own 25
723
+ // MiB open limit still opens in any browser.
728
724
  const blob = new Blob([artifact.html], {type: 'text/html;charset=utf-8'});
729
725
  return {blob, filename: context.baseName + '.html'};
730
726
  }
@@ -1171,7 +1167,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1171
1167
  return geometry.polygonProfile(corners);
1172
1168
  }
1173
1169
 
1174
- // F75-11: as the live editor (browser.js).
1170
+ // As the live editor (browser.js).
1175
1171
  function syncInlineRotatedPictures(exclude) {
1176
1172
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
1177
1173
  if (exclude.has(image) || !image.complete || !image.naturalWidth || !image.naturalHeight) continue;
@@ -1208,7 +1204,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1208
1204
  if (metadata.wrapTextBlock(element)) return 'prose';
1209
1205
  const image = element.tagName === 'P' ? element.querySelector('img') : null;
1210
1206
  if (image && imageOnly(element, image)) return 'picture';
1211
- // An empty paragraph is transparent to the owner search, as in the editor (R75).
1207
+ // An empty paragraph is transparent to the owner search, as in the editor.
1212
1208
  if (element.tagName === 'P' && !element.textContent.trim() && !image) return 'metadata';
1213
1209
  return null;
1214
1210
  }
@@ -1326,13 +1322,15 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1326
1322
  const topInset = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
1327
1323
  const height = Math.max(0, bounds.height - topInset - (parseFloat(computed.paddingBottom) || 0) - (parseFloat(computed.borderBottomWidth) || 0));
1328
1324
  let y = bounds.top - origin + topInset + geometry.linesTop(layout, lines) + Math.min(layout.y || 0, height / em) * em;
1325
+ // The standoff and the room between pictures, in the reference scale's pixels, as the live editor.
1326
+ const scale = geometry.frameScale(image), room = 10 * scale;
1329
1327
  if (!outOfFlow(layout)) {
1330
1328
  for (const obstacle of [...obstacles].sort((left, right) => left.y - right.y)) {
1331
- if (obstacle.x < left + rectangle.width + 10 && obstacle.x + obstacle.width > left - 10 &&
1332
- obstacle.y + obstacle.height > y - 10 && obstacle.y < y + rectangle.height + 10)
1333
- y = obstacle.y + obstacle.height + 10;
1329
+ if (obstacle.x < left + rectangle.width + room && obstacle.x + obstacle.width > left - room &&
1330
+ obstacle.y + obstacle.height > y - room && obstacle.y < y + rectangle.height + room)
1331
+ y = obstacle.y + obstacle.height + room;
1334
1332
  }
1335
- // R87k: a picture pushed down by an earlier one takes its owner with it. A deliberate `y` stays; only the collision push moves the paragraph.
1333
+ // A picture pushed down by an earlier one takes its owner with it. A deliberate `y` stays; only the collision push moves the paragraph.
1336
1334
  const ownerTop = bounds.top - origin + topInset + geometry.linesTop(layout, lines) + Math.min(layout.y || 0, height / em) * em;
1337
1335
  // Only the first picture of an owner takes it down.
1338
1336
  if (!ownerLed && y > ownerTop + 0.5) {
@@ -1340,7 +1338,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1340
1338
  bounds = box(element);
1341
1339
  }
1342
1340
  ownerLed = true;
1343
- obstacles.push(...geometry.linesSlices(geometry.pictureSlices(profile(image, layout), left, y, rectangle.width, rectangle.height), layout, lines, y, rectangle.height));
1341
+ obstacles.push(...geometry.linesSlices(geometry.pictureSlices(profile(image, layout), left, y, rectangle.width, rectangle.height, scale), layout, lines, y, rectangle.height));
1344
1342
  }
1345
1343
  placed.push({source, image, rectangle, left, y, wrap: layout.wrap,
1346
1344
  visualLeft: left + visualDeltaX, visualTop: y + visualDeltaY, visualWidth: fit.fit.width, visualHeight: fit.fit.height,
@@ -214,7 +214,7 @@ export function polygonProfile(corners) {
214
214
  }};
215
215
  }
216
216
 
217
- // F75-11: w'=w|cos|+h|sin|, h'=w|sin|+h|cos| (markdown-standard.md). Shared by browser.js, interchange.js and share.js.
217
+ // w'=w|cos|+h|sin|, h'=w|sin|+h|cos| (markdown-standard.md). Shared by browser.js, interchange.js and share.js.
218
218
  export function rotatedBoundsRad(width, height, rad) {
219
219
  if (!rad) return {width, height};
220
220
  return {width: width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad)),
@@ -255,7 +255,7 @@ export function rotatedRasterAlpha(baseAlpha, width, height, rad) {
255
255
  return bandsProfile(bands);
256
256
  }
257
257
 
258
- // One occupancy descriptor (I02): the editor's profile sampled at 48 band centres, shipped as data-rapier-occupancy, read back here.
258
+ // One occupancy descriptor: the editor's profile sampled at 48 band centres, shipped as data-rapier-occupancy, read back here.
259
259
  export function bandsProfile(bands) {
260
260
  if (!Array.isArray(bands) || !bands.length) return null;
261
261
  const at = t => bands[Math.max(0, Math.min(bands.length - 1, Math.floor(t * bands.length)))] || [];
@@ -294,10 +294,20 @@ export function parseProfile(text) {
294
294
  // stroke and 10 off a level one; this stands 10 off both, within a step.
295
295
  const shoulderSteps = 3, shoulders = Array.from({length: shoulderSteps}, (_, k) => ({from: k / shoulderSteps, to: (k + 1) / shoulderSteps, reach: Math.sqrt(1 - ((k + .5) / shoulderSteps) ** 2)}));
296
296
 
297
- export function pictureSlices(profile, x, y, width, height, gap = 10) {
297
+ // The reference scale at an element (docs/markdown-standard.md, "The reference scale"): its unit, `--md-unit` (a registered
298
+ // length, 16 px at M), over 16. Every length the planner adds of its own (the standoff, a band's height, the room between two
299
+ // pictures) is that many reference pixels times it, so a zoom moves no word against a picture.
300
+ export function frameScale(element) {
301
+ const view = element?.ownerDocument?.defaultView;
302
+ const unit = view ? parseFloat(view.getComputedStyle(element).getPropertyValue('--md-unit')) : NaN;
303
+ return unit > 0 ? unit / 16 : 1;
304
+ }
305
+
306
+ export function pictureSlices(profile, x, y, width, height, scale = 1) {
307
+ const gap = 10 * scale;
298
308
  if (!profile) return [{x: x - gap, y: y - gap, width: width + gap * 2, height: height + gap * 2}];
299
- // 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.
300
- const count = Math.max(shapeGrid, Math.min(sliceGridMax, Math.ceil((finite(height) && height > 0 ? height : 0) / slicePixelsPerBand)));
309
+ // A band is at most 6 reference px high at the shown size, never coarser than the descriptor's 48 nor finer than the profile's 192.
310
+ const count = Math.max(shapeGrid, Math.min(sliceGridMax, Math.ceil((finite(height) && height > 0 ? height : 0) / (slicePixelsPerBand * scale))));
301
311
  let byCount = profileBands.get(profile);
302
312
  if (!byCount) profileBands.set(profile, byCount = new Map());
303
313
  let bands = byCount.get(count);
@@ -313,12 +323,9 @@ export function pictureSlices(profile, x, y, width, height, gap = 10) {
313
323
  return span ? [span] : [];
314
324
  }).filter(run => Number.isFinite(run[0]) && Number.isFinite(run[1]) && run[1] > run[0])
315
325
  .sort((a, b) => a[0] - b[0]);
316
- const merged = [];
317
- for (const run of runs) {
318
- const last = merged.at(-1);
319
- if (last && run[0] <= last[1]) last[1] = Math.max(last[1], run[1]);
320
- else merged.push([...run]);
321
- }
326
+ // Words wrap the outer contour: a band reserves from its leftmost ink to its rightmost, so no word lands
327
+ // inside a hollow outline or between two strokes of one picture.
328
+ const merged = runs.length ? [[runs[0][0], Math.max(...runs.map(run => run[1]))]] : [];
322
329
  const current = new Map();
323
330
  for (const [left, right] of merged) {
324
331
  const key = left + ':' + right;
@@ -369,7 +369,7 @@ export function serializeAsset(asset) {
369
369
  const value = assetTitle(asset.title), title = value ? ' "' + value.replace(/&/g, '&amp;').replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"' : '';
370
370
  return '[' + asset.label + ']: ' + asset.url + title;
371
371
  }
372
- // Pure: re-derived at commit against the moved document; appends only at the text's end (docs/kernel.md).
372
+ // Pure: re-derived at commit against the moved document; appends only at the text's end.
373
373
  export function appendAssetText(source, asset) {
374
374
  if (typeof source !== 'string' || !asset || normalizeLabel(asset.label) !== asset.id) return fail('image_source_invalid');
375
375
  const parsed = documentAssets(source), title = assetTitle(asset.title);
@@ -2,11 +2,11 @@
2
2
 
3
3
  // `rotate`: degrees in (-180, 180], one decimal, omitted when 0; never written for a Rapier drawing (its turn lives in its SVG).
4
4
  // `opacity`: a picture's fade, a whole percent from 5 to 100, omitted at 100; the picture's bytes never change.
5
- // `lines`: a picture's height in lines of the text beside it, set as a drop cap is (CSS `initial-letter`): its top on the first
6
- // line's cap height, its foot on the Nth line's baseline, its width from its own aspect. A whole number 1 to 12; never with `width`.
7
- // `first`/`indent`: a paragraph's first-line indent and its whole-block indent, in levels of one step (2em), a whole number from 1 to 4, omitted when 0; text only.
8
- // `lock=on`: a picture locked in place (the founder, 5 October): a tap goes to the words, never the picture; a hold takes it
9
- // to unlock it. Omitted when off.
5
+ // `lines`: a picture's height in lines of the text beside it, set as a drop cap is (CSS `initial-letter`): its top on the first line's cap height, its foot on the
6
+ // Nth line's baseline, its width from its own aspect. A whole number 1 to 12; never with `width`.
7
+ // `first`/`indent`: a paragraph's first-line indent and its whole-block indent, in levels of one step (2em), a whole number from 1 to 4, omitted when 0; text
8
+ // only.
9
+ // `lock=on`: a picture locked in place: a tap goes to the words, never the picture; a hold takes it to unlock it. Omitted when off.
10
10
  export const fields = new Set(['align', 'width', 'lines', 'wrap', 'x', 'y', 'rotate', 'opacity', 'first', 'indent', 'lock']);
11
11
  export const alignments = new Set(['left', 'center', 'right', 'justify']);
12
12
  // `behind`/`front`: out of flow; the paragraph lays out as though the picture were absent.
@@ -9,16 +9,16 @@ const inkReader = () => {
9
9
  const COLOR_NAME_ALT = Object.keys(TEXT_COLOR_NAMES).join('|');
10
10
  const COLOR_VALUE = '(?:#[0-9a-f]{6}|' + COLOR_NAME_ALT + ')';
11
11
 
12
- // Ink (docs/markdown-standard.md, "Ink"; the design docs/briefs/ink.md): a pen stroke that stays with the words it
13
- // marks, a paired comment with the colour span's shape. The opener holds the stroke:
12
+ // Ink (docs/markdown-standard.md, "Ink"): a pen stroke that stays with the words it marks, a paired comment with the colour span's shape. The
13
+ // opener holds the stroke:
14
14
  // <!--ink KIND [NUMBER] [COLOUR] [w=N] [box=W,H] [at=X,Y] [PATH]-->words<!--/ink-->
15
- // KIND is the stroke's kind, decided once when it was lifted; COLOUR is exactly the colour span's value, red when
16
- // absent; box is the frame's size when the stroke was drawn and at the frame's offset from the words' box, both in
17
- // hundredths of an em; w is the pen's width as Draw counts a nib (2 to 24; 9 is the default and is never written): the stroke is drawn 0.11 em
18
- // wide at 9 and in proportion to w otherwise; PATH is the stroke, its first point absolute in the frame and the rest as
19
- // moves, hundredths of an em. One spelling: the fields in this order, one space between, integers only. A comment that does not read this way
20
- // is ordinary comment text and marks nothing. Ink pairs do not nest in each other. Arrow/end alone take a pairing
21
- // number, an end takes no other data; under, strike and arrow may omit a path for a line derived from their words.
15
+ // KIND is the stroke's kind, decided once when it was lifted; COLOUR is exactly the colour span's value, red when absent; box is the frame's
16
+ // size when the stroke was drawn and at the frame's offset from the words' box, both in hundredths of an em; w is the pen's width as Draw
17
+ // counts a nib (2 to 24; 9 is the default and is never written): the stroke is drawn 0.11 em wide at 9 and in proportion to w otherwise; PATH
18
+ // is the stroke, its first point absolute in the frame and the rest as moves, hundredths of an em. One spelling: the fields in this order,
19
+ // one space between, integers only. A comment that does not read this way is ordinary comment text and marks nothing. Ink pairs do not nest
20
+ // in each other. Arrow/end alone take a pairing number, an end takes no other data; under, strike and arrow may omit a path for a line
21
+ // derived from their words.
22
22
  const INK_KINDS = Object.freeze(['under', 'strike', 'ring', 'bracket', 'free', 'arrow', 'end']);
23
23
 
24
24
  const INK_PATH_MAX = 160;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.1.65",
3
+ "version": "1.1.73",
4
4
  "description": "Read, render, write and style Self-contained Markdown, and carry its optional edit ledger: the shared HTML writer, semantic renderer, stylesheet, layout, marks, pictures, Will/1, replay, authorship and offline merge. MIT, no npm dependencies.",
5
5
  "keywords": [
6
6
  "markdown",
package/style.css CHANGED
@@ -3,28 +3,35 @@
3
3
  * Root defaults also expose the palette to an optional page frame and the shared task glyphs
4
4
  * to Notes. Visual rules stay inside .md-render; this sheet loads no fonts or network resources.
5
5
  */
6
+ /* The reference scale's unit (docs/markdown-standard.md, "The reference scale"): 16 px at M, a length every reader can read
7
+ back as pixels (a planner scales its own lengths by it). */
8
+ @property --md-unit{syntax:'<length>';inherits:true;initial-value:16px}
6
9
  :root,.md-render{
10
+ --md-unit:1rem;
11
+ --md-hair:calc(var(--md-unit) / 16);
12
+ /* The reading measure (docs/markdown-standard.md, "The reference scale"): 36 ems of the body at M; it does not follow the step. */
13
+ --md-measure:39.6rem;
7
14
  --md-font-mono:'Geist Mono',monospace;
8
15
  --md-font-sans:'Geist',system-ui,sans-serif;
9
- --md-text-sm:0.875rem;
16
+ --md-text-sm:calc(0.875 * var(--md-unit));
10
17
  --md-color-text:light-dark(#121212,#fafafa);
11
18
  --md-fw-medium:500;
12
19
  --md-color-bg:light-dark(#ffffff,#000000);
13
- --md-text-xs:0.75rem;
20
+ --md-text-xs:calc(0.75 * var(--md-unit));
14
21
  --md-color-inset:light-dark(#f2f1ef,#1d1d1d);
15
22
  --md-color-surface:light-dark(#f7f6f3,#0d0d0d);
16
- --md-text-base:1rem;
23
+ --md-text-base:calc(1 * var(--md-unit));
17
24
  --md-fw-semibold:600;
18
25
  --md-fw-regular:400;
19
- --md-text-body:1.1rem;
20
- --md-line:1.75rem;
21
- --md-space-6:24px;
22
- --md-space-4:16px;
23
- --md-space-3:12px;
26
+ --md-text-body:calc(1.1 * var(--md-unit));
27
+ --md-line:calc(1.75 * var(--md-unit));
28
+ --md-space-6:calc(1.5 * var(--md-unit));
29
+ --md-space-4:calc(1 * var(--md-unit));
30
+ --md-space-3:calc(.75 * var(--md-unit));
24
31
  --md-color-border:light-dark(#dbdad7,#1c1c1c);
25
- --md-space-5:20px;
26
- --md-space-2:8px;
27
- --md-space-1:4px;
32
+ --md-space-5:calc(1.25 * var(--md-unit));
33
+ --md-space-2:calc(.5 * var(--md-unit));
34
+ --md-space-1:calc(.25 * var(--md-unit));
28
35
  --md-color-line:light-dark(#000000,#ffffff);
29
36
  --md-color-accent-text:light-dark(color-mix(in srgb,var(--md-color-accent) 50%,#000000),color-mix(in srgb,var(--md-color-accent) 80%,#ffffff));
30
37
  --md-track-caps:.035em;
@@ -35,7 +42,7 @@
35
42
  --md-color-success:light-dark(#188844,#45d483);
36
43
  --md-color-warning:light-dark(#b26105,#f5a623);
37
44
  --md-color-error:light-dark(#c00,#ec5156);
38
- --md-space-8:32px;
45
+ --md-space-8:calc(2 * var(--md-unit));
39
46
  --md-color-accent:#12A594;
40
47
  --md-color-text-subtle:light-dark(#636363,#858585);
41
48
  --md-heading-color:var(--md-color-text);
@@ -51,8 +58,8 @@
51
58
  --md-image-width:100%;
52
59
  --md-drawing-width:0px;
53
60
  --md-task-box:clamp(14px,.92em,24px);
54
- --md-jump-gap:.9rem;
55
- --md-jump-marker-inline-start:-1.18rem;
61
+ --md-jump-gap:calc(.9 * var(--md-unit));
62
+ --md-jump-marker-inline-start:calc(-1.18 * var(--md-unit));
56
63
  --md-jump-marker-center:1em;
57
64
  --md-diagram-ink:light-dark(#121212,#fafafa);
58
65
  --md-diagram-box:light-dark(#f0f0f0,#1d1d1d);
@@ -260,14 +267,13 @@
260
267
  :where(.md-render) svg.rapier-diagram circle.state-start,:where(.md-render) svg.rapier-diagram .node .fork-join,:where(.md-render) svg.rapier-diagram .end-state-inner{fill:var(--md-color-text)!important;stroke:var(--md-color-text)!important}
261
268
  :where(.md-render) svg.rapier-diagram circle.state-end{fill:var(--md-color-bg)!important;stroke:var(--md-color-text)!important}
262
269
  /* The kinds that are colour by nature -- a pie's slices, a mindmap's branches, a timeline's periods,
263
- a git graph's branches, a gantt chart's bars -- are drawn in the nine colours of the document, the
264
- founder's own pairs (rapier-notes.css, "The nine note colours"; docs/intent.md, "The nine colours"),
265
- the mate the theme gives a note card: the pale under pure black ink on light paper, the deep under
266
- pure white ink on dark. Mermaid's own greys were a light-paper drawing: on dark paper a mindmap and
267
- a timeline became white cards under the page's white type, words nobody could read. The hex is
268
- copied here because a written page carries this sheet and not the Notes sheet; mermaid-sanitised
269
- holds the two copies equal, pair for pair. The order keeps neighbours apart: blue, orange, teal,
270
- pink, yellow, red, green, purple, brown. */
270
+ a git graph's branches, a gantt chart's bars -- are drawn in the nine colours of the document
271
+ (rapier-notes.css, "The nine note colours"), the mate the theme gives a note card: the pale under
272
+ pure black ink on light paper, the deep under pure white ink on dark. Mermaid's own greys are a
273
+ light-paper drawing: on dark paper a mindmap and a timeline would become white cards under the
274
+ page's white type. The hex is copied here because a written page carries this sheet and not the
275
+ Notes sheet; mermaid-sanitised holds the two copies equal, pair for pair. The order keeps
276
+ neighbours apart: blue, orange, teal, pink, yellow, red, green, purple, brown. */
271
277
  :where(.md-render) svg.rapier-diagram{
272
278
  font-variant-numeric:tabular-nums;
273
279
  }
@@ -353,12 +359,12 @@
353
359
  opacity: .78;
354
360
  }
355
361
 
356
- /* The line model (item 2c, the founder's: the way a Word document reads). One line, --md-line, is the body's line
357
- 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.
358
- Every block ends one line below its last line; a heading ends half a line below. A heading's own box is the whole
359
- 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.
360
- Nothing sets a top margin, so a plain page (sibling margins collapsing) and the editor (a wrapper per block) space
361
- alike, and the space before any block is the line its predecessor leaves. */
362
+ /* The line model (the way a Word document reads). One line, --md-line, is the body's line box: 1.75rem, 1.6 lines
363
+ of the 1.1rem body, a whole 28 px at the browser's 16 px root so baselines land on pixels. Every block ends one
364
+ line below its last line; a heading ends half a line below. A heading's own box is the whole or half lines its
365
+ 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. Nothing sets a top
366
+ margin, so a plain page (sibling margins collapsing) and the editor (a wrapper per block) space alike, and the
367
+ space before any block is the line its predecessor leaves. */
362
368
  .md-render{
363
369
  font:var(--md-fw-regular) var(--md-text-body)/var(--md-line) var(--md-font-sans);color:var(--md-color-text);text-align:start}
364
370
  .md-render>:first-child,.md-render :is(blockquote,li,td,th,dd,.callout,details)>:first-child{margin-top:0}
@@ -423,15 +429,13 @@
423
429
  vertical-align: -3px;
424
430
  touch-action: manipulation;
425
431
  }
426
- /* The box and its tick are Feather's square and check-square (the founder, 24 September 2026:
427
- "copy that style exactly except with squared corners"): drawn on Feather's 24-unit grid with its
428
- 2-unit stroke, the box's corners mitred square, and a ticked box opens at its top right where
429
- the tick breaks out through it. The grid is a third wider than the box itself (the box is units
430
- 3 to 21), so the painted square keeps exactly the place and size --rapier-task-box gave it; the
431
- ink is --color-line through a mask, so the theme recolours it and an export needs nothing more
432
- than the data: images its policy already admits. The three glyphs are written here once, and a Notes
433
- card's check rows read these same three (rapier-notes.css; docs/intent.md "The afternoon list", law 3:
434
- one SVG per icon across Rapier). */
432
+ /* The box and its tick are Feather's square and check-square, with squared corners: drawn on Feather's
433
+ 24-unit grid with its 2-unit stroke, the box's corners mitred square, and a ticked box opens at its
434
+ top right where the tick breaks out through it. The grid is a third wider than the box itself (the
435
+ box is units 3 to 21), so the painted square keeps exactly the place and size --rapier-task-box gave
436
+ it; the ink is --color-line through a mask, so the theme recolours it and an export needs nothing
437
+ more than the data: images its policy already admits. The three glyphs are written here once, and a
438
+ Notes card's check rows read these same three (rapier-notes.css: one SVG per icon across Rapier). */
435
439
  :root,.md-render {
436
440
  --md-glyph-task-box: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Cpath d='M3 3h18v18H3z'/%3E%3C/svg%3E");
437
441
  --md-glyph-task-open: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M21 12v9H3V3h13'/%3E%3C/svg%3E");
@@ -458,28 +462,28 @@
458
462
 
459
463
  .md-render ol{
460
464
  list-style:none;
461
- padding-inline-start:2.75rem;
465
+ padding-inline-start:calc(2.75 * var(--md-unit));
462
466
  margin:0 0 var(--md-line);
463
467
  counter-reset:rapier-ol;
464
468
  }
465
469
  .md-render ol>li{
466
470
  position:relative;
467
471
  counter-increment:rapier-ol;
468
- min-height:2rem;
469
- padding-top:0.15rem;
472
+ min-height:calc(2 * var(--md-unit));
473
+ padding-top:calc(0.15 * var(--md-unit));
470
474
  }
471
475
  .md-render ol>li::before{
472
476
  content:counter(rapier-ol);
473
477
  position:absolute;
474
478
  /* The circle is centred on the item's first line: the li's own padding, plus half of what the 2rem circle is taller than one line. */
475
- inset-inline-start:-2.75rem; top:calc(0.15rem + (var(--md-line) - 2rem) / 2);
476
- width:2rem; height:2rem;
479
+ inset-inline-start:calc(-2.75 * var(--md-unit)); top:calc(calc(0.15 * var(--md-unit)) + (var(--md-line) - calc(2 * var(--md-unit))) / 2);
480
+ width:calc(2 * var(--md-unit)); height:calc(2 * var(--md-unit));
477
481
  display:flex; align-items:center; justify-content:center;
478
482
  border:2px solid var(--md-color-line);
479
483
  border-radius:999px;
480
484
  background:var(--md-color-bg);
481
485
  color:var(--md-color-line);
482
- font:var(--md-fw-medium) 0.95rem/1 var(--md-font-sans);
486
+ font:var(--md-fw-medium) calc(0.95 * var(--md-unit))/1 var(--md-font-sans);
483
487
  font-variant-numeric:tabular-nums;
484
488
  box-sizing:border-box;
485
489
  z-index:1;
@@ -489,8 +493,8 @@
489
493
  content:'';
490
494
  position:absolute;
491
495
 
492
- inset-inline-start:calc(-1.75rem - 1.5px);
493
- top:2.2rem;
496
+ inset-inline-start:calc(calc(-1.75 * var(--md-unit)) - 1.5px);
497
+ top:calc(2.2 * var(--md-unit));
494
498
  bottom:calc(var(--md-line) / -4);
495
499
  border-inline-start:3px dotted var(--md-color-line);
496
500
  z-index:0;
@@ -499,11 +503,11 @@
499
503
  _rapierMarkListContinuations marks them): the heading stands on the margin the item text stands on, and the dotted
500
504
  line runs on, out of the first part's last item, down the heading's own height, to the next circle. */
501
505
  .md-render ol[data-rapier-list-continues]>li:last-child::after{bottom:calc(-1 * var(--md-line))}
502
- .md-render :is(h1,h2,h3,h4,h5,h6)[data-rapier-list-through]{position:relative;padding-inline-start:2.75rem}
506
+ .md-render :is(h1,h2,h3,h4,h5,h6)[data-rapier-list-through]{position:relative;padding-inline-start:calc(2.75 * var(--md-unit))}
503
507
  .md-render :is(h1,h2,h3,h4,h5,h6)[data-rapier-list-through]::before{
504
508
  content:'';
505
509
  position:absolute;
506
- inset-inline-start:calc(1rem - 1.5px);
510
+ inset-inline-start:calc(calc(1 * var(--md-unit)) - 1.5px);
507
511
  top:0;
508
512
  bottom:calc(var(--md-line) / -2);
509
513
  border-inline-start:3px dotted var(--md-color-line);
@@ -517,25 +521,25 @@
517
521
  .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}
518
522
 
519
523
  .md-render .rapier-jump-label{
520
- margin:0 0 .5rem;
521
- font:var(--md-fw-semibold) .72rem/1.4 var(--md-font-mono);
524
+ margin:0 0 calc(.5 * var(--md-unit));
525
+ font:var(--md-fw-semibold) calc(.72 * var(--md-unit))/1.4 var(--md-font-mono);
522
526
  letter-spacing:var(--md-track-caps);
523
527
  text-transform:uppercase;
524
528
  color:var(--md-color-text);
525
529
  }
526
530
  .md-render .rapier-jump-label strong{font:inherit}
527
531
  .md-render ul.rapier-jump-list{
528
- --md-jump-gap:.9rem;
529
- --md-jump-marker-inline-start:-1.18rem;
532
+ --md-jump-gap:calc(.9 * var(--md-unit));
533
+ --md-jump-marker-inline-start:calc(-1.18 * var(--md-unit));
530
534
  --md-jump-marker-center:1em;
531
535
  list-style:none;
532
- padding-inline-start:1.85rem;
536
+ padding-inline-start:calc(1.85 * var(--md-unit));
533
537
  margin:0 0 var(--md-line);
534
538
  }
535
539
  .md-render ul.rapier-jump-list>li{
536
540
  position:relative;
537
541
  margin:0 0 var(--md-jump-gap);
538
- padding:.18rem 0 .18rem .15rem;
542
+ padding:calc(.18 * var(--md-unit)) 0 calc(.18 * var(--md-unit)) calc(.15 * var(--md-unit));
539
543
  }
540
544
  .md-render ul.rapier-jump-list>li:last-child{margin-bottom:0}
541
545
  .md-render ul.rapier-jump-list>li::before{
@@ -574,7 +578,7 @@
574
578
  .md-render em{font-style:italic}
575
579
  .md-render :not(pre)>code{line-height:1}
576
580
  .md-render code{font-family:var(--md-font-mono);font-size:0.85em;
577
- background:var(--md-color-inset);border:1px solid var(--md-color-border);padding:1px 5px}
581
+ background:var(--md-color-inset);border:var(--md-hair) solid var(--md-color-border);padding:var(--md-hair) calc(5 * var(--md-hair))}
578
582
  .md-render pre{margin:0 0 var(--md-line);padding:var(--md-space-3) var(--md-space-4);
579
583
  background:var(--md-color-surface-2);border:1px solid var(--md-color-border);overflow-x:auto}
580
584
  .md-render pre code{background:none;border:none;padding:0;font-size:calc(var(--md-text-body) * 0.7386);line-height:calc(var(--md-line) * .75)}
@@ -602,18 +606,18 @@
602
606
  .md-render math:not([display="block"]){font-size:1em}
603
607
  .md-render blockquote{margin:0 0 var(--md-line);
604
608
  padding-block:var(--md-space-1);padding-inline-start:var(--md-space-4);padding-inline-end:0;
605
- border-inline-start:2px solid var(--md-color-border-focus);color:var(--md-color-text-secondary)}
609
+ border-inline-start:calc(2 * var(--md-hair)) solid var(--md-color-border-focus);color:var(--md-color-text-secondary)}
606
610
  .md-render blockquote blockquote{
607
611
  margin:calc(var(--md-line) / 2) 0 calc(var(--md-line) / 4);padding-inline-start:var(--md-space-3);
608
612
  border-inline-start-color:color-mix(in srgb,var(--md-color-border-focus) 70%,var(--md-color-text-muted));
609
613
  color:color-mix(in srgb,var(--md-color-text-secondary) 84%,var(--md-color-text-muted))
610
614
  }
611
615
  .md-render blockquote blockquote blockquote{
612
- border-inline-start-width:1px;color:var(--md-color-text-muted)
616
+ border-inline-start-width:var(--md-hair);color:var(--md-color-text-muted)
613
617
  }
614
618
  .md-render blockquote.callout{
615
619
  --md-callout-color:var(--md-callout-note);
616
- padding:var(--md-space-2) var(--md-space-4);border:0;border-inline-start:4px solid var(--md-callout-color);
620
+ padding:var(--md-space-2) var(--md-space-4);border:0;border-inline-start:calc(4 * var(--md-hair)) solid var(--md-callout-color);
617
621
  background:none;color:var(--md-color-text);border-radius:0
618
622
  }
619
623
  .md-render blockquote.callout-tip{--md-callout-color:var(--md-color-success)}
@@ -629,7 +633,7 @@
629
633
  .md-render blockquote.callout>:last-child{margin-bottom:0}
630
634
  @media print{.md-render{--md-callout-note:#0969da;--md-callout-important:#8250df}}
631
635
 
632
- .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); }
636
+ .md-render details { border: var(--md-hair) solid var(--md-color-border); border-radius: 0; padding: var(--md-space-2) var(--md-space-4); margin: 0 0 var(--md-line); }
633
637
  .md-render details[open] { padding-bottom: var(--md-space-3); }
634
638
  /* A section with nothing typed into it yet has no paragraph to tap: without this, the only body
635
639
  space below the summary is the plain padding-bottom above (12px), too thin a margin for a real
@@ -662,7 +666,7 @@
662
666
  position:absolute;
663
667
  left:50%;
664
668
  top:50%;
665
- width:7.5rem;
669
+ width:calc(7.5 * var(--md-unit));
666
670
  height:2px;
667
671
  transform:translate(-50%,-50%);
668
672
  background:linear-gradient(to right,
@@ -705,10 +709,10 @@
705
709
  border-collapse:collapse;
706
710
  margin:0 0 var(--md-line);
707
711
  font-size:var(--md-text-sm);
708
- border:2px solid var(--md-color-line);
712
+ border:calc(2 * var(--md-hair)) solid var(--md-color-line);
709
713
  }
710
714
  .md-render th,.md-render td{
711
- border:1px solid color-mix(in srgb, var(--md-color-line) 74%, transparent);
715
+ border:var(--md-hair) solid color-mix(in srgb, var(--md-color-line) 74%, transparent);
712
716
  padding:var(--md-space-2) var(--md-space-3);
713
717
  text-align:left;
714
718
  }
@@ -825,7 +829,7 @@
825
829
  this is the sheet every written page carries -- G3 reached the same conclusion independently, and
826
830
  the content sheet is where a look belongs. */
827
831
  .md-render pre[data-rapier-code-lines]{position:relative;max-height:33em;overflow:auto;padding-top:28px}
828
- .md-render .rapier-code-lines-note{position:absolute;top:6px;right:10px;font:.7rem/1 var(--md-font-sans);color:var(--md-color-text-muted);background:var(--md-color-inset);padding:.15em .5em;pointer-events:none}
832
+ .md-render .rapier-code-lines-note{position:absolute;top:6px;right:10px;font:calc(.7 * var(--md-unit))/1 var(--md-font-sans);color:var(--md-color-text-muted);background:var(--md-color-inset);padding:.15em .5em;pointer-events:none}
829
833
  @media print{
830
834
  .md-render pre[data-rapier-code-lines]{max-height:none!important;overflow:visible!important;padding-top:12px!important}
831
835
  .md-render .rapier-code-lines-note{display:none!important}