rapier-markdown-kit 1.1.102 → 1.2.3

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 (82) hide show
  1. package/LICENSE +5 -2
  2. package/README.md +245 -54
  3. package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
  4. package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
  5. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
  6. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
  7. package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
  8. package/dist/agent/vendor/pretext/LICENSE +208 -0
  9. package/dist/agent/vendor/pretext/NOTICE +26 -13
  10. package/dist/agent/vendor/pretext/SOURCE.json +213 -45
  11. package/dist/agent/vendor/pretext/analysis.js +675 -1224
  12. package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
  13. package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
  14. package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
  15. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
  16. package/dist/agent/vendor/pretext/graphemes.js +98 -0
  17. package/dist/agent/vendor/pretext/han-kerning.js +253 -0
  18. package/dist/agent/vendor/pretext/layout.js +352 -290
  19. package/dist/agent/vendor/pretext/line-break.js +1154 -588
  20. package/dist/agent/vendor/pretext/line-breaks.js +892 -0
  21. package/dist/agent/vendor/pretext/line-text.js +81 -39
  22. package/dist/agent/vendor/pretext/measurement.js +1105 -176
  23. package/dist/agent/vendor/pretext/prepare.js +807 -0
  24. package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
  25. package/dist/agent/will.mjs +2 -0
  26. package/dist/images/gif.mjs +47 -0
  27. package/dist/images/playback.mjs +101 -0
  28. package/dist/kit/assets.mjs +5 -1
  29. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  30. package/dist/kit/conformance/04-wrap-box.md +1 -1
  31. package/dist/kit/conformance/05-behind.md +1 -1
  32. package/dist/kit/conformance/06-front.md +1 -1
  33. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  34. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  35. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  36. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  37. package/dist/kit/conformance/10-both-sides.md +1 -1
  38. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  39. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  40. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  41. package/dist/kit/conformance/README.md +19 -28
  42. package/dist/kit/conformance/run.mjs +13 -11
  43. package/dist/kit/export-fonts.mjs +26 -11
  44. package/dist/kit/index.mjs +1 -0
  45. package/dist/kit/ledger/carried.mjs +17 -10
  46. package/dist/kit/ledger/data.mjs +73 -13
  47. package/dist/kit/ledger/format.mjs +176 -54
  48. package/dist/kit/ledger/hash.mjs +91 -18
  49. package/dist/kit/ledger/history.mjs +156 -116
  50. package/dist/kit/ledger/inverse-source.mjs +158 -19
  51. package/dist/kit/ledger/journal-records.mjs +292 -67
  52. package/dist/kit/ledger/merge.mjs +4 -3
  53. package/dist/kit/model.mjs +1 -1
  54. package/dist/kit/pdf-glyphs.mjs +4 -4
  55. package/dist/kit/pdf-vectors.mjs +4 -1
  56. package/dist/kit/rapier-ledger.mjs +2 -2
  57. package/dist/kit/render-ink.mjs +1 -1
  58. package/dist/kit/render-markdown.mjs +400 -148
  59. package/dist/kit/render-print.mjs +131 -55
  60. package/dist/kit/render-sanitize.mjs +30 -7
  61. package/dist/kit/render-work.mjs +99 -20
  62. package/dist/kit/render.mjs +133 -140
  63. package/dist/kit/typography.mjs +2 -0
  64. package/dist/layout/hyphenation.mjs +127 -0
  65. package/dist/layout/line-plan.mjs +88 -96
  66. package/dist/layout/model.mjs +71 -88
  67. package/dist/layout/paragraph-fit.mjs +191 -0
  68. package/dist/layout/preparation.mjs +373 -0
  69. package/dist/layout/prepared-paragraph.mjs +223 -0
  70. package/dist/layout/typography.mjs +345 -111
  71. package/dist/packages/rapier-diff/index.mjs +359 -224
  72. package/dist/spec/document-fonts.mjs +14 -5
  73. package/dist/spec/document-settings.mjs +11 -164
  74. package/dist/spec/html-blocks.mjs +618 -79
  75. package/dist/spec/md-assets.mjs +178 -36
  76. package/dist/spec/md-layout.mjs +13 -2
  77. package/dist/spec/md-marks.mjs +220 -127
  78. package/dist/spec/md-source.mjs +157 -0
  79. package/package.json +2 -1
  80. package/style.css +15 -50
  81. package/dist/agent/vendor/pretext/bidi.js +0 -151
  82. package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
