rapier-markdown-kit 1.1.79 → 1.1.80
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/dist/kit/render-markdown.mjs +489 -12
- package/dist/kit/render-print.mjs +264 -3
- package/dist/kit/render-sanitize.mjs +128 -8
- package/dist/kit/render-styles.mjs +5 -5
- package/dist/kit/render.mjs +32 -10
- package/dist/spec/md-assets.mjs +3 -2
- package/package.json +1 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
const RAPIER_TABLE_CAPTION_PREFIX_RE = /^(?:Table:|:) /;
|
|
3
|
+
const RAPIER_PORTABLE_HIGHLIGHT_HEX = Object.freeze({
|
|
4
|
+
green: '#35c57a', red: '#f05a62', blue: '#4f91f7', yellow: '#e6b91e', purple: '#9b6dea',
|
|
5
|
+
});
|
|
6
|
+
const _RAPIER_RTL_SCRIPT = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\p{Script=Phoenician}\p{Script=Imperial_Aramaic}\p{Script=Palmyrene}\p{Script=Nabataean}\p{Script=Hatran}\p{Script=Old_South_Arabian}\p{Script=Old_North_Arabian}\p{Script=Mende_Kikakui}\p{Script=Old_Hungarian}\p{Script=Old_Sogdian}\p{Script=Sogdian}\p{Script=Elymaic}\p{Script=Chorasmian}\p{Script=Yezidi}\p{Script=Old_Uyghur}\p{Script=Hanifi_Rohingya}]/u;
|
|
3
7
|
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
4
8
|
function createMarkdownRenderer(runtime) {
|
|
5
|
-
const {RAPIER_HIGHLIGHT_COLOR_BY_MARKER, RAPIER_MARKDOWN_SPEC, RAPIER_RENDERED_HEADING_SELECTOR, _rapierApplyMarkdownSpec,
|
|
9
|
+
const {Node, _rapierPrefixPortableAnchors, RAPIER_HIGHLIGHT_COLOR_BY_MARKER, RAPIER_MARKDOWN_SPEC, RAPIER_RENDERED_HEADING_SELECTOR, _rapierApplyMarkdownSpec, _rapierChromeOwnsId, _rapierCodeHtml, _rapierDeriveDarkColor, _rapierDormantHeadings, _rapierEmbedAssetSource, _rapierHeadingSlugBase, _rapierNextHeadingSlug, _rapierHighlightAdmitted, _rapierInstallMarkdownMath, _rapierMarkdownPreview, _rapierPlainLayout, _rapierProviders, _rapierRemoteContent, _rapierRemoteImagePlaceholder, _rapierRemoteSubresourceOrigin, _rapierRenderedAnchorOriginalIds, _rapierSourceCharEscaped, _rapierSplitOpeningFrontmatter, _rapierUiDiagram, _rapierUiMath, document, escapeRapierHtmlText, globalThis, sanitizeRapierHtml, window} = runtime;
|
|
6
10
|
let md = runtime.md;
|
|
7
11
|
function initMarkdownIt() {
|
|
8
12
|
if (typeof window.markdownit !== 'function') throw new Error('markdownit');
|
|
@@ -112,9 +116,10 @@ function initMarkdownIt() {
|
|
|
112
116
|
const layout = token.meta?.mdLayout?.imageOnly ? token.meta.mdLayout.marker : '';
|
|
113
117
|
const asset = globalThis.RapierImageAssets.dataImage(src), reference = token.meta?.mdImage?.reference;
|
|
114
118
|
// A connected host's own asset; a host without one (the server) supplies no port.
|
|
115
|
-
if (
|
|
119
|
+
if (!env.rapierPage && typeof globalThis.RapierEmbeddedImages?.imageHtml === 'function' &&
|
|
120
|
+
(_rapierEmbedAssetSource?.(src) || asset && (reference || asset.codec === 'image/jxl' || asset.codec === 'image/svg+xml'))) return globalThis.RapierEmbeddedImages.imageHtml(
|
|
116
121
|
reference, alt, title, size, layout, rawAlt, token.meta?.mdImage?.source, src);
|
|
117
|
-
if (!_rapierRemoteContent.allowed && _rapierRemoteSubresourceOrigin(src)) {
|
|
122
|
+
if (!env.rapierPage && !_rapierRemoteContent.allowed && _rapierRemoteSubresourceOrigin(src)) {
|
|
118
123
|
const html = _rapierRemoteImagePlaceholder(src, alt, title, size, layout, rawAlt);
|
|
119
124
|
return html.replace('<span ', '<span data-rapier-markdown-image="" ' +
|
|
120
125
|
(token.meta?.mdImage?.source ? 'data-rapier-image-source="' + md.utils.escapeHtml(encodeURIComponent(token.meta.mdImage.source)) + '" ' : ''));
|
|
@@ -246,7 +251,7 @@ function initMarkdownIt() {
|
|
|
246
251
|
token.attrSet('data-rapier-fence', token.markup || '```');
|
|
247
252
|
token.attrSet('data-rapier-fence-info', token.info == null ? '' : String(token.info));
|
|
248
253
|
const lang = String(token.info || '').trim().split(/\s+/)[0].toLowerCase();
|
|
249
|
-
if (lang !== 'mermaid') return _fenceDefault(tokens, idx, opts, env, self);
|
|
254
|
+
if (lang !== 'mermaid' || env.rapierPage) return _fenceDefault(tokens, idx, opts, env, self);
|
|
250
255
|
const src = String(token.content || '').replace(/\n$/, '');
|
|
251
256
|
const fence = md.utils.escapeHtml(token.markup || '```');
|
|
252
257
|
const info = token.info == null ? '' : String(token.info);
|
|
@@ -299,9 +304,10 @@ function initMarkdownIt() {
|
|
|
299
304
|
|
|
300
305
|
{
|
|
301
306
|
let _mathPromptedOnce = false;
|
|
302
|
-
const _renderMath = (src, displayMode) => {
|
|
307
|
+
const _renderMath = (src, displayMode, page) => {
|
|
303
308
|
const marker = displayMode ? '$$' : '$';
|
|
304
309
|
const source = marker + src + marker, literal = md.utils.escapeHtml(source);
|
|
310
|
+
if (page) return '<span data-rapier-math-source="">' + literal + '</span>';
|
|
305
311
|
const mathProvider = _rapierProviders.math;
|
|
306
312
|
if (!mathProvider || mathProvider.status !== 'ready' || typeof mathProvider.renderToString !== 'function') {
|
|
307
313
|
if (!_mathPromptedOnce) {
|
|
@@ -318,14 +324,14 @@ function initMarkdownIt() {
|
|
|
318
324
|
}
|
|
319
325
|
catch (_) { return '<span data-rapier-math-source="">' + literal + '</span>'; }
|
|
320
326
|
};
|
|
321
|
-
md.renderer.rules.math_inline = (tokens, idx) => _renderMath(tokens[idx].content, false);
|
|
322
|
-
md.renderer.rules.math_block = (tokens, idx) => wrapDisplayMath(_renderMath(tokens[idx].content, true)) + '\n';
|
|
327
|
+
md.renderer.rules.math_inline = (tokens, idx, _opts, env) => _renderMath(tokens[idx].content, false, env.rapierPage);
|
|
328
|
+
md.renderer.rules.math_block = (tokens, idx, _opts, env) => wrapDisplayMath(_renderMath(tokens[idx].content, true, env.rapierPage)) + '\n';
|
|
323
329
|
}
|
|
324
330
|
globalThis.RapierMarkdownLayout.installMarkdownLayout(md);
|
|
325
331
|
globalThis.RapierImageAssets.configureParser(md, _rapierSplitOpeningFrontmatter);
|
|
326
332
|
}
|
|
327
333
|
|
|
328
|
-
function _rapierRenderSemanticRoot(canonical, metadata) {
|
|
334
|
+
function _rapierRenderSemanticRoot(canonical, metadata, options = {}) {
|
|
329
335
|
const root = document.createElement('div');
|
|
330
336
|
root.setAttribute('data-rapier-semantic-root', 'true');
|
|
331
337
|
|
|
@@ -343,7 +349,7 @@ function _rapierRenderSemanticRoot(canonical, metadata) {
|
|
|
343
349
|
}
|
|
344
350
|
|
|
345
351
|
const markdown = _rapierSplitOpeningFrontmatter(canonical).body;
|
|
346
|
-
const env = {};
|
|
352
|
+
const env = options.page ? {rapierPage: true} : {};
|
|
347
353
|
let rendered = md
|
|
348
354
|
? md.render(markdown, env)
|
|
349
355
|
: '<pre><code>' + escapeRapierHtmlText(markdown) + '</code></pre>';
|
|
@@ -352,7 +358,7 @@ function _rapierRenderSemanticRoot(canonical, metadata) {
|
|
|
352
358
|
String(item && item.label || ('inline-' + (index + 1))));
|
|
353
359
|
rendered = _relabelFootnoteHtml(rendered, labels);
|
|
354
360
|
}
|
|
355
|
-
root.innerHTML = sanitizeRapierHtml(rendered, 'export');
|
|
361
|
+
root.innerHTML = sanitizeRapierHtml(rendered, options.page ? 'source' : 'export');
|
|
356
362
|
_rapierApplyBlockDirection(root);
|
|
357
363
|
|
|
358
364
|
_rapierAssignHeadingSlugs(root, RAPIER_RENDERED_HEADING_SELECTOR, false);
|
|
@@ -818,6 +824,477 @@ function wrapDisplayMath(html) {
|
|
|
818
824
|
return '<span class="math-display-wrap">' + html + '</span>';
|
|
819
825
|
}
|
|
820
826
|
|
|
821
|
-
|
|
827
|
+
function _rapierCalloutInfo(blockquote) {
|
|
828
|
+
const first = blockquote && blockquote.querySelector(':scope > p:first-child');
|
|
829
|
+
if (!first) return null;
|
|
830
|
+
const existing = first.querySelector('.callout__label');
|
|
831
|
+
if (existing) {
|
|
832
|
+
const type = String(existing.textContent || '').trim().toUpperCase();
|
|
833
|
+
return /^(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER|INFO)$/.test(type) ? { first, type, existing } : null;
|
|
834
|
+
}
|
|
835
|
+
const match = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER|INFO)\]/i.exec(first.textContent || '');
|
|
836
|
+
return match ? { first, type: match[1].toUpperCase(), existing: null } : null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function _rapierRemoveCalloutMarker(first) {
|
|
840
|
+
if (!first) return;
|
|
841
|
+
first.innerHTML = first.innerHTML.replace(
|
|
842
|
+
/^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER|INFO)\]\s*(?:<br\s*\/?>)?\s*/i,
|
|
843
|
+
''
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function _rapierMathSource(node) {
|
|
848
|
+
if (!node) return '';
|
|
849
|
+
const carrier = node.matches && node.matches('[data-math-src]')
|
|
850
|
+
? node
|
|
851
|
+
: node.querySelector && node.querySelector('[data-math-src]');
|
|
852
|
+
if (carrier) {
|
|
853
|
+
const encoded = carrier.getAttribute('data-math-src') || '';
|
|
854
|
+
try { return decodeURIComponent(encoded); } catch (_) { return encoded; }
|
|
855
|
+
}
|
|
856
|
+
const placeholder = node.matches && node.matches('.math-placeholder')
|
|
857
|
+
? node
|
|
858
|
+
: node.querySelector && node.querySelector('.math-placeholder');
|
|
859
|
+
return String((placeholder || node).textContent || '').trim();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function _rapierFlattenPortableDetails(root) {
|
|
863
|
+
const blockTags = new Set([
|
|
864
|
+
'ADDRESS','ARTICLE','ASIDE','BLOCKQUOTE','DIV','DL','FIELDSET','FIGURE',
|
|
865
|
+
'FOOTER','FORM','H1','H2','H3','H4','H5','H6','HEADER','HR','MAIN',
|
|
866
|
+
'NAV','OL','P','PRE','SECTION','TABLE','UL',
|
|
867
|
+
]);
|
|
868
|
+
Array.from(root.querySelectorAll('details')).reverse().forEach(details => {
|
|
869
|
+
const summary = Array.from(details.children).find(child => child.tagName === 'SUMMARY');
|
|
870
|
+
const section = document.createElement('section');
|
|
871
|
+
if (details.id) section.id = details.id;
|
|
872
|
+
if (summary && (summary.textContent.trim() || summary.children.length)) {
|
|
873
|
+
const heading = summary.querySelector('h1,h2,h3,h4,h5,h6');
|
|
874
|
+
const label = document.createElement(heading ? 'div' : 'p');
|
|
875
|
+
if (summary.id) label.id = summary.id;
|
|
876
|
+
const content = heading ? label : document.createElement('strong');
|
|
877
|
+
while (summary.firstChild) content.appendChild(summary.firstChild);
|
|
878
|
+
if (!heading) label.appendChild(content);
|
|
879
|
+
section.appendChild(label);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
let paragraph = null;
|
|
883
|
+
Array.from(details.childNodes).forEach(child => {
|
|
884
|
+
if (child === summary) return;
|
|
885
|
+
const isText = child.nodeType === Node.TEXT_NODE;
|
|
886
|
+
if (isText && !String(child.nodeValue || '').trim()) return;
|
|
887
|
+
const isBlock = child.nodeType === Node.ELEMENT_NODE && blockTags.has(child.tagName);
|
|
888
|
+
if (!isBlock) {
|
|
889
|
+
if (!paragraph) {
|
|
890
|
+
paragraph = document.createElement('p');
|
|
891
|
+
section.appendChild(paragraph);
|
|
892
|
+
}
|
|
893
|
+
paragraph.appendChild(child);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
paragraph = null;
|
|
897
|
+
section.appendChild(child);
|
|
898
|
+
});
|
|
899
|
+
details.replaceWith(section);
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function _rapierProjectPortableRoot(semanticRoot, options) {
|
|
904
|
+
const opts = options || {};
|
|
905
|
+
// This projection is HTML-shaped (width baked into a CSS `style`, `data-*` stripped) for the
|
|
906
|
+
// consumers that read HTML -- publishing/fragment export, TXT. DOCX is not an HTML consumer: its
|
|
907
|
+
// writer (interchange/docx.mjs) reads the layout comment itself, so stripping it first and
|
|
908
|
+
// handing back CSS only throws the fact away before the writer ever sees it. `keepLayoutData`
|
|
909
|
+
// leaves `data-rapier-image-layout` (width, align AND rotation) and the paragraph's
|
|
910
|
+
// `data-md-align` exactly as the semantic root already carries them, instead of the CSS-only
|
|
911
|
+
// projection, so the one existing layout representation reaches the writer.
|
|
912
|
+
const keepLayoutData = !!opts.keepLayoutData;
|
|
913
|
+
const root = semanticRoot.cloneNode(true), nativeElements = new Set();
|
|
914
|
+
// A source soft break is a reading space, never the editor's invisible token.
|
|
915
|
+
_rapierResolveSoftBreakTokens(root);
|
|
916
|
+
if (opts.keepDiagrams) for (const figure of root.querySelectorAll('.diagram-block[data-diagram-native]')) {
|
|
917
|
+
const svg = figure.querySelector('.diagram-cache > svg.rapier-native-flowchart');
|
|
918
|
+
if (figure.getAttribute('data-diagram-state') === 'ready' && svg) {
|
|
919
|
+
// A class in authored HTML grants no exemption: only the native materialization above does.
|
|
920
|
+
for (const element of [svg, ...svg.querySelectorAll('*')]) nativeElements.add(element);
|
|
921
|
+
figure.replaceWith(svg);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
root.querySelectorAll('blockquote').forEach(blockquote => {
|
|
926
|
+
const info = _rapierCalloutInfo(blockquote);
|
|
927
|
+
if (!info) return;
|
|
928
|
+
if (info.existing) info.existing.remove();
|
|
929
|
+
else _rapierRemoveCalloutMarker(info.first);
|
|
930
|
+
const label = document.createElement('strong');
|
|
931
|
+
label.textContent = info.type.charAt(0) + info.type.slice(1).toLowerCase() + ': ';
|
|
932
|
+
info.first.insertBefore(label, info.first.firstChild);
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
root.querySelectorAll('input[type="checkbox"]').forEach(input => {
|
|
936
|
+
const nextStartsWithSpace = input.nextSibling
|
|
937
|
+
&& input.nextSibling.nodeType === Node.TEXT_NODE
|
|
938
|
+
&& /^\s/.test(input.nextSibling.nodeValue || '');
|
|
939
|
+
input.replaceWith(document.createTextNode((input.checked ? '☒' : '☐') + (nextStartsWithSpace ? '' : ' ')));
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
root.querySelectorAll('.math-display-wrap').forEach(wrapper => {
|
|
943
|
+
const source = _rapierMathSource(wrapper);
|
|
944
|
+
const p = document.createElement('p');
|
|
945
|
+
const code = document.createElement('code');
|
|
946
|
+
code.textContent = source || '[equation]';
|
|
947
|
+
p.appendChild(code);
|
|
948
|
+
wrapper.replaceWith(p);
|
|
949
|
+
});
|
|
950
|
+
root.querySelectorAll('.math-rendered,.math-placeholder').forEach(math => {
|
|
951
|
+
const code = document.createElement('code');
|
|
952
|
+
code.textContent = _rapierMathSource(math) || '[equation]';
|
|
953
|
+
math.replaceWith(code);
|
|
954
|
+
});
|
|
955
|
+
root.querySelectorAll('math').forEach(math => {
|
|
956
|
+
const code = document.createElement('code');
|
|
957
|
+
code.textContent = String(math.textContent || '').trim() || '[equation]';
|
|
958
|
+
math.replaceWith(code);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
_rapierFlattenPortableDetails(root);
|
|
962
|
+
|
|
963
|
+
root.querySelectorAll('section.footnotes').forEach(section => {
|
|
964
|
+
section.querySelectorAll('.footnotes-sep').forEach(node => node.remove());
|
|
965
|
+
if (!section.querySelector(':scope > h1,:scope > h2,:scope > h3')) {
|
|
966
|
+
const heading = document.createElement('h2');
|
|
967
|
+
heading.textContent = 'Footnotes';
|
|
968
|
+
section.insertBefore(heading, section.firstChild);
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
root.querySelectorAll('.table-scroll-wrap,.math-display-wrap').forEach(_rapierUnwrapElement);
|
|
973
|
+
|
|
974
|
+
root.querySelectorAll('pre > code').forEach(_rapierNormalizeCodeElement);
|
|
975
|
+
|
|
976
|
+
root.querySelectorAll('svg').forEach(svg => {
|
|
977
|
+
if (nativeElements.has(svg)) return;
|
|
978
|
+
const title = svg.getAttribute('aria-label')
|
|
979
|
+
|| (svg.querySelector('title') && svg.querySelector('title').textContent)
|
|
980
|
+
|| '';
|
|
981
|
+
svg.replaceWith(document.createTextNode(title ? '[Image: ' + title.trim() + ']' : '[Image]'));
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
_rapierPrefixPortableAnchors(root, opts.baseName);
|
|
985
|
+
|
|
986
|
+
root.querySelectorAll('*').forEach(element => {
|
|
987
|
+
if (nativeElements.has(element)) return;
|
|
988
|
+
const lang = element.tagName === 'CODE' ? _rapierLanguageClass(element) : '';
|
|
989
|
+
const columnAlignment = /^(TH|TD)$/.test(element.tagName) && /^(left|center|right)$/.test(element.style.textAlign)
|
|
990
|
+
? element.style.textAlign : '';
|
|
991
|
+
const alignment = globalThis.RapierMarkdownLayout.parseLayoutAttribute(element.getAttribute('data-md-layout'))?.align || columnAlignment;
|
|
992
|
+
const imageLayout = element.tagName === 'IMG' ? globalThis.RapierMarkdownLayout.parseLayoutAttribute(element.getAttribute('data-rapier-image-layout')) : null;
|
|
993
|
+
|
|
994
|
+
const colorHex = element.tagName === 'SPAN' ? element.getAttribute('data-md-color') : null;
|
|
995
|
+
|
|
996
|
+
const highlightHex = element.tagName === 'MARK'
|
|
997
|
+
? (RAPIER_PORTABLE_HIGHLIGHT_HEX[String(element.getAttribute('data-rapier-highlight') || '').toLowerCase()] || RAPIER_PORTABLE_HIGHLIGHT_HEX.yellow)
|
|
998
|
+
: null;
|
|
999
|
+
|
|
1000
|
+
const isTableCaption = element.tagName === 'P' && element.classList.contains('rapier-table-caption');
|
|
1001
|
+
|
|
1002
|
+
const isPageBreak = element.hasAttribute('data-md-break');
|
|
1003
|
+
Array.from(element.attributes).forEach(attribute => {
|
|
1004
|
+
const name = attribute.name.toLowerCase();
|
|
1005
|
+
if (keepLayoutData && (name === 'data-rapier-image-layout' || name === 'data-md-align')) return;
|
|
1006
|
+
if (name === 'class' || name === 'style' || name.startsWith('data-')
|
|
1007
|
+
|| name === 'contenteditable' || name === 'spellcheck'
|
|
1008
|
+
|| name === 'tabindex' || name === 'disabled' || name === 'aria-disabled') {
|
|
1009
|
+
element.removeAttribute(attribute.name);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
if (lang) element.className = 'language-' + lang;
|
|
1013
|
+
if (alignment) element.style.textAlign = alignment;
|
|
1014
|
+
if (imageLayout && !keepLayoutData) {
|
|
1015
|
+
const pictureStyle = globalThis.RapierMarkdownLayout.imageStyle(imageLayout);
|
|
1016
|
+
if (imageLayout.width != null) element.removeAttribute('width');
|
|
1017
|
+
if (pictureStyle) element.style.cssText = pictureStyle;
|
|
1018
|
+
}
|
|
1019
|
+
if (colorHex && /^#[0-9a-f]{6}$/i.test(colorHex)) element.style.color = colorHex.toLowerCase();
|
|
1020
|
+
if (highlightHex) element.style.backgroundColor = highlightHex;
|
|
1021
|
+
if (isTableCaption) { element.style.fontStyle = 'italic'; element.style.color = '#555555'; }
|
|
1022
|
+
|
|
1023
|
+
if (isPageBreak) {
|
|
1024
|
+
const p = document.createElement('p');
|
|
1025
|
+
p.setAttribute('style', 'page-break-before: always');
|
|
1026
|
+
element.replaceWith(p);
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
const clean = document.createElement('div');
|
|
1031
|
+
clean.innerHTML = sanitizeRapierHtml(root.innerHTML, 'export');
|
|
1032
|
+
return clean;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function _rapierPortableHtml(root) {
|
|
1036
|
+
return sanitizeRapierHtml(root ? root.innerHTML : '', 'export').trim();
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function _rapierNormalizeInlineText(value) {
|
|
1040
|
+
return String(value || '')
|
|
1041
|
+
.replace(/\u00a0/g, ' ')
|
|
1042
|
+
.replace(/[\t\f\v ]+/g, ' ')
|
|
1043
|
+
.replace(/ *\n */g, '\n')
|
|
1044
|
+
.trim();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// What a text file says where a picture stands: its words, and its address unless the picture is carried in the document itself.
|
|
1048
|
+
function _rapierPictureWords(alt, src) {
|
|
1049
|
+
if (/^data:image\//i.test(src)) return alt ? '[Image: ' + alt + ']' : '[Image]';
|
|
1050
|
+
if (src) return alt ? '[Image: ' + alt + ' — ' + src + ']' : '[Image: ' + src + ']';
|
|
1051
|
+
return alt ? '[Image: ' + alt + ']' : '[Image]';
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// A remote picture written as Markdown stands in the editor, until the person allows remote content, as a placeholder: its words, its origin and a
|
|
1055
|
+
// button. The text file says the picture as it does once the person has allowed it, its words and its address, never the placeholder's controls.
|
|
1056
|
+
function _rapierHeldPicturesAsWords(root) {
|
|
1057
|
+
const copy = root.cloneNode(true);
|
|
1058
|
+
copy.querySelectorAll('span[data-rapier-remote-src]').forEach(held => held.replaceWith(document.createTextNode(
|
|
1059
|
+
_rapierPictureWords(String(held.getAttribute('data-rapier-remote-alt') || '').trim(), String(held.getAttribute('data-rapier-remote-src') || '').trim()))));
|
|
1060
|
+
return copy;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function _rapierPortableInlineText(node, options) {
|
|
1064
|
+
const opts = options || {};
|
|
1065
|
+
if (!node) return '';
|
|
1066
|
+
if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || '';
|
|
1067
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
1068
|
+
const tag = node.tagName;
|
|
1069
|
+
if (tag === 'BR') return '\n';
|
|
1070
|
+
// A task's box is what the person sees of it.
|
|
1071
|
+
if (tag === 'INPUT') return /^checkbox$/i.test(node.type || '') ? (node.checked || node.hasAttribute('checked') ? '☑ ' : '☐ ') : '';
|
|
1072
|
+
if (tag === 'IMG') {
|
|
1073
|
+
if (opts.visible) return '';
|
|
1074
|
+
const alt = String(node.getAttribute('alt') || '').trim();
|
|
1075
|
+
const src = String(node.getAttribute('src') || '').trim();
|
|
1076
|
+
return _rapierPictureWords(alt, src);
|
|
1077
|
+
}
|
|
1078
|
+
if (tag === 'A') {
|
|
1079
|
+
const label = _rapierNormalizeInlineText(Array.from(node.childNodes)
|
|
1080
|
+
.map(child => _rapierPortableInlineText(child, opts)).join(''));
|
|
1081
|
+
const href = String(node.getAttribute('href') || '').trim();
|
|
1082
|
+
if (opts.visible || !href || href.charAt(0) === '#') return label;
|
|
1083
|
+
if (!label || label === href) return href;
|
|
1084
|
+
return label + ' (' + href + ')';
|
|
1085
|
+
}
|
|
1086
|
+
if (tag === 'CODE' && !opts.inPre) {
|
|
1087
|
+
const source = String(node.textContent || '');
|
|
1088
|
+
return opts.visible ? source : source ? '`' + source + '`' : '';
|
|
1089
|
+
}
|
|
1090
|
+
return Array.from(node.childNodes)
|
|
1091
|
+
.map(child => _rapierPortableInlineText(child, opts)).join('');
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function _rapierPortableListText(list, depth, options) {
|
|
1095
|
+
const opts = options || {};
|
|
1096
|
+
const level = Math.max(0, depth | 0);
|
|
1097
|
+
const ordered = list.tagName === 'OL';
|
|
1098
|
+
let counter = ordered ? (parseInt(list.getAttribute('start'), 10) || 1) : 0;
|
|
1099
|
+
const lines = [];
|
|
1100
|
+
Array.from(list.children).filter(child => child.tagName === 'LI').forEach(item => {
|
|
1101
|
+
const nested = Array.from(item.children).filter(child => child.tagName === 'UL' || child.tagName === 'OL');
|
|
1102
|
+
const content = Array.from(item.childNodes)
|
|
1103
|
+
.filter(child => !(child.nodeType === Node.ELEMENT_NODE && (child.tagName === 'UL' || child.tagName === 'OL')))
|
|
1104
|
+
.map(child => _rapierPortableInlineText(child, opts)).join('');
|
|
1105
|
+
const normalized = _rapierNormalizeInlineText(content);
|
|
1106
|
+
const indent = ' '.repeat(level);
|
|
1107
|
+
// A task shows its box where a bullet would be.
|
|
1108
|
+
const marker = /^[☐☑☒]/.test(normalized) ? '' : ordered ? (counter++) + '. ' : '• ';
|
|
1109
|
+
const parts = (normalized || '').split('\n');
|
|
1110
|
+
lines.push(indent + marker + (parts.shift() || ''));
|
|
1111
|
+
parts.forEach(part => lines.push(indent + ' ' + part));
|
|
1112
|
+
nested.forEach(child => {
|
|
1113
|
+
const nestedText = _rapierPortableListText(child, level + 1, opts);
|
|
1114
|
+
if (nestedText) lines.push(nestedText);
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
return lines.join('\n');
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// A table, or a part of one a selection clipped out (a head, a body, a row): its rows.
|
|
1121
|
+
function _rapierPortableTableText(table, options) {
|
|
1122
|
+
const opts = options || {};
|
|
1123
|
+
const rows = table.tagName === 'TR' ? [table] : Array.from(table.querySelectorAll('tr'));
|
|
1124
|
+
return rows.map(row => {
|
|
1125
|
+
return Array.from(row.children)
|
|
1126
|
+
.filter(cell => cell.tagName === 'TH' || cell.tagName === 'TD')
|
|
1127
|
+
.map(cell => _rapierNormalizeInlineText(_rapierPortableInlineText(cell, opts)))
|
|
1128
|
+
.join(opts.visible ? '\t' : ' | ');
|
|
1129
|
+
}).filter(Boolean).join('\n');
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function _rapierPortablePlainText(root, options) {
|
|
1133
|
+
const opts = options || {};
|
|
1134
|
+
const blocks = [];
|
|
1135
|
+
const blockTags = new Set([
|
|
1136
|
+
'ADDRESS','ARTICLE','ASIDE','BLOCKQUOTE','DETAILS','DIV','DL','FIELDSET','FIGURE','FOOTER',
|
|
1137
|
+
'FORM','H1','H2','H3','H4','H5','H6','HEADER','HR','MAIN','NAV','OL','P','PRE','SECTION','TABLE','UL'
|
|
1138
|
+
]);
|
|
1139
|
+
|
|
1140
|
+
const pushBlock = (text, preserve) => {
|
|
1141
|
+
let value = String(text == null ? '' : text).replace(/\r\n?/g, '\n');
|
|
1142
|
+
if (preserve) value = value.replace(/\n+$/, '');
|
|
1143
|
+
else value = value.replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim();
|
|
1144
|
+
if (value) blocks.push({ text: value, preserve: !!preserve });
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const collectChildren = parent => {
|
|
1148
|
+
let inline = [];
|
|
1149
|
+
const flush = () => {
|
|
1150
|
+
if (!inline.length) return;
|
|
1151
|
+
pushBlock(_rapierNormalizeInlineText(inline.map(node => _rapierPortableInlineText(node, opts)).join('')), false);
|
|
1152
|
+
inline = [];
|
|
1153
|
+
};
|
|
1154
|
+
const tablePart = node => node.nodeType === Node.ELEMENT_NODE && /^(THEAD|TBODY|TFOOT|TR)$/.test(node.tagName);
|
|
1155
|
+
const blankText = node => node.nodeType === Node.TEXT_NODE && !String(node.nodeValue || '').trim();
|
|
1156
|
+
const children = Array.from(parent.childNodes);
|
|
1157
|
+
const inTableRun = index => { for (let at = index - 1; at >= 0; at--) { if (!blankText(children[at])) return tablePart(children[at]); } return false; };
|
|
1158
|
+
children.forEach((child, index) => {
|
|
1159
|
+
if (tablePart(child)) {
|
|
1160
|
+
// The parts of a table a selection clipped out of it: one block of rows, whichever parts arrived.
|
|
1161
|
+
if (inTableRun(index)) return;
|
|
1162
|
+
flush();
|
|
1163
|
+
const parts = [];
|
|
1164
|
+
for (let at = index; at < children.length && (tablePart(children[at]) || blankText(children[at])); at++) {
|
|
1165
|
+
if (tablePart(children[at])) parts.push(_rapierPortableTableText(children[at], opts));
|
|
1166
|
+
}
|
|
1167
|
+
pushBlock(parts.filter(Boolean).join('\n'), false);
|
|
1168
|
+
} else if (blankText(child) && inTableRun(index)) {
|
|
1169
|
+
return;
|
|
1170
|
+
} else if (child.nodeType === Node.ELEMENT_NODE && blockTags.has(child.tagName)) {
|
|
1171
|
+
flush();
|
|
1172
|
+
collectBlock(child);
|
|
1173
|
+
} else if (opts.visible && child.nodeType === Node.ELEMENT_NODE && child.tagName === 'CODE' && String(child.textContent || '').includes('\n')) {
|
|
1174
|
+
// Lines of a code block taken without their block: the code as shown, every space kept.
|
|
1175
|
+
flush();
|
|
1176
|
+
pushBlock(String(child.textContent || ''), true);
|
|
1177
|
+
} else {
|
|
1178
|
+
inline.push(child);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
flush();
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const collectBlock = node => {
|
|
1185
|
+
if (!node || node.nodeType !== Node.ELEMENT_NODE) return;
|
|
1186
|
+
const tag = node.tagName;
|
|
1187
|
+
if (/^H[1-6]$/.test(tag) || tag === 'P') {
|
|
1188
|
+
const text = _rapierNormalizeInlineText(_rapierPortableInlineText(node, opts));
|
|
1189
|
+
// An empty paragraph (the line Enter makes) is a line of its own, as it is in Word's plain text: one newline more between its neighbours.
|
|
1190
|
+
if (tag === 'P' && !text && node.childNodes.length && !node.hasAttribute('data-md-break')) blocks.push({ text: '', blank: true });
|
|
1191
|
+
else pushBlock(text, false);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (tag === 'HR') { pushBlock('────────', false); return; }
|
|
1195
|
+
if (tag === 'PRE') {
|
|
1196
|
+
const code = node.querySelector(':scope > code') || node;
|
|
1197
|
+
const lang = _rapierLanguageClass(code);
|
|
1198
|
+
const source = String(code.textContent || '').replace(/\r\n?/g, '\n').replace(/\n$/, '');
|
|
1199
|
+
if (opts.visible) { pushBlock(source, true); return; }
|
|
1200
|
+
const label = lang ? lang.charAt(0).toUpperCase() + lang.slice(1) + ':\n\n' : '';
|
|
1201
|
+
pushBlock(label + source.split('\n').map(line => ' ' + line).join('\n'), true);
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
if (tag === 'UL' || tag === 'OL') {
|
|
1205
|
+
pushBlock(_rapierPortableListText(node, 0, opts), false);
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
if (tag === 'TABLE') {
|
|
1209
|
+
pushBlock(_rapierPortableTableText(node, opts), false);
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (tag === 'BLOCKQUOTE') {
|
|
1213
|
+
const before = blocks.length;
|
|
1214
|
+
collectChildren(node);
|
|
1215
|
+
const quoted = blocks.splice(before).filter(block => !block.blank).map(block => block.text).join('\n\n');
|
|
1216
|
+
if (quoted) pushBlock(opts.visible ? quoted : quoted.split('\n').map(line => line ? '> ' + line : '>').join('\n'), true);
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
if (tag === 'DL') {
|
|
1220
|
+
const children = Array.from(node.children);
|
|
1221
|
+
for (let i = 0; i < children.length; i++) {
|
|
1222
|
+
if (children[i].tagName !== 'DT') continue;
|
|
1223
|
+
const term = _rapierNormalizeInlineText(_rapierPortableInlineText(children[i], opts));
|
|
1224
|
+
const defs = [];
|
|
1225
|
+
while (children[i + 1] && children[i + 1].tagName === 'DD') {
|
|
1226
|
+
defs.push(_rapierNormalizeInlineText(_rapierPortableInlineText(children[++i], opts)));
|
|
1227
|
+
}
|
|
1228
|
+
pushBlock(term + (defs.length ? ' — ' + defs.join('\n ') : ''), false);
|
|
1229
|
+
}
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (tag === 'DETAILS') {
|
|
1233
|
+
const summary = node.querySelector(':scope > summary');
|
|
1234
|
+
if (summary) pushBlock(_rapierNormalizeInlineText(_rapierPortableInlineText(summary, opts)), false);
|
|
1235
|
+
Array.from(node.children).filter(child => child !== summary).forEach(collectBlock);
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
if (tag === 'FIGURE') {
|
|
1239
|
+
const image = node.querySelector(':scope > img');
|
|
1240
|
+
if (image) pushBlock(_rapierPortableInlineText(image, opts), false);
|
|
1241
|
+
const caption = node.querySelector(':scope > figcaption');
|
|
1242
|
+
if (caption) pushBlock(_rapierNormalizeInlineText(_rapierPortableInlineText(caption, opts)), false);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
collectChildren(node);
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
collectChildren(root);
|
|
1249
|
+
// Each block stands a blank line from the next, and each empty paragraph between them adds one newline more.
|
|
1250
|
+
let joined = '', blanks = 0;
|
|
1251
|
+
for (const block of blocks) {
|
|
1252
|
+
if (block.blank) { blanks++; continue; }
|
|
1253
|
+
joined += (joined ? '\n\n' + '\n'.repeat(blanks) : '') + block.text;
|
|
1254
|
+
blanks = 0;
|
|
1255
|
+
}
|
|
1256
|
+
const text = joined.trim();
|
|
1257
|
+
return opts.finalNewline && text ? text + '\n' : text;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// The text file Export writes: a Markdown document's portable words and one final newline, any other document's own text.
|
|
1261
|
+
// The editor's Export and the door's txt both end here.
|
|
1262
|
+
function _rapierPlainTextFile(context) {
|
|
1263
|
+
return context.metadata.docKind === 'markdown'
|
|
1264
|
+
? _rapierPortablePlainText(_rapierProjectPortableRoot(_rapierHeldPicturesAsWords(context.semanticRoot), { baseName: context.baseName }), { finalNewline: true })
|
|
1265
|
+
: context.canonical;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function _rapierSourceTokenValue(node) {
|
|
1269
|
+
if (!node?.hasAttribute('data-rapier-source') || !node.hasAttribute('data-rapier-visible') ||
|
|
1270
|
+
Array.from(node.childNodes).some(child => child.nodeType !== Node.TEXT_NODE)) return null;
|
|
1271
|
+
try {
|
|
1272
|
+
return node.textContent === decodeURIComponent(node.getAttribute('data-rapier-visible'))
|
|
1273
|
+
? decodeURIComponent(node.getAttribute('data-rapier-source')) : null;
|
|
1274
|
+
} catch (_) { return null; }
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function _rapierResolveSoftBreakTokens(root) {
|
|
1278
|
+
root.querySelectorAll('.rapier-source-token--softbreak').forEach(token => {
|
|
1279
|
+
if (_rapierSourceTokenValue(token) === '\n') token.replaceWith(document.createTextNode(' '));
|
|
1280
|
+
else token.replaceWith(...token.childNodes);
|
|
1281
|
+
});
|
|
1282
|
+
return root;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function _rapierUnwrapElement(element) {
|
|
1286
|
+
while (element.firstChild) element.parentNode.insertBefore(element.firstChild, element);
|
|
1287
|
+
element.remove();
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function _rapierBidiStrong(cp) {
|
|
1291
|
+
const ch = String.fromCodePoint(cp);
|
|
1292
|
+
if (/\p{Nd}/u.test(ch)) return null;
|
|
1293
|
+
if (_RAPIER_RTL_SCRIPT.test(ch)) return 'R';
|
|
1294
|
+
if (/\p{L}/u.test(ch)) return 'L';
|
|
1295
|
+
return null;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
return {_rapierCalloutInfo, _rapierRemoveCalloutMarker, _rapierMathSource, _rapierFlattenPortableDetails, _rapierProjectPortableRoot, _rapierPortableHtml, _rapierNormalizeInlineText, _rapierPortableInlineText, _rapierPortableListText, _rapierPortableTableText, _rapierPortablePlainText, _rapierPlainTextFile, _rapierHeldPicturesAsWords, _rapierSourceTokenValue, _rapierResolveSoftBreakTokens, _rapierUnwrapElement, _rapierBidiStrong, render: (source, metadata = {}) => { if (!md) initMarkdownIt(); return _rapierRenderSemanticRoot(source, metadata).innerHTML; }, initMarkdownIt, _rapierRenderSemanticRoot, _rapierInstallSemanticProbe, _rapierImageAltSize, _rapierImageAltSourceParts, _rapierImageAltText, _rapierImageSizeAttributes, _rapierRawHtmlFragment, _rapierApplyBlockDirection, _rapierFirstStrongDir, _rapierAssignHeadingSlugs, _rapierDisambiguateRenderedAnchors, _rapierDecodeHash, _rapierDecorateJumpLists, _rapierMarkFigureCaptions, _rapierMarkListContinuations, _rapierMarkTableCaptions, _rapierNormalizeCodeElement, _rapierLanguageClass, _relabelFootnoteHtml, _footnoteIdFromLabel, wrapDisplayMath, parser: () => md};
|
|
822
1299
|
}
|
|
823
|
-
export {RAPIER_TABLE_CAPTION_PREFIX_RE as tableCaptionPrefix, createMarkdownRenderer};
|
|
1300
|
+
export {RAPIER_TABLE_CAPTION_PREFIX_RE as tableCaptionPrefix, RAPIER_PORTABLE_HIGHLIGHT_HEX as portableHighlightHex, _RAPIER_RTL_SCRIPT as rtlScripts, createMarkdownRenderer};
|
|
@@ -61,6 +61,9 @@ function _rapierPrintWillFont(markers) {
|
|
|
61
61
|
u16(segments.length * 2 - power * 2), segments.map(s => u16(s[1])), u16(0), segments.map(s => u16(s[0])),
|
|
62
62
|
segments.map(s => u16(s[2])), segments.map(() => u16(0)));
|
|
63
63
|
const full = cat(u16(12), u16(0), u32(16 + wide.length * 12), u32(0), u32(wide.length), wide.map(g => cat(u32(g.start), u32(g.end), u32(g.glyph))));
|
|
64
|
+
const cmap = narrow.length <= 0xffff
|
|
65
|
+
? cat(u16(0), u16(2), u16(3), u16(1), u32(20), u16(3), u16(10), u32(20 + narrow.length), narrow, full)
|
|
66
|
+
: cat(u16(0), u16(1), u16(3), u16(10), u32(12), full);
|
|
64
67
|
const outline = cat(u16(1), u16(0), u16(-2), u16(10), u16(2), u16(2), u16(0), [1, 1, 1], u16(0), u16(10), u16(-5), u16(-2), u16(4), u16(-2), [0]);
|
|
65
68
|
const utf16 = text => Array.from(text, ch => u16(ch.charCodeAt(0)));
|
|
66
69
|
const names = [[1, 'RapierWill'], [2, 'Regular'], [4, 'RapierWill Regular'], [6, 'RapierWill-Regular']];
|
|
@@ -70,7 +73,7 @@ function _rapierPrintWillFont(markers) {
|
|
|
70
73
|
u16(50), u16(300), u16(0), new Array(10).fill(0), u32(0), u32(0), u32(0), u32(0), [78, 79, 78, 69], u16(0x40),
|
|
71
74
|
u16(order.find(code => code < 0xffff) || 0x20), u16(order.filter(code => code < 0xffff).pop() || 0x20), u16(500), u16(-500), u16(0),
|
|
72
75
|
u16(500), u16(500), u32(1), u32(0), u16(500), u16(700), u16(0), u16(32), u16(0)),
|
|
73
|
-
cmap
|
|
76
|
+
cmap,
|
|
74
77
|
glyf: Array.from({ length: count }, () => outline).flat(),
|
|
75
78
|
head: cat(u32(0x00010000), u32(0x00010000), u32(0), u32(0x5f0f3cf5), u16(1), u16(1000), new Array(16).fill(0), u16(0), u16(-2), u16(10), u16(2),
|
|
76
79
|
u16(0), u16(8), u16(2), u16(1), u16(0)),
|
|
@@ -93,7 +96,7 @@ function _rapierPrintWillFont(markers) {
|
|
|
93
96
|
const file = cat(u32(0x00010000), u16(tags.length), u16((1 << tableSelect) * 16), u16(tableSelect), u16(tags.length * 16 - (1 << tableSelect) * 16), directory, tags.map(tag => padded(tables[tag])));
|
|
94
97
|
const headAt = 12 + tags.length * 16 + tags.slice(0, tags.indexOf('head')).reduce((total, tag) => total + padded(tables[tag]).length, 0);
|
|
95
98
|
file.splice(headAt + 8, 4, ...u32((0xb1b0afba - sum(file)) >>> 0));
|
|
96
|
-
return { family: 'RapierWillCarrier', bytes: Uint8Array.from(file), size };
|
|
99
|
+
return { family: 'RapierWillCarrier', bytes: Uint8Array.from(file), size, codes: order };
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
function _rapierPrintWillMetrics(markers) {
|
|
@@ -160,6 +163,264 @@ function _rapierPrintWillPageCss() {
|
|
|
160
163
|
+ '}';
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
|
|
166
|
+
// The capture's own limits (agent/visual.mjs VISUAL_LIMITS): the longest edge and the most pixels one capture returns.
|
|
167
|
+
const CAPTURE_EDGE = 4096, CAPTURE_PIXELS = 4 * 1024 * 1024;
|
|
168
|
+
|
|
169
|
+
async function _rapierWritePrintPdf({root, css, canonical, filename, title, settings}, {capture, signal, current, maxBytes = 8 * 1024 * 1024} = {}) {
|
|
170
|
+
const refuse = code => { throw Object.assign(new Error(code), {code}); };
|
|
171
|
+
const check = () => {
|
|
172
|
+
if (signal?.aborted) refuse('cancelled');
|
|
173
|
+
if (current?.() === false) refuse('document_changed');
|
|
174
|
+
};
|
|
175
|
+
if (!root?.ownerDocument || typeof capture !== 'function') refuse('pdf_render_unavailable');
|
|
176
|
+
// The native printer chooses its default paper. A file made without a printer uses A4,
|
|
177
|
+
// with the same explicit document settings and default margins as the print artifact.
|
|
178
|
+
const paper = settings?.papersize || {width: 11906, height: 16838};
|
|
179
|
+
const width = paper.width / 20, height = paper.height / 20;
|
|
180
|
+
const margin = settings?.geometry ? settings.geometry.twips / 20 : null;
|
|
181
|
+
const margins = {top: margin ?? 12 * 72 / 25.4, right: margin ?? 14 * 72 / 25.4,
|
|
182
|
+
bottom: margin ?? 14 * 72 / 25.4, left: margin ?? 14 * 72 / 25.4};
|
|
183
|
+
const bodyWidth = Math.floor((width - margins.left - margins.right) * 4 / 3);
|
|
184
|
+
const bodyHeight = Math.floor((height - margins.top - margins.bottom) * 4 / 3);
|
|
185
|
+
if (bodyWidth < 1 || bodyHeight < 1 || bodyWidth * bodyHeight > 4 * 1024 * 1024) refuse('pdf_page_geometry_invalid');
|
|
186
|
+
const frame = document.createElement('iframe');
|
|
187
|
+
let rejectAbort;
|
|
188
|
+
const aborted = new Promise((_, reject) => { rejectAbort = reject; });
|
|
189
|
+
aborted.catch(() => {});
|
|
190
|
+
const abort = () => rejectAbort(Object.assign(new Error('cancelled'), {code: 'cancelled'}));
|
|
191
|
+
signal?.addEventListener('abort', abort, {once: true});
|
|
192
|
+
const step = async work => {
|
|
193
|
+
check();
|
|
194
|
+
let timer;
|
|
195
|
+
try {
|
|
196
|
+
const timeout = new Promise((_, reject) => {
|
|
197
|
+
timer = setTimeout(() => reject(Object.assign(new Error('pdf_render_timeout'), {code: 'pdf_render_timeout'})), 15000);
|
|
198
|
+
});
|
|
199
|
+
const value = await Promise.race([work, aborted, timeout]); check(); return value;
|
|
200
|
+
} finally { clearTimeout(timer); }
|
|
201
|
+
};
|
|
202
|
+
frame.setAttribute('aria-hidden', 'true');
|
|
203
|
+
frame.style.cssText = `position:fixed;left:-100000px;top:0;width:${bodyWidth}px;height:${bodyHeight}px;border:0;pointer-events:none`;
|
|
204
|
+
document.body.append(frame);
|
|
205
|
+
try {
|
|
206
|
+
check();
|
|
207
|
+
const into = frame.contentDocument;
|
|
208
|
+
if (!into?.body) refuse('pdf_render_unavailable');
|
|
209
|
+
into.documentElement.setAttribute('data-rapier-theme', 'light');
|
|
210
|
+
into.documentElement.setAttribute('data-highlights', document.documentElement.getAttribute('data-highlights') || 'standard');
|
|
211
|
+
into.body.className = 'light';
|
|
212
|
+
const sheet = into.createElement('style'); sheet.textContent = String(css || ''); into.head.append(sheet);
|
|
213
|
+
const printRules = [];
|
|
214
|
+
const collect = rules => {
|
|
215
|
+
for (const rule of rules) {
|
|
216
|
+
if (rule.media?.mediaText === 'print') printRules.push(...Array.from(rule.cssRules, item => item.cssText));
|
|
217
|
+
else if (rule.cssRules && !rule.media) collect(rule.cssRules);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
collect(sheet.sheet.cssRules);
|
|
221
|
+
const printed = into.createElement('style');
|
|
222
|
+
printed.textContent = printRules.join('\n') + `\nhtml,body{width:${bodyWidth}px!important;height:${bodyHeight}px!important;margin:0!important;padding:0!important;overflow:visible!important}`;
|
|
223
|
+
into.head.append(printed);
|
|
224
|
+
const surface = into.createElement('div'), strip = into.createElement('div');
|
|
225
|
+
surface.style.cssText = `width:${bodyWidth}px;height:${bodyHeight}px;overflow:hidden;position:relative;background:#fff`;
|
|
226
|
+
strip.style.cssText = `width:${bodyWidth}px;height:${bodyHeight}px;position:relative`;
|
|
227
|
+
strip.append(root); surface.append(strip); into.body.append(surface);
|
|
228
|
+
const main = root.querySelector('main');
|
|
229
|
+
if (!main) refuse('pdf_render_unavailable');
|
|
230
|
+
// A file for paper is light whatever the person's own scheme: the page's text takes its colour scheme from this attribute.
|
|
231
|
+
main.setAttribute('data-md-theme', 'light');
|
|
232
|
+
for (const details of main.querySelectorAll('details')) details.open = true;
|
|
233
|
+
main.getBoundingClientRect();
|
|
234
|
+
await step(into.fonts.ready);
|
|
235
|
+
for (const image of main.querySelectorAll('img')) { image.loading = 'eager'; await step(image.decode()); }
|
|
236
|
+
check();
|
|
237
|
+
// The print sheet owns the words and styles. CSS fragmentation supplies the file's
|
|
238
|
+
// pages without a print dialog; its break constraints become column constraints here.
|
|
239
|
+
for (const node of [main, ...main.querySelectorAll('*')]) {
|
|
240
|
+
const style = into.defaultView.getComputedStyle(node);
|
|
241
|
+
for (const name of ['break-before', 'break-after', 'break-inside']) {
|
|
242
|
+
const value = style.getPropertyValue(name);
|
|
243
|
+
if (value.includes('page')) node.style.setProperty(name, value.replace('page', 'column'), 'important');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const [name, value] of Object.entries({width: bodyWidth + 'px', height: bodyHeight + 'px',
|
|
247
|
+
'max-width': 'none', 'min-height': '0', padding: '0', margin: '0', overflow: 'visible',
|
|
248
|
+
'column-width': bodyWidth + 'px', 'column-gap': '0', 'column-count': 'auto', 'column-fill': 'auto'}))
|
|
249
|
+
main.style.setProperty(name, value, 'important');
|
|
250
|
+
const count = Math.max(1, Math.ceil((main.scrollWidth - 1) / bodyWidth));
|
|
251
|
+
if (!Number.isSafeInteger(count)) refuse('pdf_page_geometry_invalid');
|
|
252
|
+
const textPages = await _rapierPrintTextPages(main, bodyWidth, count, check);
|
|
253
|
+
const text = textPages.flatMap(page => page.map(run => run.text));
|
|
254
|
+
const glyphs = new Set();
|
|
255
|
+
for (const line of text) for (const character of line) {
|
|
256
|
+
glyphs.add(character);
|
|
257
|
+
if (glyphs.size > 65000) refuse('pdf_text_glyph_limit');
|
|
258
|
+
}
|
|
259
|
+
const font = _rapierPrintWillFont(text);
|
|
260
|
+
strip.style.width = count * bodyWidth + 'px';
|
|
261
|
+
// A capture costs the whole document, however little of it the clip takes, so the pages that carry no picture share captures: as many to one
|
|
262
|
+
// as the capture's own limits allow, fewer when the capture refuses its size. A page with a picture is captured alone, so the picture keeps
|
|
263
|
+
// the capture's byte limit to itself.
|
|
264
|
+
let share = main.querySelector('img,svg,canvas,video,picture') ? 1
|
|
265
|
+
: Math.max(1, Math.min(Math.floor(CAPTURE_EDGE / bodyWidth), Math.floor(CAPTURE_PIXELS / (bodyWidth * bodyHeight))));
|
|
266
|
+
const pages = [];
|
|
267
|
+
let imageBytes = 0;
|
|
268
|
+
for (let first = 0; first < count;) {
|
|
269
|
+
check();
|
|
270
|
+
const span = Math.min(share, count - first);
|
|
271
|
+
surface.style.width = span * bodyWidth + 'px';
|
|
272
|
+
strip.style.transform = `translateX(${-first * bodyWidth}px)`;
|
|
273
|
+
let captured;
|
|
274
|
+
try { captured = await capture({root: surface, clip: {x: 0, y: 0, width: span * bodyWidth, height: bodyHeight}, signal, current}); }
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error?.code === 'visual_too_large' && span > 1) { share = Math.ceil(span / 2); continue; }
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
check();
|
|
280
|
+
const image = new into.defaultView.Image();
|
|
281
|
+
image.src = 'data:' + captured.mimeType + ';base64,' + captured.data;
|
|
282
|
+
try {
|
|
283
|
+
await step(image.decode());
|
|
284
|
+
for (let offset = 0; offset < span; offset++) {
|
|
285
|
+
const canvas = into.createElement('canvas'); canvas.width = bodyWidth; canvas.height = bodyHeight;
|
|
286
|
+
try {
|
|
287
|
+
const context = canvas.getContext('2d', {alpha: false});
|
|
288
|
+
if (!context) refuse('pdf_render_unavailable');
|
|
289
|
+
context.fillStyle = '#fff'; context.fillRect(0, 0, bodyWidth, bodyHeight);
|
|
290
|
+
context.drawImage(image, offset * bodyWidth, 0, bodyWidth, bodyHeight, 0, 0, bodyWidth, bodyHeight);
|
|
291
|
+
const blob = await step(new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.95)));
|
|
292
|
+
if (!blob) refuse('pdf_render_unavailable');
|
|
293
|
+
imageBytes += blob.size;
|
|
294
|
+
if (imageBytes > maxBytes) refuse('export_too_large');
|
|
295
|
+
pages.push(new Uint8Array(await blob.arrayBuffer()));
|
|
296
|
+
} finally { canvas.width = canvas.height = 0; }
|
|
297
|
+
}
|
|
298
|
+
} finally { image.src = ''; }
|
|
299
|
+
first += span;
|
|
300
|
+
}
|
|
301
|
+
check();
|
|
302
|
+
const bytes = await writeRasterPdf({pages, textPages, canonical, filename, title, font, width, height, margins,
|
|
303
|
+
pixelWidth: bodyWidth, pixelHeight: bodyHeight, pageNumbers: settings?.pagestyle === 'plain', maxBytes, check});
|
|
304
|
+
check();
|
|
305
|
+
return {bytes, pages: count};
|
|
306
|
+
} finally { signal?.removeEventListener('abort', abort); frame.remove(); }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Ranges locate the actual text fragments after column layout. Splitting a text node at
|
|
310
|
+
// its line and page boundaries keeps a PDF search on the page that carries those words.
|
|
311
|
+
async function _rapierPrintTextPages(main, pageWidth, count, check) {
|
|
312
|
+
const into = main.ownerDocument, origin = main.getBoundingClientRect();
|
|
313
|
+
const pages = Array.from({length: count}, () => []), walker = into.createTreeWalker(main, 4);
|
|
314
|
+
const range = into.createRange();
|
|
315
|
+
let nodes = 0, operations = 0;
|
|
316
|
+
for (let node; (node = walker.nextNode());) {
|
|
317
|
+
if (++nodes % 128 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); check(); }
|
|
318
|
+
const style = into.defaultView.getComputedStyle(node.parentElement);
|
|
319
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') continue;
|
|
320
|
+
const preserve = /pre|break-spaces/.test(style.whiteSpace);
|
|
321
|
+
const block = node.parentElement.closest('p,h1,h2,h3,h4,h5,h6,li,pre,td,th,figcaption,blockquote,.rapier-will-carrier') || node.parentElement;
|
|
322
|
+
const carrier = node.parentElement.closest('.rapier-will-carrier');
|
|
323
|
+
const value = node.data;
|
|
324
|
+
const pageOf = x => Math.max(0, Math.min(count - 1, Math.floor((x - origin.left + .01) / pageWidth)));
|
|
325
|
+
const visit = (start, end) => {
|
|
326
|
+
if (++operations % 128 === 0) check();
|
|
327
|
+
range.setStart(node, start); range.setEnd(node, end);
|
|
328
|
+
const rects = Array.from(range.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0);
|
|
329
|
+
if (!rects.length) return;
|
|
330
|
+
const first = rects[0], page = pageOf(first.left);
|
|
331
|
+
const oneLine = rects.every(rect => pageOf(rect.left) === page && pageOf(rect.right - .02) === page && Math.abs(rect.top - first.top) < .5);
|
|
332
|
+
let middle = Math.floor((start + end) / 2);
|
|
333
|
+
if (middle > start && /[\uD800-\uDBFF]/.test(value[middle - 1]) && /[\uDC00-\uDFFF]/.test(value[middle])) middle--;
|
|
334
|
+
if (!oneLine && middle > start && middle < end) { visit(start, middle); visit(middle, end); return; }
|
|
335
|
+
let text = value.slice(start, end);
|
|
336
|
+
text = preserve ? text.replace(/\r\n?|\n/g, '') : text.replace(/[ \t\r\n\f]+/g, ' ');
|
|
337
|
+
if (!text) return;
|
|
338
|
+
const x = Math.min(...rects.map(rect => rect.left)) - origin.left - page * pageWidth;
|
|
339
|
+
const y = Math.min(...rects.map(rect => rect.top)) - origin.top;
|
|
340
|
+
const right = Math.max(...rects.map(rect => rect.right)) - origin.left - page * pageWidth;
|
|
341
|
+
const bottom = Math.max(...rects.map(rect => rect.bottom)) - origin.top;
|
|
342
|
+
const last = pages[page].at(-1);
|
|
343
|
+
if (last && last.block === block && last.carrier === carrier && Math.abs(last.y - y) < Math.max(2, Math.min(last.height, bottom - y) * .2)) {
|
|
344
|
+
last.text = preserve ? last.text + text : (last.text + text).replace(/[ \t\r\n\f]+/g, ' ');
|
|
345
|
+
last.width = Math.max(last.x + last.width, right) - Math.min(last.x, x);
|
|
346
|
+
last.height = Math.max(last.y + last.height, bottom) - Math.min(last.y, y);
|
|
347
|
+
last.x = Math.min(last.x, x); last.y = Math.min(last.y, y);
|
|
348
|
+
} else pages[page].push({text, x, y, width: right - x, height: bottom - y, block, carrier});
|
|
349
|
+
};
|
|
350
|
+
visit(0, value.length);
|
|
351
|
+
}
|
|
352
|
+
check();
|
|
353
|
+
return pages.map(page => page.map(({block, carrier, ...run}) => run));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return {_rapierWillSentinelPlan, _rapierPrintWillAdmit, _rapierPrintWillFont, _rapierPrintWillMetrics, _rapierPrintWillPlace, _rapierPrintWillLost, _rapierPrintWillPageCss, _rapierWritePrintPdf};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// A standard PDF page tree, image streams, Unicode text and an associated source file.
|
|
360
|
+
// It accepts already-rendered pages, so there is no second document grammar or layout engine.
|
|
361
|
+
async function writeRasterPdf({pages, textPages, canonical, filename, title, font, width, height, margins, pixelWidth, pixelHeight, pageNumbers, maxBytes, check}) {
|
|
362
|
+
const encoder = new TextEncoder(), encode = value => encoder.encode(value);
|
|
363
|
+
const fail = code => { throw Object.assign(new Error(code), {code}); };
|
|
364
|
+
const concat = parts => {
|
|
365
|
+
const length = parts.reduce((sum, part) => sum + part.length, 0), bytes = new Uint8Array(length);
|
|
366
|
+
let at = 0; for (const part of parts) {bytes.set(part, at); at += part.length;} return bytes;
|
|
367
|
+
};
|
|
368
|
+
const compressed = async bytes => {
|
|
369
|
+
check();
|
|
370
|
+
const pipe = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'));
|
|
371
|
+
const result = new Uint8Array(await new Response(pipe).arrayBuffer()); check(); return result;
|
|
372
|
+
};
|
|
373
|
+
const objects = [null, null, null];
|
|
374
|
+
let used = 0;
|
|
375
|
+
const put = (id, bytes) => {
|
|
376
|
+
bytes = typeof bytes === 'string' ? encode(bytes) : bytes;
|
|
377
|
+
used += bytes.length + 32; if (used > maxBytes) fail('export_too_large'); objects[id] = bytes; return id;
|
|
378
|
+
};
|
|
379
|
+
const add = bytes => {objects.push(null); return put(objects.length - 1, bytes);};
|
|
380
|
+
const stream = (dict, bytes) => add(concat([encode('<<' + dict + ' /Length ' + bytes.length + '>>\nstream\n'), bytes, encode('\nendstream')]));
|
|
381
|
+
const hex = value => Array.from({length: value.length}, (_, index) => value.charCodeAt(index).toString(16).padStart(4, '0')).join('');
|
|
382
|
+
const literal = value => '(' + String(value).replace(/[\\()\r\n]/g, character => ({'\\': '\\\\', '(': '\\(', ')': '\\)', '\r': '\\r', '\n': '\\n'}[character])) + ')';
|
|
383
|
+
const source = encoder.encode(canonical);
|
|
384
|
+
const embedded = stream('/Type /EmbeddedFile /Subtype /text#2Fplain /Params <</Size ' + source.length + '>> /Filter /FlateDecode', await compressed(source));
|
|
385
|
+
const file = add('<< /Type /Filespec /F ' + literal(String(filename).replace(/[^\x20-\x7e]/g, '_')) + ' /UF <feff' + hex(filename) + '> /AFRelationship /Source /EF << /F ' + embedded + ' 0 R /UF ' + embedded + ' 0 R >> >>');
|
|
386
|
+
const codes = new Map(font.codes.map((code, index) => [code, index + 1]));
|
|
387
|
+
const rows = font.codes.map((code, index) => '<' + (index + 1).toString(16).padStart(4, '0') + '> <' + hex(String.fromCodePoint(code)) + '>');
|
|
388
|
+
let cmap = '/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /RapierText def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <ffff>\nendcodespacerange\n';
|
|
389
|
+
for (let at = 0; at < rows.length; at += 100) cmap += Math.min(100, rows.length - at) + ' beginbfchar\n' + rows.slice(at, at + 100).join('\n') + '\nendbfchar\n';
|
|
390
|
+
cmap += 'endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend';
|
|
391
|
+
const mapping = stream('/Filter /FlateDecode', await compressed(encode(cmap)));
|
|
392
|
+
const fontFile = stream('/Length1 ' + font.bytes.length + ' /Filter /FlateDecode', await compressed(font.bytes));
|
|
393
|
+
const descriptor = add('<< /Type /FontDescriptor /FontName /RapierText /Flags 4 /FontBBox [0 -2 10 2] /ItalicAngle 0 /Ascent 500 /Descent -500 /CapHeight 500 /StemV 80 /FontFile2 ' + fontFile + ' 0 R >>');
|
|
394
|
+
const descendant = add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /RapierText /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ' + descriptor + ' 0 R /DW 500 /CIDToGIDMap /Identity >>');
|
|
395
|
+
const type = add('<< /Type /Font /Subtype /Type0 /BaseFont /RapierText /Encoding /Identity-H /DescendantFonts [' + descendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
|
|
396
|
+
const numberFont = pageNumbers ? add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>') : null;
|
|
397
|
+
const kids = [], decimal = value => Number(value.toFixed(4));
|
|
398
|
+
for (let index = 0; index < pages.length; index++) {
|
|
399
|
+
check();
|
|
400
|
+
const picture = stream('/Type /XObject /Subtype /Image /Width ' + pixelWidth + ' /Height ' + pixelHeight + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode', pages[index]);
|
|
401
|
+
let commands = 'q\n' + [decimal(pixelWidth * .75), 0, 0, decimal(pixelHeight * .75), decimal(margins.left), decimal(height - margins.top - pixelHeight * .75)].join(' ') + ' cm\n/Im0 Do\nQ\n';
|
|
402
|
+
for (const run of textPages[index]) {
|
|
403
|
+
const characters = Array.from(run.text);
|
|
404
|
+
const value = characters.map(character => codes.get(character.codePointAt(0)).toString(16).padStart(4, '0')).join('');
|
|
405
|
+
const size = Math.max(.1, run.height * .75);
|
|
406
|
+
const scale = run.width * .75 / (characters.length * size * .5) * 100;
|
|
407
|
+
commands += `BT\n/F1 ${decimal(size)} Tf\n${decimal(scale)} Tz\n3 Tr\n1 0 0 1 ${decimal(margins.left + run.x * .75)} ${decimal(height - margins.top - (run.y + run.height * .8) * .75)} Tm\n<${value}> Tj\nET\n`;
|
|
408
|
+
}
|
|
409
|
+
if (numberFont) commands += `BT\n/F2 9 Tf\n0 Tr\n1 0 0 1 ${decimal(width / 2 - String(index + 1).length * 2.5)} ${decimal(margins.bottom / 2)} Tm\n(${index + 1}) Tj\nET\n`;
|
|
410
|
+
const content = stream('/Filter /FlateDecode', await compressed(encode(commands)));
|
|
411
|
+
kids.push(add('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' + decimal(width) + ' ' + decimal(height) + '] /Resources << /XObject << /Im0 ' + picture + ' 0 R >> /Font << /F1 ' + type + ' 0 R' + (numberFont ? ' /F2 ' + numberFont + ' 0 R' : '') + ' >> >> /Contents ' + content + ' 0 R >>'));
|
|
412
|
+
}
|
|
413
|
+
put(2, '<< /Type /Pages /Count ' + kids.length + ' /Kids [' + kids.map(id => id + ' 0 R').join(' ') + '] >>');
|
|
414
|
+
put(1, '<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [<feff' + hex(filename) + '> ' + file + ' 0 R] >> >> /AF [' + file + ' 0 R] >>');
|
|
415
|
+
const info = add('<< /Producer (Rapier) /Title <feff' + hex(title || filename.replace(/\.[^.]*$/, '')) + '> >>');
|
|
416
|
+
const parts = [encode('%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n')], offsets = [0];
|
|
417
|
+
let length = parts[0].length;
|
|
418
|
+
for (let id = 1; id < objects.length; id++) {
|
|
419
|
+
offsets.push(length);
|
|
420
|
+
const body = concat([encode(id + ' 0 obj\n'), objects[id], encode('\nendobj\n')]); parts.push(body); length += body.length;
|
|
421
|
+
}
|
|
422
|
+
const xref = length;
|
|
423
|
+
parts.push(encode('xref\n0 ' + objects.length + '\n0000000000 65535 f \n' + offsets.slice(1).map(at => String(at).padStart(10, '0') + ' 00000 n \n').join('') + 'trailer\n<< /Size ' + objects.length + ' /Root 1 0 R /Info ' + info + ' 0 R >>\nstartxref\n' + xref + '\n%%EOF\n'));
|
|
424
|
+
const bytes = concat(parts); if (bytes.length > maxBytes) fail('export_too_large'); return bytes;
|
|
164
425
|
}
|
|
165
426
|
export {createPrintRenderer, RAPIER_WILL_SENTINEL_MARK as sentinel};
|
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
const RAPIER_SANITIZE_FORBID_TAGS = Object.freeze(['form', 'style', 'dialog', 'template', 'iframe']);
|
|
3
3
|
const RAPIER_SANITIZE_FORBID_ATTR = Object.freeze(['action', 'formaction', 'form', 'for']);
|
|
4
|
+
// The document profiles use one allowlist in a browser and in an inert server parser.
|
|
5
|
+
const RAPIER_SANITIZE_PROFILES = Object.freeze({
|
|
6
|
+
htmlTags: Object.freeze(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),
|
|
7
|
+
svgTags: Object.freeze(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),
|
|
8
|
+
mathTags: Object.freeze(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),
|
|
9
|
+
htmlAttributes: Object.freeze(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","command","commandfor","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","exportparts","face","for","headers","height","hidden","high","href","hreflang","id","inert","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","part","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","slot","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns"]),
|
|
10
|
+
svgAttributes: Object.freeze(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dominant-baseline","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","mask-type","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","pointer-events","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-orientation","text-rendering","textlength","type","u1","u2","unicode","values","vector-effect","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),
|
|
11
|
+
mathAttributes: Object.freeze(["accent","accentunder","align","bevelled","close","columnalign","columnlines","columnspacing","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lquote","lspace","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),
|
|
12
|
+
xmlAttributes: Object.freeze(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),
|
|
13
|
+
});
|
|
14
|
+
const RAPIER_SANITIZE_DROP_CONTENTS = Object.freeze('annotation-xml.audio.colgroup.desc.foreignobject.head.iframe.math.mi.mn.mo.ms.mtext.noembed.noframes.noscript.plaintext.script.selectedcontent.style.svg.template.thead.title.video.xmp'.split('.'));
|
|
15
|
+
function _rapierSanitizeProfile(...kinds) {
|
|
16
|
+
return {ALLOWED_TAGS: Object.freeze(['#text', ...new Set(kinds.flatMap(kind => RAPIER_SANITIZE_PROFILES[kind + 'Tags']))]),
|
|
17
|
+
ALLOWED_ATTR: Object.freeze([...new Set(kinds.flatMap(kind => RAPIER_SANITIZE_PROFILES[kind + 'Attributes']).concat(kinds.some(kind => kind !== 'html') ? RAPIER_SANITIZE_PROFILES.xmlAttributes : []))]),
|
|
18
|
+
FORBID_CONTENTS: RAPIER_SANITIZE_DROP_CONTENTS};
|
|
19
|
+
}
|
|
4
20
|
const RAPIER_SANITIZE_OPTIONS = Object.freeze({
|
|
5
21
|
render: Object.freeze({
|
|
6
|
-
|
|
22
|
+
..._rapierSanitizeProfile('html', 'svg', 'math'),
|
|
7
23
|
FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
|
|
8
24
|
FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
|
|
9
25
|
}),
|
|
10
26
|
paste: Object.freeze({
|
|
11
|
-
|
|
27
|
+
..._rapierSanitizeProfile('html'),
|
|
12
28
|
FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
|
|
13
29
|
FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
|
|
14
30
|
}),
|
|
15
31
|
snippet: Object.freeze({
|
|
16
|
-
|
|
32
|
+
..._rapierSanitizeProfile('html'),
|
|
17
33
|
FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
|
|
18
34
|
FORBID_ATTR: Object.freeze([...'style']),
|
|
19
35
|
}),
|
|
20
36
|
diagram: Object.freeze({
|
|
21
|
-
|
|
37
|
+
..._rapierSanitizeProfile('html', 'svg'),
|
|
22
38
|
FORBID_TAGS: Object.freeze(['form', 'dialog', 'template', 'iframe', 'script', 'foreignobject']),
|
|
23
39
|
FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
|
|
24
40
|
}),
|
|
@@ -66,9 +82,98 @@ function prepareMathSource(input, DOMParser) {
|
|
|
66
82
|
return sourceRoot ? root.outerHTML : root;
|
|
67
83
|
}
|
|
68
84
|
|
|
85
|
+
// An inert parser and a string sanitizer use the same profile as DOMPurify. Neither port may
|
|
86
|
+
// execute document scripts or fetch resources. HTML5 parsing precedes filtering so foreign
|
|
87
|
+
// content, entities and malformed table markup cross the same boundary as a browser's parser.
|
|
88
|
+
function createInertHtmlSanitizer({parseFragment, serialize, sanitizeHtml, safeRasterDataUrl}) {
|
|
89
|
+
if (![parseFragment, serialize, sanitizeHtml, safeRasterDataUrl].every(port => typeof port === 'function'))
|
|
90
|
+
throw new TypeError('The inert HTML sanitizer requires every parser and filtering port');
|
|
91
|
+
const uri = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
|
92
|
+
const uriSafe = new Set(['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']);
|
|
93
|
+
const schemes = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp', 'matrix'];
|
|
94
|
+
const htmlNamespace = 'http://www.w3.org/1999/xhtml', svgNamespace = 'http://www.w3.org/2000/svg', mathNamespace = 'http://www.w3.org/1998/Math/MathML';
|
|
95
|
+
const svgTags = new Set(RAPIER_SANITIZE_PROFILES.svgTags), mathTags = new Set(RAPIER_SANITIZE_PROFILES.mathTags);
|
|
96
|
+
const mathIntegration = new Set(['mi', 'mo', 'mn', 'ms', 'mtext']), htmlIntegration = new Set(['annotation-xml']);
|
|
97
|
+
const sharedHtmlSvg = new Set(['title', 'style', 'font', 'a', 'script']);
|
|
98
|
+
// DOMPurify's default namespace admission applies in addition to its tag profile. In
|
|
99
|
+
// particular a MathML name parsed as HTML, or HTML inside SVG desc, is not admitted.
|
|
100
|
+
const validNamespace = (node, parent) => {
|
|
101
|
+
const tag = node.tagName.toLowerCase(), parentTag = (parent.tagName || 'template').toLowerCase();
|
|
102
|
+
const namespace = node.namespaceURI, parentNamespace = parent.namespaceURI || htmlNamespace;
|
|
103
|
+
if (namespace === svgNamespace) return parentNamespace === htmlNamespace ? tag === 'svg'
|
|
104
|
+
: parentNamespace === mathNamespace ? tag === 'svg' && (parentTag === 'annotation-xml' || mathIntegration.has(parentTag)) : svgTags.has(tag);
|
|
105
|
+
if (namespace === mathNamespace) return parentNamespace === htmlNamespace ? tag === 'math'
|
|
106
|
+
: parentNamespace === svgNamespace ? tag === 'math' && htmlIntegration.has(parentTag) : mathTags.has(tag);
|
|
107
|
+
return namespace === htmlNamespace && !(parentNamespace === svgNamespace && !htmlIntegration.has(parentTag))
|
|
108
|
+
&& !(parentNamespace === mathNamespace && !mathIntegration.has(parentTag))
|
|
109
|
+
&& !mathTags.has(tag) && (sharedHtmlSvg.has(tag) || !svgTags.has(tag));
|
|
110
|
+
};
|
|
111
|
+
return function sanitize(value, context = 'render') {
|
|
112
|
+
const selected = context === 'raw' || context === 'export' || context === 'source' || RAPIER_SANITIZE_OPTIONS[context] ? context : 'render';
|
|
113
|
+
const options = RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
|
|
114
|
+
const forbiddenTags = new Set(options.FORBID_TAGS || []), forbiddenAttributes = new Set(options.FORBID_ATTR || []);
|
|
115
|
+
const tags = new Set(options.ALLOWED_TAGS.filter(tag => tag !== '#text' && !forbiddenTags.has(tag)));
|
|
116
|
+
const attributes = new Set(options.ALLOWED_ATTR);
|
|
117
|
+
const tree = parseFragment(String(value == null ? '' : value));
|
|
118
|
+
const dropContents = new Set(RAPIER_SANITIZE_DROP_CONTENTS);
|
|
119
|
+
const admitTree = parent => {
|
|
120
|
+
for (let index = 0; index < (parent.childNodes || []).length;) {
|
|
121
|
+
const node = parent.childNodes[index], tag = node.tagName?.toLowerCase();
|
|
122
|
+
if (tag && !tags.has(tag)) {
|
|
123
|
+
const children = dropContents.has(tag) ? [] : node.childNodes || [];
|
|
124
|
+
for (const child of children) child.parentNode = parent;
|
|
125
|
+
parent.childNodes.splice(index, 1, ...children);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (tag && !validNamespace(node, parent) || node.nodeName === '#comment') {
|
|
129
|
+
parent.childNodes.splice(index, 1); continue;
|
|
130
|
+
}
|
|
131
|
+
admitTree(node); index++;
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
admitTree(tree);
|
|
135
|
+
const admitNames = node => {
|
|
136
|
+
if (node.tagName && tags.has(node.tagName.toLowerCase())) tags.add(node.tagName);
|
|
137
|
+
for (const child of node.childNodes || []) admitNames(child);
|
|
138
|
+
};
|
|
139
|
+
admitNames(tree);
|
|
140
|
+
const cleaned = sanitizeHtml(serialize(tree), {
|
|
141
|
+
allowedTags: [...tags], allowedAttributes: false,
|
|
142
|
+
disallowedTagsMode: 'discard', nonTextTags: RAPIER_SANITIZE_DROP_CONTENTS,
|
|
143
|
+
allowedSchemes: schemes, allowedSchemesByTag: {img: [...schemes, 'data']},
|
|
144
|
+
allowedSchemesAppliedToAttributes: ['href', 'src', 'xlink:href', 'cite', 'background', 'poster'],
|
|
145
|
+
parseStyleAttributes: false,
|
|
146
|
+
parser: {lowerCaseTags: false, lowerCaseAttributeNames: false, decodeEntities: true},
|
|
147
|
+
transformTags: {'*': (tagName, input) => {
|
|
148
|
+
const attribs = Object.create(null), tag = tagName.toLowerCase();
|
|
149
|
+
for (const [name, inputValue] of Object.entries(input)) {
|
|
150
|
+
const key = name.toLowerCase();
|
|
151
|
+
const value = name === 'value' ? inputValue : inputValue.trim();
|
|
152
|
+
if (forbiddenAttributes.has(key) || key.startsWith('on')) continue;
|
|
153
|
+
if (/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i.test(value)
|
|
154
|
+
|| key === 'attributename' && value.includes('href')) continue;
|
|
155
|
+
const data = /^data-[\-\w.\u00b7-\uffff]+$/.test(key), aria = /^aria-[\-\w]+$/.test(key);
|
|
156
|
+
const pageBreak = key === 'contenteditable' && value === 'false' && tag === 'div' && input['data-md-break'] === 'page';
|
|
157
|
+
if (!(data && selected !== 'raw') && !aria && !pageBreak && !attributes.has(key)) continue;
|
|
158
|
+
const compact = value.replace(/[\u0000-\u0020\u00a0\u1680\u180e\u2000-\u2029\u205f\u3000]/g, '');
|
|
159
|
+
if (!data && !aria && !pageBreak && !uriSafe.has(key) && compact) {
|
|
160
|
+
if (/^data:/i.test(compact)) {
|
|
161
|
+
if (key !== 'src' || tag !== 'img' || !safeRasterDataUrl(compact)) continue;
|
|
162
|
+
} else if (!uri.test(compact)) continue;
|
|
163
|
+
}
|
|
164
|
+
attribs[name] = value;
|
|
165
|
+
}
|
|
166
|
+
if ((tag === 'a' || tag === 'area') && attribs.target?.trim().toLowerCase() === '_blank') attribs.rel = 'noopener noreferrer';
|
|
167
|
+
return {tagName, attribs};
|
|
168
|
+
}},
|
|
169
|
+
});
|
|
170
|
+
return serialize(parseFragment(cleaned));
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
69
174
|
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
70
175
|
function createRenderSanitizer(runtime) {
|
|
71
|
-
const {CSSStyleSheet, DOMPurify, RAPIER_RASTER_DATA_URL_RE, URL, _rapierCssDeclaration, _rapierDropRemoteDeclarations, _rapierRemoteContent, _rapierRuleDescriptorIsRemote, _rapierSanitizeRuntime,
|
|
176
|
+
const {CSSStyleSheet, DOMPurify, RAPIER_RASTER_DATA_URL_RE, URL, _rapierCssDeclaration, _rapierDropRemoteDeclarations, _rapierRemoteContent, _rapierRuleDescriptorIsRemote, _rapierSanitizeRuntime, document, globalThis, location} = runtime;
|
|
72
177
|
|
|
73
178
|
// Only the diagram reader and its styled export admit static foreignObject labels. The
|
|
74
179
|
// ordinary SVG reader still rejects HTML subdocuments. The export class selects scope;
|
|
@@ -257,7 +362,10 @@ function sanitizeRapierHtml(value, context = 'render') {
|
|
|
257
362
|
let options = selected === 'raw' ? { ...RAPIER_SANITIZE_OPTIONS.render, ALLOW_DATA_ATTR: false } : RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
|
|
258
363
|
const labels = selected === 'diagram' || selected === 'export';
|
|
259
364
|
if (labels) {
|
|
260
|
-
|
|
365
|
+
// DOMPurify's USE_PROFILES replaces explicit allowlists. Add MathML to the
|
|
366
|
+
// selected profile without discarding its admitted HTML and SVG vocabulary.
|
|
367
|
+
options = {...options, ALLOWED_TAGS: [...new Set([...options.ALLOWED_TAGS, ...RAPIER_SANITIZE_PROFILES.mathTags])],
|
|
368
|
+
ALLOWED_ATTR: [...new Set([...options.ALLOWED_ATTR, ...RAPIER_SANITIZE_PROFILES.mathAttributes])], ADD_TAGS: ['foreignobject'],
|
|
261
369
|
HTML_INTEGRATION_POINTS: {foreignobject: true}, FORBID_TAGS: options.FORBID_TAGS.filter(tag => tag !== 'foreignobject')};
|
|
262
370
|
DOMPurify.addHook('uponSanitizeElement', diagramLabelElement);
|
|
263
371
|
DOMPurify.addHook('uponSanitizeAttribute', diagramLabelAttribute);
|
|
@@ -356,6 +464,18 @@ function _rapierDropRemoteRules(rules, owner) {
|
|
|
356
464
|
}
|
|
357
465
|
}
|
|
358
466
|
|
|
359
|
-
|
|
467
|
+
function _rapierVerifyRasterBytes(bytes, mime) {
|
|
468
|
+
if (mime === 'image/jxl') return globalThis.RapierImageAssets.isJxl(bytes);
|
|
469
|
+
|
|
470
|
+
// declaration, doctype or generator/license comment before <svg itself, of whatever length its
|
|
471
|
+
|
|
472
|
+
if (mime === 'image/svg+xml') return /<svg[\s>]/i.test(new TextDecoder('utf-8', {fatal: false}).decode(bytes));
|
|
473
|
+
if (mime === 'image/png') return bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
|
|
474
|
+
if (mime === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
|
475
|
+
if (mime === 'image/webp') return bytes.length >= 12 && String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF' && String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {_rapierVerifyRasterBytes, _rapierInstallSanitizeHooks, sanitizeRapierHtml, diagramLabelStyle, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
|
|
360
480
|
}
|
|
361
|
-
export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, RAPIER_SANITIZE_OPTIONS as options, prepareMathSource};
|
|
481
|
+
export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, createInertHtmlSanitizer, RAPIER_SANITIZE_OPTIONS as options, prepareMathSource};
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
3
3
|
function createRenderStyles(runtime) {
|
|
4
4
|
const {_rapierArtifactAccent, _rapierStyleText} = runtime;
|
|
5
|
-
function _rapierArtifactThemeCss() {
|
|
5
|
+
function _rapierArtifactThemeCss(houseDefaults = false) {
|
|
6
6
|
// Geometry and the selected accent belong to this page; palette and type belong to the
|
|
7
7
|
// MIT reference sheet. Only the optional code lexer still reads the app token names.
|
|
8
|
-
const accent = _rapierArtifactAccent();
|
|
8
|
+
const accent = houseDefaults ? '' : ':root,.md-render{--md-color-accent:' + _rapierArtifactAccent() + '}';
|
|
9
9
|
return `
|
|
10
|
-
|
|
10
|
+
${accent}
|
|
11
11
|
html[data-rapier-theme="dark"]{color-scheme:dark}
|
|
12
12
|
html[data-rapier-theme="light"]{color-scheme:light}
|
|
13
13
|
html[data-rapier-theme="system"]{color-scheme:light dark}
|
|
@@ -71,11 +71,11 @@ function _rapierDocumentPrintCss(hostSelector) {
|
|
|
71
71
|
}`;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function _rapierArtifactStyles(theme, includeFonts, printMode) {
|
|
74
|
+
function _rapierArtifactStyles(theme, includeFonts, printMode, houseDefaults = false) {
|
|
75
75
|
const parts = [];
|
|
76
76
|
if (includeFonts) parts.push(_rapierStyleText('rapier-font-style'));
|
|
77
77
|
parts.push(_rapierStyleText('rapier-content-style'));
|
|
78
|
-
parts.push(_rapierArtifactThemeCss());
|
|
78
|
+
parts.push(_rapierArtifactThemeCss(houseDefaults));
|
|
79
79
|
// Where the faces do not ride (the written page's policy carries no font-src), the reader's own sans is
|
|
80
80
|
// matched to Geist's metrics: one family per system face, src:local() only (fetched by nothing, so the
|
|
81
81
|
// policy is untouched), size-adjust from Geist's weighted average advance over the face's own (Geist 467
|
package/dist/kit/render.mjs
CHANGED
|
@@ -32,7 +32,7 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
32
32
|
|
|
33
33
|
// The semantic export root is detached and has never entered the live visibility queue.
|
|
34
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]')) {
|
|
35
|
+
for (const diagram of opts.kind === 'page' ? [] : context.semanticRoot.querySelectorAll('.diagram-block[data-diagram-src]')) {
|
|
36
36
|
await _rapierFillDiagram(diagram, true);
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -41,21 +41,23 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
41
41
|
const styledRoot = _rapierProjectStyledRoot(context.semanticRoot, {
|
|
42
42
|
metadata,
|
|
43
43
|
print: !!opts.print,
|
|
44
|
+
sourceCode: opts.kind === 'page',
|
|
44
45
|
baseName: base,
|
|
45
46
|
});
|
|
46
47
|
// _rapierProjectStyledRoot already returns the sanitized projection.
|
|
47
48
|
// Print never emits the layout reflow script (see below), so a print artifact has no use for
|
|
48
49
|
// this annotation -- skip it there rather than pay the cost for numbers nothing will read.
|
|
49
|
-
if (!opts.print) _rapierAnnotateExportBoxPolygons(styledRoot);
|
|
50
|
+
if (!opts.print && opts.kind !== 'page') _rapierAnnotateExportBoxPolygons(styledRoot);
|
|
50
51
|
// Share adds its nearest-side no-script float and long-code cap here. Offline-image
|
|
51
52
|
// admission above and the page policy below belong to this common writer, not its callers.
|
|
52
53
|
if (typeof opts.afterRoot === 'function') opts.afterRoot(styledRoot);
|
|
53
54
|
const carrier = opts.kind === 'standalone' ? await _rapierSharedSourceCarrier(context, styledRoot) : '';
|
|
54
|
-
const bodyHtml = styledRoot.innerHTML;
|
|
55
|
+
const bodyHtml = opts.kind === 'page' ? _rapierPageHtml(styledRoot) : styledRoot.innerHTML;
|
|
55
56
|
const theme = opts.print
|
|
56
57
|
? 'light'
|
|
58
|
+
: opts.kind === 'page' ? 'system'
|
|
57
59
|
: _rapierArtifactPreference('theme', ['system', 'light', 'dark']);
|
|
58
|
-
const highlights = _rapierArtifactPreference('highlights', ['accent', 'standard']);
|
|
60
|
+
const highlights = opts.kind === 'page' ? 'standard' : _rapierArtifactPreference('highlights', ['accent', 'standard']);
|
|
59
61
|
const versionMeta = document.querySelector('meta[name="rapier-version"]');
|
|
60
62
|
const version = versionMeta && versionMeta.content ? versionMeta.content.trim() : '0.0.0';
|
|
61
63
|
const bodyClass = theme === 'light' ? 'light' : '';
|
|
@@ -79,7 +81,7 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
79
81
|
const settings = _rapierDocumentSettingsOf(context.canonical);
|
|
80
82
|
const settingsCss = _rapierDocumentSettingsCss(settings);
|
|
81
83
|
const pageTitle = settings && settings.title ? globalThis.RapierMarkdownSpec.documentTitle(settings) : metadata.filename;
|
|
82
|
-
const css = _rapierArtifactStyles(theme, !!opts.print, !!opts.print || opts.kind === 'standalone')
|
|
84
|
+
const css = _rapierArtifactStyles(theme, !!opts.print, !!opts.print || opts.kind === 'standalone', opts.kind === 'page')
|
|
83
85
|
+ (opts.extraCss ? '\n\n' + opts.extraCss : '')
|
|
84
86
|
+ (willFont ? '\n\n@font-face{font-family:' + willFont.family + ';src:url(' + await _rapierBlobDataUrl(new Blob([willFont.bytes], { type: 'font/ttf' })) + ') format("truetype")}' : '')
|
|
85
87
|
+ (settingsCss ? '\n' + settingsCss : '');
|
|
@@ -90,11 +92,11 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
90
92
|
// The reflow script is written first, because whether it exists decides the policy. A page that carries no script says so: script-src 'none' refuses every
|
|
91
93
|
// script outright. Declaring a nonce a page has no use for is a widening, small but real -- anything that could inject markup into the file could read the
|
|
92
94
|
// nonce out of it and be admitted.
|
|
93
|
-
const layoutScript = !opts.print ? _rapierArtifactLayoutScript(styledRoot, nonce) : '';
|
|
95
|
+
const layoutScript = !opts.print && opts.kind !== 'page' ? _rapierArtifactLayoutScript(styledRoot, nonce) : '';
|
|
94
96
|
// The lexer for the page's code, under the same nonce, only when a block earned it: the CPU
|
|
95
97
|
// spans are the first paint, and where the reader's browser has WebGPU the lexer repaints them.
|
|
96
|
-
const lexerScript = !opts.print ? _rapierArtifactLexerScript(styledRoot, nonce) : '';
|
|
97
|
-
const inkScript = !opts.print ? _rapierArtifactInkScript(styledRoot, nonce) : '';
|
|
98
|
+
const lexerScript = !opts.print && opts.kind !== 'page' ? _rapierArtifactLexerScript(styledRoot, nonce) : '';
|
|
99
|
+
const inkScript = !opts.print && opts.kind !== 'page' ? _rapierArtifactInkScript(styledRoot, nonce) : '';
|
|
98
100
|
const page = '<!DOCTYPE html>\n'
|
|
99
101
|
+ '<html lang="en" data-rapier-theme="' + theme + '" data-highlights="' + highlights + '">\n'
|
|
100
102
|
+ '<head>\n<meta charset="UTF-8">\n' + (carrier ? RapierPageReturnAddress.PAGE_SEED + '\n' : '')
|
|
@@ -116,12 +118,31 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
116
118
|
html: page,
|
|
117
119
|
filename: base + '.html',
|
|
118
120
|
bodyHtml,
|
|
121
|
+
css,
|
|
119
122
|
docClass,
|
|
120
123
|
metadata,
|
|
121
124
|
documentTitle: settings && settings.title ? pageTitle : '',
|
|
122
125
|
};
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// Page files have one byte representation across native and inert DOM hosts. Attribute
|
|
129
|
+
// insertion order and a host's optional entity escapes are not document content.
|
|
130
|
+
function _rapierPageHtml(root) {
|
|
131
|
+
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
132
|
+
const children = node => Array.from(node.childNodes || [], write).join('');
|
|
133
|
+
function write(node) {
|
|
134
|
+
if (node.nodeType === 3) return escapeRapierHtmlText(node.nodeValue || '');
|
|
135
|
+
if (node.nodeType !== 1) return '';
|
|
136
|
+
// This writer emits text/html; the HTML reader restores SVG's camel-cased names.
|
|
137
|
+
const tag = node.localName.toLowerCase();
|
|
138
|
+
const attributes = Array.from(node.attributes).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
|
139
|
+
.map(attribute => ' ' + attribute.name + '="' + escapeRapierHtmlText(attribute.value) + '"').join('');
|
|
140
|
+
const open = '<' + tag + attributes + '>';
|
|
141
|
+
return voidTags.has(tag) ? open : open + children(node) + '</' + tag + '>';
|
|
142
|
+
}
|
|
143
|
+
return children(root);
|
|
144
|
+
}
|
|
145
|
+
|
|
125
146
|
function _rapierProjectStyledRoot(semanticRoot, options) {
|
|
126
147
|
const opts = options || {};
|
|
127
148
|
const root = semanticRoot.cloneNode(true);
|
|
@@ -138,11 +159,12 @@ function _rapierProjectStyledRoot(semanticRoot, options) {
|
|
|
138
159
|
if (code) {
|
|
139
160
|
const lang = _rapierLanguageClass(code) || String(metadata.codeLang || '');
|
|
140
161
|
code.classList.add('artifact-code');
|
|
141
|
-
if (metadata.docKind === 'code') { _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang)); _rapierArtifactMarkLexed(code, lang); }
|
|
162
|
+
if (metadata.docKind === 'code' && !opts.sourceCode) { _rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang)); _rapierArtifactMarkLexed(code, lang); }
|
|
142
163
|
if (lang) code.classList.add('language-' + String(lang).replace(/[^a-z0-9_-]/gi, ''));
|
|
143
164
|
}
|
|
144
165
|
} else {
|
|
145
166
|
root.querySelectorAll('pre > code').forEach(code => {
|
|
167
|
+
if (opts.sourceCode) return;
|
|
146
168
|
const lang = _rapierLanguageClass(code) || 'text';
|
|
147
169
|
_rapierPaintCode(code, _rapierArtifactHighlight(code.textContent || '', lang));
|
|
148
170
|
_rapierArtifactMarkLexed(code, lang);
|
|
@@ -184,7 +206,7 @@ function _rapierProjectStyledRoot(semanticRoot, options) {
|
|
|
184
206
|
_rapierPrefixPortableAnchors(root, opts.baseName || metadata.filename || 'document');
|
|
185
207
|
|
|
186
208
|
const clean = document.createElement('div');
|
|
187
|
-
clean.innerHTML = sanitizeRapierHtml(root.innerHTML, 'export');
|
|
209
|
+
clean.innerHTML = sanitizeRapierHtml(root.innerHTML, opts.sourceCode ? 'source' : 'export');
|
|
188
210
|
return clean;
|
|
189
211
|
}
|
|
190
212
|
|
package/dist/spec/md-assets.mjs
CHANGED
|
@@ -370,7 +370,8 @@ export function serializeAsset(asset) {
|
|
|
370
370
|
return '[' + asset.label + ']: ' + asset.url + title;
|
|
371
371
|
}
|
|
372
372
|
// Pure: re-derived at commit against the moved document; appends only at the text's end.
|
|
373
|
-
|
|
373
|
+
// A replay supplies the retained definition's line ending instead of choosing one from later prose.
|
|
374
|
+
export function appendAssetText(source, asset, lineEnding) {
|
|
374
375
|
if (typeof source !== 'string' || !asset || normalizeLabel(asset.label) !== asset.id) return fail('image_source_invalid');
|
|
375
376
|
const parsed = documentAssets(source), title = assetTitle(asset.title);
|
|
376
377
|
const existing = [...parsed.assets.values()].find(row => row.url === asset.url && row.title === title);
|
|
@@ -382,7 +383,7 @@ export function appendAssetText(source, asset) {
|
|
|
382
383
|
return {source, id: asset.id, reference: asset.label, added: false, suffix: ''};
|
|
383
384
|
}
|
|
384
385
|
if (parsed.assets.size >= IMAGE_LIMITS.assets) return fail('image_asset_count_limit');
|
|
385
|
-
const eol = /\r\n|\n|\r/.exec(source)?.[0] || '\n', tail = /(?:\r\n?|\n)[ \t]*$/.exec(source);
|
|
386
|
+
const eol = lineEnding || /\r\n|\n|\r/.exec(source)?.[0] || '\n', tail = /(?:\r\n?|\n)[ \t]*$/.exec(source);
|
|
386
387
|
const separator = !source || tail && /[\r\n][ \t]*$/.test(source.slice(0, tail.index)) ? '' : tail ? eol : eol + eol;
|
|
387
388
|
const suffix = separator + block + eol, nextSource = source + suffix;
|
|
388
389
|
if (nextSource.length > IMAGE_LIMITS.sourceChars) return fail('image_source_limit');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rapier-markdown-kit",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.80",
|
|
4
4
|
"description": "Read, render, write and style Self-contained Markdown, and carry its optional edit ledger: the shared HTML writer, semantic renderer, stylesheet, layout, marks, pictures, Will/1, replay, authorship and offline merge. MIT, no npm dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"markdown",
|