rapier-markdown-kit 1.1.98 → 1.1.101

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.
@@ -1,6 +1,73 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ import {htmlImageRecord, mountHtmlBlocks} from '../spec/html-blocks.mjs';
2
3
  import {finish, finishAsync, cloneTree, cleanTree, serializeTree, encodeUtf8Steps, pause} from './render-work.mjs';
4
+ import {formatFontOpen, fontFamilyCss, FONT_CLOSE} from '../spec/md-marks.mjs';
5
+ import {readFontAssets, documentFontsCss} from '../spec/document-fonts.mjs';
6
+ import {fontRequests, requireDocumentFonts} from './export-fonts.mjs';
7
+ import {documentLanguage, documentSettingsFonts} from '../spec/document-settings.mjs';
8
+ import {createTypography} from '../layout/typography.mjs';
9
+ import {createLineMeasurer, linesHeight} from '../layout/model.mjs';
10
+ import {markPrintCapitals, createPrintCapitals, followingPrintProse} from './print-capitals.mjs';
11
+ import {freezePublishedInk} from './render-ink.mjs';
3
12
  // The document's renderer. DOM, codecs and host state are explicit inputs.
13
+ // Rapier generates attribute selectors for dark paper. Project those owned rules onto their
14
+ // SVG elements before export removes stylesheets; the inert host and the editor share this pass.
15
+ export function presentNativeDiagram(drawing) {
16
+ const winners=new Map();
17
+ for(const style of drawing.querySelectorAll('style')){
18
+ const media=/^\s*@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{([\s\S]*)\}\s*$/.exec(style.textContent);
19
+ if(media)for(const rule of media[1].matchAll(/([^{}]+)\{([^{}]*)\}/g)){
20
+ // The browser's CSS sanitizer serializes owned hex colours as rgb()/rgba().
21
+ const properties=[...rule[2].matchAll(/(?:^|;)\s*(fill|stroke|color|stop-color)\s*:\s*(#[\da-f]{3,8}|rgba?\([\d.,%/\s]+\)|none)\s*(?:!important)?(?=;|$)/gi)];
22
+ for(const selector of rule[1].split(',')){
23
+ // Draw's selectors contain only attributes, descendants and immediate children.
24
+ if(!/^\s*\[[^\[\]{};]+\](?:\s*(?:>\s*)?\[[^\[\]{};]+\])*\s*$/.test(selector))continue;
25
+ const rank=(selector.match(/\[/g)||[]).length;
26
+ for(const element of [...(drawing.matches(selector)?[drawing]:[]),...drawing.querySelectorAll(selector)]){
27
+ let paints=winners.get(element);if(!paints)winners.set(element,paints=new Map());
28
+ for(const [,property,dark] of properties){const light=element.getAttribute(property);
29
+ if(light&&(paints.get(property)?.rank??-1)<=rank)paints.set(property,{light,dark,rank});}
30
+ }
31
+ }
32
+ }
33
+ style.remove();
34
+ }
35
+ for(const [element,paints] of winners)for(const [property,{light,dark}] of paints)
36
+ element.style.setProperty(property,'light-dark('+light+', '+dark+')','important');
37
+ drawing.classList.add('rapier-native-flowchart');drawing.setAttribute('role','img');
38
+ drawing.setAttribute('aria-roledescription',drawing.getAttribute('data-rapier-diagram-kind')||'flowchart');
39
+ const width=Number(drawing.getAttribute('width')),
40
+ sizes=[...drawing.querySelectorAll('text[font-size]')].map(text=>Number(text.getAttribute('font-size'))).filter(size=>Number.isFinite(size)&&size>0),
41
+ minimum=sizes.reduce((smallest,size)=>Math.min(smallest,size),Infinity);
42
+ if(Number.isFinite(width)&&width>0){
43
+ const readable=Math.ceil(width*14/(Number.isFinite(minimum)?minimum:14)*1000)/1000;
44
+ drawing.style.width='clamp('+readable+'px, 100%, '+width+'px)';
45
+ }
46
+ drawing.style.maxWidth='none';
47
+ }
48
+
49
+ // Set the first view once. Subsequent rendering keeps the person's horizontal position.
50
+ export function positionNativeDiagram(drawing) {
51
+ const surface=drawing?.closest?.('.diagram-block,.diagram-live-preview');
52
+ if(!surface?.clientWidth)return;
53
+ if(surface._rapierDiagramScroll){
54
+ surface.scrollLeft=surface._rapierDiagramScroll.left;
55
+ surface.scrollTop=surface._rapierDiagramScroll.top;
56
+ delete surface._rapierDiagramScroll;
57
+ surface._rapierDiagramPositioned=true;
58
+ return;
59
+ }
60
+ if(surface._rapierDiagramPositioned)return;
61
+ surface._rapierDiagramPositioned=true;
62
+ if(surface.scrollLeft||!drawing.hasAttribute('data-rapier-diagram-start-x'))return;
63
+ const start=Number(drawing.getAttribute('data-rapier-diagram-start-x')),
64
+ view=(drawing.getAttribute('viewBox')||'').trim().split(/[\s,]+/).map(Number);
65
+ if(!Number.isFinite(start)||view.length!==4||!(view[2]>0))return;
66
+ const bounds=drawing.getBoundingClientRect(),host=surface.getBoundingClientRect(),
67
+ left=bounds.left-host.left+(start-view[0])*bounds.width/view[2]-surface.clientWidth/2;
68
+ surface.scrollLeft=Math.max(0,Math.min(surface.scrollWidth-surface.clientWidth,left));
69
+ }
70
+
4
71
  function createRenderer(runtime) {
5
72
  const {RAPIER_COLOR_CLOSE, RapierLedgerCarried, RapierPageReturnAddress, RapierTextCodec, _rapierArtifactHighlight, _rapierArtifactPreference, _rapierArtifactStyles, _rapierBlobDataUrl, _rapierBuildInterchangeContext, _rapierDocumentNameIsAdmissible, _rapierLedgerParts, _rapierDrawReadSVGRecipe, _rapierDrawShapeProfileFor, _rapierExportFontCss, _rapierFillDiagram, _rapierFinalizeExportCode, _rapierFormatColorOpen, _rapierLanguageClass, _rapierLineStartOffsets, _rapierMarkdownEnvironment, _rapierPortableHtml, _rapierPrepareInterchangeContext, _rapierProjectPortableRoot, _rapierProviders, _rapierSourceCharEscaped, _rapierSourceLineSpan, crypto, document, escapeRapierHtmlText, globalThis, rapierConfirm, sanitizeRapierHtml} = runtime;
6
73
  let md = runtime.md;
@@ -9,7 +76,9 @@ function createRenderer(runtime) {
9
76
  const filename = options.filename || 'document.md';
10
77
  if (typeof filename !== 'string' || !filename || /[\\/\u0000-\u001f\u007f]/.test(filename)) throw new TypeError('Invalid document filename');
11
78
  const bom = source.charCodeAt(0) === 0xfeff;
12
- const canonical = RapierTextCodec.normalizeDocument(source);
79
+ let canonical = RapierTextCodec.normalizeDocument(source);
80
+ if ((!options.docKind || options.docKind === 'markdown') && runtime.materializeDocumentFonts)
81
+ canonical = await runtime.materializeDocumentFonts(canonical, {parser: md});
13
82
  const captured = {canonical, metadata: {filename, docKind: options.docKind || 'markdown', codeLang: options.codeLang || '', bom}, baseName: filename.replace(/\.[^.]*$/, '') || 'document'};
14
83
  const written = await _rapierBuildSharedPage(captured);
15
84
  return {html: await written.blob.text(), filename: written.filename};
@@ -20,14 +89,36 @@ async function _rapierBuildArtifact(options, providedContext) {
20
89
  const metadata = context.metadata;
21
90
  const base = context.baseName;
22
91
  const work = context.work || opts.work;
92
+ const completePage = opts.kind === 'standalone' || opts.kind === 'page';
93
+ const settings = _rapierDocumentSettingsOf(context.canonical);
94
+ const language = documentLanguage(settings, globalThis.navigator?.language);
95
+ const fontIndex = metadata.docKind === 'markdown' ? readFontAssets(context.canonical, md) : null;
96
+ const fontAssets = fontIndex?.assets || [];
97
+ const fontCss = documentFontsCss(fontAssets);
23
98
 
24
99
  if (opts.kind === 'publishing' || opts.kind === 'fragment') {
25
100
  for (const diagram of context.semanticRoot.querySelectorAll('.diagram-block[data-diagram-native]'))
26
101
  await _rapierFillDiagram(diagram, true);
27
102
  const portableRoot = work ? await runtime._rapierProjectPortableRootAsync(context.semanticRoot, {baseName: base, keepDiagrams: true, work})
28
103
  : _rapierProjectPortableRoot(context.semanticRoot, { baseName: base, keepDiagrams: true });
104
+ for (const element of portableRoot.children) if (!element.hasAttribute('lang')) element.setAttribute('lang', language);
105
+ if (fontCss || settings?.defaultfont) {
106
+ // Keep authored font scopes inside this fragment; no page wrapper or global settings rules.
107
+ const {main, sans: heading, mono: code} = documentSettingsFonts(settings), body = main || heading;
108
+ if (body) for (const element of portableRoot.children)
109
+ if (!element.matches('pre,code,kbd,samp,svg,math') && !element.style.fontFamily)
110
+ element.style.fontFamily = body.css || fontFamilyCss(body.family);
111
+ if (heading) for (const element of portableRoot.querySelectorAll('h1,h2,h3,h4,h5,h6'))
112
+ element.style.fontFamily = heading.css || fontFamilyCss(heading.family);
113
+ if (code) for (const element of portableRoot.querySelectorAll('pre,code,kbd,samp'))
114
+ element.style.fontFamily = code.css || fontFamilyCss(code.family);
115
+ }
116
+ if (fontIndex) await requireDocumentFonts(context.canonical, {parser: md, index: fontIndex, root: portableRoot, settings, fontCss, validateFont: runtime.validateFont,
117
+ css: _rapierArtifactStyles('system', false, false, false, '') + '\n' + _rapierDocumentSettingsCss(settings)});
118
+ const html = work ? await runtime._rapierPortableHtmlAsync(portableRoot, work) : _rapierPortableHtml(portableRoot);
29
119
  return {
30
- html: work ? await runtime._rapierPortableHtmlAsync(portableRoot, work) : _rapierPortableHtml(portableRoot),
120
+ // Only validated data-font rules bypass the content sanitizer, which still rejects authored styles.
121
+ html: (fontCss ? '<style>' + fontCss + '</style>\n' : '') + html,
31
122
  filename: base + '-publishing.html',
32
123
  metadata,
33
124
  };
@@ -35,46 +126,53 @@ async function _rapierBuildArtifact(options, providedContext) {
35
126
 
36
127
  // The semantic export root is detached and has never entered the live visibility queue.
37
128
  // Await the same materialization owner before any projection clones or serializes it.
38
- for (const diagram of opts.kind === 'page' ? [] : context.semanticRoot.querySelectorAll('.diagram-block[data-diagram-src]')) {
39
- await _rapierFillDiagram(diagram, true);
40
- if (opts.kind !== 'standalone' || diagram.hasAttribute('data-diagram-unclosed')) continue;
41
- const state = diagram.getAttribute('data-diagram-state');
129
+ const unavailable = plugin => Object.assign(new Error('Offline web page not written; install the ' + plugin + ' plug-in before exporting this document.'),
130
+ {code: 'offline_plugin_unavailable', plugin});
131
+ if (completePage && context.semanticRoot.querySelector('.math-placeholder[data-rapier-math-source]')) throw unavailable('math');
132
+ for (const diagram of context.semanticRoot.querySelectorAll('.diagram-block[data-diagram-src]')) {
133
+ const retained = diagram.getAttribute('data-diagram-state') === 'ready' && diagram.querySelector('.diagram-cache')?.querySelector('svg');
134
+ if (!(completePage && retained && _rapierProviders?.mermaid?.status !== 'ready')) await _rapierFillDiagram(diagram, true);
135
+ if (!completePage || diagram.hasAttribute('data-diagram-unclosed')) continue;
136
+ if (diagram.getAttribute('data-diagram-state') === 'absent') throw unavailable('mermaid');
42
137
  // A completed diagram must have its drawing. Syntax and renderer failures both refuse
43
138
  // the file; an unclosed fence above is still authored source, not a completed diagram.
44
- if (state !== 'ready' || !diagram.querySelector('.diagram-cache svg'))
139
+ if (diagram.getAttribute('data-diagram-state') !== 'ready' || !diagram.querySelector('.diagram-cache svg'))
45
140
  throw Object.assign(new Error('Web page not written; a required diagram could not be rendered'), {code: 'EXPORT_DIAGRAM_UNAVAILABLE'});
46
141
  }
47
142
 
48
- if (opts.kind === 'standalone') {
49
- if (context.semanticRoot.querySelector('.math-placeholder'))
50
- throw new Error('Web page not written; the math plug-in is required to include its equations');
143
+ if (completePage) {
51
144
  // Invalid TeX renders as a static error SVG. A source-only span means
52
145
  // rendering itself failed, so publishing it would silently omit the equation.
53
146
  if (context.semanticRoot.querySelector('span[data-rapier-math-source]'))
54
147
  throw new Error('Web page not written; a required equation could not be rendered');
148
+ await _rapierRequireOfflinePageImages(context.semanticRoot);
149
+ }
150
+ if (!completePage) for (const image of context.semanticRoot.querySelectorAll('svg image')) {
151
+ const href = image.getAttribute('data-rapier-remote-src') || image.getAttribute('href') || image.getAttribute('xlink:href') || '';
152
+ if (!globalThis.RapierImageAssets.dataImage(href)) throw new Error('Embed the diagram picture before exporting an offline page: ' + href);
55
153
  }
56
- if (opts.kind === 'standalone' || opts.kind === 'page') await _rapierRequireOfflinePageImages(context.semanticRoot);
57
154
 
58
- const styledOptions = {metadata, print: !!opts.print, sourceCode: opts.kind === 'page', baseName: base, work};
155
+ const styledOptions = {metadata, print: !!opts.print, baseName: base, work};
59
156
  const styledRoot = work ? await _rapierProjectStyledRootAsync(context.semanticRoot, styledOptions)
60
157
  : _rapierProjectStyledRoot(context.semanticRoot, styledOptions);
61
- if (opts.kind === 'standalone' && typeof _rapierFinalizeExportCode === 'function') await _rapierFinalizeExportCode(styledRoot);
158
+ styledRoot.setAttribute('lang', language);
159
+ if (completePage && typeof _rapierFinalizeExportCode === 'function') await _rapierFinalizeExportCode(styledRoot);
62
160
  // _rapierProjectStyledRoot already returns the sanitized projection.
63
161
  // Print never emits the layout reflow script (see below), so a print artifact has no use for
64
162
  // this annotation -- skip it there rather than pay the cost for numbers nothing will read.
65
- if (!opts.print && opts.kind !== 'page') _rapierAnnotateExportBoxPolygons(styledRoot);
66
- // Share adds its nearest-side no-script float and long-code cap here. Offline-image
67
- // admission above and the page policy below belong to this common writer, not its callers.
163
+ if (!opts.print) _rapierAnnotateExportBoxPolygons(styledRoot);
164
+ if (completePage && !opts.print) {
165
+ if (work) {
166
+ await finishAsync(_rapierSharedPageLayoutSteps(styledRoot), work);
167
+ await finishAsync(_rapierShareCapLongCodeBlocksSteps(styledRoot), work);
168
+ } else { _rapierSharedPageLayout(styledRoot); _rapierShareCapLongCodeBlocks(styledRoot); }
169
+ }
68
170
  if (typeof opts.afterRoot === 'function') await opts.afterRoot(styledRoot);
69
- const carrier = opts.kind === 'standalone' ? await _rapierSharedSourceCarrier(context, styledRoot) : '';
70
- const bodyHtml = opts.kind === 'page' ? _rapierPageHtml(styledRoot) : work
71
- ? await finishAsync(serializeTree(styledRoot), work) : styledRoot.innerHTML;
72
- if (work) await pause(work, .95);
171
+ markPrintCapitals(styledRoot);
73
172
  const theme = opts.print
74
173
  ? 'light'
75
- : opts.kind === 'page' ? 'system'
76
174
  : _rapierArtifactPreference('theme', ['system', 'light', 'dark']);
77
- const highlights = opts.kind === 'page' ? 'standard' : _rapierArtifactPreference('highlights', ['accent', 'standard']);
175
+ const highlights = _rapierArtifactPreference('highlights', ['accent', 'standard']);
78
176
  const versionMeta = document.querySelector('meta[name="rapier-version"]');
79
177
  const version = versionMeta && versionMeta.content ? versionMeta.content.trim() : '0.0.0';
80
178
  const bodyClass = theme === 'light' ? 'light' : '';
@@ -85,23 +183,41 @@ async function _rapierBuildArtifact(options, providedContext) {
85
183
  // .md-render set and the highlight rules, so a numbered list, a checkbox, a callout and a
86
184
  // table are drawn in an exported page exactly as they are drawn here. The one caller with
87
185
  // rules of its own -- Share, for the no-script float it alone writes -- appends them.
88
- const includeFonts = !!opts.print || opts.kind === 'standalone';
186
+ // Print retains its existing factory resources. HTML adds only the optional small Sans subset below.
187
+ const includeFonts = !!opts.print;
89
188
  const zenFontCss = opts.print && styledRoot.querySelector('svg[aria-roledescription="zenuml"]')
90
189
  ? (_rapierProviders?.mermaid?.exportFontCss?.() || '') : '';
91
190
  const willFont = opts.print && opts.willFont ? opts.willFont : null;
92
- const settings = _rapierDocumentSettingsOf(context.canonical);
93
191
  const settingsCss = _rapierDocumentSettingsCss(settings);
192
+ const refined = styledRoot.querySelector('p[data-md-hyphens="auto"],p[data-md-lines],p[data-md-caps="small"],p[data-md-hanging="on"],img[data-rapier-print-lines]');
193
+ const printMargin = settings?.geometry ? settings.geometry.twips / 15 : 14 * 96 / 25.4;
194
+ const printWidth = settings?.papersize ? settings.papersize.width / 15 - 2 * printMargin : null;
195
+ const typographyPrintCss = refined ? '@media print{.md-render{--rapier-print-margin-left:' + printMargin + 'px;--rapier-print-margin-right:' + printMargin + 'px'
196
+ + (printWidth > 0 ? ';--rapier-print-width:' + printWidth + 'px' : '') + '}'
197
+ + (printWidth > 0 ? '.rapier-page.md-render{width:' + printWidth + 'px!important}' : '') + '}' : '';
94
198
  const pageTitle = settings && settings.title ? globalThis.RapierMarkdownSpec.documentTitle(settings) : metadata.filename;
95
- let css = _rapierArtifactStyles(theme, includeFonts, !!opts.print || opts.kind === 'standalone', opts.kind === 'page', opts.kind === 'standalone' ? '' : null)
96
- + (zenFontCss ? '\n\n' + zenFontCss : '')
199
+ let css = _rapierArtifactStyles(theme, includeFonts, !!opts.print || completePage, false, opts.print && opts.kind === 'standalone' ? '' : null)
200
+ + (completePage ? '\n\n' + _rapierSharedPageFallbackCss() : '')
97
201
  + (opts.extraCss ? '\n\n' + opts.extraCss : '')
98
202
  + (willFont ? '\n\n@font-face{font-family:' + willFont.family + ';src:url(' + await _rapierBlobDataUrl(new Blob([willFont.bytes], { type: 'font/ttf' })) + ') format("truetype")}' : '')
99
- + (settingsCss ? '\n' + settingsCss : '');
100
- // Match the document's measured faces with only the glyphs this immutable page uses.
101
- if (opts.kind === 'standalone') {
203
+ + (settingsCss ? '\n' + settingsCss : '')
204
+ + (typographyPrintCss ? '\n' + typographyPrintCss : '');
205
+ let factoryFontCss = '';
206
+ if (!opts.print) {
207
+ if (fontIndex) await requireDocumentFonts(context.canonical, {parser: md, index: fontIndex, root: styledRoot, settings, css, fontCss, validateFont: runtime.validateFont});
208
+ if (typeof _rapierExportFontCss === 'function')
209
+ factoryFontCss = await _rapierExportFontCss(styledRoot, {settings, css, theme, portableFactory: true, customFontCss: fontCss});
210
+ if (factoryFontCss) css += '\n' + factoryFontCss;
211
+ } else if (opts.kind === 'standalone') {
102
212
  const fonts = await _rapierExportFontCss(styledRoot, {settings, css, theme});
103
213
  if (fonts) css += '\n' + fonts;
104
214
  }
215
+ if (fontCss) css += '\n' + fontCss;
216
+ if (typeof opts.afterFonts === 'function') await opts.afterFonts(styledRoot, {css, fontCss, settings, language, theme});
217
+ const carrier = completePage ? await _rapierSharedSourceCarrier(context, styledRoot) : '';
218
+ const bodyHtml = work
219
+ ? await finishAsync(serializeTree(styledRoot), work) : styledRoot.innerHTML;
220
+ if (work) await pause(work, .95);
105
221
  // Every written page is offline. Only the writer's nonce script may run; it, pictures,
106
222
  // styles and other page resources get no network authority. Following an ordinary link
107
223
  // is the reader's navigation, with no Referer, not a background resource request.
@@ -109,10 +225,12 @@ async function _rapierBuildArtifact(options, providedContext) {
109
225
  // 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
110
226
  // 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
111
227
  // nonce out of it and be admitted.
112
- const layoutScript = !opts.print && opts.kind !== 'page' ? _rapierArtifactLayoutScript(styledRoot, nonce) : '';
113
- const inkScript = !opts.print && opts.kind !== 'page' ? _rapierArtifactInkScript(styledRoot, nonce) : '';
228
+ const layoutScript = !opts.print && opts.scripts !== false ? _rapierArtifactLayoutScript(styledRoot, nonce) : '';
229
+ const inkScript = !opts.print && opts.scripts !== false ? _rapierArtifactInkScript(styledRoot, nonce) : '';
230
+ const typographyScript = opts.scripts !== false ? _rapierArtifactTypographyScript(styledRoot, nonce) : '';
231
+ const htmlScript = opts.scripts !== false && styledRoot.querySelector('[data-rapier-html]') ? '<script nonce="' + nonce + '">(' + mountHtmlBlocks.toString().replace(/<\/script/gi, '<\\/script') + ')(document);</script>' : '';
114
232
  const page = '<!DOCTYPE html>\n'
115
- + '<html lang="en" data-rapier-theme="' + theme + '" data-highlights="' + highlights + '">\n'
233
+ + '<html lang="' + escapeRapierHtmlText(language) + '" data-rapier-theme="' + theme + '" data-highlights="' + highlights + '">\n'
116
234
  + '<head>\n<meta charset="UTF-8">\n' + (carrier ? RapierPageReturnAddress.PAGE_SEED + '\n' : '')
117
235
  + '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
118
236
  // Chrome 154's responsively sized frames: a host frame styled `frame-sizing: content-height` sizes
@@ -120,42 +238,45 @@ async function _rapierBuildArtifact(options, providedContext) {
120
238
  // someone's page shows whole, with no inner scroll. Any origin may read the height: the page is the
121
239
  // document, published by the person who exported it.
122
240
  + '<meta name="responsive-embedded-sizing" content="allow-origins=*">\n'
123
- + '<meta http-equiv="Content-Security-Policy" content="' + _rapierExportedPageCsp(layoutScript || inkScript ? nonce : '', includeFonts) + '">\n'
241
+ + '<meta http-equiv="Content-Security-Policy" content="' + _rapierExportedPageCsp(layoutScript || inkScript || typographyScript || htmlScript ? nonce : '', includeFonts || !!fontCss || !!factoryFontCss) + '">\n'
124
242
  + '<meta name="referrer" content="no-referrer">\n'
125
243
  + '<link rel="icon" href="data:,">\n'
126
244
  + '<meta name="generator" content="Rapier ' + escapeRapierHtmlText(version) + '">\n'
127
245
  + '<title>' + escapeRapierHtmlText(pageTitle) + '</title>\n'
128
246
  + '<style>' + css + '</style>\n</head>\n'
129
- + '<body class="' + bodyClass + '"><main class="' + docClass + '" data-md-theme="' + theme + '">'
130
- + bodyHtml + '</main>' + carrier + layoutScript + inkScript + '</body>\n</html>';
247
+ + '<body class="' + bodyClass + '"><main class="' + docClass + '" data-md-theme="' + theme + '"' + (settings?.space ? ' data-md-space' : '') + '>'
248
+ + bodyHtml + '</main>' + carrier + layoutScript + inkScript + typographyScript + htmlScript + '</body>\n</html>';
131
249
 
132
250
  return {
133
251
  html: page,
134
252
  filename: base + '.html',
135
253
  bodyHtml,
136
254
  css,
255
+ fontCss,
256
+ fontRequests: fontCss ? fontRequests(styledRoot, {settings, css, fontCss}) : [],
257
+ settings,
137
258
  docClass,
259
+ language,
138
260
  metadata,
139
261
  documentTitle: settings && settings.title ? pageTitle : '',
140
262
  };
141
263
  }
142
264
 
143
- // Page files have one byte representation across native and inert DOM hosts. Attribute
144
- // insertion order and a host's optional entity escapes are not document content.
145
- function _rapierPageHtml(root) {
146
- const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
147
- const children = node => Array.from(node.childNodes || [], write).join('');
148
- function write(node) {
149
- if (node.nodeType === 3) return escapeRapierHtmlText(node.nodeValue || '');
150
- if (node.nodeType !== 1) return '';
151
- // This writer emits text/html; the HTML reader restores SVG's camel-cased names.
152
- const tag = node.localName.toLowerCase();
153
- const attributes = Array.from(node.attributes).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
154
- .map(attribute => ' ' + attribute.name + '="' + escapeRapierHtmlText(attribute.value) + '"').join('');
155
- const open = '<' + tag + attributes + '>';
156
- return voidTags.has(tag) ? open : open + children(node) + '</' + tag + '>';
157
- }
158
- return children(root);
265
+ async function _rapierBuildPublishedPage(context) {
266
+ if (typeof context?.canonical !== 'string' || !context.semanticRoot)
267
+ throw new TypeError('Publishing requires a captured document.');
268
+ // Public readers receive only the current document. The editing session's previous
269
+ // source and attribution ledger never acquire public custody through an export option.
270
+ const current = {...context, ledger: null, carried: 'none', comparisonBase: null};
271
+ return _rapierBuildArtifact({
272
+ kind: 'standalone', scripts: false, extraCss: _rapierSharedPageFallbackCss(),
273
+ async afterRoot(root) {
274
+ if (current.work) await finishAsync(_rapierSharedPageLayoutSteps(root), current.work);
275
+ else _rapierSharedPageLayout(root);
276
+ },
277
+ afterFonts: (root, style) => freezePublishedInk(root, {...style, document, work: current.work,
278
+ spec: globalThis.RapierMarkdownSpec, ink: globalThis.RapierInk, draw: globalThis.RapierInkDraw}),
279
+ }, current);
159
280
  }
160
281
 
161
282
  function _rapierProjectStyledRoot(semanticRoot, options) { return finish(_rapierProjectStyledRootSteps(semanticRoot, options)); }
@@ -177,12 +298,55 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
177
298
  if (code) {
178
299
  const lang = _rapierLanguageClass(code) || String(metadata.codeLang || '');
179
300
  code.classList.add('artifact-code');
180
- if (metadata.docKind === 'code' && !opts.sourceCode) _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang));
301
+ if (metadata.docKind === 'code') _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang));
181
302
  if (lang) code.classList.add('language-' + String(lang).replace(/[^a-z0-9_-]/gi, ''));
182
303
  }
183
304
  } else {
305
+ if (root.hasAttribute('data-md-space')) {
306
+ // One raw HTML token may have several siblings. Its authored heights stay inside one line-sized box.
307
+ for (let element = root.firstElementChild; element;) {
308
+ const group = element.getAttribute('data-md-html-block');
309
+ if (group === null) { element = element.nextElementSibling; continue; }
310
+ let first = element;
311
+ // Sanitizing a container or repairing a table can leave its text before the first surviving tag.
312
+ while (first.previousSibling && first.previousSibling.nodeType !== 1) { first = first.previousSibling; yield; }
313
+ const wrap = document.createElement('div');
314
+ wrap.className = 'md-space-block';
315
+ root.insertBefore(wrap, first);
316
+ let node = first;
317
+ while (node && (node.nodeType !== 1 || node.getAttribute('data-md-html-block') === group)) {
318
+ const next = node.nextSibling;
319
+ wrap.appendChild(node);
320
+ node = next;
321
+ yield;
322
+ }
323
+ element = wrap.nextElementSibling;
324
+ }
325
+ // A removed raw container can leave only text. Reserve that surviving content in this projection.
326
+ for (let first = root.firstChild; first;) {
327
+ if (first.nodeType === 1) { first = first.nextSibling; continue; }
328
+ let end = first, hasText = false;
329
+ while (end && end.nodeType !== 1) {
330
+ if (end.nodeType === 3 && end.nodeValue.trim()) hasText = true;
331
+ end = end.nextSibling;
332
+ yield;
333
+ }
334
+ if (hasText) {
335
+ const wrap = document.createElement('div');
336
+ wrap.className = 'md-space-block';
337
+ root.insertBefore(wrap, first);
338
+ for (let node = first; node !== end;) {
339
+ const next = node.nextSibling;
340
+ wrap.appendChild(node);
341
+ node = next;
342
+ yield;
343
+ }
344
+ }
345
+ first = end;
346
+ }
347
+ }
184
348
  for (const code of root.querySelectorAll('pre > code')) { yield;
185
- if (opts.sourceCode || code.parentElement?.classList.contains('diagram-source') &&
349
+ if (code.parentElement?.classList.contains('diagram-source') &&
186
350
  code.closest('.diagram-block')?.getAttribute('data-diagram-state') === 'ready') continue;
187
351
  const lang = _rapierLanguageClass(code) || 'text';
188
352
  _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang));
@@ -206,6 +370,11 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
206
370
 
207
371
  for (const input of root.querySelectorAll('input[type="checkbox"]')) { yield;
208
372
  const item = input.closest('.task-list-item');
373
+ if (item) {
374
+ const label = item.cloneNode(true);
375
+ for (const node of label.querySelectorAll('input,ul,ol')) { yield; node.remove(); }
376
+ input.setAttribute('aria-label', String(label.textContent || '').replace(/\s+/g, ' ').trim() || 'Task');
377
+ }
209
378
  input.disabled = !!opts.print || !item;
210
379
  if (input.disabled) { input.setAttribute('aria-disabled', 'true'); continue; }
211
380
  // Native checked state changes this reader's view, never the carried source or disk.
@@ -213,9 +382,6 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
213
382
  input.removeAttribute('aria-checked');
214
383
  input.setAttribute('autocomplete', 'off');
215
384
  input.setAttribute('title', 'Ticks change this view, not the saved file.');
216
- const label = item.cloneNode(true);
217
- for (const node of label.querySelectorAll('input,ul,ol')) { yield;node.remove();}
218
- input.setAttribute('aria-label', String(label.textContent || '').replace(/\s+/g, ' ').trim() || 'Task');
219
385
  }
220
386
 
221
387
  if (opts.print) for (const details of root.querySelectorAll('details')) { yield; details.open = true; }
@@ -223,7 +389,74 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
223
389
 
224
390
  yield* _rapierPrefixPortableAnchorsSteps(root, opts.baseName || metadata.filename || 'document');
225
391
 
226
- return yield* cleanTree(root, sanitizeRapierHtml, opts.sourceCode ? 'source' : 'export');
392
+ return yield* cleanTree(root, sanitizeRapierHtml, 'export');
393
+ }
394
+
395
+ function _rapierDocxRenderStyles(root) {
396
+ // The semantic root is detached and its code is normalized text. Use the same
397
+ // highlighter and light reference sheet that paint a page, then retain native run facts.
398
+ const codes = Array.from(root.querySelectorAll('pre > code'));
399
+ for (const code of codes) {
400
+ const lang = _rapierLanguageClass(code);
401
+ if (lang) _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang));
402
+ }
403
+ const coloured = codes.filter(code => code.querySelector('span'));
404
+ const quotes = Array.from(root.querySelectorAll('blockquote'));
405
+ if (!coloured.length && !quotes.length) return;
406
+ const frame = document.createElement('iframe');
407
+ frame.setAttribute('aria-hidden', 'true');
408
+ frame.style.cssText = 'position:fixed;left:-100000px;top:0;width:1px;height:1px;border:0;pointer-events:none';
409
+ document.body.appendChild(frame);
410
+ try {
411
+ const into = frame.contentDocument;
412
+ if (!into?.body) throw new Error('Word text styles could not be read.');
413
+ into.open(); into.write('<!doctype html><html><head></head><body></body></html>'); into.close();
414
+ into.documentElement.setAttribute('data-rapier-theme', 'light');
415
+ into.documentElement.setAttribute('data-highlights', _rapierArtifactPreference('highlights', ['accent', 'standard']));
416
+ into.body.className = 'light';
417
+ const sheet = into.createElement('style');
418
+ sheet.textContent = _rapierArtifactStyles('light', false, false);
419
+ into.head.appendChild(sheet);
420
+ const surface = into.createElement('div'); surface.className = 'md-render'; into.body.appendChild(surface);
421
+ const canvas = into.createElement('canvas'); canvas.width = canvas.height = 1;
422
+ const paint = canvas.getContext('2d'), colours = new Map();
423
+ if (!paint) throw new Error('Word text colours could not be read.');
424
+ const hex = colour => {
425
+ if (!colours.has(colour)) {
426
+ // CSS may resolve an accent to oklch/color-mix. The browser supplies its
427
+ // sRGB paper colour; the writer does not invent a second colour parser.
428
+ paint.fillStyle = '#ffffff'; paint.fillRect(0, 0, 1, 1);
429
+ paint.fillStyle = colour; paint.fillRect(0, 0, 1, 1);
430
+ colours.set(colour, '#' + Array.from(paint.getImageData(0, 0, 1, 1).data).slice(0, 3)
431
+ .map(byte => byte.toString(16).padStart(2, '0')).join(''));
432
+ }
433
+ return colours.get(colour);
434
+ };
435
+ for (const code of coloured) {
436
+ const pre = into.createElement('pre'), probe = into.importNode(code, true);
437
+ pre.appendChild(probe); surface.appendChild(pre);
438
+ const originals = Array.from(code.querySelectorAll('span'));
439
+ Array.from(probe.querySelectorAll('span')).forEach((span, index) => {
440
+ const style = into.defaultView.getComputedStyle(span), target = originals[index];
441
+ target.style.color = hex(style.color);
442
+ // Carry generated syntax emphasis as Word's semantic bold slot.
443
+ target.style.fontWeight ||= style.fontWeight === style.getPropertyValue('--md-fw-semibold').trim() ? 'bold' : style.fontWeight;
444
+ target.style.fontStyle = style.fontStyle;
445
+ });
446
+ pre.remove();
447
+ }
448
+ const quoteProbes = new Map();
449
+ for (const quote of quotes) {
450
+ const probe = into.createElement('blockquote'); probe.className = quote.className;
451
+ probe.style.borderInlineStartColor = quote.style.borderInlineStartColor;
452
+ // Nested quotes have their own reference border colour. Preserve the quote
453
+ // ancestry without cloning the document's pictures or other resources.
454
+ const parent = quoteProbes.get(quote.parentElement?.closest('blockquote')) || surface;
455
+ parent.appendChild(probe); quoteProbes.set(quote, probe);
456
+ }
457
+ for (const [quote, probe] of quoteProbes)
458
+ quote.style.borderInlineStartColor = hex(into.defaultView.getComputedStyle(probe).borderInlineStartColor);
459
+ } finally { frame.remove(); }
227
460
  }
228
461
 
229
462
  function _rapierPaintCode(el, html) {
@@ -335,7 +568,7 @@ function _rapierDocumentSettingsCss(settings) {
335
568
  }
336
569
 
337
570
  function _rapierExportedPageCsp(nonce, fonts) {
338
- return "default-src 'none'; connect-src 'none'; img-src data:; style-src 'unsafe-inline'; "
571
+ return "default-src 'none'; connect-src 'none'; frame-src 'self'; img-src data:; style-src 'unsafe-inline'; "
339
572
  + (fonts ? "font-src data:; " : '')
340
573
  + "script-src " + (nonce ? "'nonce-" + nonce + "'" : "'none'") + "; base-uri 'none'; form-action 'none'";
341
574
  }
@@ -394,12 +627,12 @@ async function _rapierSharedSourceHash(source, work = null) {
394
627
  return Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('');
395
628
  }
396
629
 
397
- function _rapierSharedSourceForms(source, root, substitutions) { return finish(_rapierSharedSourceFormsSteps(source, root, substitutions)); }
398
- async function _rapierSharedSourceFormsAsync(source, root, substitutions, work) {
630
+ function _rapierSharedSourceForms(source, root) { return finish(_rapierSharedSourceFormsSteps(source, root)); }
631
+ async function _rapierSharedSourceFormsAsync(source, root, work) {
399
632
  const destinations = await _rapierDataImageDestinationsAsync(source, work);
400
- return finishAsync(_rapierSharedSourceFormsSteps(source, root, substitutions, destinations), work);
633
+ return finishAsync(_rapierSharedSourceFormsSteps(source, root, destinations), work);
401
634
  }
402
- function* _rapierSharedSourceFormsSteps(source, root, substitutions, preparedDestinations = null) {
635
+ function* _rapierSharedSourceFormsSteps(source, root, preparedDestinations = null) {
403
636
  const assets = globalThis.RapierImageAssets;
404
637
  const destinations = (preparedDestinations || _rapierImageDestinations(source, url => !!assets.dataImage(url))).sort((a, b) => b.start - a.start);
405
638
  const byUrl = new Map(), ids = new Map(), labels = new Map();
@@ -410,14 +643,14 @@ function* _rapierSharedSourceFormsSteps(source, root, substitutions, preparedDes
410
643
  if (!assets.dataImage(url) || byUrl.has(url)) continue;
411
644
  byUrl.set(url, image);
412
645
  }
413
- let resolved = source;
414
646
  const carrierEdits = [];
415
647
  const used = [], definitions = [];
416
648
  for (const row of destinations) {
417
649
  yield;
418
- const shownUrl = substitutions?.get(row.destination) || row.destination;
419
- const image = byUrl.get(shownUrl);
420
- if (!image) { if (shownUrl !== row.destination) { resolved = resolved.slice(0, row.start) + shownUrl + resolved.slice(row.end); carrierEdits.push({start: row.start, end: row.end, text: _rapierSharedEncode(shownUrl)}); } continue; }
650
+ // Share can display a converted picture. Only identical authored bytes can be
651
+ // borrowed from that picture; otherwise the source keeps its own data URL.
652
+ const image = byUrl.get(row.destination);
653
+ if (!image) continue;
421
654
  let id = image.id;
422
655
  if (!id || ids.get(id) !== 1 || !_RAPIER_SHARED_ID.test(id)) {
423
656
  const label = labels.get(row.destination);
@@ -428,7 +661,6 @@ function* _rapierSharedSourceFormsSteps(source, root, substitutions, preparedDes
428
661
  }
429
662
  if (!used.includes(id)) used.push(id);
430
663
  if (row.reference && !definitions.includes(row.reference)) definitions.push(row.reference);
431
- resolved = resolved.slice(0, row.start) + shownUrl + resolved.slice(row.end);
432
664
  carrierEdits.push({start: row.start, end: row.end, text: '#' + id});
433
665
  }
434
666
  let carried = '', cursor = source.length;
@@ -438,13 +670,13 @@ function* _rapierSharedSourceFormsSteps(source, root, substitutions, preparedDes
438
670
  cursor = edit.start;
439
671
  }
440
672
  carried = (yield* _rapierSharedEncodeSteps(source.slice(0, cursor))) + carried;
441
- return { resolved, carried, ids: used, definitions };
673
+ return { resolved: source, carried, ids: used, definitions };
442
674
  }
443
675
 
444
676
  async function _rapierSharedSourceCarrier(context, root) {
445
677
  const source = (context.metadata.bom ? '\uFEFF' : '') + context.canonical;
446
- const forms = context.work ? await _rapierSharedSourceFormsAsync(source, root, context.imageSubstitutions, context.work)
447
- : _rapierSharedSourceForms(source, root, context.imageSubstitutions);
678
+ const forms = context.work ? await _rapierSharedSourceFormsAsync(source, root, context.work)
679
+ : _rapierSharedSourceForms(source, root);
448
680
  const esc = escapeRapierHtmlText;
449
681
  return '<script type="' + _RAPIER_SHARED_SOURCE_TYPE + '" data-filename="' + esc(context.metadata.filename) +
450
682
  '" data-kind="' + esc(context.metadata.docKind) + '" data-sha256="' + await _rapierSharedSourceHash(forms.resolved, context.work) +
@@ -740,12 +972,32 @@ async function _rapierRequireOfflinePageImages(root) {
740
972
  if (!source || source.startsWith('#') || embedded(source)) return;
741
973
  missing.push(label + ': ' + (source.startsWith('data:') ? '(unsupported embedded resource)' : source));
742
974
  };
743
- for (const [index, image] of [...root.querySelectorAll('img,[data-rapier-remote-src]')].entries()) {
744
- const src = image.getAttribute('data-rapier-remote-src') ?? image.getAttribute('src') ?? '';
745
- if (embedded(src)) continue;
975
+ for (const [index, image] of [...root.querySelectorAll('img,[data-rapier-remote-src],image,source[srcset]')].entries()) {
976
+ const tag = image.tagName.toLowerCase();
977
+ const src = image.getAttribute('data-rapier-remote-src') ?? image.getAttribute(tag === 'image' ? 'href' : 'src') ?? image.getAttribute('xlink:href') ?? '';
978
+ const srcset = image.getAttribute('srcset') || '';
979
+ const sources = [];
980
+ const picture = image.parentElement?.tagName.toLowerCase() === 'picture' && image.parentElement;
981
+ const alternative = picture && [...picture.querySelectorAll('source[srcset]')].some(source => source.getAttribute('srcset').trim());
982
+ if (tag !== 'source' && (src || !srcset && !alternative)) sources.push(src);
983
+ // A data URL contains its own comma. Read each URL before its optional
984
+ // density/width descriptor, as HTML's image candidate list does.
985
+ for (let at = 0; at < srcset.length;) {
986
+ while (at < srcset.length && /[\t\n\f\r ,]/.test(srcset[at])) at++;
987
+ const start = at;
988
+ while (at < srcset.length && !/[\t\n\f\r ]/.test(srcset[at])) at++;
989
+ const candidate = srcset.slice(start, at);
990
+ if (!candidate) break;
991
+ sources.push(candidate.replace(/,+$/, ''));
992
+ if (candidate.endsWith(',')) continue;
993
+ while (at < srcset.length && srcset[at++] !== ',') {}
994
+ }
746
995
  const alt = image.getAttribute('data-rapier-remote-alt') ?? image.getAttribute('alt') ?? '';
747
- const destination = /^data:/i.test(src) ? '(unsupported embedded image)' : src || '(missing image source)';
748
- missing.push('Picture ' + (index + 1) + (alt ? ' — ' + alt : '') + ': ' + destination);
996
+ for (const source of new Set(sources)) {
997
+ if (globalThis.RapierImageAssets.dataImage(source)) continue;
998
+ const destination = /^data:/i.test(source) ? '(unsupported embedded image)' : source || '(missing image source)';
999
+ missing.push('Picture ' + (index + 1) + (alt ? ' — ' + alt : '') + ': ' + destination);
1000
+ }
749
1001
  }
750
1002
  // A source candidate or an SVG/CSS picture is still an automatic resource. Inspect
751
1003
  // the retained data; ordinary links remain navigation and local fragment references stay local.
@@ -758,8 +1010,6 @@ async function _rapierRequireOfflinePageImages(root) {
758
1010
  if (tag === 'object') requireResource(element.getAttribute('data'), 'object source');
759
1011
  if (tag === 'link' || element.namespaceURI === 'http://www.w3.org/2000/svg' && tag !== 'a')
760
1012
  for (const attribute of ['href', 'xlink:href']) requireResource(element.getAttribute(attribute), tag + ' ' + attribute);
761
- if (tag === 'img' || tag === 'source') for (const source of _rapierSrcsetUrls(element.getAttribute('srcset') || ''))
762
- requireResource(source, tag + ' source candidate');
763
1013
  for (const attribute of element.attributes || []) if (cssAttributes.has(attribute.name.toLowerCase())) {
764
1014
  const urls = _rapierCssValueUrls(attribute.value);
765
1015
  if (urls === null) missing.push(tag + ' ' + attribute.name + ': (unresolved CSS resource)');
@@ -767,39 +1017,9 @@ async function _rapierRequireOfflinePageImages(root) {
767
1017
  }
768
1018
  }
769
1019
  if (!missing.length) return;
770
- // An acknowledged, scrollable sheet, not a disappearing toast or a partial-file opt-in.
771
- // Both Close and Cancel below refuse; neither can authorize a page with missing pictures.
772
- if (typeof rapierConfirm === 'function') await rapierConfirm({
773
- title: 'pictures not included',
774
- message: 'No web page was written. Embed these pictures before exporting or sharing an offline page. '
775
- + 'Your document is unchanged. ' + missing.join('; '),
776
- confirmLabel: 'close',
777
- });
778
- throw Object.assign(new Error('Web page not written; embed the listed pictures and try again'), {code: 'EXPORT_IMAGE_UNAVAILABLE'});
779
- }
780
-
781
- function _rapierSrcsetUrls(value) {
782
- const urls = [];
783
- let at = 0;
784
- while (at < value.length) {
785
- while (at < value.length && /[\t\n\f\r ,]/.test(value[at])) at++;
786
- const start = at;
787
- while (at < value.length && !/[\t\n\f\r ]/.test(value[at])) at++;
788
- const source = value.slice(start, at);
789
- if (!source) break;
790
- // A data URL's comma belongs to its URL. Only trailing separators or the
791
- // comma after a descriptor finish a candidate.
792
- if (source.endsWith(',')) { urls.push(source.replace(/,+$/, '')); continue; }
793
- urls.push(source);
794
- let parentheses = 0;
795
- while (at < value.length) {
796
- const char = value[at++];
797
- if (char === '(') parentheses++;
798
- else if (char === ')' && parentheses) parentheses--;
799
- else if (char === ',' && !parentheses) break;
800
- }
801
- }
802
- return urls;
1020
+ // The writer refuses immediately; interactive callers own any explanatory sheet.
1021
+ throw Object.assign(new Error('Web page not written; embed these pictures and try again. ' + missing.join('; ')),
1022
+ {code: 'offline_image_unavailable'});
803
1023
  }
804
1024
 
805
1025
  // Shared by live CSS admission and offline resource admission. Quoted strings are
@@ -879,27 +1099,8 @@ function _rapierCssValueUrls(value) {
879
1099
 
880
1100
  async function _rapierBuildSharedPage(captured) {
881
1101
  const context = await _rapierPrepareInterchangeContext({kind: 'share'}, captured);
882
- // One owner for every HTML file Rapier writes, including offline-image admission and CSP.
883
- // Share adds only the nearest-side no-script float and the long-code treatment.
884
- const artifact = await _rapierBuildArtifact({
885
- kind: 'standalone',
886
- extraCss: _rapierSharedPageFallbackCss(),
887
- async afterRoot(root) {
888
- /* Unlike TXT/DOCX (which route through _rapierProjectPortableRoot and have no
889
- native disclosure widget to fall back on), the shared page is a real HTML
890
- document a real browser renders — the same styled root the standalone HTML
891
- export already leaves untouched (_rapierBuildArtifact never flattens
892
- details for kind:'standalone'). <details>/<summary> pass the 'export'
893
- sanitize profile unchanged (only <form> is forbidden), so flattening here
894
- only threw away a native, JS-free expand/collapse the architecture's own
895
- fidelity table promises ("Complete shared/styled HTML: Exact recoverable
896
- source"). Leave it as authored, exactly like standalone HTML export. */
897
- if (context.work) {
898
- await finishAsync(_rapierSharedPageLayoutSteps(root), context.work);
899
- await finishAsync(_rapierShareCapLongCodeBlocksSteps(root), context.work);
900
- } else { _rapierSharedPageLayout(root); _rapierShareCapLongCodeBlocks(root); }
901
- },
902
- }, context);
1102
+ // Share and Export write the same complete page, including native disclosures.
1103
+ const artifact = await _rapierBuildArtifact({kind: 'standalone'}, context);
903
1104
  // No cap and no warning: the page is as large as the document is. A page over the editor's own 25
904
1105
  // MiB open limit still opens in any browser.
905
1106
  const blob = new Blob([artifact.html], {type: 'text/html;charset=utf-8'});
@@ -1004,6 +1205,7 @@ function _rapierImageDestinations(source, accepts) {
1004
1205
 
1005
1206
  function _rapierScanMarkdownImages(raw, parsedTokens = null) {
1006
1207
  const source = String(raw || '');
1208
+ const html = htmlImageRecord(source); if (html) return [html];
1007
1209
  const found = [];
1008
1210
 
1009
1211
  if (source.indexOf('![') < 0) return found;
@@ -1185,6 +1387,11 @@ function _rapierImageOpenRunsList(tokens) {
1185
1387
  continue;
1186
1388
  }
1187
1389
  if (token.type === 'rapier_color_close') { stack.pop(); continue; }
1390
+ if (token.type === 'rapier_font_open') {
1391
+ stack.push({open: token.meta?.mdFont?.source || formatFontOpen(token.attrGet('data-md-font')), close: FONT_CLOSE});
1392
+ continue;
1393
+ }
1394
+ if (token.type === 'rapier_font_close') { stack.pop(); continue; }
1188
1395
  if (token.type === 'strong_open' || token.type === 'em_open' || token.type === 's_open') {
1189
1396
  stack.push({open: token.markup, close: token.markup});
1190
1397
  continue;
@@ -1512,7 +1719,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1512
1719
  restore();
1513
1720
 
1514
1721
  if (!root.isConnected || printing || view.matchMedia?.('print').matches) return;
1515
- const children = [...root.children];
1722
+ const children = [...root.children].filter(element => !element.hasAttribute('data-rapier-html-preview'));
1516
1723
  if (children.length > 8192) return;
1517
1724
  // behind/front collapse and place like around/box but are never obstacles.
1518
1725
  const outOfFlow = value => value?.wrap === 'behind' || value?.wrap === 'front';
@@ -1546,6 +1753,12 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1546
1753
  const lines = layout.lines != null ? geometry.lineMetrics(element) : null;
1547
1754
  const unrotated = geometry.imageBox(width, image.naturalWidth, image.naturalHeight, layout, imageWidth, lines);
1548
1755
  if (!unrotated) { restore(); return; }
1756
+ if (image.hasAttribute('data-rapier-html')) {
1757
+ const attrs = JSON.parse(decodeURIComponent(image.getAttribute('data-rapier-html'))).attrs;
1758
+ const line = parseFloat(computed.lineHeight) || (parseFloat(computed.fontSize) || 16) * 1.6;
1759
+ unrotated.height = !attrs.height || attrs.height === 'auto'
1760
+ ? Number(image.dataset.rapierNaturalHeight) || box(image).height : Number(attrs.height) * line;
1761
+ }
1549
1762
  // rasterReserved (layout/model.mjs).
1550
1763
  const rasterRad = (layout.rotate || 0) * Math.PI / 180;
1551
1764
  const fit = geometry.rasterReserved(width, unrotated, rasterRad);
@@ -1618,6 +1831,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1618
1831
  observer?.observe(root);
1619
1832
  root.addEventListener('load', schedule, true);
1620
1833
  root.addEventListener('error', schedule, true);
1834
+ root.addEventListener('rapier-html-resize', schedule);
1621
1835
  root.addEventListener('toggle', schedule, true);
1622
1836
  view.addEventListener('resize', schedule);
1623
1837
  view.addEventListener('pageshow', schedule);
@@ -1632,6 +1846,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
1632
1846
  observer?.disconnect(); restore();
1633
1847
  root.removeEventListener('load', schedule, true);
1634
1848
  root.removeEventListener('error', schedule, true);
1849
+ root.removeEventListener('rapier-html-resize', schedule);
1635
1850
  root.removeEventListener('toggle', schedule, true);
1636
1851
  view.removeEventListener('resize', schedule);
1637
1852
  view.removeEventListener('pageshow', schedule);
@@ -1668,6 +1883,23 @@ function _rapierArtifactInkScript(root, nonce = '') {
1668
1883
  return '<script' + (nonce ? ' nonce="' + nonce + '"' : '') + '>\n' + script.replace(/<\/script/gi, '<\\/script') + '\n</script>';
1669
1884
  }
1670
1885
 
1671
- return {_rapierDataImageDestinationsAsync, _rapierSharedSourceFormsAsync, _rapierPrefixPortableAnchorsSteps, _rapierProjectStyledRootAsync, _rapierBuildArtifact, _rapierProjectStyledRoot, _rapierPaintCode, _rapierPrefixPortableAnchors, _rapierDocumentSettingsOf, _rapierDocumentSettingsCss, _rapierExportedPageCsp, _rapierArtifactNonce, _rapierAnnotateExportBoxPolygons, _rapierSharedSourceHash, _rapierSharedSourceForms, _rapierSharedSourceCarrier, _rapierSharedResolve, _rapierReadSharedDocument, _rapierSharedPageFallbackCss, _rapierShareWrapKind, _rapierSharePictureParagraph, _rapierSharedPageLayout, _rapierShareCapLongCodeBlocks, _rapierRequireOfflinePageImages, _rapierCssValueUrls, _rapierBuildSharedPage, _rapierImageSources, _rapierImageDestinations, _rapierScanMarkdownImages, _rapierParseMarkdownImageDestination, _rapierImageOpenRunsList, _rapierArtifactFactoryModules, _rapierProjectArtifactLayout, _rapierArtifactLayoutScript, _rapierArtifactInkScript , render};
1886
+ function _rapierArtifactTypographyScript(root, nonce = '') {
1887
+ const pictures = root.querySelector('img[data-rapier-print-lines]');
1888
+ if (!pictures && !root.querySelector('p[data-md-hyphens="auto"],p[data-md-lines],p[data-md-caps="small"],p[data-md-hanging="on"]')) return '';
1889
+ const script = '(() => {\nconst typography = (' + createTypography.toString() + ')();\n' +
1890
+ (pictures ? 'const capitals = (' + createPrintCapitals.toString() + ')((' + createLineMeasurer.toString() + ')(),(' + linesHeight.toString() + '),(' + followingPrintProse.toString() + '));\n' : '') +
1891
+ 'const root = document.querySelector("main.rapier-page"); let frame = 0, width = -1, printing = matchMedia("print").matches;\n' +
1892
+ 'const apply = () => { frame = 0; ' + (pictures ? 'if (printing) capitals.apply(root); else capitals.clear(root); ' : '') + 'typography.apply(root, {all:true}); };\n' +
1893
+ 'const schedule = () => { if (!frame) frame = requestAnimationFrame(apply); };\n' +
1894
+ 'globalThis.__RAPIER_EXPORT_READY__ = Promise.all([document.fonts?.ready, ...Array.from(root.querySelectorAll("img"), image => image.decode?.().catch(() => {}))]).then(() => new Promise(resolve => requestAnimationFrame(() => { apply(); resolve(); })));\n' +
1895
+ 'addEventListener("resize", schedule); addEventListener("beforeprint", () => { printing = true; apply(); });\n' +
1896
+ 'addEventListener("afterprint", () => { printing = false; ' + (pictures ? 'capitals.clear(root); ' : '') + 'schedule(); });\n' +
1897
+ 'root.addEventListener("load", schedule, true);\n' +
1898
+ 'if (typeof ResizeObserver === "function") new ResizeObserver(() => { const next = root.getBoundingClientRect().width; if (next !== width) { width = next; schedule(); } }).observe(root);\n' +
1899
+ '})();';
1900
+ return '<script' + (nonce ? ' nonce="' + nonce + '"' : '') + '>\n' + script.replace(/<\/script/gi, '<\\/script') + '\n</script>';
1901
+ }
1902
+
1903
+ return {_rapierDataImageDestinationsAsync, _rapierSharedSourceFormsAsync, _rapierPrefixPortableAnchorsSteps, _rapierProjectStyledRootAsync, _rapierBuildArtifact, _rapierBuildPublishedPage, _rapierProjectStyledRoot, _rapierDocxRenderStyles, _rapierPaintCode, _rapierPrefixPortableAnchors, _rapierDocumentSettingsOf, _rapierDocumentSettingsCss, _rapierExportedPageCsp, _rapierArtifactNonce, _rapierAnnotateExportBoxPolygons, _rapierSharedSourceHash, _rapierSharedSourceForms, _rapierSharedSourceCarrier, _rapierSharedResolve, _rapierReadSharedDocument, _rapierSharedPageFallbackCss, _rapierShareWrapKind, _rapierSharePictureParagraph, _rapierSharedPageLayout, _rapierShareCapLongCodeBlocks, _rapierRequireOfflinePageImages, _rapierCssValueUrls, _rapierBuildSharedPage, _rapierImageSources, _rapierImageDestinations, _rapierScanMarkdownImages, _rapierParseMarkdownImageDestination, _rapierImageOpenRunsList, _rapierArtifactFactoryModules, _rapierProjectArtifactLayout, _rapierArtifactLayoutScript, _rapierArtifactInkScript , render};
1672
1904
  }
1673
1905
  export {createRenderer};