rapier-markdown-kit 1.2.30 → 1.2.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -164,7 +164,7 @@ family name share the host's font namespace. Print retains its existing factory
164
164
 
165
165
  | Import | What it is |
166
166
  | --- | --- |
167
- | `rapier-markdown-kit/markdown-it` | `md.use(rapierMarkdown)`: the reference style's markup in markdown-it, in one line. |
167
+ | `rapier-markdown-kit/markdown-it` | `md.use(rapierMarkdown)`: the reference style's markup in markdown-it, in one line, with rung 1 of `md-layout:v1` (align, indents, list markers, picture width, lines and opacity, paragraph typography). |
168
168
  | `rapier-markdown-kit/parsed` | A frozen, serializable view of completed Markdown tokens, bound to source, revision and grammar. |
169
169
  | `rapier-markdown-kit/layout` | The `md-layout:v1` comment a picture carries: parse it, validate it, write it back. |
170
170
  | `rapier-markdown-kit/marks` | Unified typography values, span writer and parser installer; preserved color/font comments, ink and page breaks. |
@@ -1,7 +1,7 @@
1
1
  # The conformance kit
2
2
 
3
3
  Thirteen small documents, each covering one placement fact from [the Markdown
4
- standard](https://rapier.website/markdown-standard): inline (no layout comment), width/x/align, `wrap=around`
4
+ standard](https://github.com/jackskip22/rapier/blob/main/docs/markdown-standard.md): inline (no layout comment), width/x/align, `wrap=around`
5
5
  with a real alpha silhouette, `wrap=box`, `behind`, `front`, `rotate=` on a plain raster, a foreign
6
6
  SVG "drawing" already turned in its own bytes, a ring drawing whose unfilled interior takes a line of
7
7
  text, both-sides wrapping around a centred picture, the neighbour search skipping a second, unrelated
@@ -50,15 +50,27 @@ const OPEN = '\n\n<!-- rapier-carried/1\n', CLOSE = '\n-->\n';
50
50
  export function documentTrailer(text, options) {
51
51
  const parts = validateParts(text, options);
52
52
  if (!parts.ledger && !parts.authorship) return '';
53
- const json = JSON.stringify(parts).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
53
+ const json = JSON.stringify(parts).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/-/g, '\\u002d');
54
54
  return OPEN + json + CLOSE;
55
55
  }
56
56
  export function writeDocument(text, options) {
57
57
  return text + documentTrailer(text, options);
58
58
  }
