rapier-markdown-kit 1.2.6 → 1.2.7

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.
@@ -77,6 +77,14 @@ function shape(value, font, preserve, ligatures) {
77
77
  const result = [];
78
78
  for (let at = 0; at < value.length;) {
79
79
  const code = value.codePointAt(at), character = String.fromCodePoint(code), start = at;
80
+ if (code === 0x00ad) {
81
+ at += character.length;
82
+ const own = font.glyph(code), hyphen = font.glyph(0x2d), glyph = own || hyphen;
83
+ // A discretionary hyphen with no glyph stays in the page paint. Do not skip the lookup.
84
+ if (!glyph || glyph >= font.glyphCount) return null;
85
+ result.push({start, end: at, text: own ? character : '-', glyph, soft: true});
86
+ continue;
87
+ }
80
88
  if (/[\p{Mark}\p{Format}]/u.test(character) || (/[\p{Letter}]/u.test(character) && !/\p{Script=Latin}/u.test(character)) || code === 0xfb01 || code === 0xfb02) return null;
81
89
  at += character.length;
82
90
  if (!preserve && /[ \t\r\n\f]/.test(character)) {while (at < value.length && /[ \t\r\n\f]/.test(value[at])) at++;}
@@ -97,7 +105,13 @@ export async function collectPdfGlyphs(main, {width, check, faces = []}) {
97
105
  const document = main.ownerDocument, window = document.defaultView, origin = main.getBoundingClientRect();
98
106
  const nodes = new Map(), hidden = [], canvas = document.createElement('canvas'); canvas.width = canvas.height = 1;
99
107
  const context = canvas.getContext('2d', {willReadFrequently: true});
100
- const restore = () => {for (const row of hidden) {if (row.value) row.node.style.setProperty('-webkit-text-fill-color', row.value, row.priority); else row.node.style.removeProperty('-webkit-text-fill-color');} canvas.width = canvas.height = 0;};
108
+ const restore = () => {
109
+ for (const row of hidden) {
110
+ if (row.wrap) {row.wrap.replaceWith(...row.wrap.childNodes); continue;}
111
+ if (row.value) row.node.style.setProperty('-webkit-text-fill-color', row.value, row.priority); else row.node.style.removeProperty('-webkit-text-fill-color');
112
+ }
113
+ canvas.width = canvas.height = 0;
114
+ };
101
115
  if (!context) return {nodes, restore};
102
116
  const colorCache = new Map(), styles = new Map(), styleOf = node => {if (!styles.has(node)) styles.set(node, window.getComputedStyle(node)); return styles.get(node);};
103
117
  const color = value => {if (!colorCache.has(value)) {context.clearRect(0, 0, 1, 1); context.fillStyle = value; context.fillRect(0, 0, 1, 1); colorCache.set(value, Array.from(context.getImageData(0, 0, 1, 1).data, item => item / 255));} return colorCache.get(value);};
@@ -118,7 +132,7 @@ export async function collectPdfGlyphs(main, {width, check, faces = []}) {
118
132
  if (++visited % 128 === 0) {await new Promise(resolve => setTimeout(resolve, 0)); check();}
119
133
  // Leaving the existing element in place preserves selectors, counters, line breaking and inline marks.
120
134
  // Keep preformatted blocks, their inline struts and syntax paint together.
121
- if (element.namespaceURI !== 'http://www.w3.org/1999/xhtml' || element.children.length || element.closest('svg,pre,.rapier-will-carrier') || element.localName === 'li') continue;
135
+ if (element.namespaceURI !== 'http://www.w3.org/1999/xhtml' || element.children.length || element.closest('svg,pre,.rapier-will-carrier')) continue;
122
136
  const textNodes = Array.from(element.childNodes).filter(node => node.nodeType === 3);
123
137
  if (textNodes.length !== 1 || !textNodes[0].data.trim() || !safe(element)) continue;
124
138
  const language = (element.closest('[lang]')?.getAttribute('lang') || document.documentElement.lang || '').toLowerCase();
@@ -142,15 +156,39 @@ export async function collectPdfGlyphs(main, {width, check, faces = []}) {
142
156
  if (!Number.isFinite(ascent)) continue;
143
157
  const rgba = color(style.webkitTextFillColor || style.color), range = document.createRange();
144
158
  let valid = true;
159
+ const placed = [];
145
160
  for (const item of shaped) {
146
161
  range.setStart(node, item.start); range.setEnd(node, item.end);
147
- const rects = Array.from(range.getClientRects()).filter(rect => rect.height > 0), first = rects[0];
148
- if (!first || rects.some(rect => Math.abs(rect.top - first.top) > .25)) {valid = false; break;}
162
+ const ink = Array.from(range.getClientRects()).filter(rect => rect.height > 0);
163
+ // A break after a soft hyphen can leave the next letter with a phantom box on the
164
+ // hyphen's line. Keep the line that actually holds the ink.
165
+ const lines = [];
166
+ for (const rect of ink) {
167
+ const line = lines.find(row => Math.abs(row[0].top - rect.top) <= .25);
168
+ if (line) line.push(rect); else lines.push([rect]);
169
+ }
170
+ lines.sort((a, b) => b.reduce((sum, rect) => sum + rect.width, 0) - a.reduce((sum, rect) => sum + rect.width, 0));
171
+ const previous = shaped[shaped.indexOf(item) - 1];
172
+ if (lines.length > 1 && !item.soft && !previous?.soft) {valid = false; break;}
173
+ const rects = lines[0] || [];
174
+ const first = (item.soft ? rects.find(rect => rect.width > 0) : null) || rects[0];
175
+ // An unbroken soft hyphen has no ink. Leave it out of the vector run.
176
+ if (item.soft && !(first?.width > 0)) continue;
177
+ if (!first) {valid = false; break;}
149
178
  const page = Math.max(0, Math.floor((first.left - origin.left + .01) / width));
150
179
  Object.assign(item, {font, size, rgba, x: first.left - origin.left - page * width, baseline: first.top - origin.top + ascent});
180
+ placed.push(item);
181
+ }
182
+ if (!valid || !placed.length) continue;
183
+ nodes.set(node, placed);
184
+ if (element.localName === 'li') {
185
+ // A list item's marker inherits its fill and is not vectorized: hide only the words.
186
+ const wrap = document.createElement('span');
187
+ node.replaceWith(wrap); wrap.append(node);
188
+ wrap.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
189
+ hidden.push({wrap});
190
+ continue;
151
191
  }
152
- if (!valid) continue;
153
- nodes.set(node, shaped);
154
192
  hidden.push({node: element, value: element.style.getPropertyValue('-webkit-text-fill-color'), priority: element.style.getPropertyPriority('-webkit-text-fill-color')});
155
193
  element.style.setProperty('-webkit-text-fill-color', 'transparent', 'important');
156
194
  }
@@ -42,8 +42,12 @@ function _rapierPrintWillAdmit(canonical, plan) {
42
42
  if (new Set(plan.markers.flatMap(marker => Array.from(marker))).size > 65000) throw _rapierPrintWillLost('the markers use more distinct characters than a font can hold', 'Shorten them');
43
43
  }
44
44
 
45
- function _rapierPrintWillFont(markers) {
46
- const { size, advance } = _rapierPrintWillMetrics(markers);
45
+ function _rapierPrintWillFont(markers, advanceOverride) {
46
+ const metrics = _rapierPrintWillMetrics(markers);
47
+ const size = metrics.size;
48
+ // The page's carrier face keeps the short marker advance. Searchable text is
49
+ // positioned at 500 units, which is also /DW, so this copy can be asked to match it.
50
+ const advance = advanceOverride ?? metrics.advance;
47
51
  const scalars = Array.from(new Set(markers.flatMap(marker => Array.from(marker, ch => ch.codePointAt(0)))));
48
52
  const order = scalars.filter(code => code < 0x10000).sort((a, b) => a - b)
49
53
  .concat(scalars.filter(code => code >= 0x10000).sort((a, b) => b - a));
@@ -296,7 +300,7 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
296
300
  glyphs.add(character);
297
301
  if (glyphs.size > 65000) refuse('pdf_text_glyph_limit');
298
302
  }
299
- const font = _rapierPrintWillFont(text);
303
+ const font = _rapierPrintWillFont(text, 500);
300
304
  strip.style.width = count * bodyWidth + 'px';
301
305
  strip.style.transform = 'none';
302
306
  // One styled film is reused for every page window. Batches stay inside the capture edge
@@ -1,13 +1,15 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {typography} from '../spec/md-marks.mjs';
3
3
  const RAPIER_SANITIZE_FORBID_TAGS = Object.freeze(['form', 'style', 'dialog', 'template', 'iframe']);
4
- const RAPIER_SANITIZE_FORBID_ATTR = Object.freeze(['action', 'formaction', 'form', 'for']);
4
+ // Invokers (DOMPurify 3.4.16 allows them) act on another element by id, and a document shares the page with the editor's own
5
+ // controls: a document's button could open or close them.
6
+ const RAPIER_SANITIZE_FORBID_ATTR = Object.freeze(['action', 'formaction', 'form', 'for', 'command', 'commandfor', 'popover', 'popovertarget', 'popovertargetaction']);
5
7
  // The document profiles use one allowlist in a browser and in an inert server parser.
6
8
  const RAPIER_SANITIZE_PROFILES = Object.freeze({
7
9
  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"]),
8
10
  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"]),
9
11
  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"]),
10
- htmlAttributes: Object.freeze(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","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","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"]),
12
+ 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"]),
11
13
  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"]),
12
14
  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"]),
13
15
  xmlAttributes: Object.freeze(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),
@@ -270,7 +272,7 @@ function _rapierInstallSanitizeHooks() {
270
272
  if (!/^data:/i.test(compact)) return;
271
273
  const tag = node?.nodeName === 'IMG' ? 'img'
272
274
  : node?.namespaceURI === DIAGRAM_LABEL_SVG_NAMESPACE && node.localName === 'image' ? 'image' : '';
273
- const rasterAllowed = (/^(?:render|export|raw|source)$/.test(_rapierSanitizeRuntime.context)
275
+ const rasterAllowed = (/^(?:paste|render|export|raw|source)$/.test(_rapierSanitizeRuntime.context)
274
276
  || _rapierSanitizeRuntime.context === 'diagram' && tag === 'image')
275
277
  && _rapierImageDataAttributeAllowed(tag, data.attrName.toLowerCase(), compact, _rapierSafeRasterDataUrl);
276
278
  if (!rasterAllowed) data.keepAttr = false;
@@ -309,14 +311,14 @@ function _rapierInstallSanitizeHooks() {
309
311
  // know: a marker's style is only `mso-list:Ignore`. The paste carries both as data-* first, where they stay,
310
312
  // for the conversion that reads them (_rapierPasteWordLists).
311
313
  DOMPurify.addHook('uponSanitizeElement', node => {
312
- if (_rapierSanitizeRuntime.context !== 'paste' || !node || !/^(?:P|SPAN)$/.test(node.nodeName)) return;
314
+ if (_rapierSanitizeRuntime.context !== 'paste' || !node || !/^(?:P|LI|H[1-6]|SPAN)$/.test(node.nodeName)) return;
313
315
  // Only the style names them: a page cannot bring these attributes of its own.
314
316
  node.removeAttribute('data-rapier-word-level');
315
317
  node.removeAttribute('data-rapier-word-marker');
316
318
  const style = node.getAttribute('style');
317
319
  if (!style || !/mso-list/i.test(style)) return;
318
320
  const {wordListLevel, wordListIsMarker} = globalThis.RapierMarkdownSpec;
319
- const level = node.nodeName === 'P' ? wordListLevel(style) : 0;
321
+ const level = /^(?:P|LI|H[1-6])$/.test(node.nodeName) ? wordListLevel(style) : 0;
320
322
  if (level) node.setAttribute('data-rapier-word-level', String(level));
321
323
  else if (node.nodeName === 'SPAN' && wordListIsMarker(style)) node.setAttribute('data-rapier-word-marker', '1');
322
324
  });
@@ -54,7 +54,7 @@ function _rapierDocumentPrintCss(hostSelector) {
54
54
  @page{size:auto;margin:12mm 14mm 14mm}
55
55
  @media print{
56
56
  ${p('.md-render')}{--md-color-line:#000000!important}
57
- ${list('.md-render h1','.md-render h2','.md-render h3','.md-render h4','.md-render h5','.md-render h6')}{break-after:avoid-page;page-break-after:avoid;color:#111}
57
+ ${list('.md-render h1','.md-render h2','.md-render h3','.md-render h4','.md-render h5','.md-render h6')}{page-break-after:avoid;break-after:avoid-page;color:#111}
58
58
  ${list('.md-render p','.md-render li','.md-render blockquote')}{orphans:3;widows:3}
59
59
  ${p('.md-render img')}{max-width:100%!important;height:auto!important;break-inside:avoid-page}
60
60
  ${list('.md-render pre','.artifact-flat')}{white-space:pre-wrap!important;overflow-wrap:anywhere!important;word-break:break-word!important;overflow:visible!important;max-height:none!important}
@@ -66,7 +66,7 @@ function _rapierDocumentPrintCss(hostSelector) {
66
66
  ${list('.md-render th','.md-render td')}{overflow-wrap:anywhere;word-break:normal}
67
67
  ${p('.md-render thead')}{display:table-header-group}
68
68
  ${p('.md-render tfoot')}{display:table-footer-group}
69
- ${p('.md-render tr')}{break-inside:avoid-page;page-break-inside:avoid}
69
+ ${p('.md-render tr')}{page-break-inside:avoid;break-inside:avoid-page}
70
70
  ${p('.md-render details>*:not(summary)')}{display:block!important}
71
71
  ${p('.md-render details')}{break-inside:auto}
72
72
  ${p('.md-render a')}{color:#111!important;text-decoration:underline}
@@ -161,7 +161,9 @@ async function _rapierBuildArtifact(options, providedContext) {
161
161
  + (printWidth > 0 ? '.rapier-page.md-render{width:' + printWidth + 'px!important}' : '') + '}' : '';
162
162
  const pageTitle = settings && settings.title ? globalThis.RapierMarkdownSpec.documentTitle(settings) : metadata.filename;
163
163
  let css = _rapierArtifactStyles(theme, includeFonts, !!opts.print || completePage, false, opts.print && opts.kind === 'standalone' ? '' : null)
164
- + (completePage ? '\n\n' + _rapierSharedPageFallbackCss() : '')
164
+ // Print already floats pictures. Tables, code, figures and rules clear them
165
+ // through the same clearing rule a complete page uses; print keeps its own page flow.
166
+ + (completePage || opts.print ? '\n\n' + _rapierSharedPageFallbackCss(!completePage) : '')
165
167
  + (opts.extraCss ? '\n\n' + opts.extraCss : '')
166
168
  + (willFont ? '\n\n@font-face{font-family:' + willFont.family + ';src:url(' + await _rapierBlobDataUrl(new Blob([willFont.bytes], { type: 'font/ttf' })) + ') format("truetype")}' : '')
167
169
  + (settingsCss ? '\n' + settingsCss : '')
@@ -671,13 +673,13 @@ async function _rapierReadSharedDocument(text, filename) {
671
673
  return {source, filename: name, kind, bom, ...parts, comparisonBase};
672
674
  }
673
675
 
674
- function _rapierSharedPageFallbackCss() {
675
- return `
676
+ function _rapierSharedPageFallbackCss(clearOnly = false) {
677
+ const clear = '.rapier-page>:is(pre,table,hr,figure,.table-scroll-wrap,.math-display-wrap){clear:both}\n';
678
+ return clearOnly ? clear : `
676
679
  .rapier-page{display:flow-root}
677
680
  .rapier-page>:first-child{margin-top:0}
678
681
  .md-render h1{display:flow-root}
679
- .rapier-page>:is(pre,table,hr,figure,.table-scroll-wrap,.math-display-wrap){clear:both}
680
- `;
682
+ ` + clear;
681
683
  }
682
684
 
683
685
  function _rapierShareWrapKind(element) {
@@ -10,6 +10,91 @@ import {normalizeFontFamily, fontFamilyCss, FONT_STACKS} from './md-marks.mjs';
10
10
  const NUMBER = /^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
11
11
  export const DOCUMENT_LEADING = 1.75 / 1.1;
12
12
 
13
+ // Before/after are points; line is a multiple of the element's own type size.
14
+ // Presets and native exports read this same table. Explicit measures do not grow with font size.
15
+ export const SPACING_TYPES = Object.freeze(['paragraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
16
+ 'list', 'item', 'table', 'picture', 'drawing', 'quote', 'code', 'rule', 'footnote', 'toc']);
17
+ export const SPACING_PRESETS = Object.freeze({compact: .7, standard: 1, roomy: 1.5});
18
+ const SPACING_SELECTORS = Object.freeze({paragraph: 'p', h1: 'h1', h2: 'h2', h3: 'h3', h4: 'h4', h5: 'h5', h6: 'h6',
19
+ list: ':is(ul,ol)', item: 'li', table: ':is(table,.table-scroll-wrap)', picture: 'p[data-md-picture]',
20
+ drawing: ':is(.diagram-block,p[data-md-picture]:has(img[data-md-svg],img[src^="data:image/svg+xml"]))', quote: 'blockquote',
21
+ code: 'pre', rule: 'hr:not(.footnotes-sep):not([data-md-break])', footnote: '.footnotes', toc: '.rapier-jump-list'});
22
+ const HEADING_LINE = [2 / 2.6, 1.5 / 1.9, 1.2 / 1.5, 1 / 1.25, .9 / 1.1, .82];
23
+
24
+ export function normalizeDocumentSpacing(value) {
25
+ if (typeof value === 'string') {
26
+ if (Object.hasOwn(SPACING_PRESETS, value)) value = {preset: value};
27
+ else { try { value = JSON.parse(value); } catch (_) { return null; } }
28
+ }
29
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
30
+ const result = {};
31
+ for (const [key, entry] of Object.entries(value)) {
32
+ if (key === 'preset') {
33
+ if (!Object.hasOwn(SPACING_PRESETS, entry)) return null;
34
+ result.preset = entry; continue;
35
+ }
36
+ if (!SPACING_TYPES.includes(key) || !entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
37
+ const row = {};
38
+ for (const [field, number] of Object.entries(entry)) {
39
+ if (!['before', 'after', 'line'].includes(field) || typeof number !== 'number' || !Number.isFinite(number) ||
40
+ number < (field === 'line' ? .5 : 0) || number > (field === 'line' ? 4 : 720)) return null;
41
+ row[field] = number;
42
+ }
43
+ result[key] = Object.freeze(row);
44
+ }
45
+ return Object.freeze(result);
46
+ }
47
+
48
+ export function documentSpacing(settings, kind = 'paragraph') {
49
+ const type = SPACING_TYPES.includes(kind) ? kind : 'paragraph', spacing = settings?.spacing;
50
+ const preset = spacing?.preset || 'standard', scale = SPACING_PRESETS[preset];
51
+ const size = (settings?.fontsize?.unit || 1.1) / 1.1;
52
+ const heading = /^h([1-6])$/.exec(type), leading = settings?.linestretch?.factor ??
53
+ (preset === 'compact' ? 1.3 : preset === 'roomy' ? 1.85 : DOCUMENT_LEADING);
54
+ const before = heading ? 15 : type === 'footnote' ? 9 : 0;
55
+ const after = heading ? 3 : type === 'paragraph' ? 6 : type === 'item' ? 3 : type === 'footnote' ? 0 : 9;
56
+ const line = heading ? leading * HEADING_LINE[Number(heading[1]) - 1] : type === 'code' ? leading * .75 / .7386 : leading;
57
+ if (settings?.word) return {before: (heading ? 12 : type === 'footnote' ? 9 : 0) * scale,
58
+ after: (heading ? 6 : type === 'paragraph' ? 8 : type === 'item' || type === 'footnote' ? 0 : 9) * scale,
59
+ line: leading, ...spacing?.[type]};
60
+ return {before: before * scale * size, after: after * scale * size, line, ...spacing?.[type]};
61
+ }
62
+
63
+ // Word's widely supported Calibri/11 pt Normal template. Explicit document settings always win.
64
+ export function wordDocumentSettings(settings) {
65
+ return {...settings, word: true, fontsize: settings?.fontsize || fontSize('11pt'),
66
+ mainfont: settings?.mainfont || documentFont('Calibri'), sansfont: settings?.sansfont || documentFont('Calibri'),
67
+ linestretch: settings?.linestretch || leading('1.08')};
68
+ }
69
+ export function documentHeadingSize(settings, level) {
70
+ const body = (settings?.fontsize?.unit || 1.1) * 12;
71
+ return body * (settings?.word ? [16/11,13/11,12/11,1,1,1] : [2.6,1.9,1.5,1.25,1.1,1])[level - 1];
72
+ }
73
+
74
+ export function documentSpacingCss(settings, scope = '.md-render', defaults = false) {
75
+ if (!defaults && !settings?.spacing && !settings?.linestretch && !settings?.fontsize) return '';
76
+ const length = points => 'calc(' + Number((points / 12).toFixed(8)) + ' * var(--md-unit))';
77
+ let css = '';
78
+ for (const type of SPACING_TYPES) {
79
+ const row = documentSpacing(settings, type), selector = scope + ' ' + SPACING_SELECTORS[type];
80
+ css += selector + '{margin-block:' + length(row.before) + ' ' + length(row.after) + ';line-height:' + row.line + '}';
81
+ if (settings?.word && /^h[1-6]$/.test(type)) css += selector + '{font-size:' + length(documentHeadingSize(settings, Number(type[1]))) + '}';
82
+ }
83
+ // Compound blocks own the outside gap; the last child does not add it again.
84
+ css += scope + ' :is(li,td,th,blockquote)>:last-child{margin-bottom:0}' +
85
+ scope + ' li:last-child{margin-bottom:0}' + scope + ' li>:is(ul,ol){margin-bottom:0}' +
86
+ scope + ' .table-scroll-wrap>table{margin-block:0}' +
87
+ scope + ' pre{font-size:calc(var(--md-text-body) * .7386)}' +
88
+ scope + ' pre code{line-height:inherit}' + scope + ' .footnotes{padding-top:0}' +
89
+ scope + ' .footnotes-list{margin-block:0}' + scope + ' blockquote:not(.callout){padding-block:0}';
90
+ const item = documentSpacing(settings, 'item');
91
+ css += scope + ' ul.rapier-jump-list>li{padding-block:0;margin-block:' + length(item.before) + ' ' + length(item.after) + '}' +
92
+ scope + ' ul.rapier-jump-list>li:last-child{margin-bottom:0}';
93
+ for (const [type, selector] of [['item','li>p'], ['table',':is(td,th)>p'], ['quote','blockquote>p'], ['footnote','.footnotes p'], ['toc','.rapier-jump-list li']])
94
+ css += scope + ' ' + selector + '{line-height:' + documentSpacing(settings, type).line + '}';
95
+ return css;
96
+ }
97
+
13
98
  function measure(value, positive = false) {
14
99
  const text = unquote(value);
15
100
  const number = NUMBER.test(text) ? Number(text) : NaN;
@@ -40,7 +125,7 @@ const PAPERS = Object.freeze({
40
125
  });
41
126
  const EMPTY = Object.freeze({
42
127
  title: null, subtitle: null, lang: null, fontsize: null, mainfont: null, sansfont: null, monofont: null, defaultfont: null, linestretch: null,
43
- papersize: null, geometry: null, pagestyle: null,
128
+ papersize: null, geometry: null, pagestyle: null, spacing: null,
44
129
  });
45
130
 
46
131
  function unquote(value) {
@@ -257,6 +342,7 @@ export function readDocumentSettings(markdown) {
257
342
  return Object.freeze({
258
343
  title: take('title') || null, subtitle: take('subtitle') || null, lang: languageTag(take('lang')),
259
344
  fontsize, mainfont, sansfont, monofont, defaultfont, linestretch, papersize, geometry,
345
+ spacing: normalizeDocumentSpacing(take('spacing')),
260
346
  pagestyle: page === 'plain' || page === 'empty' ? page : null,
261
347
  });
262
348
  }
@@ -276,14 +362,14 @@ export function updateDocumentSettings(markdown, patch) {
276
362
  text: (source[field.start - 1] === ':' ? ' ' : '') + text + (source[field.end] === '#' ? ' ' : '')});
277
363
  for (const [key, supplied] of Object.entries(patch || {})) {
278
364
  const font = ['mainfont', 'sansfont', 'monofont', 'defaultfont'].includes(key);
279
- if (!font && !['fontsize', 'linestretch'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
365
+ if (!font && !['fontsize', 'linestretch', 'spacing'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
280
366
  const matches = fields.filter(item => item.key === key);
281
367
  if (matches.length > 1) throw new RangeError('Duplicate document setting: ' + key);
282
368
  const field = matches[0];
283
- const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : leading(supplied);
369
+ const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : key === 'spacing' ? normalizeDocumentSpacing(supplied) : leading(supplied);
284
370
  if (supplied !== null && !value) throw new TypeError('Invalid document setting: ' + key);
285
- const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : String(value.factor);
286
- if (value !== null && before[key] && (font ? before[key].family === value.family : key === 'fontsize' ? same(before.fontsize.unit, value.unit) : before.linestretch.factor === value.factor)) continue;
371
+ const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : key === 'spacing' ? JSON.stringify(value) : String(value.factor);
372
+ if (value !== null && before[key] && (font ? before[key].family === value.family : key === 'fontsize' ? same(before.fontsize.unit, value.unit) : key === 'spacing' ? JSON.stringify(before.spacing) === encoded : before.linestretch.factor === value.factor)) continue;
287
373
  if (key === 'fontsize' && value !== null && fontSize(encoded)?.unit !== value.unit) refuse(key);
288
374
  if (!field) { if (value !== null) append.push(key + ': ' + encoded + eol); continue; }
289
375
  if (field.opaque || field.invalid || field.multiline || field.block) refuse(key);
@@ -327,7 +413,7 @@ export function documentSettingsStyle(settings, defaultFont) {
327
413
  if (sans) decls.push('--md-font-sans:' + (sans.css || fontFamilyCss(sans.family)));
328
414
  if (main) decls.push('font-family:' + (main.css || fontFamilyCss(main.family)));
329
415
  if (mono) decls.push('--md-font-mono:' + (mono.css || fontFamilyCss(mono.family)));
330
- return decls.length ? '.md-render{' + decls.join(';') + '}' : '';
416
+ return (decls.length ? '.md-render{' + decls.join(';') + '}' : '') + documentSpacingCss(settings);
331
417
  }
332
418
 
333
419
  // Null when the page keys do not change the print sheet. The view never asks.
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Pure text and markdown-it tokens: no DOM, no byte decoding, no globalThis.Rapier* (MIT kit; decoders stay in images/assets.mjs).
3
+ import {isWordSidecarBlock} from './word-sidecar.mjs';
3
4
 
4
5
  export const IMAGE_LIMITS = Object.freeze({bytes: 16 * 1024 * 1024, sourceChars: 64 * 1024 * 1024, dimension: 16384, pixels: 24000000, assets: 1024});
5
6
  const parsers = new WeakMap();
@@ -93,6 +94,11 @@ const unheldLines = new Proxy(Array.prototype, {
93
94
  // lines begins (undefined when the source ends before it). `env` is the whole pass's. The tables hold at most
94
95
  // the lines between two visits: BLOCK_WINDOW of them, or as many as one block's rules read.
95
96
  export function eachBlockWindow(source, bodyOffset, parser, env, visit, keepNul = false) {
97
+ for (const window of blockWindows(source, bodyOffset, parser, env, keepNul)) visit(window);
98
+ }
99
+
100
+ // The same tokenizer can pause between complete top-level windows; no grammar rule is suspended.
101
+ export function* blockWindows(source, bodyOffset, parser, env, keepNul = false) {
96
102
  const lineEnds = source.slice(bodyOffset).replace(/\r\n?/g, '\n'), body = keepNul ? lineEnds : lineEnds.replace(/\0/g, '\ufffd');
97
103
  // The body is the source with every CRLF one unit shorter: count the pairs before a body offset, from the last
98
104
  // offset asked.
@@ -105,7 +111,7 @@ export function eachBlockWindow(source, bodyOffset, parser, env, visit, keepNul
105
111
  const tokens = [];
106
112
  let origin = 0, first = 0, state;
107
113
  // A line begins after the newline that ends the line before it. No rule writes eMarks.
108
- const report = () => visit({tokens, firstLine: first, lineStart: line => {
114
+ const report = () => ({tokens, firstLine: first, lineStart: line => {
109
115
  if (!line) return sourceOffset(origin);
110
116
  const end = Object.hasOwn(state.eMarks, line - 1) ? state.eMarks[line - 1] : body.length;
111
117
  return end < body.length ? sourceOffset(end + 1) : undefined;
@@ -115,7 +121,7 @@ export function eachBlockWindow(source, bodyOffset, parser, env, visit, keepNul
115
121
  if (lines <= BLOCK_WINDOW) {
116
122
  state = new parser.block.State(body, parser, env, tokens);
117
123
  parser.block.tokenize(state, state.line, state.lineMax);
118
- return report();
124
+ yield report(); return;
119
125
  }
120
126
  // The parser's own count of a last line that no newline ends: it does not count one of only spaces.
121
127
  state = new parser.block.State(body.slice(body.lastIndexOf('\n') + 1), parser, env, tokens);
@@ -180,14 +186,14 @@ export function eachBlockWindow(source, bodyOffset, parser, env, visit, keepNul
180
186
  else if (!parser.utils.isSpace(code)) break;
181
187
  }
182
188
  frontier = origin; ended = false; betweenBlocks = false;
183
- state.line = 0; state.lineMax = lines - first;
189
+ state.line = 0; state.lineMax = lines - first; state.rapierFirstLine = first;
184
190
  const outer = unheldLine;
185
191
  unheldLine = answer;
186
192
  let finished = true;
187
193
  try { parser.block.tokenize(state, 0, state.lineMax); }
188
194
  catch (error) { if (error !== WINDOW_END) throw error; finished = false; }
189
195
  finally { unheldLine = outer; }
190
- report();
196
+ yield report();
191
197
  if (finished) return;
192
198
  tokens.length = 0;
193
199
  origin = state.eMarks[state.line - 1] + 1; first += state.line;
@@ -209,6 +215,19 @@ export function dataImage(url) {
209
215
 
210
216
  // Markdown-it owns recognition, nesting, duplicate precedence and escapes; block parse only.
211
217
  export function parseAssets(source, factory = configured || globalThis.markdownit, bodyOffset = 0) {
218
+ const steps = assetSteps(source, factory, bodyOffset);
219
+ let next = steps.next();
220
+ while (!next.done) next = steps.next();
221
+ return next.value;
222
+ }
223
+ export async function parseAssetsWork(source, factory, bodyOffset, work) {
224
+ const steps = assetSteps(source, factory, bodyOffset);
225
+ work.signal?.throwIfAborted();
226
+ let next = steps.next();
227
+ while (!next.done) { await work.yield(); work.signal?.throwIfAborted(); next = steps.next(); }
228
+ return next.value;
229
+ }
230
+ function* assetSteps(source, factory = configured || globalThis.markdownit, bodyOffset = 0) {
212
231
  if (typeof source !== 'string') return fail('image_source_invalid');
213
232
  if (source.length > IMAGE_LIMITS.sourceChars) return fail('image_source_limit');
214
233
  if (!source.includes(']:')) return {assets: new Map(), blocks: [], duplicateLabels: new Set(), appendixStart: source.length, references: Object.create(null)};
@@ -218,7 +237,7 @@ export function parseAssets(source, factory = configured || globalThis.markdowni
218
237
  if (definition) return definition.index;
219
238
  const parser = parserFor(factory), env = {};
220
239
  const assets = new Map(), blocks = [], seen = new Set(), duplicateLabels = new Set();
221
- eachBlockWindow(source, bodyOffset, parser, env, ({tokens, lineStart}) => {
240
+ for (const {tokens, lineStart} of blockWindows(source, bodyOffset, parser, env)) {
222
241
  for (const token of tokens) {
223
242
  if (token.type !== 'reference_definition' || !token.map) continue;
224
243
  const id = token.meta.label, definition = token.meta.mdImageDefinition, first = !seen.has(id);
@@ -241,7 +260,8 @@ export function parseAssets(source, factory = configured || globalThis.markdowni
241
260
  blocks.push(row);
242
261
  if (first) assets.set(id, row);
243
262
  }
244
- });
263
+ yield;
264
+ }
245
265
  const references = env.references || Object.create(null);
246
266
  if (assets.size > IMAGE_LIMITS.assets) for (const asset of assets.values()) asset.status = 'asset_count_limit';
247
267
  let appendixStart = source.length;
@@ -562,6 +582,7 @@ export function assetOmissions(source) {
562
582
  return spans;
563
583
  }
564
584
  export function isAssetBlock(raw) {
585
+ if (isWordSidecarBlock(raw)) return true;
565
586
  if (typeof raw !== 'string' || !/data:image\//i.test(raw)) return false;
566
587
  const parsed = parseAssets(raw);
567
588
  if (!parsed.blocks.length) return false;
@@ -169,9 +169,9 @@ export function wrapTextBlock(element) {
169
169
 
170
170
  // The one wrap-owner rule (layout/browser.js, kit/render.mjs, editor/share.js): text after, else before, skipping pictures,
171
171
  // metadata and empty paragraphs; any other block is a barrier in that direction.
172
- export function wrapNeighbour(node, classify) {
173
- for (const step of [n => n && n.nextElementSibling, n => n && n.previousElementSibling]) {
174
- for (let sibling = step(node); sibling; sibling = step(sibling)) {
172
+ export function wrapNeighbour(node, classify, step = (n, direction) => n && n[direction > 0 ? 'nextElementSibling' : 'previousElementSibling']) {
173
+ for (const direction of [1, -1]) {
174
+ for (let sibling = step(node, direction); sibling; sibling = step(sibling, direction)) {
175
175
  const kind = classify(sibling);
176
176
  if (kind && typeof kind === 'object') return kind.stop;
177
177
  if (kind === 'metadata') continue;
@@ -115,9 +115,13 @@ export function markdownSourcePositions(source, parser) {
115
115
 
116
116
  // Capture only table-cell provenance while the parser still owns container-adjusted row
117
117
  // bounds. Looking for cell text in a whole row aliases literal pipes and quote/list markers.
118
- export function markdownTableSources(source, parser, environment, lineStarts = null) {
118
+ export function markdownTableSources(source, parser, environment, lineStarts = null, parse = null) {
119
119
  const cells = new WeakMap();
120
- if (!source.includes('|')) return {tokens: parser.parse(source, environment), cells};
120
+ const read = owner => {
121
+ if (parse) return parse(owner, source, environment).then(tokens => ({tokens, cells}));
122
+ return {tokens: owner.parse(source, environment), cells};
123
+ };
124
+ if (!source.includes('|')) return read(parser);
121
125
  const lines = lineStarts || [0];
122
126
  if (!lineStarts) for (const row of source.matchAll(/\r\n?|\n/g)) lines.push(row.index + row[0].length);
123
127
  const owner = Object.create(parser), block = Object.create(parser.block), ruler = Object.create(parser.block.ruler);
@@ -131,14 +135,14 @@ export function markdownTableSources(source, parser, environment, lineStarts = n
131
135
  if (token.type === 'tr_open') {
132
136
  const row = token.map[0], from = state.bMarks[row] + state.tShift[row];
133
137
  const line = state.src.lastIndexOf('\n', from - 1) + 1;
134
- cell = tableSource(state.src.slice(from, state.eMarks[row]), lines[row] + from - line);
138
+ cell = tableSource(state.src.slice(from, state.eMarks[row]), lines[row + (state.rapierFirstLine || 0)] + from - line);
135
139
  } else if (token.type === 'inline' && cell) cells.set(token, cell(token.content));
136
140
  else if (token.type === 'tr_close') cell = null;
137
141
  }
138
142
  }
139
143
  return matched;
140
144
  });
141
- return {tokens: owner.parse(source, environment), cells};
145
+ return read(owner);
142
146
  }
143
147
 
144
148
  function tableSource(row, base) {
@@ -0,0 +1,7 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // An inert document asset, kept in Source and excluded from the reading surface.
3
+ // This is recognition only: interchange/docx.mjs admits the bounded JSON and native XML.
4
+ export const WORD_SIDECAR_OPEN = '<!-- rapier-word:v1\n';
5
+ export function isWordSidecarBlock(raw) {
6
+ return typeof raw === 'string' && /^<!-- rapier-word:v1\r?\n[^\r\n]*\r?\n-->[ \t\r\n]*$/.test(raw);
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.2.6",
3
+ "version": "1.2.7",
4
4
  "description": "Read, render and write Self-contained Markdown: one .md file that carries its pictures, drawings and layout, and that any Markdown app can read. HTML renderer, stylesheet, text layout, Will/1 agent permissions, and an edit history with authorship and offline merge. MIT, no dependencies.",
5
5
  "keywords": [
6
6
  "markdown",
package/style.css CHANGED
@@ -361,15 +361,15 @@
361
361
  position:relative;
362
362
  --md-marker:0px;
363
363
  counter-increment:rapier-ol;
364
- min-height:calc(2 * var(--md-unit));
365
- padding-top:calc(0.15 * var(--md-unit));
364
+ min-height:0;
365
+ padding-top:0;
366
366
  }
367
367
  .md-render ol>li::before{
368
368
  content:counter(rapier-ol);
369
369
  position:absolute;
370
370
  /* The circle is centered on the item's first line: the li's own padding, plus half of what the 2rem circle is taller than one line. */
371
- inset-inline-start:calc(-2.75 * var(--md-unit) + var(--md-marker, 0px)); top:calc(calc(0.15 * var(--md-unit)) + (var(--md-line) - calc(2 * var(--md-unit))) / 2);
372
- width:calc(2 * var(--md-unit)); height:calc(2 * var(--md-unit));
371
+ inset-inline-start:calc(-2.75 * var(--md-unit) + var(--md-marker, 0px)); top:0;
372
+ width:min(2 * var(--md-unit), 1lh); height:min(2 * var(--md-unit), 1lh);
373
373
  display:flex; align-items:center; justify-content:center;
374
374
  border:2px solid var(--md-color-line);
375
375
  border-radius:999px;
@@ -650,9 +650,6 @@ html[data-invert-image-alpha="off"] :is(img,.md-render svg.rapier-native-flowcha
650
650
  vertical-align:middle;
651
651
  }
652
652
  .md-render img[data-rapier-image-size]{width:min(var(--md-image-width),100%);height:auto}
653
- /* AUTO stays relative to its column. Authored and gesture-owned inline sizing wins;
654
- Plain reads the intrinsic attributes without rerendering ordinary image blocks. */
655
- html:not([data-layout="plain"]) .md-render img[data-rapier-image-geometry="auto"]{width:min(var(--rapier-image-auto-width),var(--rapier-image-natural-width));height:auto}
656
653
  .md-render img:is([data-rapier-asset],[data-rapier-image-url]):not([data-rapier-asset-state="ready"]){background:color-mix(in srgb,var(--md-color-text) 5%,transparent)}
657
654
 
658
655
  .md-render dl{margin:0 0 var(--md-gap-block)}