rapier-markdown-kit 1.1.104 → 1.2.5

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.
Files changed (60) hide show
  1. package/LICENSE +8 -4
  2. package/README.md +50 -7
  3. package/dist/images/gif.mjs +49 -0
  4. package/dist/images/playback.mjs +103 -0
  5. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  6. package/dist/kit/conformance/04-wrap-box.md +1 -1
  7. package/dist/kit/conformance/05-behind.md +1 -1
  8. package/dist/kit/conformance/06-front.md +1 -1
  9. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  10. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  11. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  12. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  13. package/dist/kit/conformance/10-both-sides.md +1 -1
  14. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  15. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  16. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  17. package/dist/kit/conformance/README.md +19 -28
  18. package/dist/kit/conformance/run.mjs +10 -8
  19. package/dist/kit/ledger/authored-origin.mjs +49 -0
  20. package/dist/kit/ledger/authorship.mjs +18 -9
  21. package/dist/kit/ledger/carried.mjs +10 -5
  22. package/dist/kit/ledger/data.mjs +7 -4
  23. package/dist/kit/ledger/format.mjs +37 -11
  24. package/dist/kit/ledger/hash.mjs +31 -15
  25. package/dist/kit/ledger/history.mjs +48 -10
  26. package/dist/kit/ledger/inverse-source.mjs +111 -49
  27. package/dist/kit/ledger/journal-records.mjs +24 -29
  28. package/dist/kit/ledger/merge.mjs +51 -15
  29. package/dist/kit/ledger/text-merge.mjs +75 -29
  30. package/dist/kit/ledger/transport.mjs +30 -45
  31. package/dist/kit/pdf-vectors.mjs +6 -2
  32. package/dist/kit/print-capitals.mjs +0 -1
  33. package/dist/kit/rapier-ledger.mjs +4 -2
  34. package/dist/kit/render-ink.mjs +1 -1
  35. package/dist/kit/render-markdown.mjs +155 -31
  36. package/dist/kit/render-print.mjs +173 -61
  37. package/dist/kit/render-sanitize.mjs +25 -15
  38. package/dist/kit/render-styles.mjs +3 -3
  39. package/dist/kit/render.mjs +37 -115
  40. package/dist/layout/model.mjs +10 -8
  41. package/dist/layout/paragraph-fit.mjs +1 -15
  42. package/dist/layout/typography.mjs +2 -1
  43. package/dist/packages/rapier-diff/concerns.mjs +163 -0
  44. package/dist/packages/rapier-diff/diff.mjs +68 -0
  45. package/dist/packages/rapier-diff/index.mjs +4 -701
  46. package/dist/packages/rapier-diff/matching.mjs +157 -0
  47. package/dist/packages/rapier-diff/merge.mjs +226 -0
  48. package/dist/packages/rapier-diff/moves.mjs +130 -0
  49. package/dist/packages/rapier-diff/structure.mjs +50 -0
  50. package/dist/packages/rapier-diff/tokens.mjs +72 -0
  51. package/dist/packages/rapier-diff/walk.mjs +219 -0
  52. package/dist/spec/document-fonts.mjs +1 -1
  53. package/dist/spec/document-settings.mjs +11 -164
  54. package/dist/spec/html-blocks.mjs +618 -79
  55. package/dist/spec/md-assets.mjs +29 -6
  56. package/dist/spec/md-layout.mjs +9 -1
  57. package/dist/spec/md-marks.mjs +25 -147
  58. package/dist/spec/md-source.mjs +69 -26
  59. package/package.json +1 -1
  60. package/style.css +7 -51
