rapier-markdown-kit 1.1.104 → 1.2.3

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.
Files changed (41) hide show
  1. package/LICENSE +5 -2
  2. package/README.md +34 -3
  3. package/dist/images/gif.mjs +47 -0
  4. package/dist/images/playback.mjs +101 -0
  5. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  6. package/dist/kit/conformance/04-wrap-box.md +1 -1
  7. package/dist/kit/conformance/05-behind.md +1 -1
  8. package/dist/kit/conformance/06-front.md +1 -1
  9. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  10. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  11. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  12. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  13. package/dist/kit/conformance/10-both-sides.md +1 -1
  14. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  15. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  16. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  17. package/dist/kit/conformance/README.md +19 -28
  18. package/dist/kit/conformance/run.mjs +9 -7
  19. package/dist/kit/ledger/carried.mjs +10 -5
  20. package/dist/kit/ledger/format.mjs +12 -3
  21. package/dist/kit/ledger/hash.mjs +31 -15
  22. package/dist/kit/ledger/history.mjs +17 -5
  23. package/dist/kit/ledger/merge.mjs +4 -3
  24. package/dist/kit/pdf-vectors.mjs +4 -1
  25. package/dist/kit/rapier-ledger.mjs +2 -2
  26. package/dist/kit/render-ink.mjs +1 -1
  27. package/dist/kit/render-markdown.mjs +146 -31
  28. package/dist/kit/render-print.mjs +131 -55
  29. package/dist/kit/render-sanitize.mjs +6 -3
  30. package/dist/kit/render.mjs +34 -77
  31. package/dist/layout/model.mjs +10 -8
  32. package/dist/packages/rapier-diff/index.mjs +75 -32
  33. package/dist/spec/document-fonts.mjs +1 -1
  34. package/dist/spec/document-settings.mjs +11 -164
  35. package/dist/spec/html-blocks.mjs +618 -79
  36. package/dist/spec/md-assets.mjs +29 -6
  37. package/dist/spec/md-layout.mjs +8 -0
  38. package/dist/spec/md-marks.mjs +25 -147
  39. package/dist/spec/md-source.mjs +54 -19
  40. package/package.json +1 -1
  41. package/style.css +7 -51
@@ -62,6 +62,15 @@ function readLedgerState(input, expectedText, expectedMetadata) {
62
62
  (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
63
63
  return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
64
64
  }
65
+ // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
66
+ export function ledgerWithStart(input, text) {
67
+ let source = new HistorySource(documentText(text));
68
+ for (let i = input.records.length - 1; i >= 0; i--) {
69
+ source = source.apply(input.records[i].splices, true);
70
+ if (source === null) throw fault('history does not reverse from this document');
71
+ }
72
+ return {...input, start: {...input.start, text: source.slice()}};
73
+ }
65
74
  export function readLedger(input, expectedText, expectedMetadata) {
66
75
  return readLedgerState(input, expectedText, expectedMetadata).value;
67
76
  }
@@ -182,8 +191,8 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
182
191
  return step.value;
183
192
  }
184
193
  // Derived navigation, never a second history. A target older than start is intentionally unavailable.
185
- export function historyEnvelope(input, segmentIdentity = []) {
186
- const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input);
194
+ export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
195
+ const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
187
196
  const {byId, effects: {pictures}} = prepared;
188
197
  let branch = [], cursor = 0;