@@ -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,6 +431,24 @@ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = mai
416
431
  visit(0, value.length);
417
432
  }
418
433
  check();
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
+ }
419
452
  return pages.map(page => page.map(({block, carrier, ...run}) => run));
420
453
  }
421
454
 
@@ -450,6 +483,28 @@ function _rapierPrintLinkPages(main, width, height, count) {
450
483
  return {_rapierWillSentinelPlan, _rapierPrintWillAdmit, _rapierPrintWillFont, _rapierPrintWillMetrics, _rapierPrintWillPlace, _rapierPrintWillLost, _rapierPrintWillPageCss, _rapierWritePrintPdf};
451
484
  }
452
485
 
486
+ // Where each scalar of an invisible text run stands, in character slots from the run's left edge: a mark shares its letter's
487
+ // 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).
488
+ function _rapierPrintSlots(text, rtl) {
489
+ const pieces = [];
490
+ let scalars = 0, count = 0;
491
+ for (const {segment} of new Intl.Segmenter(undefined, {granularity: 'grapheme'}).segment(text)) {
492
+ 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' : '';
493
+ const cluster = {from: scalars, length: Array.from(segment).length}, last = pieces.at(-1);
494
+ scalars += cluster.length; count++;
495
+ if (last && (!dir || !last.dir || last.dir === dir)) { last.clusters.push(cluster); last.dir ||= dir; }
496
+ else pieces.push({dir, clusters: [cluster]});
497
+ }
498
+ const slots = new Array(scalars);
499
+ let slot = 0;
500
+ for (const piece of rtl ? pieces.reverse() : pieces) {
501
+ for (const cluster of (piece.dir || (rtl ? 'r' : 'l')) === 'r' ? piece.clusters.reverse() : piece.clusters) {
502
+ slots.fill(slot++, cluster.from, cluster.from + cluster.length);
503
+ }
504
+ }
505
+ return {slots, count};
506
+ }
507
+
453
508
  // A standard PDF page tree, image streams, Unicode text and an associated source file.
454
509
  // It accepts already-rendered pages, so there is no second document grammar or layout engine.
455
510
  async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [], canonical, bom = false, filename, title, font, width, height, margins, pixelWidth, pixelHeight, pageNumbers, maxBytes, check}) {
@@ -515,24 +570,31 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
515
570
  const type = add('<< /Type /Font /Subtype /Type0 /BaseFont /' + name + ' /Encoding /Identity-H /DescendantFonts [' + descendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
516
571
  nativeResources.push(entry.name + ' ' + type + ' 0 R');
517
572
  }
573
+ const sharedImages = new Map();
518
574
  for (let index = 0; index < pages.length; index++) {
519
575
  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]);