@@ -75,7 +75,7 @@ function _rapierPrintWillFont(markers) {
75
75
  const bounds = [0, -400, 10, 0];
76
76
  const outline = cat(u16(1), bounds.map(u16), u16(2), u16(0), [1, 1, 1], u16(0), u16(10), u16(-5), u16(bounds[1]), u16(bounds[3] - bounds[1]), u16((bounds[1] - bounds[3]) / 2), [0]);
77
77
  const utf16 = text => Array.from(text, ch => u16(ch.charCodeAt(0)));
78
- const names = [[1, 'RapierWill'], [2, 'Regular'], [4, 'RapierWill Regular'], [6, 'RapierWill-Regular']];
78
+ const names = [[1, 'RapierWillCarrier'], [2, 'Regular'], [4, 'RapierWillCarrier Regular'], [6, 'RapierWillCarrier-Regular']];
79
79
  let offset = 0;
80
80
  const tables = {
81
81
  'OS/2': cat(u16(4), u16(advance), u16(400), u16(5), u16(0), u16(650), u16(600), u16(0), u16(75), u16(650), u16(600), u16(0), u16(350),
@@ -175,7 +175,7 @@ function _rapierPrintWillPageCss() {
175
175
  // The capture's own limits (agent/visual.mjs VISUAL_LIMITS): the longest edge and the most pixels one capture returns.
176
176
  const CAPTURE_EDGE = 4096, CAPTURE_PIXELS = 4 * 1024 * 1024;
177
177
 
178
- async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = false, filename, title, settings}, {capture, signal, current, maxBytes = 8 * 1024 * 1024} = {}) {
178
+ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = false, filename, title, settings}, {capture, signal, current, maxBytes = 8 * 1024 * 1024, film, ink} = {}) {
179
179
  const refuse = code => { throw Object.assign(new Error(code), {code}); };
180
180
  const check = () => {
181
181
  if (signal?.aborted) refuse('cancelled');
@@ -193,7 +193,7 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
193
193
  const bodyHeight = Math.floor((height - margins.top - margins.bottom) * 4 / 3);
194
194
  if (bodyWidth < 1 || bodyHeight < 1 || bodyWidth * bodyHeight > 4 * 1024 * 1024) refuse('pdf_page_geometry_invalid');
195
195
  const frame = document.createElement('iframe');
196
- let vectors, nativeGlyphs;
196
+ let vectors, nativeGlyphs, filmSession;
197
197
  let rejectAbort;
198
198
  const aborted = new Promise((_, reject) => { rejectAbort = reject; });
199
199
  aborted.catch(() => {});
@@ -273,6 +273,8 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
273
273
  globalThis.RapierMath?.fit(main);
274
274
  applyPrintCapitals(main);
275
275
  applyTypography(main, {all: true});
276
+ // The document's ink marks, drawn on the words where they now stand on the page.
277
+ ink?.(main);
276
278
  const count = Math.max(1, Math.ceil((main.scrollWidth - 1) / bodyWidth));
277
279
  if (!Number.isSafeInteger(count)) refuse('pdf_page_geometry_invalid');
278
280
  // Captured document font bytes own a same-name family; factory glyphs must
@@ -296,48 +298,59 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
296
298
  }
297
299
  const font = _rapierPrintWillFont(text);
298
300
  strip.style.width = count * bodyWidth + 'px';
299
- // A capture costs the whole document, however little of it the clip takes, so the pages that carry no picture share captures: as many to one
300
- // as the capture's own limits allow, fewer when the capture refuses its size. A page with a picture is captured alone, so the picture keeps
301
- // the capture's byte limit to itself.
302
- let share = main.querySelector('img,svg,canvas,video,picture') ? 1
303
- : Math.max(1, Math.min(Math.floor(CAPTURE_EDGE / bodyWidth), Math.floor(CAPTURE_PIXELS / (bodyWidth * bodyHeight))));
301
+ strip.style.transform = 'none';
302
+ // One styled film is reused for every page window. Batches stay inside the capture edge
303
+ // and pixel limits. A picture no longer forces its own capture. Pixels are deflated once.
304
+ if (typeof film !== 'function') refuse('pdf_render_unavailable');
305
+ // step() can reject after the film resolves (the document changed, or the attempt was
306
+ // cancelled). The session would otherwise keep its abort listener with no owner.
307
+ let pendingFilm = null, abandonFilm = false;
308
+ const trackedFilm = Promise.resolve(film({root: surface, signal, current, frame: {width: count * bodyWidth, height: bodyHeight}})).then(session => {
309
+ if (abandonFilm) session?.close?.();
310
+ else pendingFilm = session;
311
+ return session;
312
+ });
313
+ try {
314
+ filmSession = await step(trackedFilm);
315
+ pendingFilm = null;
316
+ } catch (error) {
317
+ abandonFilm = true;
318
+ pendingFilm?.close?.();
319
+ pendingFilm = null;
320
+ throw error;
321
+ }
322
+ let share = Math.max(1, Math.min(Math.floor(CAPTURE_EDGE / bodyWidth), Math.floor(CAPTURE_PIXELS / (bodyWidth * bodyHeight))));
304
323
  const pages = [];
305
324
  let imageBytes = 0;
325
+ const pack = async rgb => {
326
+ const packed = new Uint8Array(await step(new Response(new Blob([rgb]).stream().pipeThrough(new CompressionStream('deflate'))).arrayBuffer()));
327
+ imageBytes += packed.byteLength;
328
+ if (imageBytes > maxBytes) refuse('export_too_large');
329
+ pages.push({rgb: packed});
330
+ };
306
331
  for (let first = 0; first < count;) {
307
332
  check();
308
333
  const span = Math.min(share, count - first);
309
- surface.style.width = span * bodyWidth + 'px';
310
- strip.style.transform = `translateX(${-first * bodyWidth}px)`;
311
- let captured;
312
- try { captured = await capture({root: surface, clip: {x: 0, y: 0, width: span * bodyWidth, height: bodyHeight}, signal, current}); }
313
- catch (error) {
314
- if (error?.code === 'visual_too_large' && span > 1) { share = Math.ceil(span / 2); continue; }
315
- throw error;
316
- }
317
- check();
318
- const image = new into.defaultView.Image();
319
- image.src = 'data:' + captured.mimeType + ';base64,' + captured.data;
320
334
  try {
321
- await step(image.decode());
322
- for (let offset = 0; offset < span; offset++) {
323
- const canvas = into.createElement('canvas'); canvas.width = bodyWidth; canvas.height = bodyHeight;
324
- try {
325
- const context = canvas.getContext('2d', {alpha: false});
326
- if (!context) refuse('pdf_render_unavailable');
327
- context.fillStyle = '#fff'; context.fillRect(0, 0, bodyWidth, bodyHeight);
328
- context.drawImage(image, offset * bodyWidth, 0, bodyWidth, bodyHeight, 0, 0, bodyWidth, bodyHeight);
329
- // Preserve the capture's exact pixels. A JPEG re-encode changes
330
- // glyph edges, pictures and paint even at its highest quality.
331
- const rgba = context.getImageData(0, 0, bodyWidth, bodyHeight).data;
335
+ const shot = await step(filmSession.raster({x: first * bodyWidth, y: 0, width: span * bodyWidth, height: bodyHeight}));
336
+ if (shot?.width !== span * bodyWidth || shot?.height !== bodyHeight || !(shot.rgb instanceof Uint8Array)) refuse('pdf_render_unavailable');
337
+ if (span === 1) await pack(shot.rgb);
338
+ else {
339
+ const row = bodyWidth * 3;
340
+ for (let offset = 0; offset < span; offset++) {
332
341
  const rgb = new Uint8Array(bodyWidth * bodyHeight * 3);
333
- for (let at = 0, to = 0; at < rgba.length; at += 4) {rgb[to++] = rgba[at]; rgb[to++] = rgba[at + 1]; rgb[to++] = rgba[at + 2];}
334
- const packed = new Uint8Array(await step(new Response(new Blob([rgb]).stream().pipeThrough(new CompressionStream('deflate'))).arrayBuffer()));
335
- imageBytes += packed.byteLength;
336
- if (imageBytes > maxBytes) refuse('export_too_large');
337
- pages.push({rgb: packed});
338
- } finally { canvas.width = canvas.height = 0; }
342
+ for (let y = 0; y < bodyHeight; y++) {
343
+ const from = (y * shot.width + offset * bodyWidth) * 3;
344
+ rgb.set(shot.rgb.subarray(from, from + row), y * row);
345
+ }
346
+ await pack(rgb);
347
+ }
339
348
  }
340
- } finally { image.src = ''; }
349
+ shot.rgb = null;
350
+ } catch (error) {
351
+ if (error?.code === 'visual_too_large' && span > 1) { share = Math.ceil(span / 2); continue; }
352
+ throw error;
353
+ }
341
354
  first += span;
342
355
  }
343
356
  check();
@@ -345,7 +358,7 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
345
358
  pixelWidth: bodyWidth, pixelHeight: bodyHeight, pageNumbers: settings?.pagestyle === 'plain', maxBytes, check});