189
198
  for (const {transaction: tx} of ledger.records) {
@@ -198,7 +207,7 @@ export function historyEnvelope(input, segmentIdentity = []) {
198
207
  }
199
208
  const step = (source, id, redo) => {
200
209
  const target = byId.get(id), folded = pictures.size
201
- ? pictureHistorySplices({source: source.slice(), records: prepared.records}, target, redo) : null;
210
+ ? pictureHistorySplices({get source() { return source.slice(); }, records: prepared.records}, target, redo) : null;
202
211
  return source.apply(folded || target.splices, !folded && !redo);
203
212
  };
204
213
  let traversed = source, traversedMetadata = {...metadata};
@@ -30,13 +30,11 @@ const K = new Uint32Array([
30
30
  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
31
31
  ]);
32
32
  const rotate = (x, n) => (x >>> n) | (x << (32 - n));
33
- // A bounded encoder and one compression block. No full UTF-8 or padded-input copy.
34
- // Updates retain a trailing high surrogate so an arbitrary string boundary stays exact.
35
- export function createSHA256() {
33
+ // One bounded SHA-256 compression state for file bytes and encoded source text.
34
+ export function sha256State() {
36
35
  const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
37
- const w = new Uint32Array(64), block = new Uint8Array(64), encoded = new Uint8Array(65536);
38
- const encoder = new TextEncoder();
39
- let used = 0, bytes = 0, high = '', finished = false;
36
+ const w = new Uint32Array(64), block = new Uint8Array(64);
37
+ let used = 0, bytes = 0, finished = false;
40
38
  const compress = (input, offset) => {
41
39
  for (let i = 0; i < 16; i++) {
42
40
  const at = offset + i * 4;
@@ -65,13 +63,37 @@ export function createSHA256() {
65
63
  for (; at + 64 <= size; at += 64) compress(input, at);
66
64
  if (at < size) { block.set(input.subarray(at, size), 0); used = size - at; }
67
65
  };
66
+ return {
67
+ update(input) {
68
+ if (finished || !(input instanceof Uint8Array)) throw new TypeError('SHA-256 needs bytes before finish');
69
+ if (!Number.isSafeInteger(bytes + input.length)) throw new Error('SHA-256 input length is not exact');
70
+ feed(input, input.length);
71
+ },
72
+ finish() {
73
+ if (finished) throw new Error('SHA-256 is already finished');
74
+ finished = true;
75
+ block[used++] = 0x80; block.fill(0, used);
76
+ if (used > 56) { compress(block, 0); block.fill(0); }
77
+ const view = new DataView(block.buffer);
78
+ view.setUint32(56, Math.floor(bytes / 0x20000000)); view.setUint32(60, (bytes * 8) >>> 0);
79
+ compress(block, 0);
80
+ return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
81
+ },
82
+ get bytes() { return bytes; },
83
+ };
84
+ }
85
+ // UTF-16 chunks share that byte state. Retain a trailing high surrogate so a
86
+ // caller can split a character between updates without changing its UTF-8 digest.
87
+ export function createSHA256() {
88
+ const hash = sha256State(), encoded = new Uint8Array(65536), encoder = new TextEncoder();
89
+ let high = '', finished = false;
68
90
  const write = text => {
69
91
  for (let at = 0; at < text.length;) {
70
92
  // Bound both the substring and byte allocation; encodeInto never cuts a scalar.
71
93
  let end = Math.min(text.length, at + 32768);
72
94
  if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
73
95
  const part = encoder.encodeInto(text.slice(at, end), encoded);
74
- feed(encoded, part.written); at += part.read;
96
+ hash.update(encoded.subarray(0, part.written)); at += part.read;
75
97
  }
76
98
  };
77
99
  return {
@@ -85,15 +107,9 @@ export function createSHA256() {
85
107
  if (finished) throw new Error('SHA-256 is finished');
86
108
  if (high) { write(high); high = ''; }
87
109
  finished = true;
88
- const bits = bytes * 8;
89
- block[used++] = 0x80; block.fill(0, used);
90
- if (used > 56) { compress(block, 0); block.fill(0); }
91
- const view = new DataView(block.buffer);
92
- view.setUint32(56, Math.floor(bits / 0x100000000)); view.setUint32(60, bits >>> 0);
93
- compress(block, 0);
94
- return Array.from(h, x => x.toString(16).padStart(8, '0')).join('');
110
+ return hash.finish();
95
111
  },
96
- get bytes() { return bytes; },
112
+ get bytes() { return hash.bytes; },
97
113
  };
98
114
  }
99
115
  export function sha256Parts(parts) {
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Transient projections and selective inverses of the existing canonical journal.
3
- import {_rapierTransformSplices, _rapierRecordSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierPrepareHistoryRecords} from './journal-records.mjs';
3
+ import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
4
4
  import {projectInverseSource, HistorySource} from './inverse-source.mjs';
5
5
  import {textRootParts, rootAfter} from './hash.mjs';
6
6
  import {authoredHistory} from './transport.mjs';
@@ -43,11 +43,15 @@ export function groupHistoryActs(acts) {
43
43
 
44
44
  // A picture's codec replacement travels with its original navigation step.
45
45
  // Match every complete replacement exactly before moving any source bytes.
46
- export function pictureHistorySplices({source, records}, target, redo) {
46
+ export function pictureHistorySplices(input, target, redo) {
47
+ const {records} = input;
47
48
  const id = target?.transaction?.id, at = records.indexOf(target);
48
49
  if (!id || at < 0 || target.transaction.operation === 'document.finish-picture' || !Array.isArray(target.splices)) return null;
49
50
  const later = records.slice(at + 1).filter(row => row?.transaction?.operation === 'document.finish-picture' && Array.isArray(row.splices) && row.splices.length);
50
51
  if (!later.length) return null;
52
+ // A caller may hold a persistent source tree: request its text only when
53
+ // a later picture replacement can actually affect this navigation step.
54
+ const source = input.source;
51
55
  const own = new Set(later.filter(row => row.transaction.sourceTransactionId === id));
52
56
  const step = redo ? target.splices : target.splices.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
53
57
  const plan = moveOthers => {
@@ -116,7 +120,9 @@ function prepareHistory(input) {
116
120
  row.beforeSource = initial; row.beforeMetadata = initialMetadata;
117
121
  }
118
122
  const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
119
- let root = earliestRevision === 0 ? textRootParts(initial.parts()) : null, rootRevision = earliestRevision;
123
+ // The window's owner may name its root (the source store mints it once per text); otherwise hash the initial text.
124
+ let root = earliestRevision === 0 ? (typeof input.earliestRoot === 'string' && input.earliestRoot || textRootParts(initial.parts())) : null,
125
+ rootRevision = earliestRevision;
120
126
  for (const {record, splices} of rows) {
121
127
  const hashed = Object.hasOwn(record, 'beforeHash') || Object.hasOwn(record, 'afterHash');
122
128
  if (hashed && (![record.beforeHash, record.afterHash].every(value =>
@@ -281,9 +287,15 @@ export function historyPlaces(input, blocks) {
281
287
  }
282
288
  touched.set(node, after);
283
289
  });
290
+ // Blocks one splice covers share its range (a whole-document delete maps every block to all it removed): slice each range once.
291
+ const slices = new Map(), cut = (source, side, from, to) => {
292
+ const key = side + from + ':' + to;
293
+ if (!slices.has(key)) slices.set(key, source.slice(from, to));
294
+ return slices.get(key);
295
+ };
284
296
  for (const [node, afterRange] of touched) {
285
- const beforeRange = ranges.range(node), previous = {...beforeRange, source: beforeSource.slice(beforeRange.from, beforeRange.to)},
286
- after = {...afterRange, source: afterSource.slice(afterRange.from, afterRange.to)};
297
+ const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
298
+ after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
287
299
  if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
288
300
  }
289
301
  }
@@ -190,7 +190,9 @@ export function merge(first, second) {
190
190
  const local = localIds.get(record.transaction.id);
191
191
  if (local) mappedIds.set(record.transaction.id, local.transaction.id);
192
192
  }
193
- const keepDiffers = canonicalJSON(kept(ours.text)) !== canonicalJSON(kept(theirs.text));
193
+ // Kept text arriving from the other copy waits for the person; this copy's own kept edits merge like any other.
194
+ const incomingKept = canonicalJSON(kept(theirs.text));
195
+ const keepDiffers = incomingKept !== canonicalJSON(kept(ours.text)) && incomingKept !== canonicalJSON(kept(common.text));
194
196
  let conflicted = keepDiffers && unseen.length > 0, incomingSource = common.text;
195
197
  if (!conflicted) for (const record of incoming) {
196
198
  const beforeSource = incomingSource;
@@ -240,8 +242,7 @@ export function merge(first, second) {
240
242
  let conflicts = [];
241
243
  if (conflicted) {
242
244
  if (unseen.length !== incoming.length) return refused('history', 'record_transport_conflict');
243
- if (incomingFields.size || unseen.some(record => incomingEffects.references.get(record.transaction.id)?.length))
244
- return metadataConflict('record_transport_conflict', {fields: [...incomingFields]});
245
+ if (incomingFields.size) return metadataConflict('record_transport_conflict', {fields: [...incomingFields]});
245
246
  // Restart from the original local head: partially transported records never leak into a conflict result.
246
247
  records.splice(ours.ledger.records.length); text = ours.text; metadata = {...ours.metadata}; root = ours.root;
247
248
  const merged = mergeText(keepDiffers ? null : common.text, ours.text, theirs.text, {oursId: 'copy-' + ours.ledger.sha256.slice(0, 16), theirsId: 'copy-' + theirs.ledger.sha256.slice(0, 16)});
@@ -151,7 +151,10 @@ export async function collectPdfVectors(main, {width, height, count, capture, si
151
151
  const left = Math.max(0, bounds.left - box.left - 2), top = Math.max(0, bounds.top - box.top - 2);
152
152
  const right = Math.min(box.width, bounds.right - box.left + 2), bottom = Math.min(box.height, bounds.bottom - box.top + 2);
153
153
  if (!(right > left && bottom > top)) return;
154
- const result = await capture({root: wrap, clip: {x: left, y: top, width: right - left, height: bottom - top}, transparent: true, scale: 2, signal, current}); check();
154
+ const result = await capture({root: wrap, clip: {x: left, y: top, width: right - left, height: bottom - top}, transparent: true, scale: 2, signal, current, pixels: true}); check();
155
+ if (result?.rgb instanceof Uint8Array && result?.alpha instanceof Uint8Array) {
156
+ return {image: {rgb: await compress(result.rgb), alpha: await compress(result.alpha), width: result.width, height: result.height}, x: box.left - origin.left + left, y: box.top - origin.top + top, width: right - left, height: bottom - top};
157
+ }
155
158
  const image = new window.Image(); image.src = 'data:' + result.mimeType + ';base64,' + result.data; await image.decode(); check();
156
159
  const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight;
157
160
  try {
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
- import {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, historyEnvelope, agentActorId} from './ledger/format.mjs';
3
- export {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, historyEnvelope, agentActorId};
2
+ import {exportLedger, replaceLedgerText, readLedger, ledgerWithStart, textRoot, rootAfter, historyEnvelope, agentActorId} from './ledger/format.mjs';
3
+ export {exportLedger, replaceLedgerText, readLedger, ledgerWithStart, textRoot, rootAfter, historyEnvelope, agentActorId};
4
4
  import {authorship, readAuthorship, ledgerFromAuthorship} from './ledger/authorship.mjs';
5
5
  export {authorship, readAuthorship, ledgerFromAuthorship};
6
6
  import {merge, transportInterval, transportTouchedInterval} from './ledger/merge.mjs';
@@ -63,7 +63,7 @@ export async function freezePublishedInk(root, {document: doc, css, fontCss = ''
63
63
  frame.srcdoc = '<!doctype html><html lang="' + documentLanguage(settings, language) + '" data-rapier-theme="' + theme + '"><head><meta charset="utf-8">'
64
64
  + '<meta http-equiv="Content-Security-Policy" content="' + policy + '"><meta name="referrer" content="no-referrer">'
65
65
  + '<style>' + css + '</style></head><body class="' + (theme === 'light' ? 'light' : '')
66
- + '"><main class="rapier-page md-render" data-md-theme="' + theme + '"' + (settings?.space ? ' data-md-space' : '') + '>' + root.innerHTML + '</main></body></html>';
66
+ + '"><main class="rapier-page md-render" data-md-theme="' + theme + '">' + root.innerHTML + '</main></body></html>';
67
67
  doc.body.appendChild(frame);
68
68
  await wait(loaded);
69
69
  const paper = frame.contentDocument, main = paper?.querySelector('main.rapier-page');
@@ -1,5 +1,5 @@
1
- import {htmlPlaceholder, mountHtmlBlocks, htmlImageRecord, htmlLayoutEdit} from '../spec/html-blocks.mjs';
2
1
  // SPDX-License-Identifier: MIT
2
+ import {htmlPlaceholder, mountHtmlBlocks, materializeObjects, objectPosterLabel, htmlImageRecord, htmlLayoutEdit, htmlModeEdit, mergeHtmlObjectBlocks} from '../spec/html-blocks.mjs';
3
3
  import {finish, finishAsync, cloneTree, cleanTree, serializeTree, htmlParts, admitHtml, htmlBoundary, pause, check} from './render-work.mjs';
4
4
  const RAPIER_TABLE_CAPTION_PREFIX_RE = /^(?:Table:|:) /;
5
5
  const RAPIER_PORTABLE_HIGHLIGHT_HEX = Object.freeze({
@@ -72,10 +72,8 @@ function initMarkdownIt() {
72
72
  'data-md-typography-close="' + md.utils.escapeHtml(encodeURIComponent(held.close)) + '"');
73
73
  return '<span ' + attrs.join(' ') + ' style="' + md.utils.escapeHtml(css.join(';')) + '">';
74
74
  };
75
- for (const kind of ['typography', 'color', 'font']) {
76
- md.renderer.rules['rapier_' + kind + '_open'] = renderTypography;
77
- md.renderer.rules['rapier_' + kind + '_close'] = () => '</span>';
78
- }
75
+ md.renderer.rules.rapier_typography_open = renderTypography;
76
+ md.renderer.rules.rapier_typography_close = () => '</span>';
79
77
  // Ink: the span carries its opener's body (the grammar's inkOpenBody: a sanitizer drops an attribute holding the
80
78
  // comment's close); the ink layer reads it back through the one grammar and draws the stroke over the words' own boxes.
81
79
  // The span itself shows nothing.
@@ -138,8 +136,8 @@ function initMarkdownIt() {
138
136
  const asset = globalThis.RapierImageAssets.dataImage(src), reference = token.meta?.mdImage?.reference;
139
137
  // A connected host's own asset; a host without one (the server) supplies no port.
140
138
  if (!env.rapierPage && typeof globalThis.RapierEmbeddedImages?.imageHtml === 'function' &&
141
- (_rapierEmbedAssetSource?.(src) || asset && (reference || asset.codec === 'image/jxl' || asset.codec === 'image/svg+xml'))) return globalThis.RapierEmbeddedImages.imageHtml(
142
- reference, alt, title, size, layout, rawAlt, token.meta?.mdImage?.source, src);
139
+ (_rapierEmbedAssetSource?.(src) || asset && (reference || asset.codec === 'image/jxl' || asset.codec === 'image/gif' || asset.codec === 'image/svg+xml'))) return globalThis.RapierEmbeddedImages.imageHtml(
140
+ reference, alt, title, size, layout, rawAlt, token.meta?.mdImage?.source, src, env.rapierImagePresentation !== false);
143
141
  const drawingAlt = typeof rawAlt === 'string' && alt === '' && asset?.codec === 'image/svg+xml' ? globalThis.RapierImageAssets.drawingImageAlt(src) : null;
144
142
  const shownAlt = drawingAlt ?? alt;
145
143
  let loadable = false;
@@ -158,19 +156,28 @@ function initMarkdownIt() {
158
156
  return html.replace('<span ', '<span data-rapier-markdown-image="" ' +
159
157
  (token.meta?.mdImage?.source ? 'data-rapier-image-source="' + md.utils.escapeHtml(encodeURIComponent(token.meta.mdImage.source)) + '" ' : ''));
160
158
  }
159
+ let animation = null;
160
+ try {animation = globalThis.RapierImageAssets.imageAnimation?.(src);} catch (_) {}
161
161
  const attrs = [
162
162
  'data-rapier-markdown-image=""',
163
- 'src="' + md.utils.escapeHtml(src) + '"',
163
+ 'src="' + md.utils.escapeHtml(animation?.first || src) + '"',
164
164
  'alt="' + md.utils.escapeHtml(shownAlt) + '"',
165
165
  'loading="lazy"',
166
166
  'decoding="async"',
167
167
  ];
168
+ if (animation) attrs.push('data-rapier-animation="' + md.utils.escapeHtml(src) + '"',
169
+ 'data-rapier-natural-width="' + animation.width + '"', 'data-rapier-natural-height="' + animation.height + '"');
168
170
  if (token.meta?.mdImage?.source && token.meta.mdImage.source.length <= 4096)
169
171
  attrs.push('data-rapier-image-source="' + md.utils.escapeHtml(encodeURIComponent(token.meta.mdImage.source)) + '"');
170
172
  if (title) attrs.push('title="' + md.utils.escapeHtml(title) + '"');
171
173
  if (shownAlt !== alt || size && !token.meta?.mdLayout?.layout?.width) attrs.push('data-rapier-image-alt-source="' + md.utils.escapeHtml(rawAlt) + '"');
172
174
  if (layout) attrs.push('data-rapier-image-layout="' + md.utils.escapeHtml(encodeURIComponent(layout)) + '"');
173
- return '<img ' + attrs.join(' ') + _rapierImageSizeAttributes(size, layout) + '>';
175
+ // Portable semantic consumers keep their existing native image attributes. Only
176
+ // live editor/reader presentation needs provisional geometry and its load consumer.
177
+ if (env.rapierImagePresentation === false) return '<img ' + attrs.join(' ') + _rapierImageSizeAttributes(size, layout) + '>';
178
+ const geometry = !env.rapierPage && globalThis.RapierEmbeddedImages?.geometry
179
+ ? globalThis.RapierEmbeddedImages.geometry(src) : globalThis.RapierImageAssets.imagePlaceholderDimensions(src);
180
+ return '<img ' + attrs.join(' ') + _rapierImageSizeAttributes(size, layout, geometry || {}) + '>';
174
181
  };
175
182
 
176
183
  md.renderer.rules.hr = function (tokens, idx) {
@@ -186,7 +193,9 @@ function initMarkdownIt() {
186
193
  // markup reaches DOMPurify: sanitizing each HTML token closes containers before their body.
187
194
  // The harness runs this installer alone for the parser's acceptance; without the page's sanitizer the tokens pass.
188
195
  const inPage = typeof sanitizeRapierHtml === 'function' && typeof _rapierChromeOwnsId === 'function';
189
- const rawAttribute = /\s+([A-Za-z_:][A-Za-z0-9_.:-]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^"'=<>`\s]+))?/g;
196
+ // HTML separates attributes and ends an unquoted value only at ASCII whitespace: JavaScript's \s also takes U+00A0,
197
+ // which would read a quoted value the browser never sees and keep the attributes hidden inside it.
198
+ const rawAttribute = /[\t\n\f\r ]+([A-Za-z_:][A-Za-z0-9_.:-]*)(?:[\t\n\f\r ]*=[\t\n\f\r ]*("[^"]*"|'[^']*'|[^"'=<>`\t\n\f\r ]+))?/g;
190
199
  // The id is read as HTML reads it: a numeric character reference (`&#115;ource-textarea` is `source-textarea`,
191
200
  // `&#x73;` the same) and the named references that spell a character an id can hold are decoded before the
192
201
  // chrome's ids are asked; every other name stays literal (no named reference spells a letter, a digit or a
@@ -196,7 +205,7 @@ function initMarkdownIt() {
196
205
  equals: '=', comma: ',', lpar: '(', rpar: ')', lsqb: '[', lbrack: '[', rsqb: ']', rbrack: ']', lcub: '{', lbrace: '{',
197
206
  rcub: '}', rbrace: '}', verbar: '|', vert: '|', VerticalLine: '|', bsol: '\\', Hat: '^', grave: '`', DiacriticalGrave: '`', Tab: '\t', NewLine: '\n' };
198
207
  const attributeText = value => String(value || '').replace(/^["']|["']$/g, '')
199
- .replace(/&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([A-Za-z][A-Za-z0-9]{0,31}));?/g, (whole, dec, hex, name) => {
208
+ .replace(/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]{0,31}));?/g, (whole, dec, hex, name) => {
200
209
  const code = dec ? Number(dec) : hex ? parseInt(hex, 16) : -1;
201
210
  if (code >= 0) return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd';
202
211
  return Object.hasOwn(RAW_ID_NAMED, name) ? RAW_ID_NAMED[name] : whole;
@@ -213,9 +222,15 @@ function initMarkdownIt() {
213
222
  return key.startsWith('data-') || key === 'for' || key === 'id' && _rapierChromeOwnsId(attributeText(value)) ? '' : whole;
214
223
  }) : tag;
215
224
  md.renderer.rules.html_inline = (tokens, idx) => cleanTag(tokens[idx].content);
216
- md.renderer.rules.html_block = (tokens, idx, _options, env) => _rapierRawHtmlFragment(tokens[idx].content,
217
- tag => env?.rapierDocumentSpace ? cleanTag(tag).replace(/^<([A-Za-z][A-Za-z0-9:-]*)(?=[\s/>])/, '<$1 data-md-html-block="' + idx + '"') : cleanTag(tag), md);
225
+ md.renderer.rules.html_block = (tokens, idx) => _rapierRawHtmlFragment(tokens[idx].content, cleanTag, md);
218
226
  }
227
+ // A highlight written in one of the picker's colours is that swatch, as `==🟢x==` is, and follows the theme.
228
+ const inlineHtml = md.renderer.rules.html_inline || ((tokens, idx) => tokens[idx].content);
229
+ md.renderer.rules.html_inline = (tokens, idx, ...rest) => {
230
+ const swatch = /^<mark style="background-color:(#[0-9a-f]{6});?">$/i.exec(tokens[idx].content);
231
+ const colour = swatch && Object.keys(RAPIER_PORTABLE_HIGHLIGHT_HEX).find(name => RAPIER_PORTABLE_HIGHLIGHT_HEX[name] === swatch[1].toLowerCase());
232
+ return colour ? '<mark data-rapier-highlight="' + colour + '">' : inlineHtml(tokens, idx, ...rest);
233
+ };
219
234
 
220
235
  const _ulOpenDefault = md.renderer.rules.bullet_list_open
221
236
  || function (tokens, idx, opts, _env, self) { return self.renderToken(tokens, idx, opts); };
@@ -308,8 +323,7 @@ function initMarkdownIt() {
308
323
  '</code> fence. Text below is still inside the block.</p>' + sourceHtml + '</figure>\n';
309
324
  }
310
325
  const provider = env.rapierProviders === false ? null : _rapierProviders.mermaid;
311
- const native = globalThis.RapierFlowchart?.parseFlowchart(src).ok === true ||
312
- provider?.status === 'ready' && globalThis.RapierDiagrams?.parseDiagram(src).ok === true;
326
+ const native = globalThis.RapierFlowchart?.parseFlowchart(src).ok === true;
313
327
  const ready = native || !!(provider && provider.status === 'ready' && typeof provider.renderToString === 'function');
314
328
  if (!ready && env.rapierProviders !== false && !_diagramPromptedOnce) {
315
329
  _diagramPromptedOnce = true;
@@ -360,11 +374,33 @@ function initMarkdownIt() {
360
374
  + literal + '</span>';
361
375
  }
362
376
  try {
363
- const rendered = mathProvider.renderToString(src, { displayMode });
377
+ // The explicit output-only call keeps structured results beside the unchanged SVG
378
+ // islands. MathML never enters editable text projections or source serialization.
379
+ let rendered;
380
+ if (env.rapierMathEquations) {
381
+ const combined = mathProvider.renderWithMathML(src, {displayMode});
382
+ if (!combined || typeof combined.svg !== 'string' || !combined.semantics ||
383
+ typeof combined.semantics !== 'object' || Array.isArray(combined.semantics) || !Array.isArray(combined.errors))
384
+ throw new TypeError('The Rapier Math shared result is invalid.');
385
+ rendered = combined.svg;
386
+ env.rapierMathEquations.push({source, tex: src, displayMode,
387
+ semantics: combined.semantics, errors: combined.errors});
388
+ } else rendered = mathProvider.renderToString(src, {displayMode});
389
+ let encoded;
390
+ try { encoded = encodeURIComponent(source); }
391
+ catch (error) {
392
+ // An ill-formed UTF-16 source cannot enter the URI-encoded edit marker.
393
+ // Keep the ordinary source-only HTML and the collected refusal diagnostic.
394
+ if (!(error instanceof URIError)) throw error;
395
+ return '<span data-rapier-math-source="">' + literal + '</span>';
396
+ }
364
397
  return '<span class="math-rendered" data-math-src="'
365
- + encodeURIComponent(source) + '">' + rendered + '</span>';
398
+ + encoded + '">' + rendered + '</span>';
399
+ }
400
+ catch (error) {
401
+ if (env.rapierMathEquations) throw error;
402
+ return '<span data-rapier-math-source="">' + literal + '</span>';
366
403
  }
367
- catch (_) { return '<span data-rapier-math-source="">' + literal + '</span>'; }
368
404
  };
369
405
  md.renderer.rules.math_inline = (tokens, idx, _opts, env) => _renderMath(tokens[idx].content, false, env, tokens[idx].meta?.mathSource);
370
406
  md.renderer.rules.math_block = (tokens, idx, _opts, env) => wrapDisplayMath(_renderMath(tokens[idx].content, true, env, tokens[idx].meta?.mathSource)) + '\n';
@@ -393,9 +429,10 @@ function _rapierRenderSemanticRoot(canonical, metadata, options = {}) {
393
429
  const markdown = _rapierSplitOpeningFrontmatter(canonical).body;
394
430
  // Source-semantic output is independent of installed rendering providers. Complete
395
431
  // artifacts keep the default provider path and materialize their visual content.
396
- const env = {...(options.page ? {rapierPage: true} : {}), ...(options.providers === false ? {rapierProviders: false} : {})};
397
- env.rapierDocumentSpace = !!globalThis.RapierMarkdownSpec?.readDocumentSettings?.(canonical)?.space;
398
- if (env.rapierDocumentSpace) root.setAttribute('data-md-space', '');
432
+ // A portable projection keeps natural sizing. Live readers opt into the same transient
433
+ // AUTO policy as the editor; neither a saved page nor its source inherits that choice.
434
+ const env = {rapierImagePresentation: options.imagePresentation === true, ...(options.page ? {rapierPage: true} : {}), ...(options.providers === false ? {rapierProviders: false} : {})};
435
+ if (options.mathEquations) env.rapierMathEquations = options.mathEquations;
399
436
  let rendered = md
400
437
  ? md.render(markdown, env)
401
438
  : '<pre><code>' + escapeRapierHtmlText(markdown) + '</code></pre>';
@@ -430,11 +467,10 @@ async function _rapierRenderSemanticRootAsync(canonical, metadata, options = {})
430
467
  const fallbackEnv = options.page ? {rapierPage: true} : {};
431
468
  let parsed = options.parsed || (runtime.parseAsync ? await runtime.parseAsync(markdown, options) : {tokens: md.parse(markdown, fallbackEnv), env: fallbackEnv});
432
469
  let {tokens, env} = parsed, labels = env.footnotes?.list?.map((item, index) => String(item?.label || ('inline-' + (index + 1))));
470
+ env = {...env, rapierImagePresentation: options.imagePresentation === true};
433
471
  await pause(work, .3);
434
- env.rapierDocumentSpace = !!globalThis.RapierMarkdownSpec?.readDocumentSettings?.(canonical)?.space;
435
472
  const root = document.createElement('div'), part = document.createElement('div'), boundary = htmlBoundary();
436
473
  root.setAttribute('data-rapier-semantic-root', 'true');
437
- if (env.rapierDocumentSpace) root.setAttribute('data-md-space', '');
438
474
  let html = '', began = performance.now(), complete = false;
439
475
  const append = () => {
440
476
  if (md.renderer.rapierTransformHtml) html = md.renderer.rapierTransformHtml(html);
@@ -653,16 +689,81 @@ function _rapierImageAltText(value, env = {}) {
653
689
  return md.renderer.renderInlineAsText(tokens[0]?.children || [], md.options, env);
654
690
  }
655
691
 
656
- function _rapierImageSizeAttributes(size, marker) {
692
+ // Intrinsic facts are a presentation input, never an authored width or a resource admission.
693
+ // Contain both edges within the column, sharing a 1.6-column sum-of-edges budget.
694
+ // Reciprocal portraits/landscapes have equal weight; the native-width cap never enlarges art.
695
+ function _rapierImageGeometryFacts(geometry, automatic) {
696
+ const w = Number(geometry?.width), h = Number(geometry?.height);
697
+ const known = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 && Number.isFinite(w / h) && w / h > 0;
698
+ const width = known ? w : 0, height = known ? h : 0;
699
+ const styles = known ? [['aspect-ratio', width + '/' + height]] : automatic ? [['aspect-ratio', '1/1']] : [];
700
+ if (automatic) {
701
+ const ratio = known ? width / height : 1;
702
+ const share = Math.min(1, ratio, 1.6 / (1 + 1 / ratio));
703
+ styles.push(['--rapier-image-auto-width', Number((share * 100).toFixed(6)) + '%'],
704
+ ['--rapier-image-natural-width', known ? width + 'px' : '100%']);
705
+ }
706
+ return {width, height, styles};
707
+ }
708
+
709
+ // Native loads supply their current element's dimensions synchronously. Embedded images
710
+ // supply an admitted row only after their existing document/resource ticket checks.
711
+ function _rapierApplyImageGeometry(image, geometry) {
712
+ if (image?.tagName !== 'IMG' || image.hasAttribute('data-rapier-html')) return false;
713
+ const mode = image.getAttribute('data-rapier-image-geometry');
714
+ if (!['auto', 'sized', 'natural'].includes(mode)) return false;
715
+ const native = geometry === undefined;
716
+ if (native) {
717
+ if (!image.isConnected || !image.complete || image.hasAttribute('data-rapier-asset') || image.hasAttribute('data-rapier-image-url')) return false;
718
+ geometry = {width: image.naturalWidth, height: image.naturalHeight};
719
+ }
720
+ const facts = _rapierImageGeometryFacts(geometry, mode === 'auto');
721
+ if (!facts.width || !facts.height) return false;
722
+ const attributes = [
723
+ ['data-rapier-natural-width', String(facts.width)], ['data-rapier-natural-height', String(facts.height)],
724
+ ...(!image.hasAttribute('data-rapier-image-size') ? [['width', String(facts.width)]] : []), ['height', String(facts.height)],
725
+ ].filter(([name, value]) => image.getAttribute(name) !== value);
726
+ const styles = facts.styles.filter(([name, value]) => (name === 'aspect-ratio'
727
+ ? image.style.getPropertyValue(name).replace(/\s/g, '') : image.style.getPropertyValue(name)) !== value);
728
+ // Ordinary mutations revisit mounted images. Settled facts need no source-URL
729
+ // read or parse (which can be the entire inline image), nor any DOM writes.
730
+ if (!attributes.length && !styles.length) return false;
731
+ if (native) {
732
+ const src = image.getAttribute('src'), current = image.currentSrc;
733
+ if (!src || !current) return false;
734
+ try { if (src !== current && new URL(src, image.ownerDocument.baseURI).href !== current) return false; }
735
+ catch (_) { return false; }
736
+ }
737
+ for (const [name, value] of attributes) image.setAttribute(name, value);
738
+ for (const [name, value] of styles) image.style.setProperty(name, value);
739
+ return true;
740
+ }
741
+
742
+ function _rapierImageSizeAttributes(size, marker, geometry, automatic = true) {
657
743
  const layout = globalThis.RapierMarkdownLayout.parseLayout(marker || '');
658
744
  // Plain (rung 0, docs/standard-adoption.md): every picture renders at its natural width, the
659
745
  // same as a reader with no layout facts at all -- Obsidian's `![alt|N]` alt-text width (the
660
746
  // `size` fallback below) is a different, established convention and is unaffected.
661
- const style = layout && !_rapierPlainLayout() ? globalThis.RapierMarkdownLayout.imageStyle(layout) : '';
662
- if (layout?.width != null && style) return ' data-md-image-width="' + layout.width + '" style="' + style + '"';
747
+ let style = layout && !_rapierPlainLayout() ? globalThis.RapierMarkdownLayout.imageStyle(layout) : '';
748
+ let attributes = '';
749
+ if (layout?.width != null && style) attributes = ' data-md-image-width="' + layout.width + '"';
663
750
  // A fade rides beside any width, the alt-text's own included.
664
- return size ? ' width="' + size.width + '" data-rapier-image-size="' + size.width +
665
- '" style="--md-image-width:' + size.width + 'px' + (style ? ';' + style : '') + '"' : style ? ' style="' + style + '"' : '';
751
+ else if (size) {
752
+ attributes = ' width="' + size.width + '" data-rapier-image-size="' + size.width + '"';
753
+ style = '--md-image-width:' + size.width + 'px' + (style ? ';' + style : '');
754
+ }
755
+ if (geometry !== undefined) {
756
+ const mode = size || layout?.width != null || layout?.lines != null ? 'sized' : automatic ? 'auto' : 'natural';
757
+ const facts = _rapierImageGeometryFacts(geometry, mode === 'auto');
758
+ attributes += ' data-rapier-image-geometry="' + mode + '"';
759
+ if (facts.width) {
760
+ attributes += ' data-rapier-natural-width="' + facts.width + '" data-rapier-natural-height="' + facts.height + '"';
761
+ if (!size || !attributes.includes(' width=')) attributes += ' width="' + facts.width + '"';
762
+ attributes += ' height="' + facts.height + '"';
763
+ }
764
+ style = [...facts.styles.map(([name, value]) => name + ':' + value), ...(style ? [style] : [])].join(';');
765
+ }
766
+ return attributes + (style ? ' style="' + style + '"' : '');
666
767
  }
667
768
 
668
769
  function _rapierRawHtmlFragment(source, rewrite, parser) {
@@ -1216,7 +1317,8 @@ function* _rapierProjectPortableNodesSteps(semanticRoot, options) {
1216
1317
  (name === 'data-rapier-remote-src' || name === 'data-rapier-remote-alt')) continue;
1217
1318
  if (keepLayoutData && (name === 'data-rapier-image-layout' || name === 'data-md-layout' || name === 'data-md-align' || name === 'data-rapier-ink'
1218
1319
  || name === 'data-md-first-line-end' || name === 'data-md-initial-font' || name === 'data-md-initial-size'
1219
- || name === 'data-md-picture-cap-height' || name === 'data-md-picture-cap-top')) continue;
1320
+ || name === 'data-md-picture-cap-height' || name === 'data-md-picture-cap-top' || name === 'data-rapier-highlight' || name === 'data-rapier-animation'
1321
+ || name === 'data-rapier-list-ordinal' || name === 'data-rapier-list-delimiter' || name === 'data-md-layout-tight')) continue;
1220
1322
  if (name === 'class' || name === 'style' || name.startsWith('data-')
1221
1323
  || name === 'contenteditable' || name === 'spellcheck'
1222
1324
  || name === 'tabindex' || name === 'disabled' || name === 'aria-disabled') {
@@ -1627,8 +1729,21 @@ function _rapierBidiStrong(cp) {
1627
1729
  return null;
1628
1730
  }
1629
1731
 
1630
- return {_rapierClipboardPayloadAsync, _rapierPlainTextFileAsync, _rapierCloneRootAsync, _rapierPortablePlainTextAsync, _rapierHeldPicturesAsWordsAsync, _rapierRenderSemanticRootAsync, _rapierProjectPortableRootAsync, _rapierPortableHtmlAsync, _rapierCalloutInfo, _rapierRemoveCalloutMarker, _rapierMathSource, _rapierFlattenPortableDetails, _rapierProjectPortableRoot, _rapierPortableHtml, _rapierNormalizeInlineText, _rapierPortableInlineText, _rapierPortableListText, _rapierPortableTableText, _rapierPortablePlainText, _rapierPlainTextFile, _rapierHeldPicturesAsWords, _rapierSourceTokenValue, _rapierResolveSoftBreakTokens, _rapierUnwrapElement, _rapierBidiStrong, render: (source, metadata = {}) => { if (!md) initMarkdownIt(); return _rapierRenderSemanticRoot(source, metadata).innerHTML; }, initMarkdownIt, _rapierRenderSemanticRoot, _rapierInstallSemanticProbe, _rapierImageAltSize, _rapierImageAltSourceParts, _rapierImageAltText, _rapierImageSizeAttributes, _rapierRawHtmlFragment, _rapierApplyBlockDirection, _rapierFirstStrongDir, _rapierAssignHeadingSlugs, _rapierDisambiguateRenderedAnchors, _rapierDecodeHash, _rapierDecorateJumpLists, _rapierMarkFigureCaptions, _rapierMarkListContinuations, _rapierMarkTableCaptions, _rapierNormalizeCodeElement, _rapierLanguageClass, _relabelFootnoteHtml, _footnoteIdFromLabel, wrapDisplayMath, parser: () => md};
1732
+ // A consuming operation, not an editable DOM mode. Both outputs share one admitted
1733
+ // parse per equation; callers decide whether and how to expose the MathML to people.
1734
+ function renderWithMathML(source, metadata = {}) {
1735
+ const provider = _rapierProviders.math;
1736
+ if (!provider || provider.status !== 'ready' || typeof provider.renderToString !== 'function' ||
1737
+ typeof provider.renderWithMathML !== 'function')
1738
+ throw new Error('The Rapier Math shared-result renderer is unavailable.');
1739
+ if (!md) initMarkdownIt();
1740
+ const equations = [];
1741
+ const html = _rapierRenderSemanticRoot(source, metadata, {mathEquations: equations}).innerHTML;
1742
+ return {html, equations};
1743
+ }
1744
+
1745
+ return {renderWithMathML, _rapierClipboardPayloadAsync, _rapierPlainTextFileAsync, _rapierCloneRootAsync, _rapierPortablePlainTextAsync, _rapierHeldPicturesAsWordsAsync, _rapierRenderSemanticRootAsync, _rapierProjectPortableRootAsync, _rapierPortableHtmlAsync, _rapierCalloutInfo, _rapierRemoveCalloutMarker, _rapierMathSource, _rapierFlattenPortableDetails, _rapierProjectPortableRoot, _rapierPortableHtml, _rapierNormalizeInlineText, _rapierPortableInlineText, _rapierPortableListText, _rapierPortableTableText, _rapierPortablePlainText, _rapierPlainTextFile, _rapierHeldPicturesAsWords, _rapierSourceTokenValue, _rapierResolveSoftBreakTokens, _rapierUnwrapElement, _rapierBidiStrong, render: (source, metadata = {}) => { if (!md) initMarkdownIt(); return _rapierRenderSemanticRoot(source, metadata).innerHTML; }, initMarkdownIt, _rapierRenderSemanticRoot, _rapierInstallSemanticProbe, _rapierApplyImageGeometry, _rapierImageAltSize, _rapierImageAltSourceParts, _rapierImageAltText, _rapierImageSizeAttributes, _rapierRawHtmlFragment, _rapierApplyBlockDirection, _rapierFirstStrongDir, _rapierAssignHeadingSlugs, _rapierDisambiguateRenderedAnchors, _rapierDecodeHash, _rapierDecorateJumpLists, _rapierMarkFigureCaptions, _rapierMarkListContinuations, _rapierMarkTableCaptions, _rapierNormalizeCodeElement, _rapierLanguageClass, _relabelFootnoteHtml, _footnoteIdFromLabel, wrapDisplayMath, parser: () => md};
1631
1746
  }
1632
1747
  export {RAPIER_TABLE_CAPTION_PREFIX_RE as tableCaptionPrefix, RAPIER_PORTABLE_HIGHLIGHT_HEX as portableHighlightHex, _RAPIER_RTL_SCRIPT as rtlScripts, createMarkdownRenderer};
1633
1748
 
1634
- export {mountHtmlBlocks, htmlImageRecord, htmlLayoutEdit};
1749
+ export {mountHtmlBlocks, materializeObjects, objectPosterLabel, htmlImageRecord, htmlLayoutEdit, htmlModeEdit, mergeHtmlObjectBlocks};