rapier-markdown-kit 1.1.55 → 1.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/dist/kit/index.mjs +1 -0
- package/dist/kit/render-entry.mjs +7 -0
- package/dist/kit/render-markdown.mjs +829 -0
- package/dist/kit/render-print.mjs +165 -0
- package/dist/kit/render-sanitize.mjs +223 -0
- package/dist/kit/render-styles.mjs +118 -0
- package/dist/kit/render.mjs +1436 -0
- package/package.json +4 -3
|
@@ -0,0 +1,1436 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
3
|
+
function createRenderer(runtime) {
|
|
4
|
+
const {RAPIER_COLOR_CLOSE, RapierPageReturnAddress, RapierTextCodec, _rapierArtifactHighlight, _rapierArtifactLexerScript, _rapierArtifactMarkLexed, _rapierArtifactPreference, _rapierArtifactStyles, _rapierBlobDataUrl, _rapierBuildInterchangeContext, _rapierDocumentNameIsAdmissible, _rapierDrawReadSVGRecipe, _rapierDrawShapeProfileFor, _rapierFillDiagram, _rapierFormatColorOpen, _rapierLanguageClass, _rapierLineStartOffsets, _rapierMarkdownEnvironment, _rapierPortableHtml, _rapierPrepareInterchangeContext, _rapierProjectPortableRoot, _rapierSourceCharEscaped, _rapierSourceLineSpan, crypto, document, escapeRapierHtmlText, globalThis, rapierConfirm, sanitizeRapierHtml} = runtime;
|
|
5
|
+
let md = runtime.md;
|
|
6
|
+
async function render(source, options = {}) {
|
|
7
|
+
if (typeof source !== 'string') throw new TypeError('Document source must be a string');
|
|
8
|
+
const filename = options.filename || 'document.md';
|
|
9
|
+
if (typeof filename !== 'string' || !filename || /[\\/\u0000-\u001f\u007f]/.test(filename)) throw new TypeError('Invalid document filename');
|
|
10
|
+
const bom = source.charCodeAt(0) === 0xfeff;
|
|
11
|
+
const canonical = RapierTextCodec.normalizeDocument(source);
|
|
12
|
+
const captured = {canonical, metadata: {filename, docKind: options.docKind || 'markdown', codeLang: options.codeLang || '', bom}, baseName: filename.replace(/\.[^.]*$/, '') || 'document'};
|
|
13
|
+
const written = await _rapierBuildSharedPage(captured);
|
|
14
|
+
return {html: await written.blob.text(), filename: written.filename};
|
|
15
|
+
}
|
|
16
|
+
async function _rapierBuildArtifact(options, providedContext) {
|
|
17
|
+
const opts = options || {};
|
|
18
|
+
const context = providedContext || _rapierBuildInterchangeContext(opts);
|
|
19
|
+
const metadata = context.metadata;
|
|
20
|
+
const base = context.baseName;
|
|
21
|
+
|
|
22
|
+
if (opts.kind === 'publishing' || opts.kind === 'fragment') {
|
|
23
|
+
for (const diagram of context.semanticRoot.querySelectorAll('.diagram-block[data-diagram-native]'))
|
|
24
|
+
await _rapierFillDiagram(diagram, true);
|
|
25
|
+
const portableRoot = _rapierProjectPortableRoot(context.semanticRoot, { baseName: base, keepDiagrams: true });
|
|
26
|
+
return {
|
|
27
|
+
html: _rapierPortableHtml(portableRoot),
|
|
28
|
+
filename: base + '-publishing.html',
|
|
29
|
+
metadata,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The semantic export root is detached and has never entered the live visibility queue.
|
|
34
|
+
// Await the same materialization owner before any projection clones or serializes it.
|
|
35
|
+
for (const diagram of context.semanticRoot.querySelectorAll('.diagram-block[data-diagram-src]')) {
|
|
36
|
+
await _rapierFillDiagram(diagram, true);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (opts.kind === 'standalone') await _rapierRequireOfflinePageImages(context.semanticRoot);
|
|
40
|
+
|
|
41
|
+
const styledRoot = _rapierProjectStyledRoot(context.semanticRoot, {
|
|
42
|
+
metadata,
|
|
43
|
+
print: !!opts.print,
|
|
44
|
+
baseName: base,
|
|
45
|
+
});
|
|
46
|
+
styledRoot.innerHTML = sanitizeRapierHtml(styledRoot.innerHTML, 'export');
|
|
47
|
+
// Print never emits the layout reflow script (see below), so a print artifact has no use for
|
|
48
|
+
// this annotation -- skip it there rather than pay the cost for numbers nothing will read.
|
|
49
|
+
if (!opts.print) _rapierAnnotateExportBoxPolygons(styledRoot);
|
|
50
|
+
// Share adds its nearest-side no-script float and long-code cap here. Offline-image
|
|
51
|
+
// admission above and the page policy below belong to this common writer, not its callers.
|
|
52
|
+
if (typeof opts.afterRoot === 'function') opts.afterRoot(styledRoot);
|
|
53
|
+
const carrier = opts.kind === 'standalone' ? await _rapierSharedSourceCarrier(context, styledRoot) : '';
|
|
54
|
+
const bodyHtml = styledRoot.innerHTML;
|
|
55
|
+
const theme = opts.print
|
|
56
|
+
? 'light'
|
|
57
|
+
: _rapierArtifactPreference('theme', ['system', 'light', 'dark']);
|
|
58
|
+
const highlights = _rapierArtifactPreference('highlights', ['accent', 'standard']);
|
|
59
|
+
const versionMeta = document.querySelector('meta[name="rapier-version"]');
|
|
60
|
+
const version = versionMeta && versionMeta.content ? versionMeta.content.trim() : '0.0.0';
|
|
61
|
+
const bodyClass = theme === 'light' ? 'light' : '';
|
|
62
|
+
const docClass = metadata.docKind === 'markdown'
|
|
63
|
+
? 'rapier-page md-render'
|
|
64
|
+
: 'rapier-page rapier-document--flat';
|
|
65
|
+
// Rapier's own stylesheet, not a lookalike: the theme frame, spec/markdown-style.css's whole
|
|
66
|
+
// .md-render set and the highlight rules, so a numbered list, a checkbox, a callout and a
|
|
67
|
+
// table are drawn in an exported page exactly as they are drawn here. The one caller with
|
|
68
|
+
// rules of its own -- Share, for the no-script float it alone writes -- appends them.
|
|
69
|
+
// The type faces ride only where they can load. A written page's policy is default-src
|
|
70
|
+
// 'none' with no font-src, so a data: font in one is fetched by nothing and would be tens of
|
|
71
|
+
// kilobytes of bytes no reader ever sees; those pages take the reader's own sans matched to
|
|
72
|
+
// Geist's metrics (_rapierArtifactStyles), which is the one difference between a Rapier
|
|
73
|
+
// document and its exported page. A print artifact renders inside this page, under this
|
|
74
|
+
// page's policy, so it keeps the real faces.
|
|
75
|
+
// The print page carries its faces as data: faces of its own, and a Will's carriers' font among them (the secure-runtime host prints this
|
|
76
|
+
// page, not the one it was made in). Its policy alone has font-src data:, which reaches no network, whether or not the page has a Will:
|
|
77
|
+
// with it only for a Will the same document would print in its faces with the Will and in the reader's own without (measured).
|
|
78
|
+
const willFont = opts.print && opts.willFont ? opts.willFont : null;
|
|
79
|
+
const settings = _rapierDocumentSettingsOf(context.canonical);
|
|
80
|
+
const settingsCss = _rapierDocumentSettingsCss(settings);
|
|
81
|
+
const pageTitle = settings && settings.title ? globalThis.RapierMarkdownSpec.documentTitle(settings) : metadata.filename;
|
|
82
|
+
const css = _rapierArtifactStyles(theme, !!opts.print, !!opts.print || opts.kind === 'standalone')
|
|
83
|
+
+ (opts.extraCss ? '\n\n' + opts.extraCss : '')
|
|
84
|
+
+ (willFont ? '\n\n@font-face{font-family:' + willFont.family + ';src:url(' + await _rapierBlobDataUrl(new Blob([willFont.bytes], { type: 'font/ttf' })) + ') format("truetype")}' : '')
|
|
85
|
+
+ (settingsCss ? '\n' + settingsCss : '');
|
|
86
|
+
// Every written page is offline. Only the writer's nonce script may run; it, pictures,
|
|
87
|
+
// styles and other page resources get no network authority. Following an ordinary link
|
|
88
|
+
// is the reader's navigation, with no Referer, not a background resource request.
|
|
89
|
+
const nonce = _rapierArtifactNonce();
|
|
90
|
+
// The reflow script is written first, because whether it exists decides the policy. A page that
|
|
91
|
+
// carries no script says so: script-src 'none' refuses every script outright. Declaring a nonce a page has no use for is a widening, small but real -- anything
|
|
92
|
+
// that could inject markup into the file could read the nonce out of it and be admitted. G3's
|
|
93
|
+
// return caught this against the R86k fold, which always emitted the directive; the rule here
|
|
94
|
+
// is tighter than either that fold or G3's own writer, which could not carry a script at all.
|
|
95
|
+
const layoutScript = !opts.print ? _rapierArtifactLayoutScript(styledRoot, nonce) : '';
|
|
96
|
+
// The lexer for the page's code, under the same nonce, only when a block earned it: the CPU
|
|
97
|
+
// spans are the first paint, and where the reader's browser has WebGPU the lexer repaints them.
|
|
98
|
+
const lexerScript = !opts.print ? _rapierArtifactLexerScript(styledRoot, nonce) : '';
|
|
99
|
+
const inkScript = !opts.print ? _rapierArtifactInkScript(styledRoot, nonce) : '';
|
|
100
|
+
const page = '<!DOCTYPE html>\n'
|
|
101
|
+
+ '<html lang="en" data-rapier-theme="' + theme + '" data-highlights="' + highlights + '">\n'
|
|
102
|
+
+ '<head>\n<meta charset="UTF-8">\n' + (carrier ? RapierPageReturnAddress.PAGE_SEED + '\n' : '')
|
|
103
|
+
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
104
|
+
// Chrome 154's responsively sized frames (the founder's link, 24 September 2026): a host frame styled
|
|
105
|
+
// `frame-sizing: content-height` sizes itself to a page that opts in with this meta, so a document
|
|
106
|
+
// exported from Rapier and carried in someone's page shows whole, with no inner scroll. Any origin may
|
|
107
|
+
// read the height: the page is the document, published by the person who exported it.
|
|
108
|
+
+ '<meta name="responsive-embedded-sizing" content="allow-origins=*">\n'
|
|
109
|
+
+ '<meta http-equiv="Content-Security-Policy" content="' + _rapierExportedPageCsp(layoutScript || lexerScript || inkScript ? nonce : '', !!opts.print) + '">\n'
|
|
110
|
+
+ '<meta name="referrer" content="no-referrer">\n'
|
|
111
|
+
+ '<meta name="generator" content="Rapier ' + escapeRapierHtmlText(version) + '">\n'
|
|
112
|
+
+ '<title>' + escapeRapierHtmlText(pageTitle) + '</title>\n'
|
|
113
|
+
+ '<style>' + css + '</style>\n</head>\n'
|
|
114
|
+
+ '<body class="' + bodyClass + '"><main class="' + docClass + '" data-md-theme="' + theme + '">'
|
|
115
|
+
+ bodyHtml + '</main>' + carrier + layoutScript + lexerScript + inkScript + '</body>\n</html>';
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
html: page,
|
|
119
|
+
filename: base + '.html',
|
|
120
|
+
bodyHtml,
|
|
121
|
+
docClass,
|
|
122
|
+
metadata,
|
|
123
|
+
documentTitle: settings && settings.title ? pageTitle : '',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _rapierProjectStyledRoot(semanticRoot, options) {
|
|
128
|
+
const opts = options || {};
|
|
129
|
+
const root = semanticRoot.cloneNode(true);
|
|
130
|
+
const metadata = opts.metadata || {};
|
|
131
|
+
|
|
132
|
+
if (metadata.docKind !== 'markdown') {
|
|
133
|
+
const pre = root.querySelector('pre');
|
|
134
|
+
const code = pre && pre.querySelector(':scope > code');
|
|
135
|
+
if (pre) {
|
|
136
|
+
pre.className = 'artifact-flat ' + (metadata.docKind === 'code'
|
|
137
|
+
? 'artifact-flat--code'
|
|
138
|
+
: 'artifact-flat--text');
|
|
139
|
+
}
|
|
140
|
+
if (code) {
|
|
141
|
+
const lang = _rapierLanguageClass(code) || String(metadata.codeLang || '');
|
|
142
|
+
code.classList.add('artifact-code');
|
|
143
|
+
if (metadata.docKind === 'code') { _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang)); _rapierArtifactMarkLexed(code, lang); }
|
|
144
|
+
if (lang) code.classList.add('language-' + String(lang).replace(/[^a-z0-9_-]/gi, ''));
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
root.querySelectorAll('pre > code').forEach(code => {
|
|
148
|
+
_rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', _rapierLanguageClass(code)));
|
|
149
|
+
_rapierArtifactMarkLexed(code, _rapierLanguageClass(code));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
root.querySelectorAll('table').forEach(table => {
|
|
153
|
+
if (table.parentElement && table.parentElement.classList.contains('table-scroll-wrap')) return;
|
|
154
|
+
const wrap = document.createElement('div');
|
|
155
|
+
wrap.className = 'table-scroll-wrap';
|
|
156
|
+
table.parentNode.insertBefore(wrap, table);
|
|
157
|
+
wrap.appendChild(table);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
root.querySelectorAll('math[display="block"]').forEach(math => {
|
|
161
|
+
if (math.closest('.math-display-wrap')) return;
|
|
162
|
+
const wrap = document.createElement('span');
|
|
163
|
+
wrap.className = 'math-display-wrap';
|
|
164
|
+
math.parentNode.insertBefore(wrap, math);
|
|
165
|
+
wrap.appendChild(math);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
root.querySelectorAll('input[type="checkbox"]').forEach(input => {
|
|
169
|
+
const item = input.closest('.task-list-item');
|
|
170
|
+
input.disabled = !!opts.print || !item;
|
|
171
|
+
if (input.disabled) { input.setAttribute('aria-disabled', 'true'); return; }
|
|
172
|
+
// Native checked state changes this reader's view, never the carried source or disk.
|
|
173
|
+
input.removeAttribute('aria-disabled');
|
|
174
|
+
input.removeAttribute('aria-checked');
|
|
175
|
+
input.setAttribute('autocomplete', 'off');
|
|
176
|
+
input.setAttribute('title', 'Ticks change this view, not the saved file.');
|
|
177
|
+
const label = item.cloneNode(true);
|
|
178
|
+
label.querySelectorAll('input,ul,ol').forEach(node => node.remove());
|
|
179
|
+
input.setAttribute('aria-label', String(label.textContent || '').replace(/\s+/g, ' ').trim() || 'Task');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (opts.print) root.querySelectorAll('details').forEach(details => { details.open = true; });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_rapierPrefixPortableAnchors(root, opts.baseName || metadata.filename || 'document');
|
|
186
|
+
|
|
187
|
+
const clean = document.createElement('div');
|
|
188
|
+
clean.innerHTML = sanitizeRapierHtml(root.innerHTML, 'export');
|
|
189
|
+
return clean;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _rapierPaintCode(el, html) {
|
|
193
|
+
el.innerHTML = html;
|
|
194
|
+
el.classList.add('tok');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _rapierPrefixPortableAnchors(root, baseName) {
|
|
198
|
+
const slug = String(baseName || 'document').toLowerCase()
|
|
199
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
200
|
+
.replace(/^-+|-+$/g, '') || 'document';
|
|
201
|
+
const prefix = 'rapier-' + slug + '-';
|
|
202
|
+
const ids = new Map(), svgIds = new Map();
|
|
203
|
+
const used = new Set();
|
|
204
|
+
const scopeOf = element => {
|
|
205
|
+
// querySelectorAll('*') hands back whatever the host document holds, and not every host gives
|
|
206
|
+
// each node Element.closest -- the export writer runs against parsed and stubbed documents as
|
|
207
|
+
// well as a browser's. Without this guard the whole anchor pass threw TypeError and took 107
|
|
208
|
+
// export corpus cases with it. No scope simply means the pre-R87M behaviour for that node:
|
|
209
|
+
// its references resolve document-wide, exactly as they did before drawings were scoped.
|
|
210
|
+
if (typeof element?.closest !== 'function') return null;
|
|
211
|
+
let svg = element.closest('svg');
|
|
212
|
+
while (typeof svg?.parentElement?.closest === 'function' && svg.parentElement.closest('svg')) svg = svg.parentElement.closest('svg');
|
|
213
|
+
return svg;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
root.querySelectorAll('[id]').forEach(element => {
|
|
217
|
+
const oldId = element.id;
|
|
218
|
+
if (!oldId) return;
|
|
219
|
+
const base = oldId.replace(/[^a-z0-9_-]+/gi, '-')
|
|
220
|
+
.replace(/^-+|-+$/g, '') || 'anchor';
|
|
221
|
+
let nextId = prefix + base;
|
|
222
|
+
let suffix = 2;
|
|
223
|
+
while (used.has(nextId)) nextId = prefix + base + '-' + suffix++;
|
|
224
|
+
used.add(nextId);
|
|
225
|
+
// A document link resolves the first matching anchor, as it did before renaming.
|
|
226
|
+
// SVG references resolve within their own drawing, never the last repeated drawing.
|
|
227
|
+
if (!ids.has(oldId)) ids.set(oldId, nextId);
|
|
228
|
+
const svg = scopeOf(element);
|
|
229
|
+
if (svg) {
|
|
230
|
+
let local = svgIds.get(svg);
|
|
231
|
+
if (!local) svgIds.set(svg, local = new Map());
|
|
232
|
+
if (!local.has(oldId)) local.set(oldId, nextId);
|
|
233
|
+
}
|
|
234
|
+
element.id = nextId;
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
root.querySelectorAll('*').forEach(element => {
|
|
238
|
+
const svg = scopeOf(element), local = svgIds.get(svg);
|
|
239
|
+
const rewriteId = value => (svg ? local?.get(value) : ids.get(value)) || '';
|
|
240
|
+
// URL fragments percent-decode once; ARIA/for/headers are literal IDREFs.
|
|
241
|
+
const decodeFragment = value => { try { return decodeURIComponent(value); } catch (_) { return value; } };
|
|
242
|
+
const iriProperty = /^(?:fill|stroke|filter|clip-path|mask|marker(?:-start|-mid|-end)?|cursor)$/;
|
|
243
|
+
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) => {
|
|
244
|
+
const target = (double ?? single ?? bare).replace(/\\(?:([0-9a-f]{1,6})(?:\r\n|[ \r\n\f])?|([\s\S]))/gi, (escape, hex, plain) => {
|
|
245
|
+
const point = hex && parseInt(hex, 16);
|
|
246
|
+
return hex ? (point > 0 && point <= 0x10ffff ? String.fromCodePoint(point) : '\ufffd') : plain;
|
|
247
|
+
});
|
|
248
|
+
const mapped = target[0] === '#' && rewriteId(decodeFragment(target.slice(1)));
|
|
249
|
+
return mapped ? 'url(#' + mapped + ')' : whole;
|
|
250
|
+
});
|
|
251
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
252
|
+
const name = attribute.name, value = attribute.value;
|
|
253
|
+
if (name === 'href' || name === 'xlink:href') {
|
|
254
|
+
const fragment = value.slice(1), decoded = decodeFragment(fragment);
|
|
255
|
+
const navigation = element.localName === 'a' || element.localName === 'area';
|
|
256
|
+
const next = value[0] === '#' && (navigation
|
|
257
|
+
? rewriteId(fragment) || rewriteId(decoded) || ids.get(fragment) || ids.get(decoded)
|
|
258
|
+
: rewriteId(svg ? decoded : fragment));
|
|
259
|
+
if (next) element.setAttribute(name, '#' + next);
|
|
260
|
+
} else if (name === 'for' || name === 'list') {
|
|
261
|
+
const next = rewriteId(value);
|
|
262
|
+
if (next) element.setAttribute(name, next);
|
|
263
|
+
else element.removeAttribute(name);
|
|
264
|
+
} else if (name === 'headers' || name === 'aria-labelledby' || name === 'aria-describedby') {
|
|
265
|
+
const next = value.trim().split(/\s+/).map(rewriteId).filter(Boolean).join(' ');
|
|
266
|
+
if (next) element.setAttribute(name, next);
|
|
267
|
+
else element.removeAttribute(name);
|
|
268
|
+
} else if (svg && name === 'style') {
|
|
269
|
+
// Interpret only URL-bearing presentation properties: content/metadata strings
|
|
270
|
+
// that merely SAY "url(#id)" are not a reference and retain their own words.
|
|
271
|
+
for (const property of Array.from(element.style)) {
|
|
272
|
+
if (!iriProperty.test(property)) continue;
|
|
273
|
+
const before = element.style.getPropertyValue(property), next = rewriteUrls(before);
|
|
274
|
+
if (next !== before) element.style.setProperty(property, next, element.style.getPropertyPriority(property));
|
|
275
|
+
}
|
|
276
|
+
} else if (svg && iriProperty.test(name)) {
|
|
277
|
+
const next = rewriteUrls(value);
|
|
278
|
+
if (next !== value) element.setAttribute(name, next);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function _rapierDocumentSettingsOf(canonical) {
|
|
285
|
+
const spec = globalThis.RapierMarkdownSpec;
|
|
286
|
+
if (!spec || typeof spec.readDocumentSettings !== 'function') return null;
|
|
287
|
+
try { return spec.readDocumentSettings(String(canonical || '')); }
|
|
288
|
+
catch (_) { return null; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function _rapierDocumentSettingsCss(settings) {
|
|
292
|
+
const spec = globalThis.RapierMarkdownSpec;
|
|
293
|
+
if (!settings || !spec || typeof spec.documentSettingsStyle !== 'function') return '';
|
|
294
|
+
const style = spec.documentSettingsStyle(settings) || '';
|
|
295
|
+
const page = typeof spec.documentSettingsPageRule === 'function' ? spec.documentSettingsPageRule(settings) : null;
|
|
296
|
+
return style && page ? style + '\n' + page : style || page || '';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function _rapierExportedPageCsp(nonce, fonts) {
|
|
300
|
+
return "default-src 'none'; img-src data: https:; style-src 'unsafe-inline'; "
|
|
301
|
+
+ (fonts ? "font-src data:; " : '')
|
|
302
|
+
+ "script-src " + (nonce ? "'nonce-" + nonce + "'" : "'none'") + "; base-uri 'none'; form-action 'none'";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function _rapierArtifactNonce() {
|
|
306
|
+
const bytes = new Uint8Array(18);
|
|
307
|
+
crypto.getRandomValues(bytes);
|
|
308
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function _rapierAnnotateExportBoxPolygons(root) {
|
|
312
|
+
for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
|
|
313
|
+
const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
|
|
314
|
+
if (layout?.wrap !== 'box' && layout?.wrap !== 'around') continue;
|
|
315
|
+
const match = /^data:image\/svg\+xml;base64,([A-Za-z0-9+/]+=*)$/.exec(image.getAttribute('src') || '');
|
|
316
|
+
if (!match) continue;
|
|
317
|
+
let text;
|
|
318
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(match[1]), c => c.charCodeAt(0))); }
|
|
319
|
+
catch (_) { continue; }
|
|
320
|
+
let recipe;
|
|
321
|
+
try { recipe = _rapierDrawReadSVGRecipe(text); } catch (_) { continue; }
|
|
322
|
+
if (!recipe) continue;
|
|
323
|
+
const view = recipe.view && recipe.view.w > 0 && recipe.view.h > 0 ? recipe.view : { x: 0, y: 0, w: recipe.canvas.w, h: recipe.canvas.h };
|
|
324
|
+
if (layout.wrap === 'around') {
|
|
325
|
+
// One occupancy for live view and export (Astra R74 I02): the drawing's own profile --
|
|
326
|
+
// closed faces reserved, open strokes as ribbons, labels as boxes -- computed from the same
|
|
327
|
+
// recipe the live view reads, sampled into bands the exported page reads back
|
|
328
|
+
// (layout/model.mjs parseProfile). Text and paint shapes need rendered pixels for their
|
|
329
|
+
// glyph silhouettes, so the live editor's own <img> supplies those when it can be found; a
|
|
330
|
+
// raster or a foreign SVG writes nothing and the export measures alpha, as the live view does.
|
|
331
|
+
try {
|
|
332
|
+
let glyphs = null;
|
|
333
|
+
if (recipe.shapes.some(shape => shape.recognized === 'text' || shape.recognized === 'paint' || shape.label)) {
|
|
334
|
+
const src = image.getAttribute('src') || '';
|
|
335
|
+
const live = [...document.querySelectorAll('#editor-blocks img[data-rapier-markdown-image]')].find(candidate => candidate.getAttribute('src') === src || candidate.currentSrc === src);
|
|
336
|
+
glyphs = live ? globalThis.RapierImageLayout.alphaProfile(live) : null;
|
|
337
|
+
}
|
|
338
|
+
const profile = typeof _rapierDrawShapeProfileFor === 'function' ? _rapierDrawShapeProfileFor(recipe, glyphs) : null;
|
|
339
|
+
const serialized = profile ? globalThis.RapierImageLayout.serializeProfile(profile) : '';
|
|
340
|
+
if (serialized) image.setAttribute('data-rapier-occupancy', serialized);
|
|
341
|
+
} catch (_) {}
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const corners = globalThis.RapierDrawEdit?.contentTiltBox(recipe);
|
|
345
|
+
if (!corners || corners.length < 3) continue;
|
|
346
|
+
const normalized = corners.map(([x, y]) => [(x - view.x) / view.w, (y - view.y) / view.h]);
|
|
347
|
+
if (normalized.some(([x, y]) => !Number.isFinite(x) || !Number.isFinite(y))) continue;
|
|
348
|
+
const round = value => Math.round(value * 1e6) / 1e6;
|
|
349
|
+
image.setAttribute('data-rapier-box-polygon', normalized.map(([x, y]) => round(x) + ' ' + round(y)).join(' '));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function _rapierSharedSourceHash(source) {
|
|
354
|
+
const bytes = new TextEncoder().encode(source);
|
|
355
|
+
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes));
|
|
356
|
+
return Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function _rapierSharedSourceForms(source, root, substitutions) {
|
|
360
|
+
const assets = globalThis.RapierImageAssets;
|
|
361
|
+
const destinations = _rapierImageDestinations(source, url => !!assets.dataImage(url)).sort((a, b) => b.start - a.start);
|
|
362
|
+
const byUrl = new Map(), ids = new Map(), labels = new Map();
|
|
363
|
+
for (const node of root.querySelectorAll('[id]')) ids.set(node.id, (ids.get(node.id) || 0) + 1);
|
|
364
|
+
for (const match of source.matchAll(/^ {0,3}\[([^\]\r\n]+)\]:[ \t]*(data:image\/\S+)/gim)) if (!labels.has(match[2])) labels.set(match[2], match[1]);
|
|
365
|
+
for (const image of root.querySelectorAll('img[src^="data:image/" i]')) {
|
|
366
|
+
const url = image.getAttribute('src');
|
|
367
|
+
if (!assets.dataImage(url) || byUrl.has(url)) continue;
|
|
368
|
+
byUrl.set(url, image);
|
|
369
|
+
}
|
|
370
|
+
let resolved = source;
|
|
371
|
+
const carrierEdits = [];
|
|
372
|
+
const used = [], definitions = [];
|
|
373
|
+
for (const row of destinations) {
|
|
374
|
+
const shownUrl = substitutions?.get(row.destination) || row.destination;
|
|
375
|
+
const image = byUrl.get(shownUrl);
|
|
376
|
+
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; }
|
|
377
|
+
let id = image.id;
|
|
378
|
+
if (!id || ids.get(id) !== 1 || !_RAPIER_SHARED_ID.test(id)) {
|
|
379
|
+
const label = labels.get(row.destination);
|
|
380
|
+
id = label && _RAPIER_SHARED_ID.test(label) && !ids.has(label) ? label : null;
|
|
381
|
+
for (let number = 1; !id || ids.has(id); number++) id = 'image-' + number;
|
|
382
|
+
image.id = id;
|
|
383
|
+
ids.set(id, 1);
|
|
384
|
+
}
|
|
385
|
+
if (!used.includes(id)) used.push(id);
|
|
386
|
+
if (row.reference && !definitions.includes(row.reference)) definitions.push(row.reference);
|
|
387
|
+
resolved = resolved.slice(0, row.start) + shownUrl + resolved.slice(row.end);
|
|
388
|
+
carrierEdits.push({start: row.start, end: row.end, text: '#' + id});
|
|
389
|
+
}
|
|
390
|
+
let carried = '', cursor = source.length;
|
|
391
|
+
for (const edit of carrierEdits) {
|
|
392
|
+
carried = edit.text + _rapierSharedEncode(source.slice(edit.end, cursor)) + carried;
|
|
393
|
+
cursor = edit.start;
|
|
394
|
+
}
|
|
395
|
+
carried = _rapierSharedEncode(source.slice(0, cursor)) + carried;
|
|
396
|
+
return { resolved, carried, ids: used, definitions };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async function _rapierSharedSourceCarrier(context, root) {
|
|
400
|
+
const source = (context.metadata.bom ? '\uFEFF' : '') + context.canonical;
|
|
401
|
+
const forms = _rapierSharedSourceForms(source, root, context.imageSubstitutions);
|
|
402
|
+
const esc = escapeRapierHtmlText;
|
|
403
|
+
return '<script type="' + _RAPIER_SHARED_SOURCE_TYPE + '" data-filename="' + esc(context.metadata.filename) +
|
|
404
|
+
'" data-kind="' + esc(context.metadata.docKind) + '" data-sha256="' + await _rapierSharedSourceHash(forms.resolved) +
|
|
405
|
+
'"' + (forms.ids.length ? ' data-images="' + forms.ids.join(' ') + '"' : '') +
|
|
406
|
+
(forms.definitions.length ? ' data-image-definitions="' + forms.definitions.map(encodeURIComponent).join(' ') + '"' : '') + '>\n' +
|
|
407
|
+
forms.carried + '\n</script>\n';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function _rapierSharedResolve(text, ids, images, definitions) {
|
|
411
|
+
if (!ids.length) return { text, broken: false };
|
|
412
|
+
const id = '(' + ids.map(value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')';
|
|
413
|
+
let broken = false;
|
|
414
|
+
const src = value => {
|
|
415
|
+
const found = images.get(value);
|
|
416
|
+
if (typeof found !== 'string' || !/^data:image\/(?:png|jpeg|webp|svg\+xml|jxl);base64,[A-Za-z0-9+/]+={0,2}$/i.test(found)) { broken = true; return null; }
|
|
417
|
+
return found;
|
|
418
|
+
};
|
|
419
|
+
// Authored # characters were encoded. A raw id after ]( can only be a structural
|
|
420
|
+
// substitution; matching its destination lets nested alt brackets survive unchanged.
|
|
421
|
+
const inline = new RegExp('(\\]\\([ \\t\\r\\n]*(?:<)?)#' + id + '(?=>?[ \\t\\r\\n]*\\)|>?[ \\t\\r\\n]+["\'(])', 'g');
|
|
422
|
+
const definition = new RegExp('(^ {0,3}\\[((?:\\\\.|[^\\]\\\\])+)\\]:[ \\t]*(?:(?:\\r\\n|\\r|\\n)[ \\t]*)?(?:<)?)#' + id + '(?=>?[ \\t]*$|>?[ \\t]+["\'(])', 'gm');
|
|
423
|
+
const swapInline = (match, lead, value) => { const found = src(value); return found == null ? match : lead + found; };
|
|
424
|
+
const swapDefinition = (match, lead, label, value) => {
|
|
425
|
+
if (!definitions.has(md.utils.normalizeReference(_rapierSharedDecode(label)))) return match;
|
|
426
|
+
const found = src(value);
|
|
427
|
+
return found == null ? match : lead + found;
|
|
428
|
+
};
|
|
429
|
+
return { text: text.replace(inline, swapInline).replace(definition, swapDefinition), broken };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function _rapierReadSharedDocument(text, filename) {
|
|
433
|
+
const html = String(text);
|
|
434
|
+
if (!/\.html?$/i.test(filename || '') || !html.includes('type="' + _RAPIER_SHARED_SOURCE_TYPE + '"')) return null;
|
|
435
|
+
// Template contents remain inert, including pictures and scripts in ordinary HTML.
|
|
436
|
+
const template = document.createElement('template');
|
|
437
|
+
template.innerHTML = html;
|
|
438
|
+
const invalid = () => { throw new Error('The editable source in this web page is incomplete or changed'); };
|
|
439
|
+
const carriers = template.content.querySelectorAll('script[type="' + _RAPIER_SHARED_SOURCE_TYPE + '"]');
|
|
440
|
+
if (carriers.length !== 1) return invalid();
|
|
441
|
+
const carrier = carriers[0];
|
|
442
|
+
const name = carrier.getAttribute('data-filename') || '', kind = carrier.getAttribute('data-kind'), sha256 = carrier.getAttribute('data-sha256') || '';
|
|
443
|
+
const ids = (carrier.getAttribute('data-images') || '').split(/\s+/).filter(Boolean);
|
|
444
|
+
const definitionTokens = (carrier.getAttribute('data-image-definitions') || '').split(/\s+/).filter(Boolean);
|
|
445
|
+
if (!_rapierDocumentNameIsAdmissible(name) || !['markdown', 'text', 'code'].includes(kind) || !/^[0-9a-f]{64}$/.test(sha256) ||
|
|
446
|
+
ids.some(id => !_RAPIER_SHARED_ID.test(id)) || definitionTokens.some(token => !_RAPIER_SHARED_DEFINITION.test(token))) return invalid();
|
|
447
|
+
// Only a definition the page declares is an image definition.
|
|
448
|
+
let definitions;
|
|
449
|
+
try { definitions = new Set(definitionTokens.map(decodeURIComponent)); } catch (_) { return invalid(); }
|
|
450
|
+
let source = carrier.textContent;
|
|
451
|
+
if (source.startsWith('\n')) source = source.slice(1);
|
|
452
|
+
if (source.endsWith('\n')) source = source.slice(0, -1);
|
|
453
|
+
source = source.replace(/ /g, '\r');
|
|
454
|
+
// A duplicate id (two <img id="…">) is ambiguous, so it is recorded as null rather than either
|
|
455
|
+
// src -- ties into _rapierSharedResolve's `broken` check the same way a missing image does.
|
|
456
|
+
const images = new Map();
|
|
457
|
+
for (const element of template.content.querySelectorAll('img[id]')) images.set(element.id, images.has(element.id) ? null : element.getAttribute('src'));
|
|
458
|
+
const resolved = _rapierSharedResolve(source, ids, images, definitions);
|
|
459
|
+
source = _rapierSharedDecode(resolved.text);
|
|
460
|
+
if (resolved.broken || source.length > RapierTextCodec.maxDocumentBytes || await _rapierSharedSourceHash(source) !== sha256) return invalid();
|
|
461
|
+
const bom = source.charCodeAt(0) === 0xFEFF;
|
|
462
|
+
if (RapierTextCodec.normalizeDocument(source) !== (bom ? source.slice(1) : source)) return invalid();
|
|
463
|
+
return {source, filename: name, kind, bom};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function _rapierSharedPageFallbackCss() {
|
|
467
|
+
return `
|
|
468
|
+
.rapier-page{display:flow-root}
|
|
469
|
+
.rapier-page>:first-child{margin-top:0}
|
|
470
|
+
.md-render h1{display:flow-root}
|
|
471
|
+
.rapier-page>:is(pre,table,hr,figure,.table-scroll-wrap,.math-display-wrap){clear:both}
|
|
472
|
+
`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function _rapierShareWrapKind(element) {
|
|
476
|
+
if (globalThis.RapierMarkdownLayout.wrapTextBlock(element)) return 'prose';
|
|
477
|
+
const image = element.tagName === 'P' ? element.querySelector('img') : null;
|
|
478
|
+
if (image && globalThis.RapierImageLayout.imageOnly(element, image)) return 'picture';
|
|
479
|
+
// An empty paragraph is transparent to the owner search (R75), as in the editor and the export.
|
|
480
|
+
if (element.tagName === 'P' && !element.textContent.trim() && !image) return 'metadata';
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function _rapierSharePictureParagraph(image) {
|
|
485
|
+
const occurrence = image.parentElement.tagName === 'A' ? image.parentElement : image;
|
|
486
|
+
if (occurrence !== image && (occurrence.children.length !== 1 || occurrence.textContent.trim())) return null;
|
|
487
|
+
const paragraph = occurrence.parentElement;
|
|
488
|
+
if (!paragraph || paragraph.tagName !== 'P' || paragraph.children.length !== 1 || paragraph.textContent.trim()) return null;
|
|
489
|
+
return paragraph;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function _rapierSharedPageLayout(root) {
|
|
493
|
+
// Owner search (wrapNeighbour) reads the sibling chain live, and applying one assignment
|
|
494
|
+
// splices the tree -- moving the owner into a new container moves it out of the very chain
|
|
495
|
+
// a later picture's own search would walk, and a barrier-free skip past an earlier picture
|
|
496
|
+
// can land on that new container instead (it is not a <p>, so it reads as a barrier). So
|
|
497
|
+
// every owner is resolved first, against the one original, unspliced tree, in a pass that
|
|
498
|
+
// only reads; only once all of them are known does a second pass apply any of them. Same
|
|
499
|
+
// two-phase shape as layout/interchange.js's reflow(), which resolves every picture's owner
|
|
500
|
+
// from one upfront `[...root.children]` before it changes anything about any of them.
|
|
501
|
+
const assignments = [];
|
|
502
|
+
for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
|
|
503
|
+
const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
|
|
504
|
+
if (layout?.wrap !== 'around' && layout?.wrap !== 'box') continue;
|
|
505
|
+
const paragraph = _rapierSharePictureParagraph(image);
|
|
506
|
+
if (!paragraph) continue;
|
|
507
|
+
// Prefer following text, then preceding text, looking past pictures and metadata.
|
|
508
|
+
const owner = globalThis.RapierMarkdownLayout.wrapNeighbour(paragraph, _rapierShareWrapKind);
|
|
509
|
+
if (owner) assignments.push({image, paragraph, layout, owner});
|
|
510
|
+
}
|
|
511
|
+
// A float affects every line box after it in the same flow, which is the wrap law: prose and
|
|
512
|
+
// headings carry on beside the picture across a heading or section boundary as though the blocks
|
|
513
|
+
// between were not there. So the picture paragraph is placed right before its owner's text (a
|
|
514
|
+
// float only reaches what follows it) and left in the page's own flow, uncontained; the blocks
|
|
515
|
+
// that never wrap -- tables, code, figures and rules -- clear it through the page
|
|
516
|
+
// stylesheet instead, and a bordered h1 forms its own block so its rule does not run under the
|
|
517
|
+
// picture. Static CSS, no script: the no-script fallback only -- a reader with scripting off
|
|
518
|
+
// still gets a sensible nearest-side float; the inlined planner (same owner as standalone)
|
|
519
|
+
// replaces it when scripts run.
|
|
520
|
+
for (const {image, paragraph, layout, owner} of assignments) {
|
|
521
|
+
const side = (layout.x ?? 0) < 50 ? 'left' : 'right';
|
|
522
|
+
paragraph.style.cssText = 'float:' + side + ';max-width:100%;margin:0 ' + (side === 'left' ? '1rem 1rem 0' : '0 1rem 1rem');
|
|
523
|
+
if (layout.y > 0) {
|
|
524
|
+
paragraph.style.marginTop = layout.y + 'em';
|
|
525
|
+
paragraph.style.setProperty('shape-outside', 'inset(' + layout.y + 'em 0 0)');
|
|
526
|
+
}
|
|
527
|
+
image.style.margin = '0';
|
|
528
|
+
// Astra-R75 X03: the reserved box comes from the same rotated-bounds geometry every other
|
|
529
|
+
// consumer uses (layout/browser.js, layout/interchange.js's `geometry.rotatedBoundsRad`,
|
|
530
|
+
// layout/model.mjs's one owner), expressed as a percentage of the column the way the
|
|
531
|
+
// unrotated branch below already is -- never the picture's own pixel dimensions, which
|
|
532
|
+
// have no relation to a requested percentage width (a 4000px-wide source at width=40%
|
|
533
|
+
// used to float 1788px wide in a 600px column). Intrinsic width/height supply only the
|
|
534
|
+
// aspect ratio; a percentage padding-top/bottom resolves against the same containing-block
|
|
535
|
+
// width CSS already resolves the float's own percentage width against, so one basis serves
|
|
536
|
+
// both the box and its vertical reservation, and the box stays exactly as wide as the
|
|
537
|
+
// column at every viewport width. The turned box is centred, horizontally and vertically,
|
|
538
|
+
// in the reserved (wider, taller) box rather than left-flush inside it, so the space this
|
|
539
|
+
// paragraph clears for it (the float's own width) is evenly split around the picture on
|
|
540
|
+
// every side, not just above and below. A missing `width` falls back to the picture's own
|
|
541
|
+
// natural pixel size in pixels, the same fallback the unrotated branch below uses; without
|
|
542
|
+
// either width/height attribute the picture still turns, just without the extra
|
|
543
|
+
// reservation (the same graceful narrowing Share already accepts elsewhere).
|
|
544
|
+
const naturalWidth = Number(image.getAttribute('width')) || 0, naturalHeight = Number(image.getAttribute('height')) || 0;
|
|
545
|
+
if (layout.rotate && naturalWidth > 0 && naturalHeight > 0) {
|
|
546
|
+
const rad = layout.rotate * Math.PI / 180, aspect = naturalHeight / naturalWidth;
|
|
547
|
+
// A steep turn (e.g. 90deg on a landscape source) can need the image WIDER than its own
|
|
548
|
+
// reserved box's width -- the rotated bounding box is narrower than the picture's own
|
|
549
|
+
// unrotated width whenever the turn swaps a wide dimension for a narrow one. Share's own
|
|
550
|
+
// global `img{max-width:100%}` (a sensible default everywhere else) would silently clamp
|
|
551
|
+
// exactly that case, shrinking the image and, with it, its auto height -- `max-width:none`
|
|
552
|
+
// here overrides that single rule with a more specific one, only for a picture this pass
|
|
553
|
+
// already sizes deliberately.
|
|
554
|
+
image.style.maxWidth = 'none';
|
|
555
|
+
if (layout.width != null) {
|
|
556
|
+
let width = layout.width, height = width * aspect;
|
|
557
|
+
let bounds = globalThis.RapierImageLayout.rotatedBoundsRad(width, height, rad);
|
|
558
|
+
if (bounds.width > 100) {
|
|
559
|
+
const scale = 100 / bounds.width;
|
|
560
|
+
width *= scale;
|
|
561
|
+
height *= scale;
|
|
562
|
+
bounds = globalThis.RapierImageLayout.rotatedBoundsRad(width, height, rad);
|
|
563
|
+
}
|
|
564
|
+
paragraph.style.width = bounds.width + '%';
|
|
565
|
+
paragraph.style.paddingTop = ((bounds.height - height) / 2) + '%';
|
|
566
|
+
paragraph.style.paddingBottom = ((bounds.height - height) / 2) + '%';
|
|
567
|
+
image.style.width = (width / bounds.width * 100) + '%';
|
|
568
|
+
image.style.marginLeft = image.style.marginRight = (((bounds.width - width) / 2) / bounds.width * 100) + '%';
|
|
569
|
+
} else {
|
|
570
|
+
const bounds = globalThis.RapierImageLayout.rotatedBoundsRad(naturalWidth, naturalHeight, rad);
|
|
571
|
+
paragraph.style.width = bounds.width + 'px';
|
|
572
|
+
paragraph.style.paddingTop = ((bounds.height - naturalHeight) / 2) + 'px';
|
|
573
|
+
paragraph.style.paddingBottom = ((bounds.height - naturalHeight) / 2) + 'px';
|
|
574
|
+
image.style.width = naturalWidth + 'px';
|
|
575
|
+
image.style.marginLeft = image.style.marginRight = ((bounds.width - naturalWidth) / 2) + 'px';
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
if (layout.width != null) paragraph.style.width = layout.width + '%';
|
|
579
|
+
else if (image.getAttribute('width')) paragraph.style.width = image.getAttribute('width') + 'px';
|
|
580
|
+
if (layout.width != null || image.getAttribute('width')) image.style.width = '100%';
|
|
581
|
+
}
|
|
582
|
+
if (layout.rotate) { image.style.transform = 'rotate(' + layout.rotate + 'deg)'; image.style.transformOrigin = '50% 50%'; }
|
|
583
|
+
// Astra-R75 X04: `wrap=around` wraps to the picture's own alpha silhouette the way
|
|
584
|
+
// standard-adoption.md's rung-2 paragraph already promises -- `shape-outside:url(<the same
|
|
585
|
+
// picture>)`, which every engine that reads shape-outside re-samples from the referenced
|
|
586
|
+
// image's own pixels, no script required. A CSS transform does not turn the shape a
|
|
587
|
+
// `shape-outside` url() samples (it stays the picture's unturned alpha), so a rotated
|
|
588
|
+
// `around` picture keeps the plain rectangular reservation above instead -- an honest
|
|
589
|
+
// conservative box rather than a silhouette shaped for the wrong angle. `layout.y` already
|
|
590
|
+
// owns `shape-outside` for its own inset reservation (below); the two are not combinable
|
|
591
|
+
// (a float has exactly one `shape-outside` value), so a `y`-offset `around` picture keeps
|
|
592
|
+
// that inset instead of silhouette wrapping -- a documented, narrow degradation.
|
|
593
|
+
if (layout.wrap === 'around' && !layout.rotate && !layout.y) {
|
|
594
|
+
const src = image.getAttribute('src');
|
|
595
|
+
if (src) {
|
|
596
|
+
paragraph.style.setProperty('shape-outside', 'url("' + src + '")');
|
|
597
|
+
// Standard 10% alpha cutoff (`layout/model.mjs` alphaThreshold = .1).
|
|
598
|
+
paragraph.style.setProperty('shape-image-threshold', '0.1');
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
paragraph.classList.add('rapier-wrap-picture');
|
|
602
|
+
owner.before(paragraph);
|
|
603
|
+
}
|
|
604
|
+
// Astra-R75 X04: `behind`/`front` position the picture exactly where `x`/`y`/`width` say, out
|
|
605
|
+
// of the words' flow entirely (z-index below or above them) -- the absolutely positioned
|
|
606
|
+
// picture standard-adoption.md's rung-2 paragraph already promises for these two placements,
|
|
607
|
+
// no obstacle avoidance, no script. The picture's own paragraph collapses to zero height in
|
|
608
|
+
// place (the same collapse-in-place anchor layout/interchange.js's own reflow uses for every
|
|
609
|
+
// positioned kind) so removing the picture from flow costs the surrounding text nothing.
|
|
610
|
+
const positionedAssignments = [];
|
|
611
|
+
for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
|
|
612
|
+
const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
|
|
613
|
+
if (layout?.wrap !== 'behind' && layout?.wrap !== 'front') continue;
|
|
614
|
+
const paragraph = _rapierSharePictureParagraph(image);
|
|
615
|
+
if (paragraph) positionedAssignments.push({image, paragraph, layout});
|
|
616
|
+
}
|
|
617
|
+
for (const {image, paragraph, layout} of positionedAssignments) {
|
|
618
|
+
paragraph.style.cssText = 'position:relative;height:0;margin:0;padding:0;line-height:0';
|
|
619
|
+
const x = layout.x ?? 50, naturalWidth = Number(image.getAttribute('width')) || 0;
|
|
620
|
+
if (layout.width != null) {
|
|
621
|
+
image.style.width = layout.width + '%';
|
|
622
|
+
image.style.left = (x - layout.width / 2) + '%';
|
|
623
|
+
} else if (naturalWidth > 0) {
|
|
624
|
+
image.style.width = naturalWidth + 'px';
|
|
625
|
+
image.style.left = 'calc(' + x + '% - ' + (naturalWidth / 2) + 'px)';
|
|
626
|
+
} else {
|
|
627
|
+
image.style.left = x + '%';
|
|
628
|
+
}
|
|
629
|
+
image.style.position = 'absolute';
|
|
630
|
+
image.style.top = (layout.y || 0) + 'em';
|
|
631
|
+
image.style.margin = '0';
|
|
632
|
+
if (layout.rotate) { image.style.transform = 'rotate(' + layout.rotate + 'deg)'; image.style.transformOrigin = '50% 50%'; }
|
|
633
|
+
// Same rule as the live editor and the styled export: `behind` sinks under the owner's
|
|
634
|
+
// own in-flow, non-positioned text; `front` keeps the default stack, already above it.
|
|
635
|
+
if (layout.wrap === 'behind') image.style.zIndex = '-1';
|
|
636
|
+
paragraph.classList.add('rapier-positioned-picture');
|
|
637
|
+
}
|
|
638
|
+
// F75-11: a turned picture Share does not otherwise position (inline, which this static page
|
|
639
|
+
// does not float at all) still turns -- the same picture, painted in place, never upright
|
|
640
|
+
// just because Share's own float model has nothing else to say about it.
|
|
641
|
+
const positioned = new Set([...assignments, ...positionedAssignments].map(row => row.image));
|
|
642
|
+
for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
|
|
643
|
+
if (positioned.has(image)) continue;
|
|
644
|
+
const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
|
|
645
|
+
if (!layout?.rotate) continue;
|
|
646
|
+
image.style.transform = 'rotate(' + layout.rotate + 'deg)';
|
|
647
|
+
image.style.transformOrigin = '50% 50%';
|
|
648
|
+
const naturalWidth = Number(image.getAttribute('width')) || 0, naturalHeight = Number(image.getAttribute('height')) || 0;
|
|
649
|
+
if (naturalWidth > 0 && naturalHeight > 0) {
|
|
650
|
+
const rad = layout.rotate * Math.PI / 180;
|
|
651
|
+
const boundWidth = naturalWidth * Math.abs(Math.cos(rad)) + naturalHeight * Math.abs(Math.sin(rad));
|
|
652
|
+
const boundHeight = naturalWidth * Math.abs(Math.sin(rad)) + naturalHeight * Math.abs(Math.cos(rad));
|
|
653
|
+
image.style.display = 'block';
|
|
654
|
+
image.style.marginLeft = image.style.marginRight = ((boundWidth - naturalWidth) / 2) + 'px';
|
|
655
|
+
image.style.marginTop = image.style.marginBottom = ((boundHeight - naturalHeight) / 2) + 'px';
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function _rapierShareCapLongCodeBlocks(root) {
|
|
661
|
+
root.querySelectorAll('pre').forEach(pre => {
|
|
662
|
+
const code = pre.querySelector(':scope > code') || pre;
|
|
663
|
+
const text = String(code.textContent || '').replace(/\n$/, '');
|
|
664
|
+
const lineCount = text === '' ? 0 : text.split('\n').length;
|
|
665
|
+
if (lineCount <= RAPIER_SHARE_LONG_CODE_LINES) return;
|
|
666
|
+
pre.setAttribute('data-rapier-code-lines', String(lineCount));
|
|
667
|
+
const note = document.createElement('span');
|
|
668
|
+
note.className = 'rapier-code-lines-note';
|
|
669
|
+
note.setAttribute('contenteditable', 'false');
|
|
670
|
+
note.textContent = lineCount + ' lines';
|
|
671
|
+
pre.appendChild(note);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function _rapierRequireOfflinePageImages(root) {
|
|
676
|
+
const missing = [];
|
|
677
|
+
for (const [index, image] of [...root.querySelectorAll('img,[data-rapier-remote-src]')].entries()) {
|
|
678
|
+
const src = image.getAttribute('data-rapier-remote-src') ?? image.getAttribute('src') ?? '';
|
|
679
|
+
if (globalThis.RapierImageAssets.dataImage(src)) continue;
|
|
680
|
+
const alt = image.getAttribute('data-rapier-remote-alt') ?? image.getAttribute('alt') ?? '';
|
|
681
|
+
const destination = /^data:/i.test(src) ? '(unsupported embedded image)' : src || '(missing image source)';
|
|
682
|
+
missing.push('Picture ' + (index + 1) + (alt ? ' — ' + alt : '') + ': ' + destination);
|
|
683
|
+
}
|
|
684
|
+
if (!missing.length) return;
|
|
685
|
+
// An acknowledged, scrollable sheet, not a disappearing toast or a partial-file opt-in.
|
|
686
|
+
// Both Close and Cancel below refuse; neither can authorize a page with missing pictures.
|
|
687
|
+
await rapierConfirm({
|
|
688
|
+
title: 'pictures not included',
|
|
689
|
+
message: 'No web page was written. Embed these pictures before exporting or sharing an offline page. '
|
|
690
|
+
+ 'Your document is unchanged. ' + missing.join('; '),
|
|
691
|
+
confirmLabel: 'close',
|
|
692
|
+
});
|
|
693
|
+
throw new Error('Web page not written; embed the listed pictures and try again');
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function _rapierBuildSharedPage(captured) {
|
|
697
|
+
const context = await _rapierPrepareInterchangeContext({kind: 'share'}, captured);
|
|
698
|
+
// One owner for every HTML file Rapier writes, including offline-image admission and CSP.
|
|
699
|
+
// Share adds only the nearest-side no-script float and the long-code treatment.
|
|
700
|
+
const artifact = await _rapierBuildArtifact({
|
|
701
|
+
kind: 'standalone',
|
|
702
|
+
extraCss: _rapierSharedPageFallbackCss(),
|
|
703
|
+
afterRoot(root) {
|
|
704
|
+
/* Unlike TXT/DOCX (which route through _rapierProjectPortableRoot and have no
|
|
705
|
+
native disclosure widget to fall back on), the shared page is a real HTML
|
|
706
|
+
document a real browser renders — the same styled root the standalone HTML
|
|
707
|
+
export already leaves untouched (_rapierBuildArtifact never flattens
|
|
708
|
+
details for kind:'standalone'). <details>/<summary> pass the 'export'
|
|
709
|
+
sanitize profile unchanged (only <form> is forbidden), so flattening here
|
|
710
|
+
only threw away a native, JS-free expand/collapse the architecture's own
|
|
711
|
+
fidelity table promises ("Complete shared/styled HTML: Exact recoverable
|
|
712
|
+
source"). Leave it as authored, exactly like standalone HTML export. */
|
|
713
|
+
_rapierSharedPageLayout(root);
|
|
714
|
+
_rapierShareCapLongCodeBlocks(root);
|
|
715
|
+
},
|
|
716
|
+
}, context);
|
|
717
|
+
// No cap and no warning (docs/intent.md, picture format law): the page is as large as the
|
|
718
|
+
// document is. A page over the editor's own 25 MiB open limit still opens in any browser.
|
|
719
|
+
const blob = new Blob([artifact.html], {type: 'text/html;charset=utf-8'});
|
|
720
|
+
return {blob, filename: context.baseName + '.html'};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const _RAPIER_SHARED_SOURCE_TYPE = 'text/markdown';
|
|
724
|
+
|
|
725
|
+
const _RAPIER_SHARED_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/;
|
|
726
|
+
|
|
727
|
+
const _RAPIER_SHARED_DEFINITION = /^(?:[A-Za-z0-9\-_.!~*'()]|%[0-9A-Fa-f]{2})+$/;
|
|
728
|
+
|
|
729
|
+
const _rapierSharedEncode = text => text.replace(/&/g, '&').replace(/</g, '<').replace(/#/g, '#').replace(/\r/g, ' ');
|
|
730
|
+
|
|
731
|
+
const _rapierSharedDecode = text => text.replace(/#/g, '#').replace(/ /g, '\r').replace(/</g, '<').replace(/&/g, '&');
|
|
732
|
+
|
|
733
|
+
const RAPIER_SHARE_LONG_CODE_LINES = 40;
|
|
734
|
+
|
|
735
|
+
function _rapierImageSources(tokens) {
|
|
736
|
+
const sources = new Set();
|
|
737
|
+
const visit = tokens => {
|
|
738
|
+
for (const token of tokens || []) {
|
|
739
|
+
if (token.type === 'image') sources.add(token.attrGet('src') || '');
|
|
740
|
+
else if (token.children) visit(token.children);
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
visit(tokens);
|
|
744
|
+
return sources;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function _rapierImageDestinations(source, accepts) {
|
|
748
|
+
const env = {}, tokens = md.parse(source, env), offsets = _rapierLineStartOffsets(source);
|
|
749
|
+
const hrefs = _rapierImageSources(tokens), labels = new Set(), destinations = [], seen = new Set();
|
|
750
|
+
const visit = rows => { for (const token of rows || []) {
|
|
751
|
+
if (token.type === 'image' && token.meta?.mdImage?.reference) labels.add(token.meta.mdImage.reference);
|
|
752
|
+
else if (token.children) visit(token.children);
|
|
753
|
+
}};
|
|
754
|
+
visit(tokens);
|
|
755
|
+
const destinationAt = (start, stop, expected = null, extra = {}) => {
|
|
756
|
+
const parsed = md.helpers.parseLinkDestination(source, start, stop);
|
|
757
|
+
if (!parsed?.ok || parsed.pos <= start || parsed.pos > stop) return false;
|
|
758
|
+
const destination = md.normalizeLink(parsed.str);
|
|
759
|
+
if (expected !== null && destination !== expected || !hrefs.has(destination) || !accepts(destination)) return false;
|
|
760
|
+
const outerStart = start;
|
|
761
|
+
let end = parsed.pos, outerEnd = end, delimited = false;
|
|
762
|
+
if (source[start] === '<' && source[end - 1] === '>') { start++; end--; delimited = true; }
|
|
763
|
+
const key = start + ':' + end;
|
|
764
|
+
if (!seen.has(key)) { seen.add(key); destinations.push({start, end, outerStart, outerEnd, delimited, destination, source: source.slice(start, end), ...extra}); }
|
|
765
|
+
return true;
|
|
766
|
+
};
|
|
767
|
+
for (const image of _rapierScanMarkdownImages(source, tokens)) {
|
|
768
|
+
if (image.kind !== 'inline') continue;
|
|
769
|
+
let start = image.innerStart;
|
|
770
|
+
while (start < image.innerEnd && /[ \t\r\n]/.test(source[start])) start++;
|
|
771
|
+
destinationAt(start, image.innerEnd, null, {image});
|
|
772
|
+
}
|
|
773
|
+
const definitions = new Set();
|
|
774
|
+
for (const token of tokens) {
|
|
775
|
+
if (token.type !== 'reference_definition' || !token.map) continue;
|
|
776
|
+
const label = token.meta?.label;
|
|
777
|
+
if (!label || definitions.has(label)) continue;
|
|
778
|
+
definitions.add(label);
|
|
779
|
+
const destination = env.references?.[label]?.href;
|
|
780
|
+
if (!labels.has(label) || !hrefs.has(destination) || !accepts(destination)) continue;
|
|
781
|
+
const span = _rapierSourceLineSpan(source, offsets, token.map[0], token.map[1]);
|
|
782
|
+
let found = false;
|
|
783
|
+
for (let open = source.indexOf('[', span.start); open >= 0 && open < span.end; open = source.indexOf('[', open + 1)) {
|
|
784
|
+
if (_rapierSourceCharEscaped(source, open)) continue;
|
|
785
|
+
let close = open + 1;
|
|
786
|
+
while (close < span.end && (source[close] !== ']' || _rapierSourceCharEscaped(source, close))) close++;
|
|
787
|
+
if (source[close + 1] !== ':') continue;
|
|
788
|
+
const rawLabel = source.slice(open + 1, close);
|
|
789
|
+
const normalized = md.utils.normalizeReference(rawLabel);
|
|
790
|
+
const unquoted = md.utils.normalizeReference(rawLabel.replace(/\r?\n[ \t]*(?:>[ \t]*)+/g, ' '));
|
|
791
|
+
if (normalized !== label && unquoted !== label) continue;
|
|
792
|
+
let start = close + 2;
|
|
793
|
+
while (start < span.end) {
|
|
794
|
+
if (/[ \t\r\n]/.test(source[start])) start++;
|
|
795
|
+
else if (token.level > 0 && source[start] === '>') start++;
|
|
796
|
+
else break;
|
|
797
|
+
}
|
|
798
|
+
if (destinationAt(start, span.end, destination, {reference: label, title: env.references[label].title || ''})) { found = true; break; }
|
|
799
|
+
}
|
|
800
|
+
if (!found) throw new Error('An image reference destination cannot be changed without altering its source');
|
|
801
|
+
}
|
|
802
|
+
return destinations.sort((left, right) => left.start - right.start);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function _rapierScanMarkdownImages(raw, parsedTokens = null) {
|
|
806
|
+
const source = String(raw || '');
|
|
807
|
+
const found = [];
|
|
808
|
+
|
|
809
|
+
if (source.indexOf('![') < 0) return found;
|
|
810
|
+
const max = source.length;
|
|
811
|
+
const starts = _rapierLineStartOffsets(source);
|
|
812
|
+
const tokens = parsedTokens || (md ? md.parse(source, _rapierMarkdownEnvironment()) : []);
|
|
813
|
+
const rendered = [];
|
|
814
|
+
const visit = rows => { for (const token of rows || []) {
|
|
815
|
+
if (token.type === 'image') rendered.push(token);
|
|
816
|
+
else if (token.children) visit(token.children);
|
|
817
|
+
} };
|
|
818
|
+
visit(tokens);
|
|
819
|
+
const protectedRanges = tokens.filter(token => token.map &&
|
|
820
|
+
['fence','code_block','html_block'].includes(token.type)).map(token =>
|
|
821
|
+
({start:starts[token.map[0]],end:starts[token.map[1]] ?? max}));
|
|
822
|
+
let protectedIndex = 0;
|
|
823
|
+
for (let start = 0; start < max - 3; start++) {
|
|
824
|
+
while (protectedRanges[protectedIndex]?.end <= start) protectedIndex++;
|
|
825
|
+
const protectedRange = protectedRanges[protectedIndex];
|
|
826
|
+
if (protectedRange && start >= protectedRange.start) { start = protectedRange.end-1; continue; }
|
|
827
|
+
if (source[start] === '`' && !_rapierSourceCharEscaped(source,start)) {
|
|
828
|
+
let width = 1;
|
|
829
|
+
while (source[start+width] === '`') width++;
|
|
830
|
+
let at = start+width, close = -1;
|
|
831
|
+
while ((at = source.indexOf('`',at)) >= 0) {
|
|
832
|
+
let count = 1; while (source[at+count] === '`') count++;
|
|
833
|
+
if (count === width) { close = at+count; break; }
|
|
834
|
+
at += count;
|
|
835
|
+
}
|
|
836
|
+
if (close >= 0) { start = close-1; continue; }
|
|
837
|
+
start += width-1; continue;
|
|
838
|
+
}
|
|
839
|
+
if (source[start] === '<') {
|
|
840
|
+
const html = /^(?:<!--[\s\S]*?-->|<\/?[A-Za-z](?:"[^"]*"|'[^']*'|[^'"<>])*?>)/.exec(source.slice(start));
|
|
841
|
+
if (html) { start += html[0].length-1; continue; }
|
|
842
|
+
}
|
|
843
|
+
if (source.charCodeAt(start) !== 0x21 || source.charCodeAt(start + 1) !== 0x5b ||
|
|
844
|
+
_rapierSourceCharEscaped(source, start)) continue;
|
|
845
|
+
let cursor = start + 2;
|
|
846
|
+
let bracketDepth = 1;
|
|
847
|
+
for (; cursor < max; cursor++) {
|
|
848
|
+
if (_rapierSourceCharEscaped(source, cursor)) continue;
|
|
849
|
+
const code = source.charCodeAt(cursor);
|
|
850
|
+
if (code === 0x5b) bracketDepth++;
|
|
851
|
+
else if (code === 0x5d && --bracketDepth === 0) break;
|
|
852
|
+
}
|
|
853
|
+
if (bracketDepth !== 0) continue;
|
|
854
|
+
const altStart = start + 2;
|
|
855
|
+
const altEnd = cursor;
|
|
856
|
+
const next = source.charAt(cursor + 1);
|
|
857
|
+
if (next === '(') {
|
|
858
|
+
const parenStart = cursor + 1;
|
|
859
|
+
cursor = parenStart + 1;
|
|
860
|
+
let parenDepth = 1;
|
|
861
|
+
let quote = '';
|
|
862
|
+
let angle = false;
|
|
863
|
+
for (; cursor < max; cursor++) {
|
|
864
|
+
if (_rapierSourceCharEscaped(source, cursor)) continue;
|
|
865
|
+
const char = source.charAt(cursor);
|
|
866
|
+
if (angle) {
|
|
867
|
+
if (char === '>') angle = false;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (quote) {
|
|
871
|
+
if (char === quote) quote = '';
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (char === '<' && parenDepth === 1) { angle = true; continue; }
|
|
875
|
+
if ((char === '"' || char === "'") && parenDepth === 1) { quote = char; continue; }
|
|
876
|
+
if (char === '(') { parenDepth++; continue; }
|
|
877
|
+
if (char === ')' && --parenDepth === 0) break;
|
|
878
|
+
}
|
|
879
|
+
if (parenDepth !== 0) continue;
|
|
880
|
+
const end = cursor + 1;
|
|
881
|
+
const innerStart = parenStart + 1;
|
|
882
|
+
const innerEnd = cursor;
|
|
883
|
+
const destination = _rapierParseMarkdownImageDestination(source.slice(innerStart, innerEnd));
|
|
884
|
+
found.push({
|
|
885
|
+
kind: 'inline', start, end, altStart, altEnd, parenStart, innerStart, innerEnd,
|
|
886
|
+
altSource: source.slice(altStart, altEnd),
|
|
887
|
+
destinationSource: destination.destination,
|
|
888
|
+
titleSource: destination.title,
|
|
889
|
+
referenceSource: '',
|
|
890
|
+
source: source.slice(start, end),
|
|
891
|
+
});
|
|
892
|
+
start = end - 1;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (next === '[') {
|
|
896
|
+
let refEnd = cursor + 2;
|
|
897
|
+
for (; refEnd < max; refEnd++) {
|
|
898
|
+
if (_rapierSourceCharEscaped(source, refEnd)) continue;
|
|
899
|
+
if (source.charAt(refEnd) === ']') break;
|
|
900
|
+
}
|
|
901
|
+
if (refEnd >= max) continue;
|
|
902
|
+
const end = refEnd + 1;
|
|
903
|
+
found.push({
|
|
904
|
+
kind: 'reference', start, end, altStart, altEnd,
|
|
905
|
+
altSource: source.slice(altStart, altEnd),
|
|
906
|
+
destinationSource: '', titleSource: '',
|
|
907
|
+
referenceSource: source.slice(cursor + 1, end),
|
|
908
|
+
source: source.slice(start, end),
|
|
909
|
+
});
|
|
910
|
+
start = end - 1;
|
|
911
|
+
} else {
|
|
912
|
+
const end = cursor + 1;
|
|
913
|
+
found.push({kind: 'reference', start, end, altStart, altEnd, altSource: source.slice(altStart, altEnd),
|
|
914
|
+
destinationSource: '', titleSource: '', referenceSource: '', source: source.slice(start, end)});
|
|
915
|
+
start = end - 1;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
let consumed = 0;
|
|
919
|
+
const openRunsList = _rapierImageOpenRunsList(tokens);
|
|
920
|
+
const accepted = found.filter(image => {
|
|
921
|
+
const text = image.source.replace(/\r\n?/g, '\n');
|
|
922
|
+
const index = rendered.findIndex((token, at) => at >= consumed && text === token.meta?.mdImage?.source);
|
|
923
|
+
if (index < 0) return false;
|
|
924
|
+
const token = rendered[index]; image.renderIndex = index; consumed = index + 1; image.reference = token.meta.mdImage.reference; image.destination = token.attrGet('src');
|
|
925
|
+
image.title = token.attrGet('title') || '';
|
|
926
|
+
image.openRuns = openRunsList[index] || [];
|
|
927
|
+
return true;
|
|
928
|
+
});
|
|
929
|
+
const targets = source.includes('<!--md-layout:') && md
|
|
930
|
+
? globalThis.RapierMarkdownLayout.layoutTargets(source, md, _rapierMarkdownEnvironment()) : [];
|
|
931
|
+
for (const image of accepted) {
|
|
932
|
+
image.tokenEnd = image.end;
|
|
933
|
+
const target = targets.find(row => row.imageOnly && row.marker && !row.reason &&
|
|
934
|
+
row.start <= image.start && row.end >= image.end &&
|
|
935
|
+
row.marker.start >= image.end && !source.slice(image.end, row.marker.start).trim());
|
|
936
|
+
if (!target) continue;
|
|
937
|
+
image.placement = target.layout;
|
|
938
|
+
image.layoutStart = target.marker.start;
|
|
939
|
+
image.placementSource = source.slice(image.tokenEnd, target.marker.end);
|
|
940
|
+
image.end = target.marker.end;
|
|
941
|
+
image.source = source.slice(image.start, image.end);
|
|
942
|
+
}
|
|
943
|
+
return accepted;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function _rapierParseMarkdownImageDestination(inner) {
|
|
947
|
+
const source = String(inner || '');
|
|
948
|
+
let start = 0;
|
|
949
|
+
while (start < source.length && /\s/.test(source.charAt(start))) start++;
|
|
950
|
+
if (source.charAt(start) === '<') {
|
|
951
|
+
let end = start + 1;
|
|
952
|
+
while (end < source.length && (source.charAt(end) !== '>' || _rapierSourceCharEscaped(source, end))) end++;
|
|
953
|
+
if (end < source.length) {
|
|
954
|
+
return {
|
|
955
|
+
destination: source.slice(start, end + 1),
|
|
956
|
+
title: source.slice(end + 1).trim(),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
let depth = 0;
|
|
961
|
+
let cursor = start;
|
|
962
|
+
for (; cursor < source.length; cursor++) {
|
|
963
|
+
if (_rapierSourceCharEscaped(source, cursor)) continue;
|
|
964
|
+
const char = source.charAt(cursor);
|
|
965
|
+
if (char === '(') depth++;
|
|
966
|
+
else if (char === ')' && depth > 0) depth--;
|
|
967
|
+
else if (/\s/.test(char) && depth === 0) break;
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
destination: source.slice(start, cursor),
|
|
971
|
+
title: source.slice(cursor).trim(),
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function _rapierImageOpenRunsList(tokens) {
|
|
976
|
+
const list = [];
|
|
977
|
+
for (const block of tokens) {
|
|
978
|
+
if (block.type !== 'inline' || !Array.isArray(block.children)) continue;
|
|
979
|
+
const stack = [];
|
|
980
|
+
for (const token of block.children) {
|
|
981
|
+
if (token.type === 'image') { list.push(stack.slice()); continue; }
|
|
982
|
+
if (token.type === 'rapier_color_open') {
|
|
983
|
+
const hex = token.attrGet('data-md-color');
|
|
984
|
+
stack.push({open: _rapierFormatColorOpen(hex), close: RAPIER_COLOR_CLOSE});
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (token.type === 'rapier_color_close') { stack.pop(); continue; }
|
|
988
|
+
if (token.type === 'strong_open' || token.type === 'em_open' || token.type === 's_open') {
|
|
989
|
+
stack.push({open: token.markup, close: token.markup});
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
if (token.type === 'strong_close' || token.type === 'em_close' || token.type === 's_close') stack.pop();
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return list;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function _rapierArtifactFactoryModules(dependencies, group) {
|
|
999
|
+
const paths = dependencies.groups && dependencies.groups[group] ? dependencies.groups[group] : Object.keys(dependencies.factories);
|
|
1000
|
+
return paths.map(path => 'modules[' + JSON.stringify(path) + '] = (' + Function.prototype.toString.call(dependencies.factories[path]) + ')();').join('\n');
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function _rapierProjectArtifactLayout(root, metadata, geometry, pretext) {
|
|
1004
|
+
if (!root || !metadata || !geometry || !pretext) return null;
|
|
1005
|
+
const doc = root.ownerDocument, view = doc.defaultView;
|
|
1006
|
+
const styles = new Map(), originals = new Map(), spacers = [], floats = [], profiles = new WeakMap();
|
|
1007
|
+
const css = node => view.getComputedStyle(node);
|
|
1008
|
+
const box = node => node.getBoundingClientRect();
|
|
1009
|
+
const pixels = value => (Number.isFinite(value) ? Math.round(value * 100) / 100 : 0) + 'px';
|
|
1010
|
+
let scheduled = 0, busy = false, stopped = false, printing = false, lastWidth = -1;
|
|
1011
|
+
|
|
1012
|
+
function style(node, values) {
|
|
1013
|
+
if (!styles.has(node)) styles.set(node, node.getAttribute('style'));
|
|
1014
|
+
for (const [key, value] of Object.entries(values)) node.style.setProperty(key, value);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function restore() {
|
|
1018
|
+
for (const [paragraph, nodes] of originals) paragraph.replaceChildren(...nodes);
|
|
1019
|
+
originals.clear();
|
|
1020
|
+
for (const [node, value] of styles) {
|
|
1021
|
+
if (value === null) node.removeAttribute('style');
|
|
1022
|
+
else node.setAttribute('style', value);
|
|
1023
|
+
}
|
|
1024
|
+
styles.clear();
|
|
1025
|
+
for (const spacer of spacers) spacer.remove();
|
|
1026
|
+
spacers.length = 0;
|
|
1027
|
+
for (const float of floats) float.remove();
|
|
1028
|
+
floats.length = 0;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Plain inline text uses Pretext. Complex blocks keep their original controls through floats.
|
|
1032
|
+
function prepare(paragraph) {
|
|
1033
|
+
if (!/^(P|H[1-6])$/.test(paragraph.tagName) || !paragraph.textContent.trim() || paragraph.textContent.length > 8192 ||
|
|
1034
|
+
paragraph.querySelector('img,svg,math,br,input,button,iframe,canvas,.math-rendered')) return null;
|
|
1035
|
+
const computed = css(paragraph);
|
|
1036
|
+
if (computed.textAlign === 'justify' || computed.writingMode !== 'horizontal-tb' ||
|
|
1037
|
+
computed.textTransform !== 'none' || computed.whiteSpace !== 'normal' || computed.transform !== 'none' ||
|
|
1038
|
+
// Lines start at the padding box's top-left: left, right or top inset is refused.
|
|
1039
|
+
['paddingLeft', 'paddingRight', 'paddingTop', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth']
|
|
1040
|
+
.some(key => parseFloat(computed[key]) > 0)) return null;
|
|
1041
|
+
const runs = [], items = [], walker = doc.createTreeWalker(paragraph, 4);
|
|
1042
|
+
for (let node; (node = walker.nextNode());) {
|
|
1043
|
+
if (runs.length === 192) return null;
|
|
1044
|
+
const parents = [];
|
|
1045
|
+
for (let parent = node.parentElement; parent !== paragraph; parent = parent.parentElement) {
|
|
1046
|
+
if (!parent || !/^(A|SPAN|EM|I|STRONG|B|DEL|S|MARK|CODE|U|INS|ABBR|SUP|SUB)$/.test(parent.tagName)) return null;
|
|
1047
|
+
parents.unshift(parent);
|
|
1048
|
+
}
|
|
1049
|
+
const computedRun = css(node.parentElement);
|
|
1050
|
+
if (computedRun.textTransform !== 'none' || computedRun.writingMode !== 'horizontal-tb') return null;
|
|
1051
|
+
const font = `${computedRun.fontStyle} ${computedRun.fontWeight} ${computedRun.fontSize} ${computedRun.fontFamily}`;
|
|
1052
|
+
const letterSpacing = parseFloat(computedRun.letterSpacing) || 0;
|
|
1053
|
+
const prepared = geometry.prepareRun(node.data, font, letterSpacing);
|
|
1054
|
+
if (!prepared) return null;
|
|
1055
|
+
// A chip's box is repeated by each piece of it, and it breaks as the browser breaks inline code, as the editor's projection does.
|
|
1056
|
+
const boxed = parents.some(parent => parent.tagName === 'CODE');
|
|
1057
|
+
const extraWidth = boxed ? ['paddingLeft', 'paddingRight', 'borderLeftWidth', 'borderRightWidth', 'marginLeft', 'marginRight']
|
|
1058
|
+
.reduce((width, key) => width + (parseFloat(computedRun[key]) || 0), 0) : 0;
|
|
1059
|
+
runs.push({parents, prepared, directions: parents.map(parent => parent.getAttribute('dir') === 'auto' ? css(parent).direction : null)});
|
|
1060
|
+
items.push({text: prepared.raw, font, letterSpacing, break: 'normal', extraWidth});
|
|
1061
|
+
}
|
|
1062
|
+
if (!runs.length) return null;
|
|
1063
|
+
const fontSize = parseFloat(computed.fontSize) || 16;
|
|
1064
|
+
return {paragraph, runs, flow: pretext.prepareRichInline(items), direction: computed.direction,
|
|
1065
|
+
align: computed.textAlign, fontSize, lineHeight: parseFloat(computed.lineHeight) || fontSize * 1.65,
|
|
1066
|
+
balance: computed.textWrapStyle === 'balance' ? fontSize : 0};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// An id survives on the first fragment only.
|
|
1070
|
+
const idsKept = new WeakSet();
|
|
1071
|
+
function fragmentNode(record, fragment) {
|
|
1072
|
+
const run = record.runs[fragment.itemIndex], mapped = geometry.mapFragment(run?.prepared, fragment);
|
|
1073
|
+
if (!mapped) return null;
|
|
1074
|
+
// 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.
|
|
1075
|
+
const kept = run.parents.length ? mapped.text.replace(/[ \t\n\r\f]+$/, '') : mapped.text;
|
|
1076
|
+
const split = kept && kept.length < mapped.text.length;
|
|
1077
|
+
let child = doc.createTextNode(split ? kept : mapped.text);
|
|
1078
|
+
for (let index = run.parents.length - 1; index >= 0; index--) {
|
|
1079
|
+
const shell = run.parents[index].cloneNode(false);
|
|
1080
|
+
if (shell.id) { if (idsKept.has(run.parents[index])) shell.removeAttribute('id'); else idsKept.add(run.parents[index]); }
|
|
1081
|
+
if (run.directions[index]) shell.setAttribute('dir', run.directions[index]);
|
|
1082
|
+
shell.append(child); child = shell;
|
|
1083
|
+
}
|
|
1084
|
+
if (!split) return child;
|
|
1085
|
+
const pair = doc.createDocumentFragment();
|
|
1086
|
+
pair.append(child, doc.createTextNode(mapped.text.slice(kept.length)));
|
|
1087
|
+
return pair;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function project(record, width, top, obstacles) {
|
|
1091
|
+
const plan = geometry.flowLines(record.flow, width, top, obstacles, record.lineHeight,
|
|
1092
|
+
metadata.wrapColumnFloor(record.fontSize), record.direction, record.balance);
|
|
1093
|
+
if (!plan) return false;
|
|
1094
|
+
const output = doc.createDocumentFragment();
|
|
1095
|
+
let previous = null;
|
|
1096
|
+
for (const line of plan.lines) {
|
|
1097
|
+
const element = doc.createElement('span');
|
|
1098
|
+
element.style.cssText = `display:block;position:absolute;left:${pixels(line.x)};top:${pixels(line.y)};width:${pixels(line.width)};height:${pixels(record.lineHeight)};white-space:pre;direction:${record.direction}`;
|
|
1099
|
+
element.style.textAlign = ['left', 'center', 'right', 'start', 'end'].includes(record.align)
|
|
1100
|
+
? record.align : record.direction === 'rtl' ? 'right' : 'left';
|
|
1101
|
+
for (const fragment of line.fragments) {
|
|
1102
|
+
const child = fragmentNode(record, fragment);
|
|
1103
|
+
if (!child) return false;
|
|
1104
|
+
if (hasGap(record, previous, fragment)) {
|
|
1105
|
+
const gap = doc.createElement('span');
|
|
1106
|
+
gap.style.cssText = 'display:inline-block;width:' + pixels(fragment.gapBefore);
|
|
1107
|
+
gap.textContent = ' '; element.append(gap);
|
|
1108
|
+
}
|
|
1109
|
+
element.append(child); previous = fragment;
|
|
1110
|
+
}
|
|
1111
|
+
output.append(element);
|
|
1112
|
+
}
|
|
1113
|
+
originals.set(record.paragraph, [...record.paragraph.childNodes]);
|
|
1114
|
+
record.paragraph.replaceChildren(output);
|
|
1115
|
+
style(record.paragraph, {position: 'relative', height: pixels(plan.height), 'min-height': '0'});
|
|
1116
|
+
return true;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function hasGap(record, previous, current) {
|
|
1120
|
+
if (!previous) return false;
|
|
1121
|
+
let found = false;
|
|
1122
|
+
for (let index = previous.itemIndex; index <= current.itemIndex; index++) {
|
|
1123
|
+
const run = record.runs[index].prepared;
|
|
1124
|
+
const start = index === previous.itemIndex ? geometry.cursorOffset(run, previous.end) : 0;
|
|
1125
|
+
const end = index === current.itemIndex ? geometry.cursorOffset(run, current.start) : run.raw.length;
|
|
1126
|
+
if (start == null || end == null || end < start) return false;
|
|
1127
|
+
const gap = run.raw.slice(start, end);
|
|
1128
|
+
if (gap && !/^[ \t\n\r\f]+$/.test(gap)) return false;
|
|
1129
|
+
found ||= !!gap;
|
|
1130
|
+
}
|
|
1131
|
+
return found;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// wrap=around reads data-rapier-occupancy, else alpha. Cache key: src, dimensions, layout.wrap, rotate angle.
|
|
1135
|
+
// A turned raster's profile is recomputed here as pictureProfile does, never shipped.
|
|
1136
|
+
function profile(image, layout) {
|
|
1137
|
+
const src = image.currentSrc || image.getAttribute('src') || '', rotateRad = (layout.rotate || 0) * Math.PI / 180;
|
|
1138
|
+
const cached = profiles.get(image);
|
|
1139
|
+
if (cached?.src === src && cached.width === image.naturalWidth && cached.height === image.naturalHeight &&
|
|
1140
|
+
cached.wrap === layout.wrap && cached.angle === rotateRad) return cached.profile;
|
|
1141
|
+
let value;
|
|
1142
|
+
if (layout.wrap === 'box') {
|
|
1143
|
+
value = boxProfile(image);
|
|
1144
|
+
if (!value && rotateRad) value = geometry.rasterTiltProfile(image.naturalWidth, image.naturalHeight, rotateRad);
|
|
1145
|
+
} else {
|
|
1146
|
+
const occupancy = typeof geometry.parseProfile === 'function' && geometry.parseProfile(image.getAttribute('data-rapier-occupancy'));
|
|
1147
|
+
value = occupancy || geometry.alphaProfile(image);
|
|
1148
|
+
if (!occupancy && rotateRad && value) value = geometry.rotatedRasterAlpha(value, image.naturalWidth, image.naturalHeight, rotateRad) || value;
|
|
1149
|
+
}
|
|
1150
|
+
profiles.set(image, {src, width: image.naturalWidth, height: image.naturalHeight, wrap: layout.wrap, angle: rotateRad, profile: value});
|
|
1151
|
+
return value;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// wrap=box reads data-rapier-box-polygon; this page never parses a recipe or bundles Draw. No polygon: whole rectangle.
|
|
1155
|
+
function boxProfile(image) {
|
|
1156
|
+
const raw = image.getAttribute('data-rapier-box-polygon');
|
|
1157
|
+
if (!raw) return null;
|
|
1158
|
+
const numbers = raw.trim().split(/\s+/).map(Number);
|
|
1159
|
+
if (numbers.length < 6 || numbers.length % 2 || numbers.some(value => !Number.isFinite(value))) return null;
|
|
1160
|
+
const corners = [];
|
|
1161
|
+
for (let index = 0; index < numbers.length; index += 2) corners.push([numbers[index], numbers[index + 1]]);
|
|
1162
|
+
return geometry.polygonProfile(corners);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// F75-11: as the live editor (browser.js).
|
|
1166
|
+
function syncInlineRotatedPictures(exclude) {
|
|
1167
|
+
for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
|
|
1168
|
+
if (exclude.has(image) || !image.complete || !image.naturalWidth || !image.naturalHeight) continue;
|
|
1169
|
+
const layout = metadata.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
|
|
1170
|
+
if (!layout?.rotate || ['around', 'box', 'behind', 'front'].includes(layout.wrap)) continue;
|
|
1171
|
+
const width = image.offsetWidth, height = image.offsetHeight;
|
|
1172
|
+
if (!(width > 0 && height > 0)) continue;
|
|
1173
|
+
const paragraph = image.closest('p');
|
|
1174
|
+
// geometry.imageOnly (layout/model.mjs).
|
|
1175
|
+
const alone = !!paragraph && imageOnly(paragraph, image);
|
|
1176
|
+
const baseLeft = parseFloat(css(image).marginLeft) || 0;
|
|
1177
|
+
const rotated = geometry.rotatedBoundsRad(width, height, layout.rotate * Math.PI / 180);
|
|
1178
|
+
const dx = (rotated.width - width) / 2, dy = (rotated.height - height) / 2;
|
|
1179
|
+
const values = {transform: `rotate(${layout.rotate}deg)`, 'transform-origin': '50% 50%',
|
|
1180
|
+
'margin-left': pixels(baseLeft + dx), 'margin-right': pixels(dx)};
|
|
1181
|
+
if (alone) { values.display = 'block'; values['margin-top'] = pixels(dy); values['margin-bottom'] = pixels(dy); }
|
|
1182
|
+
style(image, values);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Nothing is ever inserted between a picture and its words: a reader and the witnesses read nextElementSibling.
|
|
1187
|
+
function clearTo(element, bottom, origin, before = element) {
|
|
1188
|
+
const distance = bottom - (box(element).top - origin);
|
|
1189
|
+
if (distance <= 0) return;
|
|
1190
|
+
const spacer = doc.createElement('div');
|
|
1191
|
+
spacer.setAttribute('aria-hidden', 'true');
|
|
1192
|
+
spacer.style.cssText = 'height:' + pixels(distance) + ';clear:both;';
|
|
1193
|
+
before.before(spacer); spacers.push(spacer);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const imageOnly = geometry.imageOnly;
|
|
1197
|
+
|
|
1198
|
+
function neighbourKind(element) {
|
|
1199
|
+
if (metadata.wrapTextBlock(element)) return 'prose';
|
|
1200
|
+
const image = element.tagName === 'P' ? element.querySelector('img') : null;
|
|
1201
|
+
if (image && imageOnly(element, image)) return 'picture';
|
|
1202
|
+
// An empty paragraph is transparent to the owner search, as in the editor (R75).
|
|
1203
|
+
if (element.tagName === 'P' && !element.textContent.trim() && !image) return 'metadata';
|
|
1204
|
+
return null;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Float fallback as the editor's floatAround.
|
|
1208
|
+
const FLOAT_BLOCK = /^(P|H[1-6]|UL|OL|BLOCKQUOTE|DETAILS|SUMMARY|DL)$/;
|
|
1209
|
+
function floatAround(element, width, height, top, obstacles) {
|
|
1210
|
+
if (!FLOAT_BLOCK.test(element.tagName) || element.querySelector('table, pre, figure, img, .math-rendered')) return false;
|
|
1211
|
+
if (element.tagName === 'DETAILS' && !element.open) {
|
|
1212
|
+
const summary = element.querySelector(':scope > summary');
|
|
1213
|
+
if (!summary) return false;
|
|
1214
|
+
const bounds = box(summary), outer = box(element), computed = css(summary);
|
|
1215
|
+
const inset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.borderLeftWidth) || 0);
|
|
1216
|
+
const parentInset = (parseFloat(css(element).paddingLeft) || 0) + (parseFloat(css(element).borderLeftWidth) || 0);
|
|
1217
|
+
const contentWidth = bounds.width - inset - (parseFloat(computed.paddingRight) || 0) - (parseFloat(computed.borderRightWidth) || 0);
|
|
1218
|
+
return floatAround(summary, contentWidth, bounds.height, top + bounds.top - outer.top,
|
|
1219
|
+
obstacles.map(obstacle => ({...obstacle, x: obstacle.x - bounds.left - inset + outer.left + parentInset})));
|
|
1220
|
+
}
|
|
1221
|
+
const computed = css(element);
|
|
1222
|
+
top += (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
|
|
1223
|
+
const bottom = top + height;
|
|
1224
|
+
const inside = obstacles.filter(obstacle => obstacle.y < bottom + 4096 && obstacle.y + obstacle.height > top &&
|
|
1225
|
+
obstacle.x < width && obstacle.x + obstacle.width > 0);
|
|
1226
|
+
if (!inside.length) return false;
|
|
1227
|
+
// layout/model.mjs wrapShape; `geometry` is modules["layout/model.mjs"].
|
|
1228
|
+
const shapeFor = side => {
|
|
1229
|
+
const shape = geometry.wrapShape(side, inside, width, top);
|
|
1230
|
+
if (!shape) return null;
|
|
1231
|
+
return {...shape, shape: 'polygon(' + shape.points.map(point => pixels(point[0]) + ' ' + pixels(point[1])).join(',') + ') border-box'};
|
|
1232
|
+
};
|
|
1233
|
+
const shapes = {left: shapeFor('left'), right: shapeFor('right')};
|
|
1234
|
+
const spent = (shapes.left?.boxWidth || 0) + (shapes.right?.boxWidth || 0);
|
|
1235
|
+
if (!spent || spent > width - metadata.wrapColumnFloor(parseFloat(css(element).fontSize) || 16)) return false;
|
|
1236
|
+
const placed = [];
|
|
1237
|
+
for (const side of ['left', 'right']) {
|
|
1238
|
+
const shape = shapes[side];
|
|
1239
|
+
if (!shape) continue;
|
|
1240
|
+
const float = doc.createElement('span');
|
|
1241
|
+
float.setAttribute('aria-hidden', 'true');
|
|
1242
|
+
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`;
|
|
1243
|
+
element.prepend(float); floats.push(float); placed.push([float, shape]);
|
|
1244
|
+
}
|
|
1245
|
+
// The block contains its floats and each float ends at the block's own content bottom, so
|
|
1246
|
+
// successive blocks' floats never stack side by side (the same bound the editor applies).
|
|
1247
|
+
style(element, {display: computed.display.includes('list-item') ? 'flow-root list-item' : 'flow-root'});
|
|
1248
|
+
const contentBottom = () => {
|
|
1249
|
+
let first = element.firstChild;
|
|
1250
|
+
while (first && placed.some(([float]) => float === first)) first = first.nextSibling;
|
|
1251
|
+
if (!first || !element.lastChild) return box(element).height;
|
|
1252
|
+
const range = doc.createRange(); range.setStartBefore(first); range.setEndAfter(element.lastChild);
|
|
1253
|
+
return range.getBoundingClientRect().bottom - box(element).top;
|
|
1254
|
+
};
|
|
1255
|
+
for (let pass = 0; pass < 4; pass++) {
|
|
1256
|
+
const content = contentBottom();
|
|
1257
|
+
let changed = false;
|
|
1258
|
+
for (const [float, shape] of placed) {
|
|
1259
|
+
const bounded = Math.max(0, Math.min(shape.boxHeight, content - shape.startY));
|
|
1260
|
+
if (Math.abs((parseFloat(float.style.height) || 0) - bounded) > 0.5) { float.style.height = pixels(bounded); changed = true; }
|
|
1261
|
+
}
|
|
1262
|
+
if (!changed) break;
|
|
1263
|
+
}
|
|
1264
|
+
return true;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function reflow() {
|
|
1268
|
+
if (stopped || busy) return;
|
|
1269
|
+
busy = true;
|
|
1270
|
+
try {
|
|
1271
|
+
restore();
|
|
1272
|
+
|
|
1273
|
+
if (!root.isConnected || printing || view.matchMedia?.('print').matches) return;
|
|
1274
|
+
const children = [...root.children];
|
|
1275
|
+
if (children.length > 8192) return;
|
|
1276
|
+
// behind/front collapse and place like around/box but are never obstacles.
|
|
1277
|
+
const outOfFlow = value => value?.wrap === 'behind' || value?.wrap === 'front';
|
|
1278
|
+
const origin = box(root).top, obstacles = [], anchors = new Map(), wrapped = new Set(), placed = [], positionedImages = new Set();
|
|
1279
|
+
for (const element of children) {
|
|
1280
|
+
const layout = metadata.parseLayoutAttribute(element.getAttribute('data-md-layout'));
|
|
1281
|
+
const image = ['around', 'box', 'behind', 'front'].includes(layout?.wrap) && element.tagName === 'P'
|
|
1282
|
+
? element.querySelector('img[data-rapier-image-layout]') : null;
|
|
1283
|
+
if (!image?.complete || !image.naturalWidth || !image.naturalHeight || !imageOnly(element, image)) continue;
|
|
1284
|
+
|
|
1285
|
+
const owner = metadata.wrapNeighbour(element, neighbourKind);
|
|
1286
|
+
const computed = owner && css(owner);
|
|
1287
|
+
if (!owner || computed.writingMode !== 'horizontal-tb' || computed.transform !== 'none') continue;
|
|
1288
|
+
if (wrapped.size === 1024) { restore(); return; }
|
|
1289
|
+
const records = anchors.get(owner) || [];
|
|
1290
|
+
records.push({element, image, layout, width: box(image).width}); anchors.set(owner, records); wrapped.add(element); positionedImages.add(image);
|
|
1291
|
+
style(element, {position: 'relative', height: '0', 'min-height': '0', 'margin-top': '0', 'margin-bottom': '0', 'padding-top': '0', 'padding-bottom': '0', 'line-height': '0'});
|
|
1292
|
+
style(image, {position: 'absolute', margin: '0'});
|
|
1293
|
+
}
|
|
1294
|
+
// As the live editor.
|
|
1295
|
+
syncInlineRotatedPictures(positionedImages);
|
|
1296
|
+
for (const element of children) {
|
|
1297
|
+
if (wrapped.has(element)) continue;
|
|
1298
|
+
// `let`: a pushed picture moves its owner and every later element.
|
|
1299
|
+
let bounds = box(element);
|
|
1300
|
+
if (!bounds.width || !bounds.height) continue;
|
|
1301
|
+
const computed = css(element), inset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.borderLeftWidth) || 0);
|
|
1302
|
+
const width = bounds.width - inset - (parseFloat(computed.paddingRight) || 0) - (parseFloat(computed.borderRightWidth) || 0);
|
|
1303
|
+
|
|
1304
|
+
let ownerLed = false;
|
|
1305
|
+
for (const {element: source, image, layout, width: imageWidth} of anchors.get(element) || []) {
|
|
1306
|
+
const unrotated = geometry.imageBox(width, image.naturalWidth, image.naturalHeight, layout, imageWidth);
|
|
1307
|
+
if (!unrotated) { restore(); return; }
|
|
1308
|
+
// rasterReserved (layout/model.mjs).
|
|
1309
|
+
const rasterRad = (layout.rotate || 0) * Math.PI / 180;
|
|
1310
|
+
const fit = geometry.rasterReserved(width, unrotated, rasterRad);
|
|
1311
|
+
if (!fit) { restore(); return; }
|
|
1312
|
+
const rectangle = fit.reserved, visualDeltaX = fit.visualDeltaX, visualDeltaY = fit.visualDeltaY;
|
|
1313
|
+
const left = bounds.left + inset + rectangle.x;
|
|
1314
|
+
const em = parseFloat(computed.fontSize) || 16;
|
|
1315
|
+
const topInset = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.borderTopWidth) || 0);
|
|
1316
|
+
const height = Math.max(0, bounds.height - topInset - (parseFloat(computed.paddingBottom) || 0) - (parseFloat(computed.borderBottomWidth) || 0));
|
|
1317
|
+
let y = bounds.top - origin + topInset + Math.min(layout.y || 0, height / em) * em;
|
|
1318
|
+
if (!outOfFlow(layout)) {
|
|
1319
|
+
for (const obstacle of [...obstacles].sort((left, right) => left.y - right.y)) {
|
|
1320
|
+
if (obstacle.x < left + rectangle.width + 10 && obstacle.x + obstacle.width > left - 10 &&
|
|
1321
|
+
obstacle.y + obstacle.height > y - 10 && obstacle.y < y + rectangle.height + 10)
|
|
1322
|
+
y = obstacle.y + obstacle.height + 10;
|
|
1323
|
+
}
|
|
1324
|
+
// R87k: a picture pushed down by an earlier one takes its owner with it. A deliberate `y` stays; only the collision push moves the paragraph.
|
|
1325
|
+
const ownerTop = bounds.top - origin + topInset + Math.min(layout.y || 0, height / em) * em;
|
|
1326
|
+
// Only the first picture of an owner takes it down.
|
|
1327
|
+
if (!ownerLed && y > ownerTop + 0.5) {
|
|
1328
|
+
clearTo(element, (bounds.top - origin) + (y - ownerTop), origin, source);
|
|
1329
|
+
bounds = box(element);
|
|
1330
|
+
}
|
|
1331
|
+
ownerLed = true;
|
|
1332
|
+
obstacles.push(...geometry.pictureSlices(profile(image, layout), left, y, rectangle.width, rectangle.height));
|
|
1333
|
+
}
|
|
1334
|
+
placed.push({source, image, rectangle, left, y, wrap: layout.wrap,
|
|
1335
|
+
visualLeft: left + visualDeltaX, visualTop: y + visualDeltaY, visualWidth: fit.fit.width, visualHeight: fit.fit.height,
|
|
1336
|
+
rotateDeg: layout.rotate || 0});
|
|
1337
|
+
}
|
|
1338
|
+
const top = bounds.top - origin, active = obstacles.filter(obstacle => obstacle.y + obstacle.height > top);
|
|
1339
|
+
if (active.length) {
|
|
1340
|
+
const own = active.map(obstacle => ({...obstacle, x: obstacle.x - bounds.left - inset}));
|
|
1341
|
+
const prepared = prepare(element);
|
|
1342
|
+
if (!(prepared && width > 0 && project(prepared, width, top, own)) && !(width > 0 && floatAround(element, width, bounds.height, top, own))) {
|
|
1343
|
+
const overlapping = active.filter(obstacle => obstacle.y < top + bounds.height);
|
|
1344
|
+
if (overlapping.length) clearTo(element, Math.max(...overlapping.map(obstacle => obstacle.y + obstacle.height)), origin);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
for (const {source, image, rectangle, left, y, wrap, visualLeft, visualTop, visualWidth, visualHeight, rotateDeg} of placed) {
|
|
1349
|
+
const anchor = box(source);
|
|
1350
|
+
style(image, {position: 'absolute', left: pixels((visualLeft ?? left) - anchor.left), top: pixels((visualTop ?? y) - (anchor.top - origin)),
|
|
1351
|
+
width: pixels(visualWidth ?? rectangle.width), height: pixels(visualHeight ?? rectangle.height), 'max-width': 'none', margin: '0',
|
|
1352
|
+
transform: rotateDeg ? `rotate(${rotateDeg}deg)` : 'none', 'transform-origin': '50% 50%',
|
|
1353
|
+
// As the live editor.
|
|
1354
|
+
...(wrap === 'behind' ? {'z-index': '-1'} : {})});
|
|
1355
|
+
}
|
|
1356
|
+
if (obstacles.length) {
|
|
1357
|
+
const end = Math.max(...obstacles.map(obstacle => obstacle.y + obstacle.height));
|
|
1358
|
+
const bottom = box(root).bottom - origin - (parseFloat(css(root).paddingBottom) || 0);
|
|
1359
|
+
if (end > bottom) {
|
|
1360
|
+
const tail = doc.createElement('div');
|
|
1361
|
+
tail.setAttribute('aria-hidden', 'true'); tail.style.height = pixels(end - bottom);
|
|
1362
|
+
root.append(tail); spacers.push(tail);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
} catch (_) { restore(); }
|
|
1366
|
+
finally { busy = false; }
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function schedule() {
|
|
1370
|
+
if (scheduled || stopped) return;
|
|
1371
|
+
scheduled = view.requestAnimationFrame(() => { scheduled = 0; reflow(); });
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function beforePrint() { printing = true; restore(); }
|
|
1375
|
+
function afterPrint() { printing = false; schedule(); }
|
|
1376
|
+
|
|
1377
|
+
const observer = typeof view.ResizeObserver === 'function' ? new view.ResizeObserver(() => {
|
|
1378
|
+
const width = root.clientWidth;
|
|
1379
|
+
if (width !== lastWidth) { lastWidth = width; schedule(); }
|
|
1380
|
+
}) : null;
|
|
1381
|
+
observer?.observe(root);
|
|
1382
|
+
root.addEventListener('load', schedule, true);
|
|
1383
|
+
root.addEventListener('error', schedule, true);
|
|
1384
|
+
root.addEventListener('toggle', schedule, true);
|
|
1385
|
+
view.addEventListener('resize', schedule);
|
|
1386
|
+
view.addEventListener('pageshow', schedule);
|
|
1387
|
+
view.addEventListener('beforeprint', beforePrint);
|
|
1388
|
+
view.addEventListener('afterprint', afterPrint);
|
|
1389
|
+
doc.fonts?.addEventListener?.('loadingdone', schedule);
|
|
1390
|
+
Promise.resolve(doc.fonts?.ready).then(schedule, schedule);
|
|
1391
|
+
schedule();
|
|
1392
|
+
return Object.freeze({reflow, destroy() {
|
|
1393
|
+
stopped = true;
|
|
1394
|
+
if (scheduled) view.cancelAnimationFrame(scheduled);
|
|
1395
|
+
observer?.disconnect(); restore();
|
|
1396
|
+
root.removeEventListener('load', schedule, true);
|
|
1397
|
+
root.removeEventListener('error', schedule, true);
|
|
1398
|
+
root.removeEventListener('toggle', schedule, true);
|
|
1399
|
+
view.removeEventListener('resize', schedule);
|
|
1400
|
+
view.removeEventListener('pageshow', schedule);
|
|
1401
|
+
view.removeEventListener('beforeprint', beforePrint);
|
|
1402
|
+
view.removeEventListener('afterprint', afterPrint);
|
|
1403
|
+
doc.fonts?.removeEventListener?.('loadingdone', schedule);
|
|
1404
|
+
}});
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
function _rapierArtifactLayoutScript(root, nonce = '') {
|
|
1408
|
+
const dependencies = globalThis.RapierArtifactLayoutDependencies;
|
|
1409
|
+
// Every wrap value earns the reflow script, and an inline turned raster (keyed off data-rapier-image-layout).
|
|
1410
|
+
const layoutOf = element => globalThis.RapierMarkdownLayout.parseLayoutAttribute(element.getAttribute('data-md-layout'));
|
|
1411
|
+
const pictureLayoutOf = image => globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
|
|
1412
|
+
if (!dependencies?.factories ||
|
|
1413
|
+
(![...root.querySelectorAll('p[data-md-layout]')].some(element => ['around', 'box', 'behind', 'front'].includes(layoutOf(element)?.wrap)) &&
|
|
1414
|
+
![...root.querySelectorAll('img[data-rapier-image-layout]')].some(image => pictureLayoutOf(image)?.rotate))) return '';
|
|
1415
|
+
const modules = _rapierArtifactFactoryModules(dependencies, 'layout');
|
|
1416
|
+
const script = '/* Rapier export layout: SPDX-License-Identifier: AGPL-3.0-only */\n' +
|
|
1417
|
+
'/* Pretext 0.0.9\n' + dependencies.license + '\n*/\n' +
|
|
1418
|
+
'(() => {\nconst modules = {};\n' + modules + '\n(' + _rapierProjectArtifactLayout.toString() +
|
|
1419
|
+
')(document.querySelector("main.rapier-page"), modules["spec/md-layout.mjs"], modules["layout/model.mjs"], modules["agent/vendor/pretext/rich-inline.js"]);\n})();';
|
|
1420
|
+
|
|
1421
|
+
return '<script' + (nonce ? ' nonce="' + nonce + '"' : '') + '>\n' + script.replace(/<\/script/gi, '<\\/script') + '\n</script>';
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function _rapierArtifactInkScript(root, nonce = '') {
|
|
1425
|
+
const dependencies = globalThis.RapierArtifactLayoutDependencies;
|
|
1426
|
+
if (!dependencies?.factories || !dependencies.groups?.ink || !root.querySelector('span.rapier-ink-mark[data-rapier-ink]')) return '';
|
|
1427
|
+
const script = '/* Rapier export ink: the marks drawn from their own words. SPDX-License-Identifier: AGPL-3.0-only */\n' +
|
|
1428
|
+
'/* ' + dependencies.inkLicense + '\n*/\n' +
|
|
1429
|
+
'(() => {\nconst modules = {};\n' + _rapierArtifactFactoryModules(dependencies, 'ink') +
|
|
1430
|
+
'\nmodules["layout/ink-draw.mjs"].watchInk(document.querySelector("main.rapier-page"), modules["spec/md-marks.mjs"], modules["spec/ink.mjs"]);\n})();';
|
|
1431
|
+
return '<script' + (nonce ? ' nonce="' + nonce + '"' : '') + '>\n' + script.replace(/<\/script/gi, '<\\/script') + '\n</script>';
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return {_rapierBuildArtifact, _rapierProjectStyledRoot, _rapierPaintCode, _rapierPrefixPortableAnchors, _rapierDocumentSettingsOf, _rapierDocumentSettingsCss, _rapierExportedPageCsp, _rapierArtifactNonce, _rapierAnnotateExportBoxPolygons, _rapierSharedSourceHash, _rapierSharedSourceForms, _rapierSharedSourceCarrier, _rapierSharedResolve, _rapierReadSharedDocument, _rapierSharedPageFallbackCss, _rapierShareWrapKind, _rapierSharePictureParagraph, _rapierSharedPageLayout, _rapierShareCapLongCodeBlocks, _rapierRequireOfflinePageImages, _rapierBuildSharedPage, _rapierImageSources, _rapierImageDestinations, _rapierScanMarkdownImages, _rapierParseMarkdownImageDestination, _rapierImageOpenRunsList, _rapierArtifactFactoryModules, _rapierProjectArtifactLayout, _rapierArtifactLayoutScript, _rapierArtifactInkScript , render};
|
|
1435
|
+
}
|
|
1436
|
+
export {createRenderer};
|