346
359
  check();
347
360
  return {bytes, pages: count};
348
- } finally { nativeGlyphs?.restore(); vectors?.restore(); signal?.removeEventListener('abort', abort); frame.remove(); }
361
+ } finally { filmSession?.close(); nativeGlyphs?.restore(); vectors?.restore(); signal?.removeEventListener('abort', abort); frame.remove(); }
349
362
  }
350
363
 
351
364
  // Ranges locate the actual text fragments after column layout. Splitting a text node at
@@ -355,7 +368,7 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
355
368
  const pages = Array.from({length: count}, () => []), walker = into.createTreeWalker(main, 5);
356
369
  const markerMetrics = _rapierPrintWillMetrics(Array.from(main.querySelectorAll('.rapier-will-carrier'), node => node.textContent));
357
370
  const markerLost = () => { throw _rapierPrintWillLost('a marker has no readable position on the page', 'Try another browser'); };
358
- const range = into.createRange();
371
+ const range = into.createRange(), segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'});
359
372
  let nodes = 0, operations = 0;
360
373
  for (let node; (node = walker.nextNode());) {
361
374
  if (++nodes % 128 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); check(); }
@@ -374,6 +387,7 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
374
387
  const preserve = /pre|break-spaces/.test(style.whiteSpace);
375
388
  const block = element.closest('p,h1,h2,h3,h4,h5,h6,li,pre,td,th,figcaption,blockquote,.rapier-will-carrier') || element;
376
389
  const native = nativeNodes?.get(node);
390
+ let characters = null;
377
391
  const pageOf = x => Math.max(0, Math.min(count - 1, Math.floor((x - origin.left + .01) / pageWidth)));