59
+ function unfinishedTrailer(source) {
60
+ const at = source.lastIndexOf(OPEN);
61
+ if (at < 0) return false;
62
+ let fence = null;
63
+ for (const line of source.slice(0, at).split('\n')) {
64
+ const found = /^ {0,3}(`{3,}|~{3,})/.exec(line);
65
+ if (!found) continue;
66
+ if (!fence) fence = found[1];
67
+ else if (found[1][0] === fence[0] && found[1].length >= fence.length && !line.slice(found[0].length).trim()) fence = null;
68
+ }
69
+ return !fence;
70
+ }
59
71
  export function readDocument(source) {
60
72
  if (!source.endsWith(CLOSE)) {
61
- if (source.includes(OPEN)) throw fault('unfinished document metadata');
73
+ if (unfinishedTrailer(source)) throw fault('unfinished document metadata');
62
74
  return {text: source, ledger: null, authorship: null};
63
75
  }
64
76
  const at = source.lastIndexOf(OPEN);
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- import {parseLayout, imageStyle} from './layout.mjs';
2
+ import {parseLayout, imageStyle, textStyle} from './layout.mjs';
3
3
  import {typography, isPageBreakBlock} from './marks.mjs';
4
4
  import {installMarkdownImages} from './assets.mjs';
5
5
  import {tuneMarkdownEngine} from '../spec/md-engine.mjs';
@@ -26,11 +26,25 @@ export function rapierMarkdown(md) {
26
26
  const layout = parseLayout(marker.content);
27
27
  if (!layout) continue;
28
28
  children.pop();
29
- if (layout.align) owner.attrSet('data-md-align', layout.align);
29
+ if (layout.align && (layout.align !== 'justify' || owner.type === 'paragraph_open')) owner.attrSet('data-md-align', layout.align);
30
30
  const visible = children.filter(token => token.type !== 'text' || token.content.trim());
31
- if (visible.length === 1 && visible[0].type === 'image' && layout.width) {
32
- visible[0].attrSet('style', imageStyle(layout));
33
- visible[0].attrSet('data-md-image-width', String(layout.width));
31
+ const picture = visible.length === 1 && visible[0].type === 'image' ? visible[0]
32
+ : visible.length === 3 && visible[0].type === 'link_open' && visible[1].type === 'image' && visible[2].type === 'link_close' ? visible[1] : null;
33
+ if (picture) {
34
+ if (layout.width != null || layout.lines != null || layout.opacity != null) {
35
+ const style = imageStyle(layout);
36
+ if (style) picture.attrSet('style', style);
37
+ if (layout.width != null) picture.attrSet('data-md-image-width', String(layout.width));
38
+ }
39
+ continue;
40
+ }
41
+ for (const key of ['first', 'indent', 'before', 'after', 'lines', 'hyphens', 'caps', 'hanging']) if (layout[key]) owner.attrSet('data-md-' + key, String(layout[key]));
42
+ const style = [textStyle(layout), layout.lines != null ? '--md-drop-lines:' + layout.lines : ''].filter(Boolean).join(';');
43
+ if (style) owner.attrSet('style', style);
44
+ const item = state.tokens[i - 2];
45
+ if (layout.marker && item?.type === 'list_item_open') {
46
+ item.attrSet('data-md-marker', String(layout.marker));
47
+ item.attrSet('style', (item.attrGet('style') ? item.attrGet('style') + ';' : '') + textStyle({marker: layout.marker}));
34
48
  }
35
49
  }
36
50
  });
@@ -337,8 +337,8 @@ export async function collectPdfGlyphs(main, {width, check, faces = [], prepareF
337
337
  probe.style.setProperty('visibility', 'hidden', 'important'); probe.textContent = String(value); item.prepend(probe);
338
338
  const parts = await measure(probe.firstChild, style, font); probe.remove();
339
339
  if (!parts.length || parts.some(part => !part.font)) continue;
340
- parts.at(-1).text += ' ';
341
- markers.set(item, {text: String(value) + ' ', parts, ...Object.fromEntries(['x','y','width','height','page'].map(key => [key, parts[0][key]]))});
340
+ parts.at(-1).text += '. ';
341
+ markers.set(item, {text: String(value) + '. ', parts, ...Object.fromEntries(['x','y','width','height','page'].map(key => [key, parts[0][key]]))});
342
342
  const id = 'm' + markers.size, old = item.getAttribute('data-rapier-pdf-marker'); item.setAttribute('data-rapier-pdf-marker', id);
343
343
  restoreSteps.push(() => old == null ? item.removeAttribute('data-rapier-pdf-marker') : item.setAttribute('data-rapier-pdf-marker', old));
344
344
  markerRules.push('[data-rapier-pdf-marker="' + id + '"]::before{-webkit-text-fill-color:transparent!important}');
@@ -92,7 +92,7 @@ function geometry(node) {
92
92
  unsupported();
93
93
  }
94
94
 
95
- export async function collectPdfVectors(main, {width, height, count, capture, signal, current, check, maxBytes, sanitizeSvg, collectText}) {
95
+ export async function collectPdfVectors(main, {width, height, count, capture, signal, current, check, maxBytes, sanitizeSvg, collectText, pictures = false, underlay = false}) {
96
96
  const document = main.ownerDocument, window = document.defaultView, origin = main.getBoundingClientRect();
97
97
  const pages = Array.from({length: count}, () => []), hidden = [], temporary = [], textRoots = [], textRestores = [];
98
98
  const colorCanvas = document.createElement('canvas'); colorCanvas.width = colorCanvas.height = 1;
@@ -117,20 +117,33 @@ export async function collectPdfVectors(main, {width, height, count, capture, si
117
117
  try {
118
118
  for (const original of roots) {
119
119
  check();
120
- let root = original, mount = null;
120
+ let root = original, mount = null, turned = false, groupOpacity = 1, asImage = false, under = false;
121
121
  const originalBox = original.getBoundingClientRect();
122
122
  if (!(originalBox.width > 0 && originalBox.height > 0)) continue;
123
123
  if (original.localName === 'img') {
124
124
  if (typeof sanitizeSvg !== 'function') continue;
125
125
  const address = original.getAttribute('src') || '', match = /^data:image\/svg\+xml(?:;charset=[^;,]+)?(;base64)?,([\s\S]*)$/i.exec(address);
126
- if (!match) continue;
126
+ if (!match && !(pictures && /^data:image\//i.test(address))) continue;
127
+ asImage = !match;
128
+ }
129
+ if (original.localName === 'img' && !asImage) {
130
+ const address = original.getAttribute('src') || '', match = /^data:image\/svg\+xml(?:;charset=[^;,]+)?(;base64)?,([\s\S]*)$/i.exec(address);
127
131
  let text;
128
132
  try {text = match[1] ? new TextDecoder('utf-8', {fatal: true}).decode(Uint8Array.from(atob(match[2]), ch => ch.charCodeAt(0))) : decodeURIComponent(match[2]);} catch (_) {continue;}
129
133
  const parsed = new window.DOMParser().parseFromString(sanitizeSvg(text), 'image/svg+xml');
130
134
  if (parsed.querySelector('parsererror') || parsed.documentElement.namespaceURI !== SVG) continue;
135
+ const shown = window.getComputedStyle(original);
136
+ under = Number(shown.zIndex) < 0;
137
+ if (under && !underlay) continue;
138
+ turned = shown.transform !== 'none'; groupOpacity = Number(shown.opacity);
139
+ const unturnedWidth = turned ? Number.parseFloat(shown.width) : originalBox.width, unturnedHeight = turned ? Number.parseFloat(shown.height) : originalBox.height;
140
+ if (!(unturnedWidth > 0 && unturnedHeight > 0)) continue;
141
+ const [originX, originY] = shown.transformOrigin.split(' ').map(Number.parseFloat);
142
+ if (turned && !(Math.abs(originX - unturnedWidth / 2) < .5 && Math.abs(originY - unturnedHeight / 2) < .5)) continue;
143
+ const left = originalBox.left + originalBox.width / 2 - unturnedWidth / 2, top = originalBox.top + originalBox.height / 2 - unturnedHeight / 2;
131
144
  root = document.importNode(parsed.documentElement, true);
132
- root.style.setProperty('width', originalBox.width + 'px', 'important'); root.style.setProperty('height', originalBox.height + 'px', 'important');
133
- mount = document.createElement('div'); mount.style.cssText = `position:fixed;left:${originalBox.left}px;top:${originalBox.top}px;width:${originalBox.width}px;height:${originalBox.height}px;pointer-events:none`;
145
+ root.style.setProperty('width', unturnedWidth + 'px', 'important'); root.style.setProperty('height', unturnedHeight + 'px', 'important');
146
+ mount = document.createElement('div'); mount.style.cssText = `position:fixed;left:${left}px;top:${top}px;width:${unturnedWidth}px;height:${unturnedHeight}px;pointer-events:none` + (turned ? `;transform:${shown.transform};transform-origin:50% 50%` : '');
134
147
  mount.attachShadow({mode: 'open'}).append(root); document.body.append(mount); temporary.push(mount); await document.fonts.ready;
135
148
  const glyphs = await collectText?.(root); if (glyphs) textRestores.push(glyphs.restore);
136
149
  textRoots.push({root, original, text: glyphs});
@@ -181,7 +194,7 @@ export async function collectPdfVectors(main, {width, height, count, capture, si
181
194
  } catch (_) { sequence.push({raster: node}); return; }
182
195
  if (!path) return;
183
196
  const matrix = node.getScreenCTM(); if (!matrix || ![matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f].every(Number.isFinite)) {sequence.push({raster: node}); return;}
184
- const opacity = Number(style.opacity), fillAlpha = fill ? fill[3] * Number(style.fillOpacity) * opacity : 1, strokeAlpha = stroke ? stroke[3] * Number(style.strokeOpacity) * opacity : 1;
197
+ const opacity = Number(style.opacity) * groupOpacity, fillAlpha = fill ? fill[3] * Number(style.fillOpacity) * opacity : 1, strokeAlpha = stroke ? stroke[3] * Number(style.strokeOpacity) * opacity : 1;
185
198
  let commands = nums([matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - origin.left, matrix.f - origin.top]) + ' cm\n';
186
199
  if (fill) commands += nums(fill.slice(0, 3)) + ' rg\n';
187
200
  if (stroke) {
@@ -193,12 +206,19 @@ export async function collectPdfVectors(main, {width, height, count, capture, si
193
206
  commands += path + (fill && stroke ? style.fillRule === 'evenodd' ? 'B*' : 'B' : fill ? style.fillRule === 'evenodd' ? 'f*' : 'f' : stroke ? 'S' : 'n') + '\n';
194
207
  sequence.push({commands, fillAlpha, strokeAlpha}); geometries++;
195
208
  };
196
- await walk(root);
197
- if (!geometries && !textRoots.find(entry => entry.root === root)?.text?.nodes.size) continue;
209
+ if (asImage) sequence.push({raster: root}); else await walk(root);
210
+ if ((turned || groupOpacity !== 1) && (sequence.some(piece => piece.raster) || textRoots.find(entry => entry.root === root)?.text?.nodes.size)) {
211
+ const entry = textRoots.find(row => row.root === root);
212
+ if (entry?.text) textRestores.pop()?.();
213
+ if (entry) textRoots.splice(textRoots.indexOf(entry), 1);
214
+ mount?.remove(); temporary.splice(temporary.indexOf(mount), 1);
215
+ continue;
216
+ }
217
+ if (!geometries && !asImage && !textRoots.find(entry => entry.root === root)?.text?.nodes.size) continue;
198
218
  const painted = [];
199
219
  for (const piece of sequence) {const held = piece.raster ? await raster(piece.raster) : piece; if (held) painted.push(held);}
200
220
  const first = Math.max(0, Math.floor((box.left - origin.left + .01) / width)), last = Math.min(count - 1, Math.floor((box.right - origin.left - .01) / width));
201
- for (let page = first; page <= last; page++) pages[page].push({sequence: painted, x: -page * width, clip: {x: box.left - origin.left, y: box.top - origin.top, width: box.width, height: box.height}});
221
+ for (let page = first; page <= last; page++) pages[page].push({sequence: painted, x: -page * width, clip: {x: box.left - origin.left, y: box.top - origin.top, width: box.width, height: box.height}, ...(under && {under: true})});
202
222
  hidden.push({node: original, style: original.getAttribute('style')}); original.style.setProperty('visibility', 'hidden', 'important');
203
223
  }
204
224
  return {pages, textRoots, restore};
@@ -1243,7 +1243,7 @@ function* _rapierProjectPortableNodesSteps(semanticRoot, options) {
1243
1243
  ? (element.style.backgroundColor || RAPIER_PORTABLE_HIGHLIGHT_HEX[String(element.getAttribute('data-rapier-highlight') || '').toLowerCase()] || RAPIER_PORTABLE_HIGHLIGHT_HEX.yellow)
1244
1244
  : null;
1245
1245
 
1246
- const isTableCaption = element.tagName === 'P' && element.classList.contains('rapier-table-caption');
1246
+ const isTableCaption = element.tagName === 'P' && (element.classList.contains('rapier-table-caption') || element.classList.contains('rapier-figure-caption'));
1247
1247
 
1248
1248
  const isPageBreak = element.hasAttribute('data-md-break');
1249
1249
  for (const attribute of Array.from(element.attributes)) { yield;
@@ -1271,6 +1271,13 @@ function* _rapierProjectPortableNodesSteps(semanticRoot, options) {
1271
1271
  if (paragraphLayout.before != null) element.style.marginTop = paragraphLayout.before + 'pt';
1272
1272
  if (paragraphLayout.after != null) element.style.marginBottom = paragraphLayout.after + 'pt';
1273
1273
  }
1274
+ if (!keepLayoutData && paragraphLayout && /^(?:P|H[1-6])$/.test(element.tagName) && !element.closest('table,pre') && (paragraphLayout.first != null || paragraphLayout.indent != null)) {
1275
+ const indents = {...(paragraphLayout.first != null && {first: paragraphLayout.first}), ...(paragraphLayout.indent != null && {indent: paragraphLayout.indent})};
1276
+ for (const declaration of globalThis.RapierMarkdownLayout.textStyle(indents, {portable: true}).split(';').filter(Boolean)) {
1277
+ const at = declaration.indexOf(':');
1278
+ element.style.setProperty(declaration.slice(0, at), declaration.slice(at + 1));
1279
+ }
1280
+ }
1274
1281
  if (!keepLayoutData && element.tagName === 'P' && !element.closest('table,pre')) {
1275
1282
  if (paragraphLayout?.hyphens === 'auto') { element.style.hyphens = 'auto'; element.style.textWrap = 'pretty'; }
1276
1283
  if (paragraphLayout?.hanging === 'on') element.style.setProperty('hanging-punctuation', 'first allow-end');
@@ -3,7 +3,7 @@ import {collectPdfVectors} from './pdf-vectors.mjs';
3
3
  import {collectPdfGlyphs} from './pdf-glyphs.mjs';
4
4
  import {pdfFontSources, waitForFonts} from './export-fonts.mjs';
5
5
  import {apply as applyTypography} from '../layout/typography.mjs';
6
- import {documentLanguage} from '../spec/document-settings.mjs';
6
+ import {documentLanguage, pageSizeOf} from '../spec/document-settings.mjs';
7
7
  import {applyPrintCapitals} from './print-capitals.mjs';
8
8
  import {stripOneTerminator as _rapierWillStripOneTerminator} from '../agent/will.mjs';
9
9
  import {quotedWillSpan} from '../spec/md-marks.mjs';
@@ -196,14 +196,31 @@ function wrapMasks(main) {
196
196
  }
197
197
  }
198
198
 
199
+ const REFUSALS = {
200
+ visual_too_large: 'This document is too large for a PDF. Use Word or HTML, or split it.',
201
+ visual_resources_unavailable: 'A picture or font could not be read for the PDF.',
202
+ visual_capture_expired: 'The PDF took too long to draw. Try again, or split the document.',
203
+ pdf_render_timeout: 'The PDF took too long to draw. Try again, or split the document.',
204
+ pdf_render_unavailable: 'This browser could not draw the PDF.',
205
+ pdf_page_geometry_invalid: 'The page size is not one a PDF can use.',
206
+ pdf_text_glyph_limit: 'The PDF cannot hold this many different letters. Use Word or HTML.',
207
+ pdf_template_border_unavailable: 'The page border could not be drawn.',
208
+ document_changed: 'The document changed while the PDF was drawn. Try again.',
209
+ export_too_large: 'The file is larger than the limit. Split the document.',
210
+ };
211
+
199
212
  async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = false, filename, title, settings, templatePage = null}, {capture, signal, current, maxBytes = 8 * 1024 * 1024, film, ink} = {}) {
200
213
  const refuse = code => { throw Object.assign(new Error(code), {code}); };
214
+ const spoken = error => {
215
+ const code = error?.code || error?.message, words = REFUSALS[code];
216
+ return words && error.message !== words ? Object.assign(new Error(words), {code}) : error;
217
+ };
201
218
  const check = () => {
202
219
  if (signal?.aborted) refuse('cancelled');
203
220
  if (current?.() === false) refuse('document_changed');
204
221
  };
205
222
  if (!root?.ownerDocument || typeof capture !== 'function') refuse('pdf_render_unavailable');
206
- const paper = settings?.papersize || {width: 11906, height: 16838};
223
+ const paper = settings?.papersize || (templatePage ? pageSizeOf(settings) : {width: 11906, height: 16838});
207
224
  const width = paper.width / 20, height = paper.height / 20;
208
225
  const margins = Object.fromEntries(['top', 'right', 'bottom', 'left'].map(side =>
209
226
  [side, settings?.geometry ? settings.geometry[side] / 20 : (side === 'top' ? 12 : 14) * 72 / 25.4]));
@@ -301,7 +318,7 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
301
318
  nativeGlyphs = await collectPdfGlyphs(main, glyphOptions);
302
319
  const textPages = await _rapierPrintTextPages(main, bodyWidth, count, check, undefined, nativeGlyphs.nodes, nativeGlyphs.markers);
303
320
  const linkPages = _rapierPrintLinkPages(main, bodyWidth, bodyHeight, count);
304
- vectors = await collectPdfVectors(main, {width: bodyWidth, height: bodyHeight, count, capture, signal, current, check, maxBytes,
321
+ vectors = await collectPdfVectors(main, {width: bodyWidth, height: bodyHeight, count, capture, signal, current, check, maxBytes, underlay: true,
305
322
  sanitizeSvg: globalThis.RapierImageAssets?.sanitizeSvgText,
306
323
  collectText: root => collectPdfGlyphs(root, {...glyphOptions, origin: main.getBoundingClientRect()})});
307
324
  for (const {root, original, text} of vectors.textRoots) {
@@ -322,6 +339,8 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
322
339
  strip.style.width = count * bodyWidth + 'px';
323
340
  strip.style.transform = 'none';
324
341
  if (typeof film !== 'function') refuse('pdf_render_unavailable');
342
+ const underlay = vectors.pages.some(items => items.some(drawing => drawing.under));
343
+ if (underlay) surface.style.setProperty('background', 'transparent');
325
344
  let pendingFilm = null, abandonFilm = false;
326
345
  const trackedFilm = Promise.resolve(film({root: surface, signal, current, frame: {width: count * bodyWidth, height: bodyHeight}})).then(session => {
327
346
  if (abandonFilm) session?.close?.();
@@ -340,22 +359,23 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
340
359
  let share = Math.max(1, Math.min(Math.floor(CAPTURE_EDGE / bodyWidth), Math.floor(CAPTURE_PIXELS / (bodyWidth * bodyHeight))));
341
360
  const pages = [];
342
361
  let imageBytes = 0;
343
- const pack = async rgb => {
344
- const packed = new Uint8Array(await step(new Response(new Blob([rgb]).stream().pipeThrough(new CompressionStream('deflate'))).arrayBuffer()));
362
+ const deflate = async bytes => {
363
+ const packed = new Uint8Array(await step(new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'))).arrayBuffer()));
345
364
  imageBytes += packed.byteLength;
346
365
  if (imageBytes > maxBytes) refuse('export_too_large');
347
- pages.push({rgb: packed});
366
+ return packed;
348
367
  };
368
+ const pack = async (rgb, alpha = null) => pages.push({rgb: await deflate(rgb), ...(alpha && {alpha: await deflate(alpha)})});
349
369
  const tileWidth = Math.min(bodyWidth, CAPTURE_EDGE), tileHeight = Math.min(bodyHeight, CAPTURE_EDGE, Math.floor(CAPTURE_PIXELS / tileWidth));
350
370
  for (let index = 0; tiled && index < count; index++) {
351
371
  const tiles = [];
352
372
  for (let y = 0; y < bodyHeight; y += tileHeight) for (let x = 0; x < bodyWidth; x += tileWidth) {
353
373
  check();
354
374
  const width = Math.min(tileWidth, bodyWidth - x), height = Math.min(tileHeight, bodyHeight - y);
355
- const shot = await step(filmSession.raster({x: index * bodyWidth + x, y, width, height}));
356
- if (shot?.width !== width || shot?.height !== height || !(shot.rgb instanceof Uint8Array)) refuse('pdf_render_unavailable');
357
- await pack(shot.rgb);
358
- shot.rgb = null;
375
+ const shot = await step(filmSession.raster({x: index * bodyWidth + x, y, width, height}, {transparent: underlay}));
376
+ if (shot?.width !== width || shot?.height !== height || !(shot.rgb instanceof Uint8Array) || underlay && !(shot.alpha instanceof Uint8Array)) refuse('pdf_render_unavailable');
377
+ await pack(shot.rgb, shot.alpha);
378
+ shot.rgb = shot.alpha = null;
359
379
  tiles.push({...pages.pop(), x, y, width, height});
360
380
  }
361
381
  pages.push({tiles});
@@ -364,21 +384,22 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
364
384
  check();
365
385
  const span = Math.min(share, count - first);
366
386
  try {
367
- const shot = await step(filmSession.raster({x: first * bodyWidth, y: 0, width: span * bodyWidth, height: bodyHeight}));
368
- if (shot?.width !== span * bodyWidth || shot?.height !== bodyHeight || !(shot.rgb instanceof Uint8Array)) refuse('pdf_render_unavailable');
369
- if (span === 1) await pack(shot.rgb);
387
+ const shot = await step(filmSession.raster({x: first * bodyWidth, y: 0, width: span * bodyWidth, height: bodyHeight}, {transparent: underlay}));
388
+ if (shot?.width !== span * bodyWidth || shot?.height !== bodyHeight || !(shot.rgb instanceof Uint8Array) || underlay && !(shot.alpha instanceof Uint8Array)) refuse('pdf_render_unavailable');
389
+ if (span === 1) await pack(shot.rgb, shot.alpha);
370
390
  else {
371
391
  const row = bodyWidth * 3;
372
392
  for (let offset = 0; offset < span; offset++) {
373
- const rgb = new Uint8Array(bodyWidth * bodyHeight * 3);
393
+ const rgb = new Uint8Array(bodyWidth * bodyHeight * 3), alpha = shot.alpha ? new Uint8Array(bodyWidth * bodyHeight) : null;
374
394
  for (let y = 0; y < bodyHeight; y++) {
375
395
  const from = (y * shot.width + offset * bodyWidth) * 3;
376
396
  rgb.set(shot.rgb.subarray(from, from + row), y * row);
397
+ if (alpha) alpha.set(shot.alpha.subarray(y * shot.width + offset * bodyWidth, y * shot.width + (offset + 1) * bodyWidth), y * bodyWidth);
377
398
  }
378
- await pack(rgb);
399
+ await pack(rgb, alpha);
379
400
  }
380
401
  }
381
- shot.rgb = null;
402
+ shot.rgb = shot.alpha = null;
382
403
  } catch (error) {
383
404
  if (error?.code === 'visual_too_large' && span > 1) { share = Math.ceil(span / 2); continue; }
384
405
  throw error;
@@ -394,14 +415,15 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
394
415
  holder.append(picture); into.body.append(holder);
395
416
  await step(picture.decode());
396
417
  backdrop = await collectPdfVectors(holder, {width: sheetWidth, height: sheetHeight, count: 1, capture, signal, current, check, maxBytes,
397
- sanitizeSvg: globalThis.RapierImageAssets?.sanitizeSvgText});
418
+ sanitizeSvg: globalThis.RapierImageAssets?.sanitizeSvgText, pictures: true});
398
419
  if (!backdrop.pages[0].length) refuse('pdf_template_border_unavailable');
399
420
  }
400
421
  const bytes = await writeRasterPdf({pages, textPages, linkPages, vectorPages: vectors.pages, canonical, bom, filename, title, font, width, height, margins, backdrop: backdrop?.pages[0] || null,
401
422
  pixelWidth: bodyWidth, pixelHeight: bodyHeight, pageNumbers: settings?.pagestyle === 'plain', maxBytes, check});
402
423
  check();
403
424
  return {bytes, pages: count, painted: nativeGlyphs.painted};
404
- } finally { filmSession?.close(); backdrop?.restore(); vectors?.restore(); nativeGlyphs?.restore(); signal?.removeEventListener('abort', abort); frame.remove(); }
425
+ } catch (error) { throw spoken(error); }
426
+ finally { filmSession?.close(); backdrop?.restore(); vectors?.restore(); nativeGlyphs?.restore(); signal?.removeEventListener('abort', abort); frame.remove(); }
405
427
  }
406
428
 
407
429
  async function _rapierPrintTextPages(main, pageWidth, count, check, origin = main.getBoundingClientRect(), nativeNodes = null, markerNodes = null) {
@@ -471,14 +493,14 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
471
493
  parts = joined;
472
494
  }
473
495
  const last = pages[page].at(-1);
474
- if (last && !formula && !last.math && last.block === block && last.carrier === carrier && (last.marker || Math.abs(last.y - y) < Math.max(2, Math.min(last.height, bottom - y) * .2))) {
496
+ if (last && !formula && !last.math && !last.marker && last.block === block && last.carrier === carrier && Math.abs(last.y - y) < Math.max(2, Math.min(last.height, bottom - y) * .2)) {
475
497
  if (!preserve && last.text.endsWith(' ') && text.startsWith(' ') && parts.length) {
476
498
  const first = parts[0];
477
499
  if (first.text === ' ') parts.shift();
478
500
  else {const advance = first.width / Array.from(first.text).length; first.text = first.text.slice(1); first.x += advance; first.width -= advance;}
479
501
  }
480
502
  last.text = preserve ? last.text + text : (last.text + text).replace(/[ \t\r\n\f]+/g, ' ');
481
- last.parts.push(...parts); last.marker = false;
503
+ last.parts.push(...parts);
482
504
  last.width = Math.max(last.x + last.width, right) - Math.min(last.x, x);
483
505
  last.height = Math.max(last.y + last.height, bottom) - Math.min(last.y, y);
484
506
  last.x = Math.min(last.x, x); last.y = Math.min(last.y, y);
@@ -512,13 +534,29 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
512
534
  }
513
535
  const ROLES = {TABLE: 'Table', TR: 'TR', TD: 'TD', TH: 'TH', UL: 'L', OL: 'L', LI: 'LI', BLOCKQUOTE: 'BlockQuote', P: 'P', PRE: 'Code', FIGCAPTION: 'Caption',
514
536
  H1: 'H1', H2: 'H2', H3: 'H3', H4: 'H4', H5: 'H5', H6: 'H6'};
515
- const tags = block => {
537
+ const bodies = new WeakMap(), labels = new WeakMap();
538
+ const tags = (block, marker) => {
516
539
  const chain = [];
517
540
  for (let node = block; node && node !== main; node = node.parentElement)
518
541
  if (ROLES[node.tagName] || node === block && node.matches('.rapier-will-carrier')) chain.unshift({node, role: ROLES[node.tagName] || 'P'});
519
- return chain;
542
+ const out = [];
543
+ chain.forEach((entry, at) => {
544
+ const note = entry.node.localName === 'li' && entry.node.classList.contains('footnote-item');
545
+ out.push(note ? {...entry, role: 'Note'} : entry);
546
+ if (entry.node.localName !== 'li') return;
547
+ const last = !chain.slice(at + 1).some(inner => inner.node.localName === 'li');
548
+ if (marker && last) {
549
+ if (!labels.has(entry.node)) labels.set(entry.node, {});
550
+ out.push({node: labels.get(entry.node), role: 'Lbl'});
551
+ }
552
+ else if (!note) {
553
+ if (!bodies.has(entry.node)) bodies.set(entry.node, {});
554
+ out.push({node: bodies.get(entry.node), role: 'LBody'});
555
+ }
556
+ });
557
+ return out;
520
558
  };
521
- return pages.map(page => page.map(({block, ...run}) => ({...run, tags: tags(block)})));
559
+ return pages.map(page => page.map(({block, ...run}) => ({...run, tags: tags(block, run.marker)})));
522
560
  }
523
561
 
524
562
  function _rapierPrintLinkPages(main, width, height, count) {
@@ -595,7 +633,7 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
595
633
  const hex = value => Array.from({length: value.length}, (_, index) => value.charCodeAt(index).toString(16).padStart(4, '0')).join('');
596
634
  const literal = value => '(' + String(value).replace(/[\\()\r\n]/g, character => ({'\\': '\\\\', '(': '\\(', ')': '\\)', '\r': '\\r', '\n': '\\n'}[character])) + ')';
597
635
  const source = encoder.encode((bom ? '\uFEFF' : '') + canonical);
598
- const embedded = stream('/Type /EmbeddedFile /Subtype /text#2Fplain /Params <</Size ' + source.length + '>> /Filter /FlateDecode', await compressed(source));
636
+ const embedded = stream('/Type /EmbeddedFile /Subtype /text#2Fmarkdown /Params <</Size ' + source.length + '>> /Filter /FlateDecode', await compressed(source));
599
637
  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 >> >>');
600
638
  for (const page of textPages) for (const run of page) {
601
639
  let previous = null;
@@ -741,7 +779,9 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
741
779
  const held = pages[index], flate = !!held?.rgb;
742
780
  const tiles = held?.tiles || [{...(flate ? held : {jpeg: held}), x: 0, y: 0, width: pixelWidth, height: pixelHeight}];
743
781
  const pictures = tiles.map((tile, at) => '/Im0_' + at + ' ' + stream('/Type /XObject /Subtype /Image /Width ' + tile.width + ' /Height ' + tile.height +
744
- ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /' + (tile.rgb ? 'FlateDecode' : 'DCTDecode'), tile.rgb || tile.jpeg) + ' 0 R'), states = [];
782
+ ' /ColorSpace /DeviceRGB /BitsPerComponent 8' + (tile.alpha ? ' /SMask ' + stream('/Type /XObject /Subtype /Image /Width ' + tile.width + ' /Height ' + tile.height +
783
+ ' /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode', tile.alpha) + ' 0 R' : '') +
784
+ ' /Filter /' + (tile.rgb ? 'FlateDecode' : 'DCTDecode'), tile.rgb || tile.jpeg) + ' 0 R'), states = [];
745
785
  pages[index] = null;
746
786
  const emit = (drawing, left, top, clipWidth, clipHeight) => {
747
787
  let out = `q\n.75 0 0 -.75 ${decimal(left)} ${decimal(top)} cm\n0 0 ${clipWidth} ${clipHeight} re W n\n1 0 0 1 ${decimal(drawing.x)} 0 cm\n`;
@@ -770,9 +810,10 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
770
810
  };
771
811
  let commands = '';
772
812
  for (const drawing of backdrop || []) commands += emit(drawing, 0, height, Math.round(width * 4 / 3), Math.round(height * 4 / 3));
813
+ for (const drawing of vectorPages[index] || []) if (drawing.under) commands += emit(drawing, margins.left, height - margins.top, pixelWidth, pixelHeight);
773
814
  commands += '/Artifact BMC\n' + tiles.map((tile, at) => 'q\n' + [decimal(tile.width * .75), 0, 0, decimal(tile.height * .75), decimal(margins.left + tile.x * .75),
774
815
  decimal(height - margins.top - (tile.y + tile.height) * .75)].join(' ') + ' cm\n/Im0_' + at + ' Do\nQ\n').join('') + 'EMC\n';
775
- for (const drawing of vectorPages[index] || []) commands += emit(drawing, margins.left, height - margins.top, pixelWidth, pixelHeight);
816
+ for (const drawing of vectorPages[index] || []) if (!drawing.under) commands += emit(drawing, margins.left, height - margins.top, pixelWidth, pixelHeight);
776
817
  const textAlphas = new Map();
777
818
  for (const run of textPages[index]) {
778
819
  if (!run.text && !run.parts?.some(part => part.font)) continue;
@@ -4,7 +4,7 @@ import {finish, finishAsync, cloneTree, cleanTree, serializeTree, encodeUtf8Step
4
4
  import {fontFamilyCss} from '../spec/md-marks.mjs';
5
5
  import {readFontAssets, documentFontsCss} from '../spec/document-fonts.mjs';
6
6
  import {fontRequests, requireDocumentFonts} from './export-fonts.mjs';
7
- import {documentLanguage, documentSettingsFonts} from '../spec/document-settings.mjs';
7
+ import {documentLanguage, documentSettingsFonts, pageSizeOf} from '../spec/document-settings.mjs';
8
8
  import {createLineMeasurer, linesHeight} from '../layout/model.mjs';
9
9
  import {markPrintCapitals, createPrintCapitals, followingPrintProse} from './print-capitals.mjs';
10
10
  import {freezePublishedInk} from './render-ink.mjs';
@@ -205,23 +205,26 @@ async function _rapierBuildArtifact(options, providedContext) {
205
205
  }
206
206
 
207
207
  function _rapierTemplatePage(canonical, settings, root, darkPaper = false) {
208
- if (!isTemplatePage(canonical) || !settings?.papersize) return null;
208
+ if (!isTemplatePage(canonical)) return null;
209
+ const paper = pageSizeOf(settings);
209
210
  let url = '';
210
211
  for (const paragraph of root.querySelectorAll(':scope > p[data-md-layout]')) {
211
212
  const image = paragraph.querySelector('img');
212
213
  const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(paragraph.getAttribute('data-md-layout'));
213
214
  const source = image?.getAttribute('src') || '';
214
- if (isPageBorderLayout(layout) && /^data:image\/svg\+xml;base64,[A-Za-z0-9+/=]+$/.test(source)) { url = source; paragraph.remove(); break; }
215
+ if (isPageBorderLayout(layout) && /^data:image\/(?:png|jpeg|webp|gif|svg\+xml|jxl);base64,[A-Za-z0-9+/=]+$/i.test(source)) { url = source; paragraph.remove(); break; }
215
216
  }
216
217
  if (!url) return null;
217
- const width = settings.papersize.width / 15, height = settings.papersize.height / 15;
218
- const margins = ['top', 'right', 'bottom', 'left'].map(side => (settings.geometry?.[side] ?? 1440) / 15);
218
+ const width = paper.width / 15, height = paper.height / 15;
219
+ const margins = ['top', 'right', 'bottom', 'left'].map(side => (settings?.geometry?.[side] ?? 1440) / 15);
219
220
  const padding = margins.map(value => value + 'px').join(' ');
220
221
  const ground = 'background:var(--rapier-template-border) 0 0/100% 100% no-repeat';
221
- const shown = darkPaper ? _rapierDarkPaperSvgUrl(url, '#fafafa') : url;
222
+ const shown = darkPaper && url.startsWith('data:image/svg+xml;base64,') ? _rapierDarkPaperSvgUrl(url, '#fafafa') : url;
222
223
  const css = ':root{--rapier-template-border:url("' + url + '")}\n'
223
224
  + '@media screen{' + (shown !== url ? ':root{--rapier-template-border:url("' + shown + '")}' : '') + '.rapier-template-border{display:none}body{background:var(--md-color-inset)}.rapier-page{box-sizing:border-box;width:min(100%,' + width + 'px);min-height:' + height + 'px;margin:0 auto;padding:' + padding
224
225
  + ';background:var(--rapier-template-border) 0 0/100% ' + height + 'px repeat-y,var(--md-color-bg);color:var(--md-color-text)}}\n'
226
+ + '@media screen and (max-width:' + width + 'px){.rapier-page{min-height:auto;aspect-ratio:' + width + '/' + height + ';padding:'
227
+ + margins.map(value => 'min(' + value + 'px,' + Number((value / width * 100).toFixed(4)) + '%)').join(' ') + ';background-size:100% auto,auto}}\n'
225
228
  + '@media print{@page{size:' + width + 'px ' + height + 'px;margin:0}html,body{margin:0;-webkit-print-color-adjust:exact;print-color-adjust:exact}'
226
229
  + '.rapier-page{box-sizing:border-box;width:auto;max-width:none;margin:0;padding:' + padding + '!important;position:relative!important;z-index:1;box-decoration-break:clone;-webkit-box-decoration-break:clone}'
227
230
  + '.rapier-template-border{display:block;position:fixed;left:0;top:0;width:' + width + 'px;height:' + height + 'px;z-index:0;' + ground + '}}';
@@ -256,11 +259,7 @@ async function _rapierBuildPublishedPage(context) {
256
259
  throw new TypeError('Publishing requires a captured document.');
257
260
  const current = {...context, ledger: null, carried: 'none', comparisonBase: null};
258
261
  return _rapierBuildArtifact({
259
- kind: 'standalone', scripts: false, extraCss: _rapierSharedPageFallbackCss(),
260
- async afterRoot(root) {
261
- if (current.work) await finishAsync(_rapierSharedPageLayoutSteps(root), current.work);
262
- else _rapierSharedPageLayout(root);
263
- },
262
+ kind: 'standalone', scripts: false,
264
263
  afterFonts: (root, style) => freezePublishedInk(root, {...style, document, work: current.work,
265
264
  spec: globalThis.RapierMarkdownSpec, ink: globalThis.RapierInk, draw: globalThis.RapierInkDraw}),
266
265
  }, current);
@@ -729,6 +728,14 @@ function* _rapierSharedPageLayoutSteps(root) {
729
728
  }
730
729
  image.style.margin = '0';
731
730
  const naturalWidth = Number(image.getAttribute('width')) || 0, naturalHeight = Number(image.getAttribute('height')) || 0;
731
+ let turned = '';
732
+ const turnedCorners = (bounds, width, height, rad) => {
733
+ const cos = Math.cos(rad), sin = Math.sin(rad), round = value => Math.round(value * 100) / 100 + '%';
734
+ return [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([x, y]) => {
735
+ const dx = x * width / 2, dy = y * height / 2;
736
+ return round(50 + (dx * cos - dy * sin) / bounds.width * 100) + ' ' + round(50 + (dx * sin + dy * cos) / bounds.height * 100);
737
+ }).join(', ');
738
+ };
732
739
  if (layout.rotate && naturalWidth > 0 && naturalHeight > 0) {
733
740
  const rad = layout.rotate * Math.PI / 180, aspect = naturalHeight / naturalWidth;
734
741
  image.style.maxWidth = 'none';
@@ -741,6 +748,7 @@ function* _rapierSharedPageLayoutSteps(root) {
741
748
  height *= scale;
742
749
  bounds = globalThis.RapierImageLayout.rotatedBoundsRad(width, height, rad);
743
750
  }
751
+ turned = turnedCorners(bounds, width, height, rad);
744
752
  paragraph.style.width = bounds.width + '%';
745
753
  paragraph.style.paddingTop = ((bounds.height - height) / 2) + '%';
746
754
  paragraph.style.paddingBottom = ((bounds.height - height) / 2) + '%';
@@ -748,6 +756,7 @@ function* _rapierSharedPageLayoutSteps(root) {
748
756
  image.style.marginLeft = image.style.marginRight = (((bounds.width - width) / 2) / bounds.width * 100) + '%';
749
757
  } else {
750
758
  const bounds = globalThis.RapierImageLayout.rotatedBoundsRad(naturalWidth, naturalHeight, rad);
759
+ turned = turnedCorners(bounds, naturalWidth, naturalHeight, rad);
751
760
  paragraph.style.width = bounds.width + 'px';
752
761
  paragraph.style.paddingTop = ((bounds.height - naturalHeight) / 2) + 'px';
753
762
  paragraph.style.paddingBottom = ((bounds.height - naturalHeight) / 2) + 'px';
@@ -763,6 +772,7 @@ function* _rapierSharedPageLayoutSteps(root) {
763
772
  if (layout.width != null || image.getAttribute('width')) image.style.width = '100%';
764
773
  }
765
774
  if (layout.rotate) { image.style.transform = 'rotate(' + layout.rotate + 'deg)'; image.style.transformOrigin = '50% 50%'; }
775
+ if (turned) paragraph.style.setProperty('shape-outside', 'polygon(' + turned + ') padding-box');
766
776
  if (layout.wrap === 'around' && !layout.rotate && !layout.y && layout.width == null && !image.dataset.rapierContour?.endsWith('-box')) {
767
777
  const src = image.getAttribute('src');
768
778
  if (src) {
@@ -779,15 +789,16 @@ function* _rapierSharedPageLayoutSteps(root) {
779
789
  const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
780
790
  if (layout?.wrap !== 'behind' && layout?.wrap !== 'front') continue;
781
791
  const paragraph = _rapierSharePictureParagraph(image);
782
- if (paragraph) positionedAssignments.push({image, paragraph, layout});
792
+ if (paragraph) positionedAssignments.push({image, paragraph, layout, owner: globalThis.RapierMarkdownLayout.wrapNeighbour(paragraph, _rapierShareWrapKind)});
783
793
  }
784
- for (const {image, paragraph, layout} of positionedAssignments) {
794
+ for (const {image, paragraph, layout, owner} of positionedAssignments) {
785
795
  yield;
786
796
  paragraph.style.cssText = 'position:relative;height:0;margin:0;padding:0;line-height:0';
787
797
  const x = layout.x, naturalWidth = Number(image.getAttribute('width')) || 0;
788
- if (layout.width != null) image.style.width = layout.width + '%';
798
+ if (layout.lines != null) { image.style.width = 'auto'; image.style.height = globalThis.RapierMarkdownLayout.linesHeightCss(layout.lines); }
799
+ else if (layout.width != null) image.style.width = layout.width + '%';
789
800
  else if (naturalWidth > 0) image.style.width = naturalWidth + 'px';
790
- image.style.left = x == null ? '0' : layout.width != null ? (x - layout.width / 2) + '%'
801
+ image.style.left = x == null ? '0' : layout.width != null ? Math.max(0, Math.min(100 - layout.width, x - layout.width / 2)) + '%'
791
802
  : naturalWidth > 0 ? 'calc(' + x + '% - ' + (naturalWidth / 2) + 'px)' : x + '%';
792
803
  image.style.position = 'absolute';
793
804
  image.style.top = (layout.y || 0) + 'em';
@@ -795,6 +806,7 @@ function* _rapierSharedPageLayoutSteps(root) {
795
806
  if (layout.rotate) { image.style.transform = 'rotate(' + layout.rotate + 'deg)'; image.style.transformOrigin = '50% 50%'; }
796
807
  if (layout.wrap === 'behind') image.style.zIndex = '-1';
797
808
  paragraph.classList.add('rapier-positioned-picture');
809
+ if (owner && owner !== paragraph.nextElementSibling) owner.before(paragraph);
798
810
  }
799
811
  const positioned = new Set([...assignments, ...positionedAssignments].map(row => row.image));
800
812
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
@@ -807,11 +819,13 @@ function* _rapierSharedPageLayoutSteps(root) {
807
819
  const naturalWidth = Number(image.getAttribute('width')) || 0, naturalHeight = Number(image.getAttribute('height')) || 0;
808
820
  if (naturalWidth > 0 && naturalHeight > 0) {
809
821
  const rad = layout.rotate * Math.PI / 180;
810
- const boundWidth = naturalWidth * Math.abs(Math.cos(rad)) + naturalHeight * Math.abs(Math.sin(rad));
811
- const boundHeight = naturalWidth * Math.abs(Math.sin(rad)) + naturalHeight * Math.abs(Math.cos(rad));
822
+ const width = layout.width != null && !layout.lines ? layout.width : naturalWidth, height = layout.width != null && !layout.lines ? layout.width * naturalHeight / naturalWidth : naturalHeight;
823
+ const unit = layout.width != null && !layout.lines ? '%' : 'px';
824
+ const boundWidth = width * Math.abs(Math.cos(rad)) + height * Math.abs(Math.sin(rad));
825
+ const boundHeight = width * Math.abs(Math.sin(rad)) + height * Math.abs(Math.cos(rad));
812
826
  image.style.display = 'block';
813
- image.style.marginLeft = image.style.marginRight = ((boundWidth - naturalWidth) / 2) + 'px';
814
- image.style.marginTop = image.style.marginBottom = ((boundHeight - naturalHeight) / 2) + 'px';
827
+ image.style.marginLeft = image.style.marginRight = ((boundWidth - width) / 2) + unit;
828
+ image.style.marginTop = image.style.marginBottom = ((boundHeight - height) / 2) + unit;
815
829
  }
816
830
  }
817
831
  }
@@ -1349,6 +1363,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1349
1363
  balance: 0};
1350
1364
  }
1351
1365
 
1366
+ let inkIds = 0;
1352
1367
  function fragmentNode(record, fragment, idsKept) {
1353
1368
  const run = record.runs[fragment.itemIndex], mapped = geometry.mapFragment(run, fragment, {whiteSpace: record.whiteSpace});
1354
1369
  if (!mapped) return null;
@@ -1374,6 +1389,8 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
1374
1389
  if (!plan) return false;
1375
1390
  const output = doc.createDocumentFragment();
1376
1391
  const idsKept = new Set();
1392
+ for (const run of record.runs) for (const parent of run.parents)
1393
+ if (parent.matches?.('span.rapier-ink-mark[data-rapier-ink]') && !parent.hasAttribute('data-rapier-ink-id')) parent.setAttribute('data-rapier-ink-id', String(++inkIds));
1377
1394
  let previous = null;
1378
1395
  for (const line of plan.lines) {
1379
1396
  const element = doc.createElement('span');
@@ -1,11 +1,23 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {next, weight, exceptions, maximum} from '../agent/vendor/hyphen-en-us/patterns.js';
3
3
 
4
- function englishCuts(word) {
5
- if (!/^[A-Z]?[a-z]+$/.test(word)) return null;
4
+ export function englishCuts(word) {
5
+ if (!/^[A-Za-z]+$/.test(word)) return null;
6
+ if (word.length < 6) return [];
7
+ const cuts = [];
8
+ let offset = 0, before = 0;
9
+ for (const part of word.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+/g)) {
10
+ if (before >= 3 && part.length >= 3) cuts.push(offset);
11
+ for (const at of partCuts(part)) cuts.push(offset+at);
12
+ offset += part.length; before = part.length;
13
+ }
14
+ return cuts.filter(at => at >= 3 && at <= word.length-3).sort((a, b) => a-b).filter((at, index, all) => at !== all[index-1]);
15
+ }
16
+
17
+ function partCuts(word) {
6
18
  if (word.length < 6) return [];
7
19
  const lower = word.toLowerCase(), known = exceptions[lower];
8
- if (Array.isArray(known)) return known.filter(at => at >= 3 && at <= word.length-3);
20
+ if (Array.isArray(known)) return known;
9
21
  const source = '.'+lower+'.', weights = new Uint8Array(source.length+1);
10
22
  for (let start = 0; start < source.length; start++) {
11
23
  let node = 0;
@@ -12,7 +12,8 @@ export function mapFragment(run, fragment, {whiteSpace = 'normal'} = {}) {
12
12
  const raw = typeof run === 'string' ? run : run?.raw;
13
13
  if (run?.break === 'never') whiteSpace = 'normal';
14
14
  const start = fragment?.sourceStart, end = fragment?.sourceEnd;
15
- if (typeof raw !== 'string' || typeof fragment?.text !== 'string' ||
15
+ const derived = fragment?.derive === true;
16
+ if (typeof raw !== 'string' || !derived && typeof fragment?.text !== 'string' ||
16
17
  !Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > raw.length ||
17
18
  whiteSpace !== 'normal' && whiteSpace !== 'pre-wrap') return null;
18
19
  const pieces = [], offsets = [start];
@@ -33,6 +34,13 @@ export function mapFragment(run, fragment, {whiteSpace = 'normal'} = {}) {
33
34
  }
34
35
  }
35
36
  let text = pieces.join('');
37
+ if (derived) {
38
+ if (fragment.glyph) {
39
+ if (lastSoftHyphen === end - 1) offsets[offsets.length - 1] = lastSoftHyphen;
40
+ offsets.push(end); text += fragment.glyph;
41
+ }
42
+ return {text, offsets, start, end};
43
+ }
36
44
  if (lastSoftHyphen === end - 1 && fragment.text === text + '-') {
37
45
  offsets[offsets.length - 1] = lastSoftHyphen;
38
46
  offsets.push(end); text += '-';
@@ -22,7 +22,7 @@ export function createParagraphFitter() {
22
22
  return handle;
23
23
  }
24
24
 
25
- function* solveSteps(handle, width, {indent = 0, left = 0, right = 0, widths = null} = {}, known) {
25
+ function* solveSteps(handle, width, {indent = 0, left = 0, right = 0, widths = null, shrink = .4, stretch = .8} = {}, known) {
26
26
  const prepared = indexes.get(handle);
27
27
  if (!prepared || ![width, indent, left, right].every(Number.isFinite) || width <= 0) return null;
28
28
  const measures = Array.isArray(widths) && widths.every(value => Number.isFinite(value) && value > 0) ? widths : [];
@@ -44,7 +44,7 @@ export function createParagraphFitter() {
44
44
  const closing = Math.min(Math.max(0, right), words.at(-1).closing);
45
45
  const target = measure(base) - (segmentIndex === 0 ? indent : 0) + opening + closing;
46
46
  const count = words.length - 1;
47
- const delta = !count ? 0 : Math.max(-smallest * .4, Math.min(0, (target - natural) / count));
47
+ const delta = !count ? 0 : Math.max(-smallest * shrink, Math.min(0, (target - natural) / count));
48
48
  const overflow = natural + delta * count - target;
49
49
  const cost = 1 + 100 * Math.max(0, overflow / target) ** 2;
50
50
  if (Number.isFinite(natural) && target > 0 && cost < 2 && minimum[words.length - 1] - minimum[Math.min(words.length - 1, 1)] <= ceiling + .02 &&
@@ -111,7 +111,7 @@ export function createParagraphFitter() {
111
111
  const target = measure(base + lineClass) - (segmentIndex === 0 && startIndex === 0 ? indent : 0) + opening + closing;
112
112
  if (target <= 0) continue;
113
113
  const remaining = target - natural;
114
- const delta = !count ? 0 : Math.max(-smallest * .4, Math.min(final ? 0 : smallest * .8, remaining / count));
114
+ const delta = !count ? 0 : Math.max(-smallest * shrink, Math.min(final ? 0 : smallest * stretch, remaining / count));
115
115
  const overflow = natural + delta * count - target;
116
116
  if (overflow > .02 && (count || start.cut || end.cut || words[lastWord].breaks.length)) continue;
117
117
  const residual = Math.max(0, remaining - delta * count);
@@ -308,10 +308,27 @@ export function createTypography() {
308
308
  return {key: keys.join('\u0000'), generation:keys[0], styles, parents, supported};
309
309
  }
310
310
 
311
+ function smallCapsAsPlanned(paragraph, text, inspection) {
312
+ if (paragraph.getAttribute('data-md-caps') !== 'small') return true;
313
+ const doc = paragraph.ownerDocument, style = inspection.styles.get(paragraph);
314
+ if (!style || !doc.body) return false;
315
+ const probe = caps => {
316
+ const span = doc.createElement('span');
317
+ span.style.cssText = 'position:absolute;visibility:hidden;white-space:pre;left:0;top:0;font-synthesis-small-caps:none;font-variant-caps:' + caps;
318
+ for (const name of ['fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'fontStretch', 'letterSpacing', 'fontKerning', 'fontFeatureSettings'])
319
+ span.style[name] = style[name];
320
+ span.textContent = text.trim().slice(0, 80);
321
+ doc.body.append(span);
322
+ const width = span.getBoundingClientRect().width;
323
+ span.remove();
324
+ return width;
325
+ };
326
+ return Math.abs(probe('small-caps') - probe('normal')) < .01;
327
+ }
328
+
311
329
  function snapshot(paragraph, inspection) {
312
330
  const text = paragraph.textContent, language = paragraph.closest('[lang]')?.getAttribute('lang') ?? '';
313
- if (!inspection.supported || !text.trim() || paragraph.hasAttribute('data-md-lines') ||
314
- paragraph.getAttribute('data-md-caps') === 'small' || paragraph.closest('[data-rapier-published-ink]') ||
331
+ if (!inspection.supported || !text.trim() || !smallCapsAsPlanned(paragraph, text, inspection) || paragraph.closest('[data-rapier-published-ink]') ||
315
332
  /[^\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Script=Common}\p{Script=Inherited}]/u.test(text) ||
316
333
  /[\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(text)) return null;
317
334
  const hard = [], items = runs(paragraph, hard), atoms = [];
@@ -425,6 +442,24 @@ export function createTypography() {
425
442
  shape.box.height, shape.points.join(' ')].join(':')).join('|')};
426
443
  }
427
444
 
445
+ function dropMeasure(paragraph, view, width) {
446
+ if (!paragraph.hasAttribute('data-md-lines')) return null;
447
+ const letter = view.getComputedStyle(paragraph, '::first-letter');
448
+ const lines = Math.ceil(parseFloat(letter.initialLetter)), initial = initialRange(paragraph);
449
+ const advance = ['width', 'marginLeft', 'marginRight', 'paddingLeft', 'paddingRight', 'borderLeftWidth', 'borderRightWidth']
450
+ .reduce((sum, name) => sum + (parseFloat(letter[name]) || 0), 0);
451
+ const at = initial && point(runs(paragraph), initial.start);
452
+ if (!letter.initialLetter) return {refuse: true};
453
+ if (!(lines > 1)) return null;
454
+ if (!(advance > 0 && advance < width) || !at?.node.parentElement) return {refuse: true};
455
+ const style = view.getComputedStyle(at.node.parentElement), context = paragraph.ownerDocument.createElement('canvas').getContext('2d');
456
+ context.font = style.fontStyle + ' ' + style.fontWeight + ' ' + style.fontSize + ' ' + style.fontFamily;
457
+ context.letterSpacing = style.letterSpacing;
458
+ const text = paragraph.textContent, word = text.slice(initial.start, initial.start + (/\s/u.exec(text.slice(initial.start))?.index ?? text.length));
459
+ const lead = context.measureText(word).width - context.measureText(word.slice(initial.text.length)).width;
460
+ return {lines, advance, lead};
461
+ }
462
+
428
463
  const topology = plan => plan && plan.lines.map(line => [line.start,line.end,line.next,line.kind,line.glyph].join(':')).join('|');
429
464
  function updateProjection(projection, plan) {
430
465
  for (const {node,property,line,sign} of projection.bindings) {
@@ -517,13 +552,6 @@ export function createTypography() {
517
552
  return Math.abs(inner - job.plan.lines.length * job.beside.lineHeight) < 1;
518
553
  }
519
554
 
520
- function editKey(paragraph, generation) {
521
- const page = paragraph.ownerDocument.documentElement;
522
- return [generation, paragraph.innerHTML, ...[...paragraph.attributes].map(item => item.name + '=' + item.value),
523
- ...[...page.attributes].map(item => item.name + '=' + item.value), paragraph.ownerDocument.body?.className,
524
- paragraph.ownerDocument.defaultView.innerWidth].join('\u0000');
525
- }
526
-
527
555
  function apply(root, {all=false, editing=false}={}) {
528
556
  const started = performance.now();
529
557
  const generation = captureFontAuthority(root?.ownerDocument || globalThis.document);
@@ -533,31 +561,40 @@ export function createTypography() {
533
561
  }
534
562
  const jobs=[];let pending=false,changed=0,released=0,preparedCount=0;
535
563
  for (const paragraph of paragraphs(root)) {
564
+ if (paragraph.dataset.rapierFlow==='true') continue;
536
565
  if (paragraph.closest('table,pre,code,h1,h2,h3,h4,h5,h6')) {clear(paragraph);continue;}
537
566
  if (paragraph.closest('.block-edit')) {
538
567
  if (!editing) continue;
539
- if (paragraph.getAttribute('data-md-align')!=='justify' || paragraph.getAttribute('data-md-hyphens')!=='auto') {
540
- if (paragraph.hasAttribute(mark)) clear(paragraph);
541
- protectInitial(paragraph);continue;
542
- }
568
+ if (paragraph.hasAttribute(mark)) clear(paragraph);
569
+ protectInitial(paragraph);
570
+ continue;
543
571
  }
544
572
  else if (paragraph.isContentEditable && !paragraph.closest('.block-read')) {clear(paragraph);continue;}
545
573
  if (!paragraph.isConnected || !all && paragraph.checkVisibility && !paragraph.checkVisibility({contentVisibilityAuto:true})) continue;
546
574
  const view=paragraph.ownerDocument.defaultView;
547
575
  if (!view || !paragraph.getClientRects().length) continue;
548
- if (editing && states.get(paragraph)?.editKey===editKey(paragraph,generation)) continue;
549
576
  const style=view.getComputedStyle(paragraph),box=paragraph.getBoundingClientRect(),prior=states.get(paragraph);
550
577
  if (!all && (box.bottom < -100 || box.top > view.innerHeight+100)) continue;
551
578
  const hanging=paragraph.getAttribute('data-md-hanging')==='on',size=dimensions(paragraph,style,box,hanging);
552
579
  if (!(size.width>0)) continue;
580
+ if (paragraph.getAttribute('data-md-align')!=='justify') {size.shrink=0;size.stretch=0;}
553
581
  const beside=floatMeasures(paragraph,style,box,size.width);
554
582
  if (beside?.refuse) {clear(paragraph);continue;}
555
583
  if (beside?.widths.some(width=>!(width>0))) {clear(paragraph);continue;}
556
584
  if (beside) size.widths=beside.widths;
585
+ const drop=dropMeasure(paragraph,view,size.width);
586
+ if (drop?.refuse) {clear(paragraph);continue;}
587
+ if (drop) {
588
+ const widths=[...(size.widths||[])];
589
+ while (widths.length<drop.lines) widths.push(size.width);
590
+ for (let line=0;line<drop.lines;line++) widths[line]=Math.floor((widths[line]-drop.advance+(line?0:drop.lead))*64)/64;
591
+ if (widths.some(value=>!(value>0))) {clear(paragraph);continue;}
592
+ size.widths=widths;
593
+ }
557
594
  const parents=prior?.parents || own(paragraph);
558
595
  const inspection=inspect(paragraph,parents,prior,style);
559
596
  const reusable=prior?.html===paragraph.innerHTML && prior.key===inspection.key && (!prior.geometry || scope(paragraph.ownerDocument).current(prior.geometry));
560
- const sizeKey=[size.width,size.indent,size.left,size.right,beside?.key||''].join('|');
597
+ const sizeKey=[size.width,size.indent,size.left,size.right,beside?.key||'',drop?drop.lines+'/'+drop.advance+'/'+drop.lead:''].join('|');
561
598
  if (refused.get(paragraph)===sizeKey+'\u0000'+paragraph.textContent) {if (paragraph.hasAttribute(mark)) clear(paragraph);continue;}
562
599
  if (reusable && !prior.pending && prior.sizeKey===sizeKey) continue;
563
600
  const fitWork=reusable && prior.sizeKey===sizeKey?prior.fitWork:null;
@@ -565,12 +602,12 @@ export function createTypography() {
565
602
  if (!all && jobs.length===4) {if (!reusable) clear(paragraph);pending=true;continue;}
566
603
  jobs.push({paragraph,view,style,inspection,size,sizeKey,hanging,beside,selected:selectionBefore(paragraph),
567
604
  cap:paragraph.hasAttribute('data-md-lines'),justified:paragraph.getAttribute('data-md-align')==='justify' && paragraph.getAttribute('data-md-hyphens')==='auto',
605
+ ragged:paragraph.getAttribute('data-md-align')!=='justify' && paragraph.getAttribute('data-md-hyphens')==='auto' && /^(start|left)$/.test(style.textAlign),
568
606
  saved:reusable?prior.saved:null,geometry:reusable?prior.geometry:null,prepared:reusable?prior.prepared:null,
569
607
  work:reusable?prior.work:null,fitWork,
570
608
  prior,reusable,needsClear:paragraph.hasAttribute(mark)});
571
609
  }
572
- if (editing && jobs.some(job=>job.selected && !job.reusable)) root.ownerDocument.getSelection()?.removeAllRanges();
573
- for (const job of jobs) if (job.needsClear && !job.saved) clear(job.paragraph,{keepSelection:!editing});
610
+ for (const job of jobs) if (job.needsClear && !job.saved) clear(job.paragraph);
574
611
  function stateFor(job) {
575
612
  return {properties:new Map(),moves:[],saved:job.saved,geometry:job.geometry,prepared:job.prepared,plan:job.plan,
576
613
  work:job.work,fitWork:job.fitWork,parents:job.inspection.parents,key:job.inspection.key,generation:job.inspection.generation,sizeKey:job.sizeKey};
@@ -584,12 +621,8 @@ export function createTypography() {
584
621
  for (let jobIndex = 0; jobIndex < jobs.length; jobIndex++) {
585
622
  if (!all && jobIndex && performance.now() - started >= 8) {pending=true;break;}
586
623
  const job = jobs[jobIndex];
587
- if (!job.saved && job.justified) {
588
- if (job.needsClear) {
589
- const elements=own(job.paragraph),parents=job.inspection.parents;
590
- if (!editing || !job.prior?.saved || elements.length!==parents.length || elements.some((element,index)=>element!==parents[index]))
591
- job.inspection=inspect(job.paragraph,elements,null,job.view.getComputedStyle(job.paragraph));
592
- }
624
+ if (!job.saved && (job.justified || job.ragged)) {
625
+ if (job.needsClear) job.inspection=inspect(job.paragraph,own(job.paragraph),null,job.view.getComputedStyle(job.paragraph));
593
626
  job.saved=snapshot(job.paragraph,job.inspection);
594
627
  if (job.saved) {
595
628
  job.work=scope(job.paragraph.ownerDocument).prepareSteps(job.paragraph,job.saved.inline,{policy:'paragraph',language:job.saved.language,
@@ -611,15 +644,15 @@ export function createTypography() {
611
644
  if (job.fitWork) {
612
645
  for (;;) {
613
646
  const step=job.fitWork.next();
614
- if (step.done) {job.plan=step.value;job.fitWork=null;break;}
647
+ if (step.done) {job.plan=job.ragged && step.value ? Object.freeze({...step.value,lines:Object.freeze(step.value.lines.map(line=>Object.freeze({...line,wordSpacing:0})))}) : step.value;job.fitWork=null;break;}
615
648
  if (!all && performance.now()-started>=8) break;
616
649
  }
617
650
  if (job.fitWork) {suspend(job);continue;}
618
651
  }
619
652
  job.projection=job.reusable && job.plan && job.prior?.projection?.topology===topology(job.plan) ? job.prior.projection : null;
620
- if (job.needsClear && job.saved && !job.projection) clear(job.paragraph,{forget:false,keepSelection:!editing});
621
- const {paragraph,view,hanging,cap,justified}=job;
622
- if (!cap && !hanging && !justified) {restoreSelection(paragraph,job.selected);continue;}
653
+ if (job.needsClear && job.saved && !job.projection) clear(job.paragraph,{forget:false});
654
+ const {paragraph,view,hanging,cap,justified,ragged}=job;
655
+ if (!cap && !hanging && !justified && !ragged) {restoreSelection(paragraph,job.selected);continue;}
623
656
  const state=stateFor(job);
624
657
  states.set(paragraph,state);live.add(paragraph);paragraph.setAttribute(mark,'');
625
658
  if (cap || hanging) protectInitial(paragraph);
@@ -631,6 +664,7 @@ export function createTypography() {
631
664
  }
632
665
  if (job.projection) {updateProjection(job.projection,job.plan);state.projection=job.projection;}
633
666
  else if (job.plan) state.projection=project(paragraph,job.saved,job.geometry,job.plan);
667
+ paragraph.toggleAttribute(mark+'-ragged',!!ragged && !!state.projection);
634
668
  if (state.projection && job.beside && !laidAsPlanned(paragraph,job)) {
635
669
  refused.set(paragraph,job.sizeKey+'\u0000'+paragraph.textContent);clear(paragraph);restoreSelection(paragraph,job.selected);continue;
636
670
  }
@@ -638,18 +672,103 @@ export function createTypography() {
638
672
  else if (hanging) hangPunctuation(paragraph,state,job.style,hangingEdge(paragraph,job.style,paragraph.getBoundingClientRect()));
639
673
  state.html=paragraph.innerHTML;
640
674
  restoreSelection(paragraph,job.selected);changed++;
641
- if (editing) state.editKey=editKey(paragraph,generation);
642
675
  }
643
676
  return {changed,released,pending,prepared:preparedCount};
644
677
  }
645
678
 
679
+ const slotPlans = new WeakMap();
680
+ function slotPlan(paragraph, items, options, input) {
681
+ const {width, top, obstacles, lineHeight, minWidth, justify, hyphenate} = input;
682
+ const round = value => Math.round(value * 100) / 100;
683
+ const key = [captureFontAuthority(paragraph.ownerDocument), width, lineHeight, minWidth, justify, hyphenate, options.language, options.wordBreak,
684
+ items.map(item => [item.text, item.font, item.letterSpacing, item.break, item.extraWidth].join('\u0001')).join('\u0002'),
685
+ obstacles.map(item => [item.x, item.y - top, item.width, item.height].map(round).join(',')).join(';')].join('\u0000');
686
+ const known = slotPlans.get(paragraph);
687
+ if (known?.key === key) return known.plan;
688
+ const plan = slotLines(paragraph, items, options, input);
689
+ slotPlans.set(paragraph, {key, plan});
690
+ return plan;
691
+ }
692
+ function slotLines(paragraph, items, options, {width, top, obstacles, lineHeight, minWidth, slotsForBand, justify, hyphenate}) {
693
+ if (!obstacles.length) return null;
694
+ const doc = paragraph.ownerDocument, words = scope(doc);
695
+ const source = items.map(item => ({...item, hyphenate}));
696
+ const geometry = words.prepare(paragraph, source, {policy: 'paragraph', language: options.language, whiteSpace: 'normal',
697
+ wordBreak: options.wordBreak, hanging: false, paragraphBreaks: [0, source.length]});
698
+ if (!geometry) return null;
699
+ const sequence = [], bottom = Math.max(...obstacles.map(item => item.y + item.height)) - top;
700
+ let y = 0, rows = 0;
701
+ while (y < bottom && rows < 512 && sequence.length < 1024) {
702
+ const bands = slotsForBand(width, obstacles, top + y, lineHeight, Math.min(minWidth, width));
703
+ if (!bands) return null;
704
+ if (bands.length) {
705
+ for (const band of bands) sequence.push({x: band.x, y, width: band.width});
706
+ y += lineHeight; rows++; continue;
707
+ }
708
+ let next = Infinity;
709
+ for (const obstacle of obstacles) if (obstacle.x < width && obstacle.x + obstacle.width > 0 &&
710
+ obstacle.y < top + y + lineHeight && obstacle.y + obstacle.height > top + y) next = Math.min(next, obstacle.y + obstacle.height - top);
711
+ if (!Number.isFinite(next) || next <= y) return null;
712
+ y = next;
713
+ }
714
+ const prepared = fitter.prepare(geometry);
715
+ let slots = sequence, plan = null;
716
+ for (let pass = 0; prepared && pass < 8; pass++) {
717
+ const measures = slots.map(slot => Math.floor(slot.width * 64) / 64);
718
+ if (!measures.length) return null;
719
+ plan = words.fit(paragraph, geometry, known => fitter.solveSteps(prepared, width, {widths: measures, shrink: justify ? undefined : 0}, known));
720
+ if (!plan) return null;
721
+ const wide = new Set();
722
+ plan.lines.forEach((line, index) => {
723
+ if (index >= slots.length || line.firstWord !== line.lastWord || line.kind === 'soft' || line.kind === 'auto') return;
724
+ const word = geometry.segments[line.segment].words[line.firstWord];
725
+ if (line.start === word.start && line.end === word.end && word.width > measures[index] + .5) wide.add(slots[index]);
726
+ });
727
+ if (!wide.size) break;
728
+ slots = slots.filter(slot => !wide.has(slot)); plan = null;
729
+ }
730
+ const perRow = new Map();
731
+ for (const slot of slots) perRow.set(slot.y, (perRow.get(slot.y) || 0) + 1);
732
+ if (!plan || ![...perRow.values()].some(count => count > 1)) return null;
733
+ const lines = [], text = geometry.text, offsets = geometry.offsets, space = /^[\t\n\f\r ]*$/;
734
+ for (let index = 0; index < plan.lines.length; index++) {
735
+ const line = plan.lines[index], slot = sequence[index] || {x: 0, y: y + (index - sequence.length) * lineHeight, width};
736
+ const spacing = justify ? line.wordSpacing : 0, fragments = [];
737
+ const segment = geometry.segments[line.segment].words.slice(line.firstWord, line.lastWord + 1);
738
+ let at = 0, previous = null;
739
+ while (at < source.length - 1 && offsets[at + 1] <= line.start) at++;
740
+ for (; at < source.length && offsets[at] < line.end; at++) {
741
+ let from = Math.max(line.start, offsets[at]), to = Math.min(line.end, offsets[at + 1]);
742
+ if (previous) while (from < to && /[\t\n\f\r ]/.test(text[from])) from++;
743
+ if (offsets[at + 1] < line.end) while (to > from && /[\t\n\f\r ]/.test(text[to - 1])) to--;
744
+ if (to <= from) continue;
745
+ const fragment = {itemIndex: at, sourceStart: from - offsets[at], sourceEnd: to - offsets[at], derive: true, occupiedWidth: 0, gapItemIndex: at, gapBefore: 0};
746
+ if (previous) {
747
+ fragment.gapItemIndex = previous.itemIndex;
748
+ if (previous.end < from) {
749
+ if (!space.test(text.slice(previous.end, from))) return null;
750
+ const before = segment.find(word => word.end === previous.end);
751
+ if (!before?.gap) return null;
752
+ fragment.gapBefore = before.gap.width + spacing;
753
+ } else fragment.gapBefore = 0;
754
+ }
755
+ fragments.push(fragment); previous = {itemIndex: at, end: to};
756
+ }
757
+ if (!fragments.length) return null;
758
+ if (line.kind === 'soft' || line.kind === 'auto') fragments.at(-1).glyph = line.glyph || '-';
759
+ lines.push({x: slot.x, y: slot.y, width: slot.width, fragments, wordSpacing: spacing});
760
+ }
761
+ const last = lines.at(-1);
762
+ return {lines, height: last ? Math.max(lineHeight, last.y + lineHeight) : lineHeight};
763
+ }
764
+
646
765
  function retire(root, {documentChanged=false}={}) {
647
766
  clear(root);
648
767
  for (const paragraph of live) if (!paragraph.isConnected) clear(paragraph);
649
768
  scope(root?.ownerDocument || root || globalThis.document).retire({documentChanged});
650
769
  }
651
770
  const invalidateFonts = document => scope(document || globalThis.document).invalidateFonts();
652
- return {apply,clear,clearSelection,firstLineEnd,initialRange,protectInitial,invalidateFonts,retire};
771
+ return {apply,clear,clearSelection,firstLineEnd,initialRange,protectInitial,invalidateFonts,retire,slotPlan};
653
772
  }
654
773
 
655
774
  const typography = createTypography();
@@ -661,3 +780,4 @@ export const initialRange = typography.initialRange;
661
780
  export const protectInitial = typography.protectInitial;
662
781
  export const invalidateFonts = typography.invalidateFonts;
663
782
  export const retire = typography.retire;
783
+ export const slotPlan = typography.slotPlan;
@@ -216,6 +216,11 @@ export function documentFontUsage(markdown, parser, defaultFont) {
216
216
  for (const token of md.parse(splitOpeningFrontmatter(source).body, {})) {
217
217
  if (token.type === 'heading_open') blockFamily = sans;
218
218
  else if (token.type === 'heading_close' || token.type === 'paragraph_open') blockFamily = main;
219
+ else if (token.type === 'fence' && /^html(?:\s+\{[^{}]*\})?\s*$/i.test(token.info)) {
220
+ const text = plain(token.content);
221
+ add(blockFamily, text);
222
+ for (const name of htmlFontFamilies(token.content, md)) add(name, text);
223
+ }
219
224
  else if (token.type === 'fence' || token.type === 'code_block') add(mono, token.content);
220
225
  else if (token.type === 'html_block') {
221
226
  const text = plain(token.content);
@@ -128,10 +128,11 @@ function paperOf(value) {
128
128
  const pixels = /^(\d{3,5})x(\d{3,5})px$/.exec(name), width = Number(pixels?.[1]), height = Number(pixels?.[2]);
129
129
  return pixels && width <= 10000 && height <= 10000 ? Object.freeze({name, width: width * 15, height: height * 15, css: width + 'px ' + height + 'px'}) : null;
130
130
  }
131
+ export const pageSizeOf = settings => settings?.papersize || PAPERS.letter;
131
132
  export const PAGE_SIZES = Object.freeze(['letter', 'legal', 'tabloid', 'a3', 'a4', 'a5', '1080x1080px', '1080x1350px', '1920x1080px', '1200x630px']);
132
133
  const EMPTY = Object.freeze({
133
134
  title: null, subtitle: null, lang: null, fontsize: null, mainfont: null, sansfont: null, monofont: null, defaultfont: null, linestretch: null,
134
- papersize: null, geometry: null, pagestyle: null, spacing: null,
135
+ papersize: null, geometry: null, pagestyle: null, spacing: null, template: null,
135
136
  });
136
137
 
137
138
  function unquote(value) {
@@ -351,6 +352,7 @@ export function readDocumentSettings(markdown) {
351
352
  fontsize, mainfont, sansfont, monofont, defaultfont, linestretch, papersize, geometry,
352
353
  spacing: normalizeDocumentSpacing(take('spacing')),
353
354
  pagestyle: page === 'plain' || page === 'empty' ? page : null,
355
+ template: String(take('template') || '').toLowerCase() === 'page' ? 'page' : null,
354
356
  });
355
357
  }
356
358
 
@@ -368,14 +370,14 @@ export function updateDocumentSettings(markdown, patch) {
368
370
  text: (source[field.start - 1] === ':' ? ' ' : '') + text + (source[field.end] === '#' ? ' ' : '')});
369
371
  for (const [key, supplied] of Object.entries(patch || {})) {
370
372
  const font = ['mainfont', 'sansfont', 'monofont', 'defaultfont'].includes(key);
371
- if (!font && !['fontsize', 'linestretch', 'spacing', 'geometry', 'papersize'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
373
+ if (!font && !['fontsize', 'linestretch', 'spacing', 'geometry', 'papersize', 'pagestyle'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
372
374
  const matches = fields.filter(item => item.key === key);
373
375
  if (matches.length > 1) throw new RangeError('Duplicate document setting: ' + key);
374
376
  const field = matches[0];
375
- const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : key === 'spacing' ? normalizeDocumentSpacing(supplied) : key === 'geometry' ? marginOf(supplied) : key === 'papersize' ? paperOf(supplied) : leading(supplied);
377
+ const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : key === 'spacing' ? normalizeDocumentSpacing(supplied) : key === 'geometry' ? marginOf(supplied) : key === 'papersize' ? paperOf(supplied) : key === 'pagestyle' ? (['plain', 'empty'].includes(supplied) ? {name: supplied} : null) : leading(supplied);
376
378
  if (supplied !== null && !value) throw new TypeError('Invalid document setting: ' + key);
377
- const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : key === 'spacing' ? JSON.stringify(value) : key === 'geometry' ? unquote(supplied).trim() : key === 'papersize' ? value.name : String(value.factor);
378
- 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 : key === 'geometry' ? ['top', 'right', 'bottom', 'left'].every(side => before.geometry[side] === value[side]) : key === 'papersize' ? before.papersize.name === value.name : before.linestretch.factor === value.factor)) continue;
379
+ const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : key === 'spacing' ? JSON.stringify(value) : key === 'geometry' ? unquote(supplied).trim() : key === 'papersize' || key === 'pagestyle' ? value.name : String(value.factor);
380
+ 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 : key === 'geometry' ? ['top', 'right', 'bottom', 'left'].every(side => before.geometry[side] === value[side]) : key === 'papersize' ? before.papersize.name === value.name : key === 'pagestyle' ? before.pagestyle === value.name : before.linestretch.factor === value.factor)) continue;
379
381
  if (key === 'fontsize' && value !== null && fontSize(encoded)?.unit !== value.unit) refuse(key);
380
382
  if (!field) { if (value !== null) append.push(key + ': ' + encoded + eol); continue; }
381
383
  if (field.opaque || field.invalid || field.multiline || field.block) refuse(key);
@@ -53,7 +53,7 @@ export function htmlFence(raw) {
53
53
  const tail = raw.slice(end), attached = /^(\r?\n)(<!--md-layout:v1[^\r\n]*-->)(?:\r?\n)?$/.exec(tail);
54
54
  const layout = attached && parseLayout(attached[2]);
55
55
  if (tail.trim() && !layout) return null;
56
- if (layout && ['align', 'first', 'indent', 'marker', 'hyphens', 'caps', 'hanging'].some(key => key in layout)) return null;
56
+ if (layout && ['align', 'first', 'indent', 'marker', 'hyphens', 'caps', 'hanging', 'lines', 'before', 'after', 'line'].some(key => key in layout)) return null;
57
57
  return {attrs, info: opening[3].trim(), body: rest.slice(0, found.index), infoStart: opening[1].length + mark.length,
58
58
  infoEnd: opening[0].replace(/\r?\n$/, '').length, source: raw, start: 0, end: raw.length, fenceEnd: end,
59
59
  layout: layout || HTML_FLOW, marker: attached ? {start: end + attached[1].length, end: end + attached[1].length + attached[2].length, text: attached[2]} : null};
@@ -545,24 +545,43 @@ export function mountHtmlBlocks(doc, options = {}) {
545
545
  value.width < 1 || value.viewportHeight < 1 || value.width > 4096 || value.viewportHeight > 4096) return;
546
546
  if (Math.abs(row.frame.clientWidth - value.width) > 1 || Math.abs(row.frame.clientHeight - value.viewportHeight) > 1) {message(row, 'snapshot'); return;}
547
547
  const stamp = ++row.generation, scale = Math.min(1, 1024 / Math.max(value.width, value.viewportHeight));
548
- const canvas = doc.createElement('canvas'); canvas.width = Math.max(1, Math.ceil(value.width * scale)); canvas.height = Math.max(1, Math.ceil(value.viewportHeight * scale));
549
- const snapshot = new win.Image();
548
+ const ratio = win.devicePixelRatio || 1, sharp = Math.min(ratio, 4096 / Math.max(value.width, value.viewportHeight), Math.sqrt(16777216 / (value.width * value.viewportHeight)));
549
+ const current = () => !dead && owned.get(row.image) === row && stamp === row.generation && keyOf(row.image) === row.key;
550
+ const draw = async factor => {
551
+ const canvas = doc.createElement('canvas'), snapshot = new win.Image();
552
+ canvas.width = Math.max(1, Math.ceil(value.width * factor)); canvas.height = Math.max(1, Math.ceil(value.viewportHeight * factor));
553
+ try {
554
+ snapshot.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="' + canvas.width + '" height="' + canvas.height + '" viewBox="0 0 ' + value.width + ' ' + value.viewportHeight + '"><foreignObject width="100%" height="100%">' + value.markup + '</foreignObject></svg>');
555
+ await Promise.race([snapshot.decode(), new Promise((_, reject) => win.setTimeout(() => reject(new Error('Snapshot decode timeout')), 2000))]);
556
+ if (!current()) return null;
557
+ const ctx = canvas.getContext('2d'); ctx.drawImage(snapshot, 0, 0); ctx.getImageData(0, 0, 1, 1);
558
+ return canvas.toDataURL('image/png');
559
+ } finally {snapshot.removeAttribute('src'); canvas.width = canvas.height = 0;}
560
+ };
550
561
  try {
551
- snapshot.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="' + canvas.width + '" height="' + canvas.height + '" viewBox="0 0 ' + value.width + ' ' + value.viewportHeight + '"><foreignObject width="100%" height="100%">' + value.markup + '</foreignObject></svg>');
552
- await Promise.race([snapshot.decode(), new Promise((_, reject) => win.setTimeout(() => reject(new Error('Snapshot decode timeout')), 2000))]);
553
- if (dead || owned.get(row.image) !== row || stamp !== row.generation || keyOf(row.image) !== row.key) return;
562
+ const first = await draw(scale);
563
+ if (first == null) return;
554
564
  if (Math.abs(row.frame.clientWidth - value.width) > 1 || Math.abs(row.frame.clientHeight - value.viewportHeight) > 1) {message(row, 'snapshot'); return;}
555
- const ctx = canvas.getContext('2d'); ctx.drawImage(snapshot, 0, 0); ctx.getImageData(0, 0, 1, 1);
556
- const first = canvas.toDataURL('image/png'); if (first.length > 4 * 1024 * 1024) throw new Error('Snapshot byte budget');
557
- row.image.dataset.rapierFirstFrame = first; row.image.src = first;
565
+ if (first.length > 4 * 1024 * 1024) throw new Error('Snapshot byte budget');
566
+ const shown = sharp > scale ? await draw(sharp).catch(() => first) : first;
567
+ if (shown == null) return;
568
+ row.ratio = ratio;
569
+ row.image.dataset.rapierFirstFrame = first; row.image.src = shown;
558
570
  row.snapshot = true;
559
571
  row.image.dataset.rapierLiveSurface = '';
560
572
  row.image.dataset.rapierContour = row.animated ? 'animation-box' : value.snapshotStatus === 'sampled' ? 'sampled' : 'unavailable-box';
561
573
  row.image.dataset.rapierNaturalWidth = String(value.width); row.image.dataset.rapierNaturalHeight = String(value.viewportHeight);
562
574
  row.image.dispatchEvent(new win.Event('rapier-html-resize', {bubbles: true})); schedule();
563
575
  } catch {if (owned.get(row.image) === row) {row.image.dataset.rapierContour = 'unavailable-box'; row.image.dataset.rapierSnapshot = 'unavailable';}}
564
- finally {snapshot.removeAttribute('src'); canvas.width = canvas.height = 0;}
565
576
  }
577
+ let density = null;
578
+ const redraw = () => {
579
+ density?.removeEventListener('change', redraw);
580
+ if (dead) return;
581
+ density = win.matchMedia('(resolution: ' + (win.devicePixelRatio || 1) + 'dppx)'); density.addEventListener('change', redraw);
582
+ for (const row of owned.values()) if (row.snapshot && row.ratio !== (win.devicePixelRatio || 1)) {row.generation++; message(row, 'snapshot');}
583
+ };
584
+ redraw();
566
585
  const changedVisibility = () => {for (const row of owned.values()) applyPlayback(row);};
567
586
  const hidden = event => {if (!event.persisted) {api.close(); return;} for (const row of owned.values()) {row.visible = false; applyPlayback(row);}};
568
587
  const shown = () => {scan(); for (const row of owned.values()) {const box = row.image.getBoundingClientRect();
@@ -580,7 +599,7 @@ export function mountHtmlBlocks(doc, options = {}) {
580
599
  snapshot: image.dataset.rapierSnapshot || null, contour: image.dataset.rapierContour || null, status: image.dataset.rapierPlayback || 'ready'} : null;},
581
600
  close() {dead = true; win.cancelAnimationFrame(pending); observer.disconnect(); resize.disconnect(); visibility?.disconnect();
582
601
  win.removeEventListener('message', receive); win.removeEventListener('resize', schedule); doc.removeEventListener('scroll', schedule, true);
583
- doc.removeEventListener('visibilitychange', changedVisibility); reduced.removeEventListener('change', changedMotion);
602
+ doc.removeEventListener('visibilitychange', changedVisibility); reduced.removeEventListener('change', changedMotion); density?.removeEventListener('change', redraw);
584
603
  for (const row of [...owned.values()]) release(row); sheet.remove(); local.clear();
585
604
  win.removeEventListener('pagehide', hidden); win.removeEventListener('pageshow', shown); if (doc.rapierObjects === api) delete doc.rapierObjects;}};
586
605
  win.addEventListener('pagehide', hidden); win.addEventListener('pageshow', shown); if (!options.root) doc.rapierObjects = api; scan(); return api;
@@ -515,9 +515,10 @@ export function retireDeletedImageDefinitions(before, after, splices, factory) {
515
515
  return retired.sort((a, b) => b.start - a.start).map(row => {
516
516
  const gap = /(?:\r\n\r\n|\n\n|\r\r)$/.exec(after.slice(Math.max(0, row.start - 4), row.start));
517
517
  let tail = after.slice(row.end);
518
+ const last = /^(?:\r\n|\n|\r)?$/.test(tail);
518
519
  for (const other of retired) if (other.start >= row.end) tail = tail.replace(other.source, '');
519
520
  const whole = gap && !/\S/.test(tail) && row.start - gap[0].length > markdownBodyOffset(after);
520
- return whole ? {pos: row.start - gap[0].length, removed: gap[0] + row.source, inserted: ''} : {pos: row.start, removed: row.source, inserted: ''};
521
+ return whole ? {pos: row.start - gap[0].length, removed: gap[0] + row.source + (last ? after.slice(row.end) : ''), inserted: ''} : {pos: row.start, removed: row.source, inserted: ''};
521
522
  });
522
523
  } catch (_) { return []; }
523
524
  }
@@ -1,6 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
- export function isTemplatePage(source) {
3
- const match = /^(?:)?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(String(source).slice(0, 65536));
4
- return !!match && /^template:[ \t]*page[ \t]*(?:#.*)?$/m.test(match[1]);
5
- }
2
+ import {readDocumentSettings} from './document-settings.mjs';
3
+ export const isTemplatePage = source => readDocumentSettings(source).template === 'page';
6
4
  export const isPageBorderLayout = layout => layout?.wrap === 'behind' && layout.lock === 'on';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.2.30",
3
+ "version": "1.2.32",
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
@@ -326,6 +326,23 @@
326
326
  position: relative;
327
327
  }
328
328
 
329
+ /* The line that holds the box hangs: its later lines start under the first line's words, not under the box. The box takes
330
+ 16px and a space of the first line (the 48px target less its overhangs), which the hang gives back. */
331
+ .md-render .task-list-item > p:has(> input[type="checkbox"]:first-child),
332
+ .md-render .task-list-item:has(> input[type="checkbox"]:first-child) {
333
+ /* The space the source keeps between the box and the words (`- [ ] words`), so the later lines meet the words. */
334
+ --md-task-space: .24em;
335
+ padding-inline-start: calc(16px + var(--md-space-1) + var(--md-task-space));
336
+ text-indent: calc(-16px - var(--md-space-1) - var(--md-task-space));
337
+ }
338
+ /* The hang is the line's alone: nothing inside it (a formula, a code chip) or after it takes the indent, and a tight item's
339
+ later blocks (a nested list, a paragraph) keep their own place. */
340
+ .md-render .task-list-item > p:has(> input[type="checkbox"]:first-child) *,
341
+ .md-render .task-list-item:has(> input[type="checkbox"]:first-child) * { text-indent: 0; }
342
+ .md-render .task-list-item:has(> input[type="checkbox"]:first-child) > :is(p, ul, ol, pre, blockquote, table, div, dl, details, figure, hr) {
343
+ margin-inline-start: calc(-16px - var(--md-space-1) - var(--md-task-space));
344
+ }
345
+
329
346
  .md-render .task-list-item input[type="checkbox"] {
330
347
  --md-task-box: clamp(14px, .92em, 24px);
331
348
  /* A form control does not inherit color on its own; the shell's own app sheet gives it one