rapier-markdown-kit 1.2.8 → 1.2.10

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 (61) hide show
  1. package/dist/agent/will.mjs +0 -6
  2. package/dist/images/gif.mjs +0 -3
  3. package/dist/images/playback.mjs +0 -10
  4. package/dist/kit/assets.mjs +0 -1
  5. package/dist/kit/conformance/measurer.mjs +0 -0
  6. package/dist/kit/conformance/run.mjs +1 -8
  7. package/dist/kit/export-fonts.mjs +0 -14
  8. package/dist/kit/ledger/authored-origin.mjs +0 -5
  9. package/dist/kit/ledger/authorship.mjs +0 -2
  10. package/dist/kit/ledger/carried.mjs +4 -7
  11. package/dist/kit/ledger/data.mjs +0 -8
  12. package/dist/kit/ledger/format.mjs +0 -25
  13. package/dist/kit/ledger/hash.mjs +11 -7
  14. package/dist/kit/ledger/history.mjs +10 -29
  15. package/dist/kit/ledger/inverse-source.mjs +0 -46
  16. package/dist/kit/ledger/journal-records.mjs +24 -56
  17. package/dist/kit/ledger/merge.mjs +0 -27
  18. package/dist/kit/ledger/text-merge.mjs +0 -20
  19. package/dist/kit/ledger/transport.mjs +0 -47
  20. package/dist/kit/ledger.mjs +0 -4
  21. package/dist/kit/markdown-it.mjs +0 -3
  22. package/dist/kit/pdf-glyphs.mjs +1 -10
  23. package/dist/kit/pdf-source.mjs +5 -5
  24. package/dist/kit/pdf-vectors.mjs +0 -3
  25. package/dist/kit/print-capitals.mjs +0 -4
  26. package/dist/kit/render-entry.mjs +0 -1
  27. package/dist/kit/render-ink.mjs +0 -5
  28. package/dist/kit/render-markdown.mjs +12 -117
  29. package/dist/kit/render-print.mjs +30 -61
  30. package/dist/kit/render-sanitize.mjs +7 -59
  31. package/dist/kit/render-styles.mjs +0 -7
  32. package/dist/kit/render-work.mjs +0 -12
  33. package/dist/kit/render.mjs +46 -176
  34. package/dist/layout/hyphenation.mjs +0 -23
  35. package/dist/layout/line-plan.mjs +0 -20
  36. package/dist/layout/model.mjs +0 -55
  37. package/dist/layout/paragraph-fit.mjs +50 -60
  38. package/dist/layout/preparation.mjs +23 -56
  39. package/dist/layout/prepared-paragraph.mjs +0 -25
  40. package/dist/layout/typography.mjs +103 -41
  41. package/dist/packages/rapier-diff/concerns.mjs +0 -16
  42. package/dist/packages/rapier-diff/diff.mjs +0 -7
  43. package/dist/packages/rapier-diff/index.mjs +0 -1
  44. package/dist/packages/rapier-diff/matching.mjs +0 -19
  45. package/dist/packages/rapier-diff/merge.mjs +0 -9
  46. package/dist/packages/rapier-diff/moves.mjs +0 -7
  47. package/dist/packages/rapier-diff/structure.mjs +0 -2
  48. package/dist/packages/rapier-diff/tokens.mjs +0 -6
  49. package/dist/packages/rapier-diff/walk.mjs +0 -12
  50. package/dist/spec/document-fonts.mjs +0 -9
  51. package/dist/spec/document-settings.mjs +5 -24
  52. package/dist/spec/frontmatter.mjs +0 -1
  53. package/dist/spec/html-blocks.mjs +0 -36
  54. package/dist/spec/md-assets.mjs +2 -61
  55. package/dist/spec/md-layout.mjs +0 -34
  56. package/dist/spec/md-marks.mjs +0 -51
  57. package/dist/spec/md-source.mjs +0 -16
  58. package/dist/spec/template-page.mjs +6 -0
  59. package/dist/spec/word-sidecar.mjs +0 -2
  60. package/package.json +1 -1
  61. package/style.css +32 -16
@@ -8,7 +8,7 @@ import {documentLanguage, documentSettingsFonts} from '../spec/document-settings
8
8
  import {createLineMeasurer, linesHeight} from '../layout/model.mjs';
9
9
  import {markPrintCapitals, createPrintCapitals, followingPrintProse} from './print-capitals.mjs';
10
10
  import {freezePublishedInk} from './render-ink.mjs';