576
+ const held = pages[index], flate = !!held?.rgb;
577
+ const picture = stream('/Type /XObject /Subtype /Image /Width ' + pixelWidth + ' /Height ' + pixelHeight + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /' + (flate ? 'FlateDecode' : 'DCTDecode'), flate ? held.rgb : held);
578
+ pages[index] = null;
521
579
  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();
580
+ const pictures = ['/Im0 ' + picture + ' 0 R'], states = [];
523
581
  for (const drawing of vectorPages[index] || []) {
524
582
  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
583
  commands += [drawing.clip.x, drawing.clip.y, drawing.clip.width, drawing.clip.height].map(decimal).join(' ') + ' re W n\n';
526
584
  for (const piece of drawing.sequence) {
527
585
  if (piece.image) {
528
- let name = rasterCache.get(piece.image);
529
- if (!name) {
586
+ let shared = sharedImages.get(piece.image);
587
+ if (!shared) {
530
588
  const image = piece.image;
531
589
  const mask = stream('/Type /XObject /Subtype /Image /Width ' + image.width + ' /Height ' + image.height + ' /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode', image.alpha);
532
590
  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);
591
+ image.alpha = image.rgb = null;
592
+ const name = '/Im' + (sharedImages.size + 1);
593
+ shared = {name, resource: name + ' ' + object + ' 0 R'};
594
+ sharedImages.set(piece.image, shared);
534
595
  }
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`;
596
+ if (!pictures.includes(shared.resource)) pictures.push(shared.resource);
597
+ 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
598
  } else {
537
599
  const state = '/Gs' + states.length;
538
600
  states.push(state + ' ' + add('<< /Type /ExtGState /ca ' + decimal(piece.fillAlpha) + ' /CA ' + decimal(piece.strokeAlpha) + ' >>') + ' 0 R');
@@ -556,11 +618,12 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
556
618
  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
619
  } else {
558
620
  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;
621
+ const {slots, count} = _rapierPrintSlots(part.text, part.rtl);
622
+ const advance = part.width * .75 / count, size = Math.max(.1, advance * 2), scaleY = Math.max(.1, part.height * .75) / size;
560
623
  const baseline = decimal(height - margins.top - (part.y + part.height * .8) * .75);
561
624
  for (let letter = 0; letter < characters.length; letter++) {
562
625
  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`;
626
+ commands += `/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
627
  }
565
628
  }
566
629
  }
@@ -580,14 +643,27 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
580
643
  put(2, '<< /Type /Pages /Count ' + kids.length + ' /Kids [' + kids.map(id => id + ' 0 R').join(' ') + '] >>');
581
644
  put(1, '<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [<feff' + hex(filename) + '> ' + file + ' 0 R] >> >> /AF [' + file + ' 0 R] >>');
582
645
  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;
646
+ const header = encode('%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n'), offsets = new Array(objects.length);
647
+ let cursor = header.length;
648
+ for (let id = 1; id < objects.length; id++) {
649
+ offsets[id] = cursor;
650
+ cursor += (id + ' 0 obj\n').length + objects[id].length + 8;
651
+ }
652
+ const xrefAt = cursor;
653
+ 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');
654
+ if (cursor + xref.length > maxBytes) fail('export_too_large');
655
+ const bytes = new Uint8Array(cursor + xref.length);
656
+ bytes.set(header, 0);
657
+ let at = header.length;
585
658
  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;
659
+ const prefix = encode(id + ' 0 obj\n');
660
+ bytes.set(prefix, at); at += prefix.length;
661
+ bytes.set(objects[id], at); at += objects[id].length;
662
+ objects[id] = null;
663
+ const suffix = encode('\nendobj\n');
664
+ bytes.set(suffix, at); at += suffix.length;
588
665
  }
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;
666
+ bytes.set(xref, at);
667
+ return bytes;
592
668
  }
593
669
  export {createPrintRenderer, applyPrintCapitals, RAPIER_WILL_SENTINEL_MARK as sentinel};
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ import {typography} from '../spec/md-marks.mjs';
2
3
  const RAPIER_SANITIZE_FORBID_TAGS = Object.freeze(['form', 'style', 'dialog', 'template', 'iframe']);
3
4
  const RAPIER_SANITIZE_FORBID_ATTR = Object.freeze(['action', 'formaction', 'form', 'for']);
4
5
  // The document profiles use one allowlist in a browser and in an inert server parser.
@@ -6,7 +7,7 @@ const RAPIER_SANITIZE_PROFILES = Object.freeze({
6
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"]),
7
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"]),
8
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"]),
9
- htmlAttributes: Object.freeze(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","command","commandfor","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","exportparts","face","for","headers","height","hidden","high","href","hreflang","id","inert","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","part","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","slot","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns"]),
10
+ 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"]),
10
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"]),
11
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"]),
12
13
  xmlAttributes: Object.freeze(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),
@@ -166,7 +167,7 @@ function createInertHtmlSanitizer({parseFragment, serialize, sanitizeHtml, safeR
166
167
  if (!_rapierImageDataAttributeAllowed(tag, key, compact, safeRasterDataUrl)) continue;
167
168
  } else if (!uri.test(compact)) continue;
168
169
  }
169
- attribs[name] = value;
170
+ attribs[name] = selected === 'raw' && tag === 'span' && key === 'style' ? typography.style(typography.parseStyle(value)) : value;
170
171
  }
171
172
  if ((tag === 'a' || tag === 'area') && attribs.target?.trim().toLowerCase() === '_blank') attribs.rel = 'noopener noreferrer';
172
173
  return {tagName, attribs};
@@ -323,6 +324,11 @@ function _rapierInstallSanitizeHooks() {
323
324
  });
324
325
  DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
325
326
  const value = String(data.attrValue == null ? '' : data.attrValue);
327
+ if (_rapierSanitizeRuntime.context === 'raw' && node?.nodeName === 'SPAN' && data.attrName?.toLowerCase() === 'style') {
328
+ data.attrValue = typography.style(typography.parseStyle(value));
329
+ data.keepAttr = !!data.attrValue;
330
+ return;
331
+ }
326
332
  // A document's own HTML never carries an id the chrome owns, remote content allowed or not: the
327
333
  // allow is about what the page fetches, and this rule is about what the page's chrome finds.
328
334
  if (String(data.attrName || '').toLowerCase() === 'id' && _rapierSanitizeRuntime.context === 'raw' && _rapierChromeOwnsId(value)) { data.keepAttr = false; return; }
@@ -371,6 +377,16 @@ function _rapierInstallSanitizeHooks() {
371
377
  }
372
378
 
373
379
  function sanitizeRapierHtml(value, context = 'render') {
380
+ return sanitize(value, context, false);
381
+ }
382
+
383
+ // The same parser, profile and hooks admit both results. A private renderer can
384
+ // adopt the sanitized nodes without serializing and parsing them again.
385
+ function sanitizeRapierFragment(value, context = 'render') {
386
+ return sanitize(value, context, true);
387
+ }
388
+
389
+ function sanitize(value, context, fragment) {
374
390
  // 'export' is the render profile under the export hooks; 'raw' is a document's own HTML (initMarkdownIt, html_block):
375
391
  // the render profile without data-* attributes, which Rapier's delegated handlers read as their own controls, and
376
392
  // (the hook) without an id the chrome owns. 'source' is Markdown source HTML, written and never shown: the render
@@ -389,6 +405,8 @@ function sanitizeRapierHtml(value, context = 'render') {
389
405
  DOMPurify.addHook('uponSanitizeAttribute', diagramLabelAttribute);
390
406
  DOMPurify.addHook('afterSanitizeAttributes', diagramLabelContained);
391
407
  }
408
+ if (fragment) options = {...options, RETURN_DOM_FRAGMENT: true};
409
+ const previous = _rapierSanitizeRuntime.context;
392
410
  _rapierSanitizeRuntime.context = selected;
393
411
  try {
394
412
  return DOMPurify.sanitize(value == null ? '' : String(value), options);
@@ -398,7 +416,7 @@ function sanitizeRapierHtml(value, context = 'render') {
398
416
  DOMPurify.removeHook('uponSanitizeAttribute', diagramLabelAttribute);
399
417
  DOMPurify.removeHook('afterSanitizeAttributes', diagramLabelContained);
400
418
  }
401
- _rapierSanitizeRuntime.context = 'render';
419
+ _rapierSanitizeRuntime.context = previous;
402
420
  }
403
421
  }
404
422
 
@@ -412,9 +430,11 @@ function escapeRapierHtmlText(value) {
412
430
  }
413
431
 
414
432
  function _rapierChromeOwnsId(id) {
415
- // Every id the shell's own markup carries, read once before any document renders (initMarkdownIt primes it).
433
+ // Every id the shell's own markup carries, read once before any document renders (initMarkdownIt primes it),
434
+ // and every rapier- id: the chrome makes its dialogs, overlays, parts and plug-in list later, under that prefix
435
+ // (rapier-anchor- is the renderer's own name for a repeated document anchor, and stays the document's).
416
436
  if (!_rapierSanitizeRuntime.chromeIds) _rapierSanitizeRuntime.chromeIds = new Set(Array.from(typeof document?.querySelectorAll === 'function' ? document.querySelectorAll('[id]') : [], element => element.id));
417
- return _rapierSanitizeRuntime.chromeIds.has(String(id));
437
+ return _rapierSanitizeRuntime.chromeIds.has(String(id)) || /^rapier-(?!anchor-)/.test(id);
418
438
  }
419
439
 
420
440
  function _rapierSafeRasterDataUrl(value) {
@@ -472,7 +492,7 @@ function _rapierStyleWithoutRemoteUrls(value) {
472
492
  if (!raw) return raw;
473
493
  const declaration = _rapierCssDeclaration();
474
494
  declaration.cssText = raw;
475
- if (!declaration.length) return '';
495
+ if (!declaration.length) return typography.style(typography.parseStyle(raw));
476
496
  return _rapierDropRemoteDeclarations(declaration) ? declaration.cssText : raw;
477
497
  }
478
498
 
@@ -504,12 +524,15 @@ function _rapierVerifyRasterBytes(bytes, mime) {
504
524
  // declaration, doctype or generator/license comment before <svg itself, of whatever length its
505
525
 
506
526
  if (mime === 'image/svg+xml') return /<svg[\s>]/i.test(new TextDecoder('utf-8', {fatal: false}).decode(bytes));
527
+ if (mime === 'image/gif') return bytes.length >= 13 && /^GIF8[79]a$/.test(String.fromCharCode(...bytes.subarray(0, 6)));
507
528
  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;
508
529
  if (mime === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
509
530
  if (mime === 'image/webp') return bytes.length >= 12 && String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF' && String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
510
531
  return false;
511
532
  }
512
533
 
513
- return {_rapierVerifyRasterBytes, _rapierInstallSanitizeHooks, sanitizeRapierHtml, diagramLabelStyle, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
534
+ return {_rapierVerifyRasterBytes, _rapierInstallSanitizeHooks, sanitizeRapierHtml,
535
+ sanitizeRapierFragment: typeof DOMPurify?.sanitize === 'function' ? sanitizeRapierFragment : undefined,
536
+ diagramLabelStyle, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
514
537
  }
515
538
  export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, createInertHtmlSanitizer, RAPIER_SANITIZE_OPTIONS as options, prepareMathSource};
@@ -7,6 +7,7 @@ export function finish(iterator) {
7
7
  }
8
8
 
9
9
  export function check(work) {
10
+ work?.check?.();
10
11
  if (work?.signal?.aborted) throw Object.assign(new Error('The operation was cancelled.'), {name: 'AbortError', code: 'cancelled'});
11
12
  }
12
13
 
@@ -72,16 +73,18 @@ export function htmlBoundary() {
72
73
 
73
74
  export async function finishAsync(iterator, work) {
74
75
  let began = performance.now(), next;
75
- check(work);
76
- do {
77
- next = iterator.next();
78
- if (!next.done && performance.now() - began >= 8) {
79
- await pause(work);
80
- began = performance.now();
81
- }
82
- } while (!next.done);
83
- check(work);
84
- return next.value;
76
+ try {
77
+ do {
78
+ check(work);
79
+ next = iterator.next();
80
+ if (!next.done && performance.now() - began >= 8) {
81
+ await pause(work);
82
+ began = performance.now();
83
+ }
84
+ } while (!next.done);
85
+ check(work);
86
+ return next.value;
87
+ } finally { if (!next?.done) iterator.return?.(); }
85
88
  }
86
89
 
87
90
  export function* cloneTree(root) {
@@ -98,25 +101,98 @@ export function* cloneTree(root) {
98
101
  return copy;
99
102
  }
100
103
 
101
- export function* htmlParts(root, limit = 16384) {
104
+ // Null entries are traversal checkpoints, never fragment boundaries. A large
105
+ // nested unit is serialized cooperatively but is still admitted as one complete
106
+ // HTML/namespace unit by the synchronous parser and sanitizer.
107
+ export function* htmlParts(root, limit = 16384, consume = false) {
108
+ limit = Math.max(2, limit);
102
109
  const text = root.ownerDocument.createElement('div');
110
+ const small = node => {
111
+ let count = 0, chars = 0, at = node;
112
+ while (at) {
113
+ if (++count > 128) return false;
114
+ if (at.nodeType === 1) for (const attribute of at.attributes) chars += attribute.value.length + attribute.name.length;
115
+ else chars += at.nodeValue?.length || 0;
116
+ if (chars > limit) return false;
117
+ if (at.firstChild) at = at.firstChild;
118
+ else {
119
+ while (at !== node && !at.nextSibling) at = at.parentNode;
120
+ at = at === node ? null : at.nextSibling;
121
+ }
122
+ }
123
+ return true;
124
+ };
125
+ const nativeHtml = node => {
126
+ if (node.nodeType === 1) return node.outerHTML;
127
+ text.replaceChildren(node.cloneNode(false)); return text.innerHTML;
128
+ };
129
+ const nodeHtml = function* (node) {
130
+ if (small(node)) return nativeHtml(node);
131
+ const pieces = [], stack = [];
132
+ let at = node;
133
+ while (at) {
134
+ yield null;
135
+ if (at.nodeType === 1) {
136
+ if (small(at)) pieces.push(at.outerHTML);
137
+ else {
138
+ // Native shallow serialization owns tag/attribute spelling and
139
+ // escaping. Native text serialization below keeps its parent context.
140
+ const empty = at.cloneNode(false);
141
+ // A foreign element with children must retain its native closing tag
142
+ // even in an inert DOM that self-closes an empty SVG element.
143
+ if (at.firstChild) empty.appendChild(root.ownerDocument.createTextNode(''));
144
+ const shell = empty.outerHTML, close = /<\/[^<>]+>$/.exec(shell)?.[0] || '';
145
+ if (close && at.firstChild) {
146
+ pieces.push(shell.slice(0, -close.length));
147
+ stack.push({node: at, close}); at = at.firstChild; continue;
148
+ }
149
+ pieces.push(shell);
150
+ }
151
+ } else if (at.nodeType === 3 && at.nodeValue.length > limit) {
152
+ const parent = at !== node && at.parentNode?.nodeType === 1 ? at.parentNode.cloneNode(false) : text;
153
+ const value = at.nodeValue;
154
+ for (let start = 0; start < value.length;) {
155
+ let end = Math.min(start + limit, value.length);
156
+ if (end < value.length && value.charCodeAt(end - 1) >= 0xd800 && value.charCodeAt(end - 1) <= 0xdbff) end--;
157
+ parent.replaceChildren(root.ownerDocument.createTextNode(value.slice(start, end)));
158
+ pieces.push(parent.innerHTML); start = end; yield null;
159
+ }
160
+ parent.replaceChildren();
161
+ } else if (at.nodeType === 3 && at.parentNode?.nodeType === 1) {
162
+ const parent = at.parentNode.cloneNode(false);
163
+ parent.appendChild(at.cloneNode(false)); pieces.push(parent.innerHTML);
164
+ } else pieces.push(nativeHtml(at));
165
+ while (at !== node && !at.nextSibling) { const frame = stack.pop(); pieces.push(frame.close); at = frame.node; }
166
+ at = at === node ? null : at.nextSibling;
167
+ }
168
+ return pieces.join('');
169
+ };
103
170
  let part = '';
104
- for (let node = root.firstChild; node; node = node.nextSibling) {
105
- let html;
106
- if (node.nodeType === 1) html = node.outerHTML;
107
- else { text.replaceChildren(node.cloneNode(false)); html = text.innerHTML; }
171
+ for (let node = root.firstChild; node;) {
172
+ const next = node.nextSibling;
173
+ const html = yield* nodeHtml(node);
108
174
  if (part && part.length + html.length > limit) { yield part; part = ''; }
109
175
  part += html;
176
+ if (consume) root.removeChild(node);
177
+ node = next;
110
178
  }
111
179
  if (part) yield part;
112
180
  }
113
181
 
114
- export function* cleanTree(root, sanitize, context = 'export') {
182
+ export function admitHtml(root, html, sanitize, fragment = null, context = 'export') {
183
+ if (fragment) root.appendChild(fragment(html, context));
184
+ else {
185
+ // Inert string-only hosts retain their explicitly supported admission path.
186
+ const part = root.ownerDocument.createElement('div');
187
+ part.innerHTML = sanitize(html, context);
188
+ while (part.firstChild) root.appendChild(part.firstChild);
189
+ }
190
+ }
191
+
192
+ export function* cleanTree(root, sanitize, context = 'export', fragment = null) {
115
193
  const clean = root.ownerDocument.createElement('div');
116
- const part = root.ownerDocument.createElement('div');
117
194
  for (const html of htmlParts(root)) {
118
- part.innerHTML = sanitize(html, context);
119
- while (part.firstChild) clean.appendChild(part.firstChild);
195
+ if (html !== null) admitHtml(clean, html, sanitize, fragment, context);
120
196
  yield;
121
197
  }
122
198
  return clean;
@@ -124,7 +200,10 @@ export function* cleanTree(root, sanitize, context = 'export') {
124
200
 
125
201
  export function* serializeTree(root, sanitize = null, trim = false) {
126
202
  const parts = [];
127
- for (const html of htmlParts(root)) { parts.push(sanitize ? sanitize(html, 'export') : html); yield; }
203
+ for (const html of htmlParts(root)) {
204
+ if (html !== null) parts.push(sanitize ? sanitize(html, 'export') : html);
205
+ yield;
206
+ }
128
207
  const result = parts.join('');
129
208
  return trim ? result.trim() : result;
130
209
  }