378
392
  const visit = (start, end) => {
379
393
  if (++operations % 128 === 0) check();
@@ -385,7 +399,8 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
385
399
  const first = rects[0], page = pageOf(first.left);
386
400
  const oneLine = rects.every(rect => pageOf(rect.left) === page && pageOf(rect.width ? rect.right - .02 : rect.left) === page && Math.abs(rect.top - first.top) < .5);
387
401
  let middle = formula ? start : Math.floor((start + end) / 2);
388
- if (middle > start && /[\uD800-\uDBFF]/.test(value[middle - 1]) && /[\uDC00-\uDFFF]/.test(value[middle])) middle--;
402
+ // A line never breaks inside a character: halve between whole ones (a letter and its marks, a conjunct, an emoji).
403
+ if (middle > start) middle = (characters ||= segmenter.segment(value)).containing(middle).index;
389
404
  const cluster = native?.find(item => item.start < middle && item.end > middle);
390
405
  if (cluster) middle = cluster.start > start ? cluster.start : cluster.end;
391
406
  if (!oneLine && middle > start && middle < end) { visit(start, middle); visit(middle, end); return; }
@@ -398,7 +413,7 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
398
413
  if (carrier && right === x) right += Array.from(text).length * markerMetrics.advance * markerMetrics.size / 1000;
399
414
  const bottom = Math.max(...rects.map(rect => rect.bottom)) - origin.top;
400
415
  const parts = native ? native.filter(item => item.start >= start && item.end <= end && item.text).map(item => ({...item}))
401
- : [{text, x, y, width: right - x, height: bottom - y}];
416
+ : [{text, x, y, width: right - x, height: bottom - y, rtl: style.direction === 'rtl'}];
402
417
  const last = pages[page].at(-1);
403
418
  if (last && !formula && !last.math && last.block === block && last.carrier === carrier && Math.abs(last.y - y) < Math.max(2, Math.min(last.height, bottom - y) * .2)) {
404
419
  if (!preserve && last.text.endsWith(' ') && text.startsWith(' ') && parts.length) {
@@ -416,7 +431,35 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
416
431
  visit(0, value.length);
417
432
  }
418
433
  check();
419
- return pages.map(page => page.map(({block, carrier, ...run}) => run));
434
+ // Trailing-carrier print CSS pulls a last marker up so it stays on the sheet.
435
+ // Keep that paint; put the searchable object after the preceding words so
436
+ // reading order matches the source (the marker itself has no visible ink).
437
+ const pageHeight = origin.height;
438
+ for (const page of pages) {
439
+ let prev = null;
440
+ for (const run of page) {
441
+ if (run.carrier && prev && run.y + 0.25 < prev.y + prev.height * .8) {
442
+ const next = Math.min(pageHeight - 0.5, prev.y + prev.height * .85);
443
+ if (next > run.y) {
444
+ const dy = next - run.y;
445
+ run.y += dy;
446
+ for (const part of run.parts || []) part.y += dy;
447
+ }
448
+ }
449
+ prev = run;
450
+ }
451
+ }
452
+ // Each run carries the tags of its block's structure (outermost first): a PDF reader finds the document's headings, lists and
453
+ // tables from them rather than from positions.
454
+ const ROLES = {TABLE: 'Table', TR: 'TR', TD: 'TD', TH: 'TH', UL: 'L', OL: 'L', LI: 'LI', BLOCKQUOTE: 'BlockQuote', P: 'P', PRE: 'Code', FIGCAPTION: 'Caption',
455
+ H1: 'H1', H2: 'H2', H3: 'H3', H4: 'H4', H5: 'H5', H6: 'H6'};
456
+ const tags = block => {
457
+ const chain = [];
458
+ for (let node = block; node && node !== main; node = node.parentElement)
459
+ if (ROLES[node.tagName] || node === block && node.matches('.rapier-will-carrier')) chain.unshift({node, role: ROLES[node.tagName] || 'P'});
460
+ return chain;
461
+ };
462
+ return pages.map(page => page.map(({block, ...run}) => ({...run, tags: tags(block)})));
420
463
  }
421
464
 
422
465
  function _rapierPrintLinkPages(main, width, height, count) {
@@ -450,6 +493,28 @@ function _rapierPrintLinkPages(main, width, height, count) {
450
493
  return {_rapierWillSentinelPlan, _rapierPrintWillAdmit, _rapierPrintWillFont, _rapierPrintWillMetrics, _rapierPrintWillPlace, _rapierPrintWillLost, _rapierPrintWillPageCss, _rapierWritePrintPdf};
451
494
  }
452
495
 
496
+ // Where each scalar of an invisible text run stands, in character slots from the run's left edge: a mark shares its letter's
497
+ // slot, a right-to-left word runs from the right, and so do the words of a right-to-left line (strong letters only; one line).
498
+ function _rapierPrintSlots(text, rtl) {
499
+ const pieces = [];
500
+ let scalars = 0, count = 0;
501
+ for (const {segment} of new Intl.Segmenter(undefined, {granularity: 'grapheme'}).segment(text)) {
502
+ const dir = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u.test(segment) ? 'r' : /\p{L}/u.test(segment) ? 'l' : '';
503
+ const cluster = {from: scalars, length: Array.from(segment).length}, last = pieces.at(-1);
504
+ scalars += cluster.length; count++;
505
+ if (last && (!dir || !last.dir || last.dir === dir)) { last.clusters.push(cluster); last.dir ||= dir; }
506
+ else pieces.push({dir, clusters: [cluster]});
507
+ }
508
+ const slots = new Array(scalars);
509
+ let slot = 0;
510
+ for (const piece of rtl ? pieces.reverse() : pieces) {
511
+ for (const cluster of (piece.dir || (rtl ? 'r' : 'l')) === 'r' ? piece.clusters.reverse() : piece.clusters) {
512
+ slots.fill(slot++, cluster.from, cluster.from + cluster.length);
513
+ }
514
+ }
515
+ return {slots, count};
516
+ }
517
+
453
518
  // A standard PDF page tree, image streams, Unicode text and an associated source file.
454
519
  // It accepts already-rendered pages, so there is no second document grammar or layout engine.
455
520
  async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [], canonical, bom = false, filename, title, font, width, height, margins, pixelWidth, pixelHeight, pageNumbers, maxBytes, check}) {
@@ -487,6 +552,9 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
487
552
  const descriptor = add('<< /Type /FontDescriptor /FontName /RapierText /Flags 4 /FontBBox [' + font.bounds.join(' ') + '] /ItalicAngle 0 /Ascent 500 /Descent -500 /CapHeight 500 /StemV 80 /FontFile2 ' + fontFile + ' 0 R >>');
488
553
  const descendant = add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /RapierText /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ' + descriptor + ' 0 R /DW 500 /CIDToGIDMap /Identity >>');
489
554
  const type = add('<< /Type /Font /Subtype /Type0 /BaseFont /RapierText /Encoding /Identity-H /DescendantFonts [' + descendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
555
+ const carrierDescriptor = add('<< /Type /FontDescriptor /FontName /RapierWillCarrier /Flags 4 /FontBBox [' + font.bounds.join(' ') + '] /ItalicAngle 0 /Ascent 500 /Descent -500 /CapHeight 500 /StemV 80 /FontFile2 ' + fontFile + ' 0 R >>');
556
+ const carrierDescendant = add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /RapierWillCarrier /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ' + carrierDescriptor + ' 0 R /DW 500 /CIDToGIDMap /Identity >>');
557
+ const carrierType = add('<< /Type /Font /Subtype /Type0 /BaseFont /RapierWillCarrier /Encoding /Identity-H /DescendantFonts [' + carrierDescendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
490
558
  const numberFont = pageNumbers ? add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>') : null;
491
559
  const kids = pages.map(() => { objects.push(null); return objects.length - 1; }), decimal = value => Number(value.toFixed(4));
492
560
  const nativeFonts = new Map();
@@ -515,24 +583,45 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
515
583
  const type = add('<< /Type /Font /Subtype /Type0 /BaseFont /' + name + ' /Encoding /Identity-H /DescendantFonts [' + descendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
516
584
  nativeResources.push(entry.name + ' ' + type + ' 0 R');
517
585
  }
586
+ const sharedImages = new Map();
587
+ // The structure tree (PDF 1.7, 14.7): one element per tagged DOM element, each run marked with its page's next MCID.
588
+ const structs = new Map(), documentElement = {role: 'Document', kids: []}, parentTree = [];
589
+ const structOf = tags => {
590
+ let parent = documentElement;
591
+ for (const {node, role} of tags) {
592
+ let element = structs.get(node);
593
+ if (!element) {element = {role, kids: [], parent}; structs.set(node, element); parent.kids.push(element);}
594
+ parent = element;
595
+ }
596
+ return parent === documentElement ? null : parent;
597
+ };
518
598
  for (let index = 0; index < pages.length; index++) {
519
599
  check();
520
- const picture = stream('/Type /XObject /Subtype /Image /Width ' + pixelWidth + ' /Height ' + pixelHeight + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /' + (pages[index].rgb ? 'FlateDecode' : 'DCTDecode'), pages[index].rgb || pages[index]);
521
- let commands = 'q\n' + [decimal(pixelWidth * .75), 0, 0, decimal(pixelHeight * .75), decimal(margins.left), decimal(height - margins.top - pixelHeight * .75)].join(' ') + ' cm\n/Im0 Do\nQ\n';
522
- const pictures = ['/Im0 ' + picture + ' 0 R'], states = [], rasterCache = new Map();
600
+ const marked = [];
601
+ parentTree.push(marked);
602
+ const held = pages[index], flate = !!held?.rgb;
603
+ const picture = stream('/Type /XObject /Subtype /Image /Width ' + pixelWidth + ' /Height ' + pixelHeight + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /' + (flate ? 'FlateDecode' : 'DCTDecode'), flate ? held.rgb : held);
604
+ pages[index] = null;
605
+ // The page's picture is how its tagged text looks, not a figure of its own: an artifact.
606
+ let commands = '/Artifact BMC\nq\n' + [decimal(pixelWidth * .75), 0, 0, decimal(pixelHeight * .75), decimal(margins.left), decimal(height - margins.top - pixelHeight * .75)].join(' ') + ' cm\n/Im0 Do\nQ\nEMC\n';
607
+ const pictures = ['/Im0 ' + picture + ' 0 R'], states = [];
523
608
  for (const drawing of vectorPages[index] || []) {
524
609
  commands += `q\n.75 0 0 -.75 ${decimal(margins.left)} ${decimal(height - margins.top)} cm\n0 0 ${pixelWidth} ${pixelHeight} re W n\n1 0 0 1 ${decimal(drawing.x)} 0 cm\n`;
525
610
  commands += [drawing.clip.x, drawing.clip.y, drawing.clip.width, drawing.clip.height].map(decimal).join(' ') + ' re W n\n';
526
611
  for (const piece of drawing.sequence) {
527
612
  if (piece.image) {
528
- let name = rasterCache.get(piece.image);
529
- if (!name) {
613
+ let shared = sharedImages.get(piece.image);
614
+ if (!shared) {
530
615
  const image = piece.image;
531
616
  const mask = stream('/Type /XObject /Subtype /Image /Width ' + image.width + ' /Height ' + image.height + ' /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode', image.alpha);
532
617
  const object = stream('/Type /XObject /Subtype /Image /Width ' + image.width + ' /Height ' + image.height + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask ' + mask + ' 0 R /Filter /FlateDecode', image.rgb);
533
- name = '/Im' + pictures.length; pictures.push(name + ' ' + object + ' 0 R'); rasterCache.set(image, name);
618
+ image.alpha = image.rgb = null;
619
+ const name = '/Im' + (sharedImages.size + 1);
620
+ shared = {name, resource: name + ' ' + object + ' 0 R'};
621
+ sharedImages.set(piece.image, shared);
534
622
  }
535
- commands += `q\n${[piece.width, 0, 0, -piece.height, piece.x, piece.y + piece.height].map(decimal).join(' ')} cm\n${name} Do\nQ\n`;
623
+ if (!pictures.includes(shared.resource)) pictures.push(shared.resource);
624
+ commands += `q\n${[piece.width, 0, 0, -piece.height, piece.x, piece.y + piece.height].map(decimal).join(' ')} cm\n${shared.name} Do\nQ\n`;
536
625
  } else {
537
626
  const state = '/Gs' + states.length;
538
627
  states.push(state + ' ' + add('<< /Type /ExtGState /ca ' + decimal(piece.fillAlpha) + ' /CA ' + decimal(piece.strokeAlpha) + ' >>') + ' 0 R');
@@ -547,6 +636,8 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
547
636
  // Keep one logical Unicode text object, resetting its font before each scalar's direction
548
637
  // can change. Readers that ignore ActualText otherwise discard earlier words in mixed RTL/LTR.
549
638
  // Express width in the font size: some readers ignore horizontal text scaling when joining.
639
+ const element = structOf(run.tags || []);
640
+ if (element) {element.kids.push({page: index, mcid: marked.length}); commands += `/${element.role} << /MCID ${marked.length} >> BDC\n`; marked.push(element);}
550
641
  commands += `q\n/Span << /ActualText <feff${hex(run.text)}> >> BDC\nBT\n100 Tz\n`;
551
642
  for (const part of run.parts || [run]) {
552
643
  if (part.font) {
@@ -556,15 +647,16 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
556
647
  commands += `${state} gs\n${part.rgba.slice(0, 3).map(decimal).join(' ')} rg\n${entry.name} ${decimal(part.size * .75)} Tf\n0 Tr\n1 0 0 1 ${decimal(margins.left + part.x * .75)} ${decimal(height - margins.top - part.baseline * .75)} Tm\n<${value}> Tj\n`;
557
648
  } else {
558
649
  const characters = Array.from(part.text); if (!characters.length) continue;
559
- const advance = part.width * .75 / characters.length, size = Math.max(.1, advance * 2), scaleY = Math.max(.1, part.height * .75) / size;
650
+ const {slots, count} = _rapierPrintSlots(part.text, part.rtl);
651
+ const advance = part.width * .75 / count, size = Math.max(.1, advance * 2), scaleY = Math.max(.1, part.height * .75) / size;
560
652
  const baseline = decimal(height - margins.top - (part.y + part.height * .8) * .75);
561
653
  for (let letter = 0; letter < characters.length; letter++) {
562
654
  const value = codes.get(characters[letter].codePointAt(0)).toString(16).padStart(4, '0');
563
- commands += `/F1 ${decimal(size)} Tf\n3 Tr\n1 0 0 ${decimal(scaleY)} ${decimal(margins.left + part.x * .75 + letter * advance)} ${baseline} Tm\n<${value}> Tj\n`;
655
+ commands += `${run.carrier ? '/Fc' : '/F1'} ${decimal(size)} Tf\n3 Tr\n1 0 0 ${decimal(scaleY)} ${decimal(margins.left + part.x * .75 + slots[letter] * advance)} ${baseline} Tm\n<${value}> Tj\n`;
564
656
  }
565
657
  }
566
658
  }
567
- commands += 'ET\nEMC\nQ\n';
659
+ commands += 'ET\nEMC\nQ\n' + (element ? 'EMC\n' : '');
568
660
  }
569
661
  if (numberFont) commands += `BT\n/F2 9 Tf\n0 Tr\n1 0 0 1 ${decimal(width / 2 - String(index + 1).length * 2.5)} ${decimal(margins.bottom / 2)} Tm\n(${index + 1}) Tj\nET\n`;
570
662
  const content = stream('/Filter /FlateDecode', await compressed(encode(commands)));
@@ -575,19 +667,39 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
575
667
  : '/Dest [' + kids[link.target.page] + ' 0 R /XYZ ' + decimal(margins.left + link.target.x * .75) + ' ' + decimal(height - margins.top - link.target.y * .75) + ' null]';
576
668
  return add('<< /Type /Annot /Subtype /Link /Border [0 0 0] /Rect [' + rect.join(' ') + '] ' + target + ' >>');
577
669
  });
578
- put(kids[index], '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' + decimal(width) + ' ' + decimal(height) + '] /Resources << /XObject << ' + pictures.join(' ') + ' >> /ExtGState << ' + states.join(' ') + ' >> /Font << /F1 ' + type + ' 0 R' + (numberFont ? ' /F2 ' + numberFont + ' 0 R' : '') + ' ' + nativeResources.join(' ') + ' >> >> /Contents ' + content + ' 0 R' + (annotations.length ? ' /Annots [' + annotations.map(id => id + ' 0 R').join(' ') + ']' : '') + ' >>');
670
+ put(kids[index], '<< /Type /Page /Parent 2 0 R /StructParents ' + index + ' /MediaBox [0 0 ' + decimal(width) + ' ' + decimal(height) + '] /Resources << /XObject << ' + pictures.join(' ') + ' >> /ExtGState << ' + states.join(' ') + ' >> /Font << /F1 ' + type + ' 0 R /Fc ' + carrierType + ' 0 R' + (numberFont ? ' /F2 ' + numberFont + ' 0 R' : '') + ' ' + nativeResources.join(' ') + ' >> >> /Contents ' + content + ' 0 R' + (annotations.length ? ' /Annots [' + annotations.map(id => id + ' 0 R').join(' ') + ']' : '') + ' >>');
579
671
  }
580
672
  put(2, '<< /Type /Pages /Count ' + kids.length + ' /Kids [' + kids.map(id => id + ' 0 R').join(' ') + '] >>');
581
- put(1, '<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [<feff' + hex(filename) + '> ' + file + ' 0 R] >> >> /AF [' + file + ' 0 R] >>');
673
+ const tree = add(''), ids = new Map([[documentElement, add('')]]);
674
+ const reserve = element => { for (const kid of element.kids) if (kid.kids) { ids.set(kid, add('')); reserve(kid); } };
675
+ reserve(documentElement);
676
+ for (const [element, id] of ids) put(id, '<< /Type /StructElem /S /' + element.role + ' /P ' + (element.parent ? ids.get(element.parent) : tree) + ' 0 R /K [' +
677
+ element.kids.map(kid => kid.kids ? ids.get(kid) + ' 0 R' : '<< /Type /MCR /Pg ' + kids[kid.page] + ' 0 R /MCID ' + kid.mcid + ' >>').join(' ') + '] >>');
678
+ const parents = add('<< /Nums [' + parentTree.map((marked, index) => index + ' [' + marked.map(element => ids.get(element) + ' 0 R').join(' ') + ']').join(' ') + '] >>');
679
+ put(tree, '<< /Type /StructTreeRoot /K [' + ids.get(documentElement) + ' 0 R] /ParentTree ' + parents + ' 0 R /ParentTreeNextKey ' + parentTree.length + ' >>');
680
+ put(1, '<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot ' + tree + ' 0 R /Names << /EmbeddedFiles << /Names [<feff' + hex(filename) + '> ' + file + ' 0 R] >> >> /AF [' + file + ' 0 R] >>');
582
681
  const info = add('<< /Producer (Rapier) /Title <feff' + hex(title || filename.replace(/\.[^.]*$/, '')) + '> >>');
583
- const parts = [encode('%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n')], offsets = [0];
584
- let length = parts[0].length;
682
+ const header = encode('%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n'), offsets = new Array(objects.length);
683
+ let cursor = header.length;
684
+ for (let id = 1; id < objects.length; id++) {
685
+ offsets[id] = cursor;
686
+ cursor += (id + ' 0 obj\n').length + objects[id].length + 8;
687
+ }
688
+ const xrefAt = cursor;
689
+ const xref = encode('xref\n0 ' + objects.length + '\n0000000000 65535 f \n' + offsets.slice(1).map(at => String(at).padStart(10, '0') + ' 00000 n \n').join('') + 'trailer\n<< /Size ' + objects.length + ' /Root 1 0 R /Info ' + info + ' 0 R >>\nstartxref\n' + xrefAt + '\n%%EOF\n');
690
+ if (cursor + xref.length > maxBytes) fail('export_too_large');
691
+ const bytes = new Uint8Array(cursor + xref.length);
692
+ bytes.set(header, 0);
693
+ let at = header.length;
585
694
  for (let id = 1; id < objects.length; id++) {
586
- offsets.push(length);
587
- const body = concat([encode(id + ' 0 obj\n'), objects[id], encode('\nendobj\n')]); parts.push(body); length += body.length;
695
+ const prefix = encode(id + ' 0 obj\n');
696
+ bytes.set(prefix, at); at += prefix.length;
697
+ bytes.set(objects[id], at); at += objects[id].length;
698
+ objects[id] = null;
699
+ const suffix = encode('\nendobj\n');
700
+ bytes.set(suffix, at); at += suffix.length;
588
701
  }
589
- const xref = length;
590
- parts.push(encode('xref\n0 ' + objects.length + '\n0000000000 65535 f \n' + offsets.slice(1).map(at => String(at).padStart(10, '0') + ' 00000 n \n').join('') + 'trailer\n<< /Size ' + objects.length + ' /Root 1 0 R /Info ' + info + ' 0 R >>\nstartxref\n' + xref + '\n%%EOF\n'));
591
- const bytes = concat(parts); if (bytes.length > maxBytes) fail('export_too_large'); return bytes;
702
+ bytes.set(xref, at);
703
+ return bytes;
592
704
  }
593
705
  export {createPrintRenderer, applyPrintCapitals, RAPIER_WILL_SENTINEL_MARK as sentinel};
@@ -7,7 +7,7 @@ const RAPIER_SANITIZE_PROFILES = Object.freeze({
7
7
  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
8
  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
9
  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","command","commandfor","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","exportparts","face","for","headers","height","hidden","high","href","hreflang","id","inert","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","part","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","slot","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns"]),
10
+ 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"]),
11
11
  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
12
  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
13
  xmlAttributes: Object.freeze(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),
@@ -29,11 +29,6 @@ const RAPIER_SANITIZE_OPTIONS = Object.freeze({
29
29
  FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
30
30
  FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
31
31
  }),
32
- snippet: Object.freeze({
33
- ..._rapierSanitizeProfile('html'),
34
- FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
35
- FORBID_ATTR: Object.freeze([...'style']),
36
- }),
37
32
  diagram: Object.freeze({
38
33
  ..._rapierSanitizeProfile('html', 'svg'),
39
34
  FORBID_TAGS: Object.freeze(['form', 'dialog', 'template', 'iframe', 'script', 'foreignobject']),
@@ -59,6 +54,9 @@ const DIAGRAM_LABEL_SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
59
54
  const DIAGRAM_LABEL_HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
60
55
  const DIAGRAM_LABEL_MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
61
56
 
57
+ // The profile each DOMPurify instance holds (setConfig), shared by every renderer made over it.
58
+ const purifyConfigured = new WeakMap();
59
+
62
60
  function _rapierImageDataAttributeAllowed(tag, attribute, value, verify) {
63
61
  return (tag === 'img' && attribute === 'src' || tag === 'image' && /^(?:href|xlink:href)$/.test(attribute)
64
62
  && /^data:image\/(?:png|jpeg|webp|jxl);base64,/i.test(value)) && verify(value);
@@ -376,6 +374,8 @@ function _rapierInstallSanitizeHooks() {
376
374
  });
377
375
  }
378
376
 
377
+ const purifyOptions = new Map();
378
+
379
379
  function sanitizeRapierHtml(value, context = 'render') {
380
380
  return sanitize(value, context, false);
381
381
  }
@@ -392,24 +392,31 @@ function sanitize(value, context, fragment) {
392
392
  // (the hook) without an id the chrome owns. 'source' is Markdown source HTML, written and never shown: the render
393
393
  // profile's admission, and the block on remote content stays the reader's (a remote picture's URL is the person's words).
394
394
  const selected = context === 'raw' || context === 'export' || context === 'source' || RAPIER_SANITIZE_OPTIONS[context] ? context : 'render';
395
- let options = selected === 'raw' ? { ...RAPIER_SANITIZE_OPTIONS.render, ALLOW_DATA_ATTR: false } : RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
396
- if (selected === 'source') options = {...options, ADD_DATA_URI_TAGS: ['image']};
397
- const labels = selected === 'diagram' || selected === 'export';
398
- if (labels) {
395
+ const labels = selected === 'diagram' || selected === 'export', key = selected + (fragment ? ' fragment' : '');
396
+ let options = purifyOptions.get(key);
397
+ if (!options) {
398
+ options = selected === 'raw' ? { ...RAPIER_SANITIZE_OPTIONS.render, ALLOW_DATA_ATTR: false } : RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
399
+ if (selected === 'source') options = {...options, ADD_DATA_URI_TAGS: ['image']};
399
400
  // DOMPurify's USE_PROFILES replaces explicit allowlists. Add MathML to the
400
401
  // selected profile without discarding its admitted HTML and SVG vocabulary.
401
- options = {...options, ALLOWED_TAGS: [...new Set([...options.ALLOWED_TAGS, ...RAPIER_SANITIZE_PROFILES.mathTags])],
402
+ if (labels) options = {...options, ALLOWED_TAGS: [...new Set([...options.ALLOWED_TAGS, ...RAPIER_SANITIZE_PROFILES.mathTags])],
402
403
  ALLOWED_ATTR: [...new Set([...options.ALLOWED_ATTR, ...RAPIER_SANITIZE_PROFILES.mathAttributes])], ADD_TAGS: ['foreignobject'], ADD_DATA_URI_TAGS: ['image'],
403
404
  HTML_INTEGRATION_POINTS: {foreignobject: true}, FORBID_TAGS: options.FORBID_TAGS.filter(tag => tag !== 'foreignobject')};
405
+ if (fragment) options = {...options, RETURN_DOM_FRAGMENT: true};
406
+ purifyOptions.set(key, options);
407
+ }
408
+ if (labels) {
404
409
  DOMPurify.addHook('uponSanitizeElement', diagramLabelElement);
405
410
  DOMPurify.addHook('uponSanitizeAttribute', diagramLabelAttribute);
406
411
  DOMPurify.addHook('afterSanitizeAttributes', diagramLabelContained);
407
412
  }
408
- if (fragment) options = {...options, RETURN_DOM_FRAGMENT: true};
413
+ // DOMPurify reparses a per-call configuration on every call (its cache compares its own copy), about 0.45 ms
414
+ // each in Chrome 155. A profile is set once and kept until another profile is asked for; every caller comes here.
415
+ if (purifyConfigured.get(DOMPurify) !== options) { DOMPurify.setConfig(options); purifyConfigured.set(DOMPurify, options); }
409
416
  const previous = _rapierSanitizeRuntime.context;
410
417
  _rapierSanitizeRuntime.context = selected;
411
418
  try {
412
- return DOMPurify.sanitize(value == null ? '' : String(value), options);
419
+ return DOMPurify.sanitize(value == null ? '' : String(value));
413
420
  } finally {
414
421
  if (labels) {
415
422
  DOMPurify.removeHook('uponSanitizeElement', diagramLabelElement);
@@ -430,9 +437,11 @@ function escapeRapierHtmlText(value) {
430
437
  }
431
438
 
432
439
  function _rapierChromeOwnsId(id) {
433
- // Every id the shell's own markup carries, read once before any document renders (initMarkdownIt primes it).
440
+ // Every id the shell's own markup carries, read once before any document renders (initMarkdownIt primes it),
441
+ // and every rapier- id: the chrome makes its dialogs, overlays, parts and plug-in list later, under that prefix
442
+ // (rapier-anchor- is the renderer's own name for a repeated document anchor, and stays the document's).
434
443
  if (!_rapierSanitizeRuntime.chromeIds) _rapierSanitizeRuntime.chromeIds = new Set(Array.from(typeof document?.querySelectorAll === 'function' ? document.querySelectorAll('[id]') : [], element => element.id));
435
- return _rapierSanitizeRuntime.chromeIds.has(String(id));
444
+ return _rapierSanitizeRuntime.chromeIds.has(String(id)) || /^rapier-(?!anchor-)/.test(id);
436
445
  }
437
446
 
438
447
  function _rapierSafeRasterDataUrl(value) {
@@ -522,6 +531,7 @@ function _rapierVerifyRasterBytes(bytes, mime) {
522
531
  // declaration, doctype or generator/license comment before <svg itself, of whatever length its
523
532
 
524
533
  if (mime === 'image/svg+xml') return /<svg[\s>]/i.test(new TextDecoder('utf-8', {fatal: false}).decode(bytes));
534
+ if (mime === 'image/gif') return bytes.length >= 13 && /^GIF8[79]a$/.test(String.fromCharCode(...bytes.subarray(0, 6)));
525
535
  if (mime === 'image/png') return bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
526
536
  if (mime === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
527
537
  if (mime === 'image/webp') return bytes.length >= 12 && String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF' && String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
@@ -103,9 +103,9 @@ function _rapierArtifactStyles(theme, includeFonts, printMode, houseDefaults = f
103
103
  const highlightCss = _rapierStyleText('rapier-highlight-style');
104
104
  parts.push(highlightCss);
105
105
  // A page written for the system theme has no body.light to switch: under a light system
106
- // setting it takes the Standard palette's light values from the sheet's own body.light rule.
107
- const light = theme === 'system' && /html\[data-highlights="standard"\] body\.light\s*\{([^}]*)\}/.exec(highlightCss);
108
- if (light) parts.push('@media (prefers-color-scheme:light){html[data-rapier-theme="system"][data-highlights="standard"] body{' + light[1].trim() + '}}');
106
+ // setting each palette takes its light values from the sheet's own body.light rule.
107
+ if (theme === 'system') for (const [, palette, light] of highlightCss.matchAll(/html\[data-highlights="(standard|accent)"\] body\.light\s*\{([^}]*)\}/g))
108
+ parts.push('@media (prefers-color-scheme:light){html[data-rapier-theme="system"][data-highlights="' + palette + '"] body{' + light.trim() + '}}');
109
109
  if (printMode) parts.push(_rapierArtifactPrintCss());
110
110
  return parts.filter(Boolean).join('\n\n');
111
111
  }