11
- // Set the first view once. Subsequent rendering keeps the person's horizontal position.
11
+ import {isTemplatePage, isPageBorderLayout} from '../spec/template-page.mjs';
12
12
  export function positionNativeDiagram(drawing) {
13
13
  const surface=drawing?.closest?.('.diagram-block,.diagram-live-preview');
14
14
  if(!surface?.clientWidth)return;
@@ -30,7 +30,6 @@ export function positionNativeDiagram(drawing) {
30
30
  surface.scrollLeft=Math.max(0,Math.min(surface.scrollWidth-surface.clientWidth,left));
31
31
  }
32
32
 
33
- // The document's renderer. DOM, codecs and host state are explicit inputs.
34
33
  function createRenderer(runtime) {
35
34
  const {RapierLedgerCarried, RapierPageReturnAddress, RapierTextCodec, _rapierArtifactHighlight, _rapierArtifactPreference, _rapierArtifactStyles, _rapierBlobDataUrl, _rapierBuildInterchangeContext, _rapierDocumentNameIsAdmissible, _rapierLedgerParts, _rapierDrawReadSVGRecipe, _rapierDrawShapeProfileFor, _rapierExportFontCss, _rapierFillDiagram, _rapierFinalizeExportCode, _rapierLanguageClass, _rapierLineStartOffsets, _rapierMarkdownEnvironment, _rapierPortableHtml, _rapierPrepareInterchangeContext, _rapierProjectPortableRoot, _rapierProviders, _rapierSourceCharEscaped, _rapierSourceLineSpan, crypto, document, escapeRapierHtmlText, globalThis, sanitizeRapierHtml} = runtime;
36
35
  let md = runtime.md;
@@ -66,7 +65,6 @@ async function _rapierBuildArtifact(options, providedContext) {
66
65
  : _rapierProjectPortableRoot(context.semanticRoot, { baseName: base, keepDiagrams: true });
67
66
  for (const element of portableRoot.children) if (!element.hasAttribute('lang')) element.setAttribute('lang', language);
68
67
  if (fontCss || settings?.defaultfont) {
69
- // Keep authored font scopes inside this fragment; no page wrapper or global settings rules.
70
68
  const {main, sans: heading, mono: code} = documentSettingsFonts(settings), body = main || heading;
71
69
  if (body) for (const element of portableRoot.children)
72
70
  if (!element.matches('pre,code,kbd,samp,svg,math') && !element.style.fontFamily)
@@ -80,15 +78,12 @@ async function _rapierBuildArtifact(options, providedContext) {
80
78
  css: _rapierArtifactStyles('system', false, false, false, '') + '\n' + _rapierDocumentSettingsCss(settings)});
81
79
  const html = work ? await runtime._rapierPortableHtmlAsync(portableRoot, work) : _rapierPortableHtml(portableRoot);
82
80
  return {
83
- // Only validated data-font rules bypass the content sanitizer, which still rejects authored styles.
84
81
  html: (fontCss ? '<style>' + fontCss + '</style>\n' : '') + html,
85
82
  filename: base + '-publishing.html',
86
83
  metadata,
87
84
  };
88
85
  }
89
86
 
90
- // The semantic export root is detached and has never entered the live visibility queue.
91
- // Await the same materialization owner before any projection clones or serializes it.
92
87
  const unavailable = plugin => Object.assign(new Error('Offline web page not written; install the ' + plugin + ' plug-in before exporting this document.'),
93
88
  {code: 'offline_plugin_unavailable', plugin});
94
89
  if (completePage && context.semanticRoot.querySelector('.math-placeholder[data-rapier-math-source]')) throw unavailable('math');
@@ -97,15 +92,11 @@ async function _rapierBuildArtifact(options, providedContext) {
97
92
  if (!(completePage && retained && _rapierProviders?.mermaid?.status !== 'ready')) await _rapierFillDiagram(diagram, true);
98
93
  if (!completePage || diagram.hasAttribute('data-diagram-unclosed')) continue;
99
94
  if (diagram.getAttribute('data-diagram-state') === 'absent') throw unavailable('mermaid');
100
- // A completed diagram must have its drawing. Syntax and renderer failures both refuse
101
- // the file; an unclosed fence above is still authored source, not a completed diagram.
102
95
  if (diagram.getAttribute('data-diagram-state') !== 'ready' || !diagram.querySelector('.diagram-cache svg'))
103
96
  throw Object.assign(new Error('Web page not written; a required diagram could not be rendered'), {code: 'EXPORT_DIAGRAM_UNAVAILABLE'});
104
97
  }
105
98
 
106
99
  if (completePage) {
107
- // Invalid TeX renders as a static error SVG. A source-only span means
108
- // rendering itself failed, so publishing it would silently omit the equation.
109
100
  if (context.semanticRoot.querySelector('span[data-rapier-math-source]'))
110
101
  throw new Error('Web page not written; a required equation could not be rendered');
111
102
  await _rapierRequireOfflinePageImages(context.semanticRoot);
@@ -119,11 +110,9 @@ async function _rapierBuildArtifact(options, providedContext) {
119
110
  const styledRoot = work ? await _rapierProjectStyledRootAsync(context.semanticRoot, styledOptions)
120
111
  : _rapierProjectStyledRoot(context.semanticRoot, styledOptions);
121
112
  styledRoot.setAttribute('lang', language);
113
+ const templatePage = _rapierTemplatePage(context.canonical, settings, styledRoot);
122
114
  if (completePage && typeof _rapierFinalizeExportCode === 'function') await _rapierFinalizeExportCode(styledRoot);
123
- // _rapierProjectStyledRoot already returns the sanitized projection.
124
- // Picture-shape annotations have no consumer in a printed artifact.
125
115
  if (!opts.print) _rapierAnnotateExportBoxPolygons(styledRoot);
126
- // Paper keeps each picture's wrap through the same static float layout a page uses.
127
116
  if (completePage || opts.print) {
128
117
  if (work) await finishAsync(_rapierSharedPageLayoutSteps(styledRoot), work);
129
118
  else _rapierSharedPageLayout(styledRoot);
@@ -134,7 +123,7 @@ async function _rapierBuildArtifact(options, providedContext) {
134
123
  }
135
124
  if (typeof opts.afterRoot === 'function') await opts.afterRoot(styledRoot);
136
125
  markPrintCapitals(styledRoot);
137
- const theme = opts.print
126
+ const theme = opts.print || templatePage
138
127
  ? 'light'
139
128
  : _rapierArtifactPreference('theme', ['system', 'light', 'dark']);
140
129
  const highlights = _rapierArtifactPreference('highlights', ['accent', 'standard']);
@@ -145,11 +134,6 @@ async function _rapierBuildArtifact(options, providedContext) {
145
134
  const docClass = metadata.docKind === 'markdown'
146
135
  ? 'rapier-page md-render'
147
136
  : 'rapier-page rapier-document--flat';
148
- // Rapier's own stylesheet, not a lookalike: the theme frame, spec/markdown-style.css's whole
149
- // .md-render set and the highlight rules, so a numbered list, a checkbox, a callout and a
150
- // table are drawn in an exported page exactly as they are drawn here. The one caller with
151
- // rules of its own -- Share, for the no-script float it alone writes -- appends them.
152
- // Print retains its existing factory resources. HTML adds only the optional small Sans subset below.
153
137
  const includeFonts = !!opts.print;
154
138
  const willFont = opts.print && opts.willFont ? opts.willFont : null;
155
139
  const settingsCss = _rapierDocumentSettingsCss(settings);
@@ -161,13 +145,12 @@ async function _rapierBuildArtifact(options, providedContext) {
161
145
  + (printWidth > 0 ? '.rapier-page.md-render{width:' + printWidth + 'px!important}' : '') + '}' : '';
162
146
  const pageTitle = settings && settings.title ? globalThis.RapierMarkdownSpec.documentTitle(settings) : metadata.filename;
163
147
  let css = _rapierArtifactStyles(theme, includeFonts, !!opts.print || completePage, false, opts.print && opts.kind === 'standalone' ? '' : null)
164
- // Print already floats pictures. Tables, code, figures and rules clear them
165
- // through the same clearing rule a complete page uses; print keeps its own page flow.
166
148
  + (completePage || opts.print ? '\n\n' + _rapierSharedPageFallbackCss(!completePage) : '')
167
149
  + (opts.extraCss ? '\n\n' + opts.extraCss : '')
168
150
  + (willFont ? '\n\n@font-face{font-family:' + willFont.family + ';src:url(' + await _rapierBlobDataUrl(new Blob([willFont.bytes], { type: 'font/ttf' })) + ') format("truetype")}' : '')
169
151
  + (settingsCss ? '\n' + settingsCss : '')
170
- + (typographyPrintCss ? '\n' + typographyPrintCss : '');
152
+ + (typographyPrintCss ? '\n' + typographyPrintCss : '')
153
+ + (templatePage ? '\n' + templatePage.css : '');
171
154
  let factoryFontCss = '';
172
155
  if (!opts.print) {
173
156
  if (fontIndex) await requireDocumentFonts(context.canonical, {parser: md, index: fontIndex, root: styledRoot, settings, css, fontCss, validateFont: runtime.validateFont});
@@ -184,13 +167,7 @@ async function _rapierBuildArtifact(options, providedContext) {
184
167
  const bodyHtml = work
185
168
  ? await finishAsync(serializeTree(styledRoot), work) : styledRoot.innerHTML;
186
169
  if (work) await pause(work, .95);
187
- // Every written page is offline. Only the writer's nonce script may run; it, pictures,
188
- // styles and other page resources get no network authority. Following an ordinary link
189
- // is the reader's navigation, with no Referer, not a background resource request.
190
170
  const nonce = _rapierArtifactNonce();
191
- // 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
192
- // 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
193
- // nonce out of it and be admitted.
194
171
  const layoutScript = opts.scripts !== false ? _rapierArtifactLayoutScript(styledRoot, nonce, {pictures:!opts.print}) : '';
195
172
  const inkScript = !opts.print && opts.scripts !== false ? _rapierArtifactInkScript(styledRoot, nonce) : '';
196
173
  const htmlScript = !opts.print && opts.scripts !== false && styledRoot.querySelector('[data-rapier-html],[data-rapier-animation]') ? '<script nonce="' + nonce + '">' + htmlRuntimeSource().replace(/<\/script/gi, '<\\/script') + '</script>' : '';
@@ -198,18 +175,15 @@ async function _rapierBuildArtifact(options, providedContext) {
198
175
  + '<html lang="' + escapeRapierHtmlText(language) + '" data-rapier-theme="' + theme + '" data-highlights="' + highlights + '" data-invert-image-alpha="' + (invertImageAlpha ? 'on' : 'off') + '">\n'
199
176
  + '<head>\n<meta charset="UTF-8">\n' + (carrier ? RapierPageReturnAddress.PAGE_SEED + '\n' : '')
200
177
  + '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
201
- // Chrome 154's responsively sized frames: a host frame styled `frame-sizing: content-height` sizes
202
- // itself to a page that opts in with this meta, so a document exported from Rapier and carried in
203
- // someone's page shows whole, with no inner scroll. Any origin may read the height: the page is the
204
- // document, published by the person who exported it.
205
178
  + '<meta name="responsive-embedded-sizing" content="allow-origins=*">\n'
206
179
  + '<meta http-equiv="Content-Security-Policy" content="' + _rapierExportedPageCsp(layoutScript || inkScript || htmlScript ? nonce : '', includeFonts || !!fontCss || !!factoryFontCss) + '">\n'
207
180
  + '<meta name="referrer" content="no-referrer">\n'
208
181
  + '<link rel="icon" href="data:,">\n'
182
+ + _rapierPagePreviewMeta(styledRoot, pageTitle)
209
183
  + '<meta name="generator" content="Rapier ' + escapeRapierHtmlText(version) + '">\n'
210
184
  + '<title>' + escapeRapierHtmlText(pageTitle) + '</title>\n'
211
185
  + '<style>' + css + '</style>\n</head>\n'
212
- + '<body class="' + bodyClass + '"><main class="' + docClass + '" data-md-theme="' + theme + '">'
186
+ + '<body class="' + bodyClass + '">' + (templatePage ? '<div class="rapier-template-border" aria-hidden="true"></div>' : '') + '<main class="' + docClass + '" data-md-theme="' + theme + '">'
213
187
  + bodyHtml + '</main>' + carrier + layoutScript + inkScript + htmlScript + '</body>\n</html>';
214
188
 
215
189
  return {
@@ -220,6 +194,7 @@ async function _rapierBuildArtifact(options, providedContext) {
220
194
  fontCss,
221
195
  fontRequests: fontCss ? fontRequests(styledRoot, {settings, css, fontCss}) : [],
222
196
  settings,
197
+ templatePage: templatePage && {url: templatePage.url, width: templatePage.width, height: templatePage.height, margin: templatePage.margin},
223
198
  docClass,
224
199
  language,
225
200
  metadata,
@@ -227,11 +202,40 @@ async function _rapierBuildArtifact(options, providedContext) {
227
202
  };
228
203
  }
229
204
 
205
+ function _rapierTemplatePage(canonical, settings, root) {
206
+ if (!isTemplatePage(canonical) || !settings?.papersize) return null;
207
+ let url = '';
208
+ for (const paragraph of root.querySelectorAll(':scope > p[data-md-layout]')) {
209
+ const image = paragraph.querySelector('img');
210
+ const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(paragraph.getAttribute('data-md-layout'));
211
+ const source = image?.getAttribute('src') || '';
212
+ if (isPageBorderLayout(layout) && /^data:image\/svg\+xml;base64,[A-Za-z0-9+/=]+$/.test(source)) { url = source; paragraph.remove(); break; }
213
+ }
214
+ if (!url) return null;
215
+ const width = settings.papersize.width / 15, height = settings.papersize.height / 15, margin = (settings.geometry?.twips ?? 1440) / 15;
216
+ const ground = 'background:var(--rapier-template-border) 0 0/100% 100% no-repeat';
217
+ const css = ':root{--rapier-template-border:url("' + url + '")}\n'
218
+ + '@media screen{.rapier-template-border{display:none}body{background:#e8e8e8}.rapier-page{box-sizing:border-box;width:min(100%,' + width + 'px);min-height:' + height + 'px;margin:0 auto;padding:' + margin
219
+ + 'px;background:var(--rapier-template-border) 0 0/100% ' + height + 'px repeat-y,#fff;color:#111}}\n'
220
+ + '@media print{@page{size:' + width + 'px ' + height + 'px;margin:0}html,body{margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact}'
221
+ + '.rapier-page{box-sizing:border-box;width:auto;max-width:none;margin:0;padding:' + margin + 'px!important;position:relative!important;z-index:1;box-decoration-break:clone;-webkit-box-decoration-break:clone}'
222
+ + '.rapier-template-border{display:block;position:fixed;left:0;top:0;width:' + width + 'px;height:' + height + 'px;z-index:0;' + ground + '}}';
223
+ return {url, width, height, margin, css};
224
+ }
225
+
226
+ function _rapierPagePreviewMeta(root, fallbackTitle) {
227
+ const words = node => String(node?.textContent || '').replace(/\s+/g, ' ').trim();
228
+ const title = words(root.querySelector('h1,h2,h3,h4,h5,h6')) || String(fallbackTitle || '');
229
+ const text = [...root.querySelectorAll('p')].map(words).find(line => line.length >= 20) || '';
230
+ const sentence = /^.{20,300}?[.!?](?=\s|$)/.exec(text)?.[0] || (text.length <= 200 ? text : text.slice(0, 200).replace(/\s+\S*$/, '') + '…');
231
+ const meta = (key, name, value) => '<meta ' + key + '="' + name + '" content="' + escapeRapierHtmlText(value) + '">\n';
232
+ return (sentence ? meta('name', 'description', sentence) : '') + (title ? meta('property', 'og:title', title) : '') +
233
+ (sentence ? meta('property', 'og:description', sentence) : '');
234
+ }
235
+
230
236
  async function _rapierBuildPublishedPage(context) {
231
237
  if (typeof context?.canonical !== 'string' || !context.semanticRoot)
232
238
  throw new TypeError('Publishing requires a captured document.');
233
- // Public readers receive only the current document. The editing session's previous
234
- // source and attribution ledger never acquire public custody through an export option.
235
239
  const current = {...context, ledger: null, carried: 'none', comparisonBase: null};
236
240
  return _rapierBuildArtifact({
237
241
  kind: 'standalone', scripts: false, extraCss: _rapierSharedPageFallbackCss(),
@@ -250,6 +254,11 @@ async function _rapierProjectStyledRootAsync(semanticRoot, options = {}) { retur
250
254
  function* _rapierProjectStyledRootSteps(semanticRoot, options) {
251
255
  const opts = options || {};
252
256
  const root = yield* cloneTree(semanticRoot);
257
+ for (const token of root.querySelectorAll('.rapier-source-token--softbreak')) { yield;
258
+ if (token.getAttribute('data-rapier-source') !== '%0A') token.replaceWith(...token.childNodes);
259
+ else if (token.previousSibling?.classList?.contains('callout__label')) token.remove();
260
+ else token.replaceWith(root.ownerDocument.createTextNode(' '));
261
+ }
253
262
  const metadata = opts.metadata || {};
254
263
 
255
264
  if (metadata.docKind !== 'markdown') {
@@ -299,7 +308,6 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
299
308
  }
300
309
  input.disabled = !!opts.print || !item;
301
310
  if (input.disabled) { input.setAttribute('aria-disabled', 'true'); continue; }
302
- // Native checked state changes this reader's view, never the carried source or disk.
303
311
  input.removeAttribute('aria-disabled');
304
312
  input.removeAttribute('aria-checked');
305
313
  input.setAttribute('autocomplete', 'off');
@@ -315,8 +323,6 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
315
323
  }
316
324
 
317
325
  function _rapierDocxRenderStyles(root) {
318
- // The semantic root is detached and its code is normalized text. Use the same
319
- // highlighter and light reference sheet that paint a page, then retain native run facts.
320
326
  const codes = Array.from(root.querySelectorAll('pre > code'));
321
327
  for (const code of codes) {
322
328
  const lang = _rapierLanguageClass(code);
@@ -345,8 +351,6 @@ function _rapierDocxRenderStyles(root) {
345
351
  if (!paint) throw new Error('Word text colors could not be read.');
346
352
  const hex = colour => {
347
353
  if (!colours.has(colour)) {
348
- // CSS may resolve an accent to oklch/color-mix. The browser supplies its
349
- // sRGB paper colour; the writer does not invent a second colour parser.
350
354
  paint.fillStyle = '#ffffff'; paint.fillRect(0, 0, 1, 1);
351
355
  paint.fillStyle = colour; paint.fillRect(0, 0, 1, 1);
352
356
  colours.set(colour, '#' + Array.from(paint.getImageData(0, 0, 1, 1).data).slice(0, 3)
@@ -361,7 +365,6 @@ function _rapierDocxRenderStyles(root) {
361
365
  Array.from(probe.querySelectorAll('span')).forEach((span, index) => {
362
366
  const style = into.defaultView.getComputedStyle(span), target = originals[index];
363
367
  target.style.color = hex(style.color);
364
- // Carry generated syntax emphasis as Word's semantic bold slot.
365
368
  target.style.fontWeight ||= style.fontWeight === style.getPropertyValue('--md-fw-semibold').trim() ? 'bold' : style.fontWeight;
366
369
  target.style.fontStyle = style.fontStyle;
367
370
  });
@@ -371,8 +374,6 @@ function _rapierDocxRenderStyles(root) {
371
374
  for (const quote of quotes) {
372
375
  const probe = into.createElement('blockquote'); probe.className = quote.className;
373
376
  probe.style.borderInlineStartColor = quote.style.borderInlineStartColor;
374
- // Nested quotes have their own reference border colour. Preserve the quote
375
- // ancestry without cloning the document's pictures or other resources.
376
377
  const parent = quoteProbes.get(quote.parentElement?.closest('blockquote')) || surface;
377
378
  parent.appendChild(probe); quoteProbes.set(quote, probe);
378
379
  }
@@ -388,22 +389,17 @@ function _rapierPaintCode(el, html) {
388
389
 
389
390
  function _rapierPrefixPortableAnchors(root, baseName) { return finish(_rapierPrefixPortableAnchorsSteps(root, baseName)); }
390
391
 
391
- // The id an anchor carries on a portable page: the document's name, then the anchor with what an id cannot hold made a hyphen.
392
392
  function _rapierPortableAnchorId(baseName, id) {
393
393
  const slug = String(baseName || 'document').toLowerCase()
394
394
  .replace(/[^a-z0-9]+/g, '-')
395
395
  .replace(/^-+|-+$/g, '') || 'document';
396
- return 'rapier-' + slug + '-' + (String(id).replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'anchor');
396
+ return 'rapier-anchor-' + slug + '-' + (String(id).replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'anchor');
397
397
  }
398
398
 
399
399
  function* _rapierPrefixPortableAnchorsSteps(root, baseName) {
400
400
  const ids = new Map(), svgIds = new Map();
401
401
  const used = new Set();
402
402
  const scopeOf = element => {
403
- // querySelectorAll('*') hands back whatever the host document holds, and not every host gives
404
- // each node Element.closest -- the export writer runs against parsed and stubbed documents as
405
- // well as a browser's. Without this guard the whole anchor pass would throw TypeError. No scope
406
- // simply means that node's references resolve document-wide, as an unscoped drawing's do.
407
403
  if (typeof element?.closest !== 'function') return null;
408
404
  let svg = element.closest('svg');
409
405
  while (typeof svg?.parentElement?.closest === 'function' && svg.parentElement.closest('svg')) svg = svg.parentElement.closest('svg');
@@ -418,8 +414,6 @@ function* _rapierPrefixPortableAnchorsSteps(root, baseName) {
418
414
  let suffix = 2;
419
415
  while (used.has(nextId)) nextId = first + '-' + suffix++;
420
416
  used.add(nextId);
421
- // A document link resolves the first matching anchor, as it did before renaming.
422
- // SVG references resolve within their own drawing, never the last repeated drawing.
423
417
  if (!ids.has(oldId)) ids.set(oldId, nextId);
424
418
  const svg = scopeOf(element);
425
419
  if (svg) {
@@ -433,7 +427,6 @@ function* _rapierPrefixPortableAnchorsSteps(root, baseName) {
433
427
  for (const element of root.querySelectorAll('*')) { yield;
434
428
  const svg = scopeOf(element), local = svgIds.get(svg);
435
429
  const rewriteId = value => (svg ? local?.get(value) : ids.get(value)) || '';
436
- // URL fragments percent-decode once; ARIA/for/headers are literal IDREFs.
437
430
  const decodeFragment = value => { try { return decodeURIComponent(value); } catch (_) { return value; } };
438
431
  const iriProperty = /^(?:fill|stroke|filter|clip-path|mask|marker(?:-start|-mid|-end)?|cursor)$/;
439
432
  const rewriteUrls = value => value.replace(/url\(\s*(?:"((?:\\[\s\S]|[^"\\])*)"|'((?:\\[\s\S]|[^'\\])*)'|((?:\\(?:[0-9a-f]{1,6}\s?|[^\r\n\f])|[^\s\\()])+))\s*\)/gi, (whole, double, single, bare) => {
@@ -462,8 +455,6 @@ function* _rapierPrefixPortableAnchorsSteps(root, baseName) {
462
455
  if (next) element.setAttribute(name, next);
463
456
  else element.removeAttribute(name);
464
457
  } else if (svg && name === 'style') {
465
- // Interpret only URL-bearing presentation properties: content/metadata strings
466
- // that merely SAY "url(#id)" are not a reference and retain their own words.
467
458
  for (const property of Array.from(element.style)) {
468
459
  if (!iriProperty.test(property)) continue;
469
460
  const before = element.style.getPropertyValue(property), next = rewriteUrls(before);
@@ -518,12 +509,6 @@ function _rapierAnnotateExportBoxPolygons(root) {
518
509
  if (!recipe) continue;
519
510
  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 };
520
511
  if (layout.wrap === 'around') {
521
- // One occupancy for live view and export: the drawing's own profile -- closed faces reserved,
522
- // open strokes as ribbons, labels as boxes -- computed from the same recipe the live view
523
- // reads, sampled into bands the exported page reads back (layout/model.mjs parseProfile). Text
524
- // and paint shapes need rendered pixels for their glyph silhouettes, so the live editor's own
525
- // <img> supplies those when it can be found; a raster or a foreign SVG writes nothing and the
526
- // export measures alpha, as the live view does.
527
512
  try {
528
513
  let glyphs = null;
529
514
  if (recipe.shapes.some(shape => shape.recognized === 'text' || shape.recognized === 'paint' || shape.label)) {
@@ -572,8 +557,6 @@ function* _rapierSharedSourceFormsSteps(source, root, preparedDestinations = nul
572
557
  const used = [], definitions = [];
573
558
  for (const row of destinations) {
574
559
  yield;
575
- // Share can display a converted picture. Only identical authored bytes can be
576
- // borrowed from that picture; otherwise the source keeps its own data URL.
577
560
  const image = byUrl.get(row.destination);
578
561
  if (!image) continue;
579
562
  let id = image.id;
@@ -608,7 +591,6 @@ async function _rapierSharedSourceCarrier(context, root) {
608
591
  '"' + (forms.ids.length ? ' data-images="' + forms.ids.join(' ') + '"' : '') +
609
592
  (forms.definitions.length ? ' data-image-definitions="' + forms.definitions.map(encodeURIComponent).join(' ') + '"' : '') + '>\n' +
610
593
  forms.carried + '\n</script>\n' +
611
- // The optional edit ledger: carried only when the host binds its owner; a host without one writes none.
612
594
  (RapierLedgerCarried ? RapierLedgerCarried.writeParts(forms.resolved, _rapierLedgerParts(forms.resolved, context.ledger, context.carried)) +
613
595
  RapierLedgerCarried.writeBase(forms.resolved, context.comparisonBase) : '');
614
596
  }
@@ -622,10 +604,7 @@ function _rapierSharedResolve(text, ids, images, definitions) {
622
604
  if (typeof found !== 'string' || !/^data:image\/(?:png|jpeg|webp|gif|svg\+xml|jxl);base64,[A-Za-z0-9+/]+={0,2}$/i.test(found)) { broken = true; return null; }
623
605
  return found;
624
606
  };
625
- // Authored # characters were encoded. A raw id after ]( can only be a structural
626
- // substitution; matching its destination lets nested alt brackets survive unchanged.
627
607
  const inline = new RegExp('(\\]\\([ \\t\\r\\n]*(?:&lt;)?)#' + id + '(?=>?[ \\t\\r\\n]*\\)|>?[ \\t\\r\\n]+["\'(])', 'g');
628
- // A definition may sit in a quote or a list item, as the writer found it: its line starts with their markers.
629
608
  const definition = new RegExp('(^[ \\t>]*(?:(?:[-+*]|\\d{1,9}[.)])[ \\t]+[ \\t>]*)*\\[((?:\\\\.|[^\\]\\\\])+)\\]:[ \\t]*(?:(?:\\r\\n|\\r|\\n)[ \\t>]*)?(?:&lt;)?)#' + id + '(?=>?[ \\t]*$|>?[ \\t]+["\'(])', 'gm');
630
609
  const swapInline = (match, lead, value) => { const found = src(value); return found == null ? match : lead + found; };
631
610
  const swapDefinition = (match, lead, label, value) => {
@@ -640,7 +619,6 @@ function _rapierSharedResolve(text, ids, images, definitions) {
640
619
  async function _rapierReadSharedDocument(text, filename) {
641
620
  const html = String(text);
642
621
  if (!/\.html?$/i.test(filename || '') || !html.includes('type="' + _RAPIER_SHARED_SOURCE_TYPE + '"')) return null;
643
- // Template contents remain inert, including pictures and scripts in ordinary HTML.
644
622
  const template = document.createElement('template');
645
623
  template.innerHTML = html;
646
624
  const invalid = () => { throw new Error('The editable source in this web page is incomplete or changed'); };
@@ -652,15 +630,12 @@ async function _rapierReadSharedDocument(text, filename) {
652
630
  const definitionTokens = (carrier.getAttribute('data-image-definitions') || '').split(/\s+/).filter(Boolean);
653
631
  if (!_rapierDocumentNameIsAdmissible(name) || !['markdown', 'text', 'code'].includes(kind) || !/^[0-9a-f]{64}$/.test(sha256) ||
654
632
  ids.some(id => !_RAPIER_SHARED_ID.test(id)) || definitionTokens.some(token => !_RAPIER_SHARED_DEFINITION.test(token))) return invalid();
655
- // Only a definition the page declares is an image definition.
656
633
  let definitions;
657
634
  try { definitions = new Set(definitionTokens.map(decodeURIComponent)); } catch (_) { return invalid(); }
658
635
  let source = carrier.textContent;
659
636
  if (source.startsWith('\n')) source = source.slice(1);
660
637
  if (source.endsWith('\n')) source = source.slice(0, -1);
661
638
  source = source.replace(/&#13;/g, '\r');
662
- // A duplicate id (two <img id="…">) is ambiguous, so it is recorded as null rather than either
663
- // src -- ties into _rapierSharedResolve's `broken` check the same way a missing image does.
664
639
  const images = new Map();
665
640
  for (const element of template.content.querySelectorAll('img[id]')) images.set(element.id, images.has(element.id) ? null : element.getAttribute('src'));
666
641
  const resolved = _rapierSharedResolve(source, ids, images, definitions);
@@ -686,7 +661,6 @@ function _rapierShareWrapKind(element) {
686
661
  if (globalThis.RapierMarkdownLayout.wrapTextBlock(element)) return 'prose';
687
662
  const image = element.tagName === 'P' ? element.querySelector('img') : null;
688
663
  if (image && globalThis.RapierImageLayout.imageOnly(element, image)) return 'picture';
689
- // An empty paragraph is transparent to the owner search, as in the editor and the export.
690
664
  if (element.tagName === 'P' && !element.textContent.trim() && !image) return 'metadata';
691
665
  return null;
692
666
  }
@@ -701,14 +675,6 @@ function _rapierSharePictureParagraph(image) {
701
675
 
702
676
  function _rapierSharedPageLayout(root) { return finish(_rapierSharedPageLayoutSteps(root)); }
703
677
  function* _rapierSharedPageLayoutSteps(root) {
704
- // Owner search (wrapNeighbour) reads the sibling chain live, and applying one assignment
705
- // splices the tree -- moving the owner into a new container moves it out of the very chain
706
- // a later picture's own search would walk, and a barrier-free skip past an earlier picture
707
- // can land on that new container instead (it is not a <p>, so it reads as a barrier). So
708
- // every owner is resolved first, against the one original, unspliced tree, in a pass that
709
- // only reads; only once all of them are known does a second pass apply any of them. Same
710
- // two-phase shape as layout/interchange.js's reflow(), which resolves every picture's owner
711
- // from one upfront `[...root.children]` before it changes anything about any of them.
712
678
  const assignments = [];
713
679
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
714
680
  yield;
@@ -716,19 +682,9 @@ function* _rapierSharedPageLayoutSteps(root) {
716
682
  if (layout?.wrap !== 'around' && layout?.wrap !== 'box') continue;
717
683
  const paragraph = _rapierSharePictureParagraph(image);
718
684
  if (!paragraph) continue;
719
- // Prefer following text, then preceding text, looking past pictures and metadata.
720
685
  const owner = globalThis.RapierMarkdownLayout.wrapNeighbour(paragraph, _rapierShareWrapKind);
721
686
  if (owner) assignments.push({image, paragraph, layout, owner});
722
687
  }
723
- // A float affects every line box after it in the same flow, which is the wrap law: prose and
724
- // headings carry on beside the picture across a heading or section boundary as though the blocks
725
- // between were not there. So the picture paragraph is placed right before its owner's text (a
726
- // float only reaches what follows it) and left in the page's own flow, uncontained; the blocks
727
- // that never wrap -- tables, code, figures and rules -- clear it through the page
728
- // stylesheet instead, and a bordered h1 forms its own block so its rule does not run under the
729
- // picture. Static CSS, no script: the no-script fallback only -- a reader with scripting off
730
- // still gets a sensible nearest-side float; the inlined planner (same owner as standalone)
731
- // replaces it when scripts run.
732
688
  for (const {image, paragraph, layout, owner} of assignments) {
733
689
  yield;
734
690
  const side = (layout.x ?? 0) < 50 ? 'left' : 'right';
@@ -738,31 +694,9 @@ function* _rapierSharedPageLayoutSteps(root) {
738
694
  paragraph.style.setProperty('shape-outside', 'inset(' + layout.y + 'em 0 0)');
739
695
  }
740
696
  image.style.margin = '0';
741
- // The reserved box comes from the same rotated-bounds geometry every other consumer uses
742
- // (layout/browser.js, layout/interchange.js's `geometry.rotatedBoundsRad`, layout/model.mjs's
743
- // one owner), expressed as a percentage of the column the way the unrotated branch below already
744
- // is -- never the picture's own pixel dimensions, which have no relation to a requested
745
- // percentage width (a 4000px-wide source at width=40% would float 1788px wide in a 600px
746
- // column). Intrinsic width/height supply only the aspect ratio; a percentage padding-top/bottom
747
- // resolves against the same containing-block width CSS already resolves the float's own
748
- // percentage width against, so one basis serves both the box and its vertical reservation, and
749
- // the box stays exactly as wide as the column at every viewport width. The turned box is
750
- // centred, horizontally and vertically, in the reserved (wider, taller) box rather than
751
- // left-flush inside it, so the space this paragraph clears for it (the float's own width) is
752
- // evenly split around the picture on every side, not just above and below. A missing `width`
753
- // falls back to the picture's own natural pixel size in pixels, the same fallback the unrotated
754
- // branch below uses; without either width/height attribute the picture still turns, just without
755
- // the extra reservation (the same graceful narrowing Share already accepts elsewhere).
756
697
  const naturalWidth = Number(image.getAttribute('width')) || 0, naturalHeight = Number(image.getAttribute('height')) || 0;
757
698
  if (layout.rotate && naturalWidth > 0 && naturalHeight > 0) {
758
699
  const rad = layout.rotate * Math.PI / 180, aspect = naturalHeight / naturalWidth;
759
- // A steep turn (e.g. 90deg on a landscape source) can need the image WIDER than its own
760
- // reserved box's width -- the rotated bounding box is narrower than the picture's own
761
- // unrotated width whenever the turn swaps a wide dimension for a narrow one. Share's own
762
- // global `img{max-width:100%}` (a sensible default everywhere else) would silently clamp
763
- // exactly that case, shrinking the image and, with it, its auto height -- `max-width:none`
764
- // here overrides that single rule with a more specific one, only for a picture this pass
765
- // already sizes deliberately.
766
700
  image.style.maxWidth = 'none';
767
701
  if (layout.width != null) {
768
702
  let width = layout.width, height = width * aspect;
@@ -787,7 +721,6 @@ function* _rapierSharedPageLayoutSteps(root) {
787
721
  image.style.marginLeft = image.style.marginRight = ((bounds.width - naturalWidth) / 2) + 'px';
788
722
  }
789
723
  } else if (layout.lines != null) {
790
- // Sized in lines: the float shrinks to the picture, whose height is the lines (spec/md-layout.mjs imageStyle).
791
724
  image.style.width = 'auto';
792
725
  image.style.height = globalThis.RapierMarkdownLayout.linesHeightCss(layout.lines);
793
726
  } else {
@@ -796,32 +729,16 @@ function* _rapierSharedPageLayoutSteps(root) {
796
729
  if (layout.width != null || image.getAttribute('width')) image.style.width = '100%';
797
730
  }
798
731
  if (layout.rotate) { image.style.transform = 'rotate(' + layout.rotate + 'deg)'; image.style.transformOrigin = '50% 50%'; }
799
- // `wrap=around` wraps to the picture's own alpha silhouette the way standard-adoption.md's
800
- // rung-2 paragraph promises -- `shape-outside:url(<the same picture>)`, which every engine that
801
- // reads shape-outside re-samples from the referenced image's own pixels, no script required. A
802
- // CSS transform does not turn the shape a `shape-outside` url() samples (it stays the picture's
803
- // unturned alpha), so a rotated `around` picture keeps the plain rectangular reservation above
804
- // instead -- an honest conservative box rather than a silhouette shaped for the wrong angle.
805
- // `layout.y` already owns `shape-outside` for its own inset reservation (below); the two are not
806
- // combinable (a float has exactly one `shape-outside` value), so a `y`-offset `around` picture
807
- // keeps that inset instead of silhouette wrapping -- a documented, narrow degradation.
808
732
  if (layout.wrap === 'around' && !layout.rotate && !layout.y && !image.dataset.rapierContour?.endsWith('-box')) {
809
733
  const src = image.getAttribute('src');
810
734
  if (src) {
811
735
  paragraph.style.setProperty('shape-outside', 'url("' + src + '")');
812
- // Standard 10% alpha cutoff (`layout/model.mjs` alphaThreshold = .1).
813
736
  paragraph.style.setProperty('shape-image-threshold', '0.1');
814
737
  }
815
738
  }
816
739
  paragraph.classList.add('rapier-wrap-picture');
817
740
  owner.before(paragraph);
818
741
  }
819
- // `behind`/`front` position the picture exactly where `x`/`y`/`width` say, out of the words' flow
820
- // entirely (z-index below or above them) -- the absolutely positioned picture
821
- // standard-adoption.md's rung-2 paragraph promises for these two placements, no obstacle
822
- // avoidance, no script. The picture's own paragraph collapses to zero height in place (the same
823
- // collapse-in-place anchor layout/interchange.js's own reflow uses for every positioned kind) so
824
- // removing the picture from flow costs the surrounding text nothing.
825
742
  const positionedAssignments = [];
826
743
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
827
744
  yield;
@@ -833,7 +750,6 @@ function* _rapierSharedPageLayoutSteps(root) {
833
750
  for (const {image, paragraph, layout} of positionedAssignments) {
834
751
  yield;
835
752
  paragraph.style.cssText = 'position:relative;height:0;margin:0;padding:0;line-height:0';
836
- // Without x the picture starts at the left edge, as everywhere else (layout/model.mjs imageBox).
837
753
  const x = layout.x, naturalWidth = Number(image.getAttribute('width')) || 0;
838
754
  if (layout.width != null) image.style.width = layout.width + '%';
839
755
  else if (naturalWidth > 0) image.style.width = naturalWidth + 'px';
@@ -843,14 +759,9 @@ function* _rapierSharedPageLayoutSteps(root) {
843
759
  image.style.top = (layout.y || 0) + 'em';
844
760
  image.style.margin = '0';
845
761
  if (layout.rotate) { image.style.transform = 'rotate(' + layout.rotate + 'deg)'; image.style.transformOrigin = '50% 50%'; }
846
- // Same rule as the live editor and the styled export: `behind` sinks under the owner's
847
- // own in-flow, non-positioned text; `front` keeps the default stack, already above it.
848
762
  if (layout.wrap === 'behind') image.style.zIndex = '-1';
849
763
  paragraph.classList.add('rapier-positioned-picture');
850
764
  }
851
- // A turned picture Share does not otherwise position (inline, which this static page does not
852
- // float at all) still turns -- the same picture, painted in place, never upright just because
853
- // Share's own float model has nothing else to say about it.
854
765
  const positioned = new Set([...assignments, ...positionedAssignments].map(row => row.image));
855
766
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
856
767
  yield;
@@ -903,8 +814,6 @@ async function _rapierRequireOfflinePageImages(root) {
903
814
  const picture = image.parentElement?.tagName.toLowerCase() === 'picture' && image.parentElement;
904
815
  const alternative = picture && [...picture.querySelectorAll('source[srcset]')].some(source => source.getAttribute('srcset').trim());
905
816
  if (tag !== 'source' && (src || !srcset && !alternative)) sources.push(src);
906
- // A data URL contains its own comma. Read each URL before its optional
907
- // density/width descriptor, as HTML's image candidate list does.
908
817
  for (let at = 0; at < srcset.length;) {
909
818
  while (at < srcset.length && /[\t\n\f\r ,]/.test(srcset[at])) at++;
910
819
  const start = at;
@@ -922,8 +831,6 @@ async function _rapierRequireOfflinePageImages(root) {
922
831
  missing.push('Picture ' + (index + 1) + (alt ? ' — ' + alt : '') + ': ' + destination);
923
832
  }
924
833
  }
925
- // A source candidate or an SVG/CSS picture is still an automatic resource. Inspect
926
- // the retained data; ordinary links remain navigation and local fragment references stay local.
927
834
  const sourceTags = new Set(['audio', 'video', 'source', 'track', 'input', 'iframe', 'embed', 'script']);
928
835
  const cssAttributes = new Set(['style', 'fill', 'stroke', 'filter', 'clip-path', 'mask', 'marker-start', 'marker-mid', 'marker-end', 'cursor', 'color-profile']);
929
836
  for (const element of root.querySelectorAll('*')) {
@@ -940,13 +847,10 @@ async function _rapierRequireOfflinePageImages(root) {
940
847
  }
941
848
  }
942
849
  if (!missing.length) return;
943
- // The writer refuses immediately; interactive callers own any explanatory sheet.
944
850
  throw Object.assign(new Error('Web page not written; embed these pictures and try again. ' + missing.join('; ')),
945
851
  {code: 'offline_image_unavailable'});
946
852
  }
947
853
 
948
- // Shared by live CSS admission and offline resource admission. Quoted strings are
949
- // resource URLs only inside url() or as an image-set option, never arbitrary CSS text.
950
854
  function _rapierCssValueUrls(value) {
951
855
  const urls = [], functions = [];
952
856
  let at = 0;
@@ -1022,10 +926,7 @@ function _rapierCssValueUrls(value) {
1022
926
 
1023
927
  async function _rapierBuildSharedPage(captured) {
1024
928
  const context = await _rapierPrepareInterchangeContext({kind: 'share'}, captured);
1025
- // Share and Export write the same complete page, including native disclosures.
1026
929
  const artifact = await _rapierBuildArtifact({kind: 'standalone'}, context);
1027
- // No cap and no warning: the page is as large as the document is. A page over the editor's own 25
1028
- // MiB open limit still opens in any browser.
1029
930
  const blob = new Blob([artifact.html], {type: 'text/html;charset=utf-8'});
1030
931
  return {blob, filename: context.baseName + '.html'};
1031
932
  }
@@ -1067,7 +968,6 @@ async function _rapierDataImageDestinationsAsync(source, work) {
1067
968
  }
1068
969
 
1069
970
  function _rapierImageDestinations(source, accepts) {
1070
- // Only a rendered Markdown image consumes a definition; unused definitions stay literal source.
1071
971
  if (!source.includes('![')) return [];
1072
972
  const env = {}, tokens = md.parse(source, env), offsets = _rapierLineStartOffsets(source);
1073
973
  const hrefs = _rapierImageSources(tokens), labels = new Set(), destinations = [], seen = new Set();
@@ -1354,7 +1254,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1354
1254
  steps.length = 0;
1355
1255
  }
1356
1256
 
1357
- // One prepared rich paragraph retains original item text. Unsupported shaping keeps native flow.
1358
1257
  function prepare(paragraph) {
1359
1258
  const refuse = () => { prepared.forget(paragraph); return null; };
1360
1259
  if (!/^(P|H[1-6])$/.test(paragraph.tagName) || !paragraph.textContent.trim() || paragraph.textContent.length > 8192 ||
@@ -1371,7 +1270,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1371
1270
  !preparation.supportsRichStyle(computed, language) || computed.whiteSpace !== 'normal' ||
1372
1271
  computed.transform !== 'none' ||
1373
1272
  ['::before', '::after'].some(pseudo => !['none', 'normal'].includes(view.getComputedStyle(paragraph, pseudo).content)) ||
1374
- // Lines start at the padding box's top-left: left, right or top inset is refused.
1375
1273
  ['paddingLeft', 'paddingRight', 'paddingTop', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth']
1376
1274
  .some(key => parseFloat(computed[key]) > 0)) return refuse();
1377
1275
  const runs = [], items = [], walker = doc.createTreeWalker(paragraph, 4);
@@ -1417,11 +1315,9 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1417
1315
  balance: 0};
1418
1316
  }
1419
1317
 
1420
- // An id survives on the first fragment only.
1421
1318
  function fragmentNode(record, fragment, idsKept) {
1422
1319
  const run = record.runs[fragment.itemIndex], mapped = geometry.mapFragment(run, fragment, {whiteSpace: record.whiteSpace});
1423
1320
  if (!mapped) return null;
1424
- // A line ends on the white space it broke at. A chip, a link or a highlight must not paint that space, so it stands after the shell.
1425
1321
  const kept = run.parents.length ? mapped.text.replace(/[ \t\n\r\f]+$/, '') : mapped.text;
1426
1322
  const split = kept && kept.length < mapped.text.length;
1427
1323
  let child = doc.createTextNode(split ? kept : mapped.text);
@@ -1488,8 +1384,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1488
1384
  return found;
1489
1385
  }
1490
1386
 
1491
- // wrap=around reads data-rapier-occupancy, else alpha. Cache key: src, dimensions, layout.wrap, rotate angle.
1492
- // A turned raster's profile is recomputed here as pictureProfile does, never shipped.
1493
1387
  function profile(image, layout) {
1494
1388
  if (layout.wrap === 'around' && image.dataset?.rapierContour?.endsWith('-box')) return null;
1495
1389
  const src = image.currentSrc || image.getAttribute('src') || '', rotateRad = (layout.rotate || 0) * Math.PI / 180;
@@ -1509,7 +1403,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1509
1403
  return value;
1510
1404
  }
1511
1405
 
1512
- // wrap=box reads data-rapier-box-polygon; this page never parses a recipe or bundles Draw. No polygon: whole rectangle.
1513
1406
  function boxProfile(image) {
1514
1407
  const raw = image.getAttribute('data-rapier-box-polygon');
1515
1408
  if (!raw) return null;
@@ -1520,7 +1413,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1520
1413
  return geometry.polygonProfile(corners);
1521
1414
  }
1522
1415
 
1523
- // As the live editor (browser.js).
1524
1416
  function syncInlineRotatedPictures(exclude) {
1525
1417
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
1526
1418
  if (exclude.has(image) || !image.complete || !image.naturalWidth || !image.naturalHeight) continue;
@@ -1529,7 +1421,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1529
1421
  const width = image.offsetWidth, height = image.offsetHeight;
1530
1422
  if (!(width > 0 && height > 0)) continue;
1531
1423
  const paragraph = image.closest('p');
1532
- // geometry.imageOnly (layout/model.mjs).
1533
1424
  const alone = !!paragraph && imageOnly(paragraph, image);
1534
1425
  const baseLeft = parseFloat(css(image).marginLeft) || 0;
1535
1426
  const rotated = geometry.rotatedBoundsRad(width, height, layout.rotate * Math.PI / 180);
@@ -1541,8 +1432,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1541
1432
  }
1542
1433
  }
1543
1434
 
1544
- // A block that cannot wrap stands with its box just below the outline, as in the editor. Its own margin moves it, so
1545
- // nothing is inserted between a picture and its words (a reader and the witnesses read nextElementSibling).
1546
1435
  function clearTo(element, bottom, origin) {
1547
1436
  for (let pass = 0; pass < 3; pass++) {
1548
1437
  const distance = bottom - (box(element).top - origin);
@@ -1557,17 +1446,13 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1557
1446
  if (metadata.wrapTextBlock(element)) return 'prose';
1558
1447
  const image = element.tagName === 'P' ? element.querySelector('img') : null;
1559
1448
  if (image && imageOnly(element, image)) return 'picture';
1560
- // An empty paragraph is transparent to the owner search, as in the editor.
1561
1449
  if (element.tagName === 'P' && !element.textContent.trim() && !image) return 'metadata';
1562
1450
  return null;
1563
1451
  }
1564
1452
 
1565
- // Float fallback as the editor's floatAround.
1566
1453
  const FLOAT_BLOCK = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|DETAILS|SUMMARY|DL)$/;
1567
1454
  function floatAround(element, width, height, top, obstacles) {
1568
- // As the editor: a paragraph or heading floats whatever it holds; a list, quote or disclosure holding a rigid block does not.
1569
1455
  if (!FLOAT_BLOCK.test(element.tagName) || !/^(P|H[1-6])$/.test(element.tagName) && element.querySelector('table, pre, figure, img, .math-rendered')) return false;
1570
- // As the editor: a disclosure's box ends where the outline begins (layout/model.mjs boxClearance).
1571
1456
  if (element.tagName === 'DETAILS') {
1572
1457
  const computed = css(element), outer = box(element), floor = metadata.wrapColumnFloor(parseFloat(computed.fontSize) || 16);
1573
1458
  const offset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.borderLeftWidth) || 0);
@@ -1603,8 +1488,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1603
1488
  const inside = obstacles.filter(obstacle => obstacle.y < bottom + 4096 && obstacle.y + obstacle.height > top &&
1604
1489
  obstacle.x < width && obstacle.x + obstacle.width > 0);
1605
1490
  if (!inside.length) return false;
1606
- // layout/model.mjs wrapShape; `geometry` is modules["layout/model.mjs"]. As the editor, the start side reaches from
1607
- // the box's own start edge.
1608
1491
  const start = computed.direction === 'rtl' ? 'right' : 'left';
1609
1492
  const startInset = start === 'left' ? (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.borderLeftWidth) || 0)
1610
1493
  : -((parseFloat(computed.paddingRight) || 0) + (parseFloat(computed.borderRightWidth) || 0));
@@ -1621,12 +1504,11 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1621
1504
  const shape = shapes[side];
1622
1505
  if (!shape) continue;
1623
1506
  const float = doc.createElement('span');
1507
+ float.className = 'rapier-flow-float';
1624
1508
  float.setAttribute('aria-hidden', 'true');
1625
1509
  float.style.cssText = `float:${side};width:${pixels(shape.boxWidth)};height:${pixels(shape.boxHeight)};margin-top:${pixels(shape.startY)};shape-outside:${shape.shape};pointer-events:none`;
1626
1510
  element.prepend(float); floats.push(float); placed.push([float, shape]);
1627
1511
  }
1628
- // As the editor: each float ends at the block's own content bottom and the block keeps its own display, so its
1629
- // margins collapse as they do with no picture. A float the content does not reach goes.
1630
1512
  const contentBottom = () => {
1631
1513
  let first = element.firstChild;
1632
1514
  while (first && placed.some(([float]) => float === first)) first = first.nextSibling;
@@ -1644,7 +1526,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1644
1526
  }
1645
1527
  if (!changed) break;
1646
1528
  }
1647
- // As the editor: a list item's marker and a quote's bar move with the words the floats moved.
1648
1529
  const {markers, bars, steps: breaks} = geometry.edgeMarks(element, before);
1649
1530
  for (const {item, shift} of markers) style(item, {'--md-marker': 'calc(' + (item.style.getPropertyValue('--md-marker') || '0px') + ' + ' + pixels(shift) + ')'});
1650
1531
  for (const item of breaks) { item.setAttribute('data-rapier-wrap-step', ''); steps.push(item); }
@@ -1670,7 +1551,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1670
1551
  if (!root.isConnected || printing || view.matchMedia?.('print').matches) return;
1671
1552
  const children = [...root.children].filter(element => !element.hasAttribute('data-rapier-html-preview'));
1672
1553
  if (children.length > 8192) return;
1673
- // behind/front collapse and place like around/box but are never obstacles.
1674
1554
  const outOfFlow = value => value?.wrap === 'behind' || value?.wrap === 'front';
1675
1555
  const origin = box(root).top, obstacles = [], anchors = new Map(), wrapped = new Set(), placed = [], positionedImages = new Set();
1676
1556
  for (const element of children) {
@@ -1688,7 +1568,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1688
1568
  style(element, {position: 'relative', height: '0', 'min-height': '0', 'margin-top': '0', 'margin-bottom': '0', 'padding-top': '0', 'padding-bottom': '0', 'line-height': '0'});
1689
1569
  style(image, {position: 'absolute', margin: '0'});
1690
1570
  }
1691
- // As the live editor.
1692
1571
  syncInlineRotatedPictures(positionedImages);
1693
1572
  for (const element of children) {
1694
1573
  if (wrapped.has(element)) continue;
@@ -1698,7 +1577,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1698
1577
  const width = bounds.width - inset - (parseFloat(computed.paddingRight) || 0) - (parseFloat(computed.borderRightWidth) || 0);
1699
1578
 
1700
1579
  for (const {element: source, image, layout, width: imageWidth} of anchors.get(element) || []) {
1701
- // A picture sized in lines (`lines`) reads its anchor's line and stands on the first line's cap height, as the live editor.
1702
1580
  const lines = layout.lines != null ? geometry.lineMetrics(element) : null;
1703
1581
  const unrotated = geometry.imageBox(width, image.naturalWidth, image.naturalHeight, layout, imageWidth, lines);
1704
1582
  if (!unrotated) { restore(); return; }
@@ -1707,7 +1585,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1707
1585
  const line = parseFloat(computed.lineHeight) || (parseFloat(computed.fontSize) || 16) * 1.6;
1708
1586
  unrotated.height = metadata.objectHeight(unrotated.width, Number(image.dataset.rapierNaturalHeight) || box(image).height, attrs, line);
1709
1587
  }
1710
- // rasterReserved (layout/model.mjs).
1711
1588
  const rasterRad = (layout.rotate || 0) * Math.PI / 180;
1712
1589
  const fit = geometry.rasterReserved(width, unrotated, rasterRad);
1713
1590
  if (!fit) { restore(); return; }
@@ -1717,10 +1594,8 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1717
1594
  const topInset = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
1718
1595
  const height = Math.max(0, bounds.height - topInset - (parseFloat(computed.paddingBottom) || 0) - (parseFloat(computed.borderBottomWidth) || 0));
1719
1596
  let y = bounds.top - origin + topInset + geometry.linesTop(layout, lines) + Math.min(layout.y || 0, height / em) * em;
1720
- // The standoff and the room between pictures, in the reference scale's pixels, as the live editor.
1721
1597
  const scale = geometry.frameScale(image), room = 10 * scale;
1722
1598
  if (!outOfFlow(layout)) {
1723
- // A picture an earlier one stands in moves down alone; its owner keeps its place, as in the editor.
1724
1599
  for (const obstacle of [...obstacles].sort((left, right) => left.y - right.y)) {
1725
1600
  if (obstacle.x < left + rectangle.width + room && obstacle.x + obstacle.width > left - room &&
1726
1601
  obstacle.y + obstacle.height > y - room && obstacle.y < y + rectangle.height + room)
@@ -1737,7 +1612,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1737
1612
  const own = active.map(obstacle => ({...obstacle, x: obstacle.x - bounds.left - inset}));
1738
1613
  const prepared = prepare(element);
1739
1614
  if (!(prepared && width > 0 && project(prepared, width, top, own)) && !(width > 0 && floatAround(element, width, bounds.height, top, own))) {
1740
- // As the editor: only an outline that meets the block's box moves it.
1741
1615
  const overlapping = active.filter(obstacle => obstacle.y < top + bounds.height && obstacle.x < bounds.right && obstacle.x + obstacle.width > bounds.left);
1742
1616
  if (overlapping.length) clearTo(element, Math.max(...overlapping.map(obstacle => obstacle.y + obstacle.height)), origin);
1743
1617
  }
@@ -1748,7 +1622,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1748
1622
  style(image, {position: 'absolute', left: pixels((visualLeft ?? left) - anchor.left), top: pixels((visualTop ?? y) - (anchor.top - origin)),
1749
1623
  width: pixels(visualWidth ?? rectangle.width), height: pixels(visualHeight ?? rectangle.height), 'max-width': 'none', margin: '0',
1750
1624
  transform: rotateDeg ? `rotate(${rotateDeg}deg)` : 'none', 'transform-origin': '50% 50%',
1751
- // As the live editor.
1752
1625
  ...(wrap === 'behind' ? {'z-index': '-1'} : {})});
1753
1626
  }
1754
1627
  if (obstacles.length) {
@@ -1807,7 +1680,6 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1807
1680
 
1808
1681
  function _rapierArtifactLayoutScript(root, nonce = '', {pictures = true} = {}) {
1809
1682
  const dependencies = globalThis.RapierArtifactLayoutDependencies;
1810
- // Every wrap value earns the reflow script, and an inline turned raster (keyed off data-rapier-image-layout).
1811
1683
  const layoutOf = element => globalThis.RapierMarkdownLayout.parseLayoutAttribute(element.getAttribute('data-md-layout'));
1812
1684
  const pictureLayoutOf = image => globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
1813
1685
  const wrapping = pictures && ([...root.querySelectorAll('p[data-md-layout]')].some(element => ['around', 'box', 'behind', 'front'].includes(layoutOf(element)?.wrap)) ||
@@ -1843,8 +1715,6 @@ function _rapierArtifactTypographyScript(root) {
1843
1715
  return '(() => {\nconst typography = modules["layout/typography.mjs"].createTypography();\n' +
1844
1716
  (pictures ? 'const capitals = (' + createPrintCapitals.toString() + ')((' + createLineMeasurer.toString() + ')(),(' + linesHeight.toString() + '),(' + followingPrintProse.toString() + '));\n' : '') +
1845
1717
  'const root = document.querySelector("main.rapier-page"); const printMedia = matchMedia("print"); let frame = 0, width = -1, ready = null, printing = printMedia.matches;\n' +
1846
- // beforeprint can still expose the screen viewport, not the selected paper's content width.
1847
- // Only an authored paper width supplies a fixed target; automatic paper keeps native wrapping.
1848
1718
  'const apply = () => { cancelAnimationFrame(frame); frame = 0; ' + (pictures ? 'if (printing) capitals.apply(root); else capitals.clear(root); ' : '') + 'if (printing && !(parseFloat(getComputedStyle(root).getPropertyValue("--rapier-print-width")) > 0)) { typography.clear(root); ready?.(); ready = null; return; } if (typography.apply(root, {all:printing}).pending) schedule(); else { ready?.(); ready = null; } };\n' +
1849
1719
  'const schedule = () => { if (!frame) frame = requestAnimationFrame(apply); };\n' +
1850
1720
  'globalThis.__RAPIER_EXPORT_READY__ = Promise.all([document.fonts?.ready, ...Array.from(root.querySelectorAll("img"), image => image.decode?.().catch(() => {}))]).then(() => new Promise(resolve => { ready = resolve; schedule(); }));\n' +