rapier-markdown-kit 1.1.79 → 1.1.81

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.
@@ -61,6 +61,9 @@ function _rapierPrintWillFont(markers) {
61
61
  u16(segments.length * 2 - power * 2), segments.map(s => u16(s[1])), u16(0), segments.map(s => u16(s[0])),
62
62
  segments.map(s => u16(s[2])), segments.map(() => u16(0)));
63
63
  const full = cat(u16(12), u16(0), u32(16 + wide.length * 12), u32(0), u32(wide.length), wide.map(g => cat(u32(g.start), u32(g.end), u32(g.glyph))));
64
+ const cmap = narrow.length <= 0xffff
65
+ ? cat(u16(0), u16(2), u16(3), u16(1), u32(20), u16(3), u16(10), u32(20 + narrow.length), narrow, full)
66
+ : cat(u16(0), u16(1), u16(3), u16(10), u32(12), full);
64
67
  const outline = cat(u16(1), u16(0), u16(-2), u16(10), u16(2), u16(2), u16(0), [1, 1, 1], u16(0), u16(10), u16(-5), u16(-2), u16(4), u16(-2), [0]);
65
68
  const utf16 = text => Array.from(text, ch => u16(ch.charCodeAt(0)));
66
69
  const names = [[1, 'RapierWill'], [2, 'Regular'], [4, 'RapierWill Regular'], [6, 'RapierWill-Regular']];
@@ -70,7 +73,7 @@ function _rapierPrintWillFont(markers) {
70
73
  u16(50), u16(300), u16(0), new Array(10).fill(0), u32(0), u32(0), u32(0), u32(0), [78, 79, 78, 69], u16(0x40),
71
74
  u16(order.find(code => code < 0xffff) || 0x20), u16(order.filter(code => code < 0xffff).pop() || 0x20), u16(500), u16(-500), u16(0),
72
75
  u16(500), u16(500), u32(1), u32(0), u16(500), u16(700), u16(0), u16(32), u16(0)),
73
- cmap: cat(u16(0), u16(2), u16(3), u16(1), u32(20), u16(3), u16(10), u32(20 + narrow.length), narrow, full),
76
+ cmap,
74
77
  glyf: Array.from({ length: count }, () => outline).flat(),
75
78
  head: cat(u32(0x00010000), u32(0x00010000), u32(0), u32(0x5f0f3cf5), u16(1), u16(1000), new Array(16).fill(0), u16(0), u16(-2), u16(10), u16(2),
76
79
  u16(0), u16(8), u16(2), u16(1), u16(0)),
@@ -93,7 +96,7 @@ function _rapierPrintWillFont(markers) {
93
96
  const file = cat(u32(0x00010000), u16(tags.length), u16((1 << tableSelect) * 16), u16(tableSelect), u16(tags.length * 16 - (1 << tableSelect) * 16), directory, tags.map(tag => padded(tables[tag])));
94
97
  const headAt = 12 + tags.length * 16 + tags.slice(0, tags.indexOf('head')).reduce((total, tag) => total + padded(tables[tag]).length, 0);
95
98
  file.splice(headAt + 8, 4, ...u32((0xb1b0afba - sum(file)) >>> 0));
96
- return { family: 'RapierWillCarrier', bytes: Uint8Array.from(file), size };
99
+ return { family: 'RapierWillCarrier', bytes: Uint8Array.from(file), size, codes: order };
97
100
  }
98
101
 
99
102
  function _rapierPrintWillMetrics(markers) {
@@ -160,6 +163,264 @@ function _rapierPrintWillPageCss() {
160
163
  + '}';
161
164
  }
162
165
 
163
- return {_rapierWillSentinelPlan, _rapierPrintWillAdmit, _rapierPrintWillFont, _rapierPrintWillMetrics, _rapierPrintWillPlace, _rapierPrintWillLost, _rapierPrintWillPageCss};
166
+ // The capture's own limits (agent/visual.mjs VISUAL_LIMITS): the longest edge and the most pixels one capture returns.
167
+ const CAPTURE_EDGE = 4096, CAPTURE_PIXELS = 4 * 1024 * 1024;
168
+
169
+ async function _rapierWritePrintPdf({root, css, canonical, filename, title, settings}, {capture, signal, current, maxBytes = 8 * 1024 * 1024} = {}) {
170
+ const refuse = code => { throw Object.assign(new Error(code), {code}); };
171
+ const check = () => {
172
+ if (signal?.aborted) refuse('cancelled');
173
+ if (current?.() === false) refuse('document_changed');
174
+ };
175
+ if (!root?.ownerDocument || typeof capture !== 'function') refuse('pdf_render_unavailable');
176
+ // The native printer chooses its default paper. A file made without a printer uses A4,
177
+ // with the same explicit document settings and default margins as the print artifact.
178
+ const paper = settings?.papersize || {width: 11906, height: 16838};
179
+ const width = paper.width / 20, height = paper.height / 20;
180
+ const margin = settings?.geometry ? settings.geometry.twips / 20 : null;
181
+ const margins = {top: margin ?? 12 * 72 / 25.4, right: margin ?? 14 * 72 / 25.4,
182
+ bottom: margin ?? 14 * 72 / 25.4, left: margin ?? 14 * 72 / 25.4};
183
+ const bodyWidth = Math.floor((width - margins.left - margins.right) * 4 / 3);
184
+ const bodyHeight = Math.floor((height - margins.top - margins.bottom) * 4 / 3);
185
+ if (bodyWidth < 1 || bodyHeight < 1 || bodyWidth * bodyHeight > 4 * 1024 * 1024) refuse('pdf_page_geometry_invalid');
186
+ const frame = document.createElement('iframe');
187
+ let rejectAbort;
188
+ const aborted = new Promise((_, reject) => { rejectAbort = reject; });
189
+ aborted.catch(() => {});
190
+ const abort = () => rejectAbort(Object.assign(new Error('cancelled'), {code: 'cancelled'}));
191
+ signal?.addEventListener('abort', abort, {once: true});
192
+ const step = async work => {
193
+ check();
194
+ let timer;
195
+ try {
196
+ const timeout = new Promise((_, reject) => {
197
+ timer = setTimeout(() => reject(Object.assign(new Error('pdf_render_timeout'), {code: 'pdf_render_timeout'})), 15000);
198
+ });
199
+ const value = await Promise.race([work, aborted, timeout]); check(); return value;
200
+ } finally { clearTimeout(timer); }
201
+ };
202
+ frame.setAttribute('aria-hidden', 'true');
203
+ frame.style.cssText = `position:fixed;left:-100000px;top:0;width:${bodyWidth}px;height:${bodyHeight}px;border:0;pointer-events:none`;
204
+ document.body.append(frame);
205
+ try {
206
+ check();
207
+ const into = frame.contentDocument;
208
+ if (!into?.body) refuse('pdf_render_unavailable');
209
+ into.documentElement.setAttribute('data-rapier-theme', 'light');
210
+ into.documentElement.setAttribute('data-highlights', document.documentElement.getAttribute('data-highlights') || 'standard');
211
+ into.body.className = 'light';
212
+ const sheet = into.createElement('style'); sheet.textContent = String(css || ''); into.head.append(sheet);
213
+ const printRules = [];
214
+ const collect = rules => {
215
+ for (const rule of rules) {
216
+ if (rule.media?.mediaText === 'print') printRules.push(...Array.from(rule.cssRules, item => item.cssText));
217
+ else if (rule.cssRules && !rule.media) collect(rule.cssRules);
218
+ }
219
+ };
220
+ collect(sheet.sheet.cssRules);
221
+ const printed = into.createElement('style');
222
+ printed.textContent = printRules.join('\n') + `\nhtml,body{width:${bodyWidth}px!important;height:${bodyHeight}px!important;margin:0!important;padding:0!important;overflow:visible!important}`;
223
+ into.head.append(printed);
224
+ const surface = into.createElement('div'), strip = into.createElement('div');
225
+ surface.style.cssText = `width:${bodyWidth}px;height:${bodyHeight}px;overflow:hidden;position:relative;background:#fff`;
226
+ strip.style.cssText = `width:${bodyWidth}px;height:${bodyHeight}px;position:relative`;
227
+ strip.append(root); surface.append(strip); into.body.append(surface);
228
+ const main = root.querySelector('main');
229
+ if (!main) refuse('pdf_render_unavailable');
230
+ // A file for paper is light whatever the person's own scheme: the page's text takes its colour scheme from this attribute.
231
+ main.setAttribute('data-md-theme', 'light');
232
+ for (const details of main.querySelectorAll('details')) details.open = true;
233
+ main.getBoundingClientRect();
234
+ await step(into.fonts.ready);
235
+ for (const image of main.querySelectorAll('img')) { image.loading = 'eager'; await step(image.decode()); }
236
+ check();
237
+ // The print sheet owns the words and styles. CSS fragmentation supplies the file's
238
+ // pages without a print dialog; its break constraints become column constraints here.
239
+ for (const node of [main, ...main.querySelectorAll('*')]) {
240
+ const style = into.defaultView.getComputedStyle(node);
241
+ for (const name of ['break-before', 'break-after', 'break-inside']) {
242
+ const value = style.getPropertyValue(name);
243
+ if (value.includes('page')) node.style.setProperty(name, value.replace('page', 'column'), 'important');
244
+ }
245
+ }
246
+ for (const [name, value] of Object.entries({width: bodyWidth + 'px', height: bodyHeight + 'px',
247
+ 'max-width': 'none', 'min-height': '0', padding: '0', margin: '0', overflow: 'visible',
248
+ 'column-width': bodyWidth + 'px', 'column-gap': '0', 'column-count': 'auto', 'column-fill': 'auto'}))
249
+ main.style.setProperty(name, value, 'important');
250
+ const count = Math.max(1, Math.ceil((main.scrollWidth - 1) / bodyWidth));
251
+ if (!Number.isSafeInteger(count)) refuse('pdf_page_geometry_invalid');
252
+ const textPages = await _rapierPrintTextPages(main, bodyWidth, count, check);
253
+ const text = textPages.flatMap(page => page.map(run => run.text));
254
+ const glyphs = new Set();
255
+ for (const line of text) for (const character of line) {
256
+ glyphs.add(character);
257
+ if (glyphs.size > 65000) refuse('pdf_text_glyph_limit');
258
+ }
259
+ const font = _rapierPrintWillFont(text);
260
+ strip.style.width = count * bodyWidth + 'px';
261
+ // A capture costs the whole document, however little of it the clip takes, so the pages that carry no picture share captures: as many to one
262
+ // as the capture's own limits allow, fewer when the capture refuses its size. A page with a picture is captured alone, so the picture keeps
263
+ // the capture's byte limit to itself.
264
+ let share = main.querySelector('img,svg,canvas,video,picture') ? 1
265
+ : Math.max(1, Math.min(Math.floor(CAPTURE_EDGE / bodyWidth), Math.floor(CAPTURE_PIXELS / (bodyWidth * bodyHeight))));
266
+ const pages = [];
267
+ let imageBytes = 0;
268
+ for (let first = 0; first < count;) {
269
+ check();
270
+ const span = Math.min(share, count - first);
271
+ surface.style.width = span * bodyWidth + 'px';
272
+ strip.style.transform = `translateX(${-first * bodyWidth}px)`;
273
+ let captured;
274
+ try { captured = await capture({root: surface, clip: {x: 0, y: 0, width: span * bodyWidth, height: bodyHeight}, signal, current}); }
275
+ catch (error) {
276
+ if (error?.code === 'visual_too_large' && span > 1) { share = Math.ceil(span / 2); continue; }
277
+ throw error;
278
+ }
279
+ check();
280
+ const image = new into.defaultView.Image();
281
+ image.src = 'data:' + captured.mimeType + ';base64,' + captured.data;
282
+ try {
283
+ await step(image.decode());
284
+ for (let offset = 0; offset < span; offset++) {
285
+ const canvas = into.createElement('canvas'); canvas.width = bodyWidth; canvas.height = bodyHeight;
286
+ try {
287
+ const context = canvas.getContext('2d', {alpha: false});
288
+ if (!context) refuse('pdf_render_unavailable');
289
+ context.fillStyle = '#fff'; context.fillRect(0, 0, bodyWidth, bodyHeight);
290
+ context.drawImage(image, offset * bodyWidth, 0, bodyWidth, bodyHeight, 0, 0, bodyWidth, bodyHeight);
291
+ const blob = await step(new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.95)));
292
+ if (!blob) refuse('pdf_render_unavailable');
293
+ imageBytes += blob.size;
294
+ if (imageBytes > maxBytes) refuse('export_too_large');
295
+ pages.push(new Uint8Array(await blob.arrayBuffer()));
296
+ } finally { canvas.width = canvas.height = 0; }
297
+ }
298
+ } finally { image.src = ''; }
299
+ first += span;
300
+ }
301
+ check();
302
+ const bytes = await writeRasterPdf({pages, textPages, canonical, filename, title, font, width, height, margins,
303
+ pixelWidth: bodyWidth, pixelHeight: bodyHeight, pageNumbers: settings?.pagestyle === 'plain', maxBytes, check});
304
+ check();
305
+ return {bytes, pages: count};
306
+ } finally { signal?.removeEventListener('abort', abort); frame.remove(); }
307
+ }
308
+
309
+ // Ranges locate the actual text fragments after column layout. Splitting a text node at
310
+ // its line and page boundaries keeps a PDF search on the page that carries those words.
311
+ async function _rapierPrintTextPages(main, pageWidth, count, check) {
312
+ const into = main.ownerDocument, origin = main.getBoundingClientRect();
313
+ const pages = Array.from({length: count}, () => []), walker = into.createTreeWalker(main, 4);
314
+ const range = into.createRange();
315
+ let nodes = 0, operations = 0;
316
+ for (let node; (node = walker.nextNode());) {
317
+ if (++nodes % 128 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); check(); }
318
+ const style = into.defaultView.getComputedStyle(node.parentElement);
319
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') continue;
320
+ const preserve = /pre|break-spaces/.test(style.whiteSpace);
321
+ const block = node.parentElement.closest('p,h1,h2,h3,h4,h5,h6,li,pre,td,th,figcaption,blockquote,.rapier-will-carrier') || node.parentElement;
322
+ const carrier = node.parentElement.closest('.rapier-will-carrier');
323
+ const value = node.data;
324
+ const pageOf = x => Math.max(0, Math.min(count - 1, Math.floor((x - origin.left + .01) / pageWidth)));
325
+ const visit = (start, end) => {
326
+ if (++operations % 128 === 0) check();
327
+ range.setStart(node, start); range.setEnd(node, end);
328
+ const rects = Array.from(range.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0);
329
+ if (!rects.length) return;
330
+ const first = rects[0], page = pageOf(first.left);
331
+ const oneLine = rects.every(rect => pageOf(rect.left) === page && pageOf(rect.right - .02) === page && Math.abs(rect.top - first.top) < .5);
332
+ let middle = Math.floor((start + end) / 2);
333
+ if (middle > start && /[\uD800-\uDBFF]/.test(value[middle - 1]) && /[\uDC00-\uDFFF]/.test(value[middle])) middle--;
334
+ if (!oneLine && middle > start && middle < end) { visit(start, middle); visit(middle, end); return; }
335
+ let text = value.slice(start, end);
336
+ text = preserve ? text.replace(/\r\n?|\n/g, '') : text.replace(/[ \t\r\n\f]+/g, ' ');
337
+ if (!text) return;
338
+ const x = Math.min(...rects.map(rect => rect.left)) - origin.left - page * pageWidth;
339
+ const y = Math.min(...rects.map(rect => rect.top)) - origin.top;
340
+ const right = Math.max(...rects.map(rect => rect.right)) - origin.left - page * pageWidth;
341
+ const bottom = Math.max(...rects.map(rect => rect.bottom)) - origin.top;
342
+ const last = pages[page].at(-1);
343
+ if (last && last.block === block && last.carrier === carrier && Math.abs(last.y - y) < Math.max(2, Math.min(last.height, bottom - y) * .2)) {
344
+ last.text = preserve ? last.text + text : (last.text + text).replace(/[ \t\r\n\f]+/g, ' ');
345
+ last.width = Math.max(last.x + last.width, right) - Math.min(last.x, x);
346
+ last.height = Math.max(last.y + last.height, bottom) - Math.min(last.y, y);
347
+ last.x = Math.min(last.x, x); last.y = Math.min(last.y, y);
348
+ } else pages[page].push({text, x, y, width: right - x, height: bottom - y, block, carrier});
349
+ };
350
+ visit(0, value.length);
351
+ }
352
+ check();
353
+ return pages.map(page => page.map(({block, carrier, ...run}) => run));
354
+ }
355
+
356
+ return {_rapierWillSentinelPlan, _rapierPrintWillAdmit, _rapierPrintWillFont, _rapierPrintWillMetrics, _rapierPrintWillPlace, _rapierPrintWillLost, _rapierPrintWillPageCss, _rapierWritePrintPdf};
357
+ }
358
+
359
+ // A standard PDF page tree, image streams, Unicode text and an associated source file.
360
+ // It accepts already-rendered pages, so there is no second document grammar or layout engine.
361
+ async function writeRasterPdf({pages, textPages, canonical, filename, title, font, width, height, margins, pixelWidth, pixelHeight, pageNumbers, maxBytes, check}) {
362
+ const encoder = new TextEncoder(), encode = value => encoder.encode(value);
363
+ const fail = code => { throw Object.assign(new Error(code), {code}); };
364
+ const concat = parts => {
365
+ const length = parts.reduce((sum, part) => sum + part.length, 0), bytes = new Uint8Array(length);
366
+ let at = 0; for (const part of parts) {bytes.set(part, at); at += part.length;} return bytes;
367
+ };
368
+ const compressed = async bytes => {
369
+ check();
370
+ const pipe = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'));
371
+ const result = new Uint8Array(await new Response(pipe).arrayBuffer()); check(); return result;
372
+ };
373
+ const objects = [null, null, null];
374
+ let used = 0;
375
+ const put = (id, bytes) => {
376
+ bytes = typeof bytes === 'string' ? encode(bytes) : bytes;
377
+ used += bytes.length + 32; if (used > maxBytes) fail('export_too_large'); objects[id] = bytes; return id;
378
+ };
379
+ const add = bytes => {objects.push(null); return put(objects.length - 1, bytes);};
380
+ const stream = (dict, bytes) => add(concat([encode('<<' + dict + ' /Length ' + bytes.length + '>>\nstream\n'), bytes, encode('\nendstream')]));
381
+ const hex = value => Array.from({length: value.length}, (_, index) => value.charCodeAt(index).toString(16).padStart(4, '0')).join('');
382
+ const literal = value => '(' + String(value).replace(/[\\()\r\n]/g, character => ({'\\': '\\\\', '(': '\\(', ')': '\\)', '\r': '\\r', '\n': '\\n'}[character])) + ')';
383
+ const source = encoder.encode(canonical);
384
+ const embedded = stream('/Type /EmbeddedFile /Subtype /text#2Fplain /Params <</Size ' + source.length + '>> /Filter /FlateDecode', await compressed(source));
385
+ const file = add('<< /Type /Filespec /F ' + literal(String(filename).replace(/[^\x20-\x7e]/g, '_')) + ' /UF <feff' + hex(filename) + '> /AFRelationship /Source /EF << /F ' + embedded + ' 0 R /UF ' + embedded + ' 0 R >> >>');
386
+ const codes = new Map(font.codes.map((code, index) => [code, index + 1]));
387
+ const rows = font.codes.map((code, index) => '<' + (index + 1).toString(16).padStart(4, '0') + '> <' + hex(String.fromCodePoint(code)) + '>');
388
+ let cmap = '/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /RapierText def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <ffff>\nendcodespacerange\n';
389
+ for (let at = 0; at < rows.length; at += 100) cmap += Math.min(100, rows.length - at) + ' beginbfchar\n' + rows.slice(at, at + 100).join('\n') + '\nendbfchar\n';
390
+ cmap += 'endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend';
391
+ const mapping = stream('/Filter /FlateDecode', await compressed(encode(cmap)));
392
+ const fontFile = stream('/Length1 ' + font.bytes.length + ' /Filter /FlateDecode', await compressed(font.bytes));
393
+ const descriptor = add('<< /Type /FontDescriptor /FontName /RapierText /Flags 4 /FontBBox [0 -2 10 2] /ItalicAngle 0 /Ascent 500 /Descent -500 /CapHeight 500 /StemV 80 /FontFile2 ' + fontFile + ' 0 R >>');
394
+ const descendant = add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /RapierText /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ' + descriptor + ' 0 R /DW 500 /CIDToGIDMap /Identity >>');
395
+ const type = add('<< /Type /Font /Subtype /Type0 /BaseFont /RapierText /Encoding /Identity-H /DescendantFonts [' + descendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
396
+ const numberFont = pageNumbers ? add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>') : null;
397
+ const kids = [], decimal = value => Number(value.toFixed(4));
398
+ for (let index = 0; index < pages.length; index++) {
399
+ check();
400
+ const picture = stream('/Type /XObject /Subtype /Image /Width ' + pixelWidth + ' /Height ' + pixelHeight + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode', pages[index]);
401
+ let commands = 'q\n' + [decimal(pixelWidth * .75), 0, 0, decimal(pixelHeight * .75), decimal(margins.left), decimal(height - margins.top - pixelHeight * .75)].join(' ') + ' cm\n/Im0 Do\nQ\n';
402
+ for (const run of textPages[index]) {
403
+ const characters = Array.from(run.text);
404
+ const value = characters.map(character => codes.get(character.codePointAt(0)).toString(16).padStart(4, '0')).join('');
405
+ const size = Math.max(.1, run.height * .75);
406
+ const scale = run.width * .75 / (characters.length * size * .5) * 100;
407
+ commands += `BT\n/F1 ${decimal(size)} Tf\n${decimal(scale)} Tz\n3 Tr\n1 0 0 1 ${decimal(margins.left + run.x * .75)} ${decimal(height - margins.top - (run.y + run.height * .8) * .75)} Tm\n<${value}> Tj\nET\n`;
408
+ }
409
+ if (numberFont) commands += `BT\n/F2 9 Tf\n0 Tr\n1 0 0 1 ${decimal(width / 2 - String(index + 1).length * 2.5)} ${decimal(margins.bottom / 2)} Tm\n(${index + 1}) Tj\nET\n`;
410
+ const content = stream('/Filter /FlateDecode', await compressed(encode(commands)));
411
+ kids.push(add('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' + decimal(width) + ' ' + decimal(height) + '] /Resources << /XObject << /Im0 ' + picture + ' 0 R >> /Font << /F1 ' + type + ' 0 R' + (numberFont ? ' /F2 ' + numberFont + ' 0 R' : '') + ' >> >> /Contents ' + content + ' 0 R >>'));
412
+ }
413
+ put(2, '<< /Type /Pages /Count ' + kids.length + ' /Kids [' + kids.map(id => id + ' 0 R').join(' ') + '] >>');
414
+ put(1, '<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [<feff' + hex(filename) + '> ' + file + ' 0 R] >> >> /AF [' + file + ' 0 R] >>');
415
+ const info = add('<< /Producer (Rapier) /Title <feff' + hex(title || filename.replace(/\.[^.]*$/, '')) + '> >>');
416
+ const parts = [encode('%PDF-1.7\n%\u00e2\u00e3\u00cf\u00d3\n')], offsets = [0];
417
+ let length = parts[0].length;
418
+ for (let id = 1; id < objects.length; id++) {
419
+ offsets.push(length);
420
+ const body = concat([encode(id + ' 0 obj\n'), objects[id], encode('\nendobj\n')]); parts.push(body); length += body.length;
421
+ }
422
+ const xref = length;
423
+ parts.push(encode('xref\n0 ' + objects.length + '\n0000000000 65535 f \n' + offsets.slice(1).map(at => String(at).padStart(10, '0') + ' 00000 n \n').join('') + 'trailer\n<< /Size ' + objects.length + ' /Root 1 0 R /Info ' + info + ' 0 R >>\nstartxref\n' + xref + '\n%%EOF\n'));
424
+ const bytes = concat(parts); if (bytes.length > maxBytes) fail('export_too_large'); return bytes;
164
425
  }
165
426
  export {createPrintRenderer, RAPIER_WILL_SENTINEL_MARK as sentinel};
@@ -1,24 +1,40 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  const RAPIER_SANITIZE_FORBID_TAGS = Object.freeze(['form', 'style', 'dialog', 'template', 'iframe']);
3
3
  const RAPIER_SANITIZE_FORBID_ATTR = Object.freeze(['action', 'formaction', 'form', 'for']);
4
+ // The document profiles use one allowlist in a browser and in an inert server parser.
5
+ const RAPIER_SANITIZE_PROFILES = Object.freeze({
6
+ htmlTags: Object.freeze(["a","abbr","acronym","address","area","article","aside","audio","b","bdi","bdo","big","blink","blockquote","body","br","button","canvas","caption","center","cite","code","col","colgroup","content","data","datalist","dd","decorator","del","details","dfn","dialog","dir","div","dl","dt","element","em","fieldset","figcaption","figure","font","footer","form","h1","h2","h3","h4","h5","h6","head","header","hgroup","hr","html","i","img","input","ins","kbd","label","legend","li","main","map","mark","marquee","menu","menuitem","meter","nav","nobr","ol","optgroup","option","output","p","picture","pre","progress","q","rp","rt","ruby","s","samp","search","section","select","shadow","slot","small","source","spacer","span","strike","strong","style","sub","summary","sup","table","tbody","td","template","textarea","tfoot","th","thead","time","tr","track","tt","u","ul","var","video","wbr"]),
7
+ svgTags: Object.freeze(["svg","a","altglyph","altglyphdef","altglyphitem","animatecolor","animatemotion","animatetransform","circle","clippath","defs","desc","ellipse","enterkeyhint","exportparts","filter","font","g","glyph","glyphref","hkern","image","inputmode","line","lineargradient","marker","mask","metadata","mpath","part","path","pattern","polygon","polyline","radialgradient","rect","stop","style","switch","symbol","text","textpath","title","tref","tspan","view","vkern"]),
8
+ mathTags: Object.freeze(["math","menclose","merror","mfenced","mfrac","mglyph","mi","mlabeledtr","mmultiscripts","mn","mo","mover","mpadded","mphantom","mroot","mrow","ms","mspace","msqrt","mstyle","msub","msup","msubsup","mtable","mtd","mtext","mtr","munder","munderover","mprescripts"]),
9
+ htmlAttributes: Object.freeze(["accept","action","align","alt","autocapitalize","autocomplete","autopictureinpicture","autoplay","background","bgcolor","border","capture","cellpadding","cellspacing","checked","cite","class","clear","color","cols","colspan","command","commandfor","controls","controlslist","coords","crossorigin","datetime","decoding","default","dir","disabled","disablepictureinpicture","disableremoteplayback","download","draggable","enctype","enterkeyhint","exportparts","face","for","headers","height","hidden","high","href","hreflang","id","inert","inputmode","integrity","ismap","kind","label","lang","list","loading","loop","low","max","maxlength","media","method","min","minlength","multiple","muted","name","nonce","noshade","novalidate","nowrap","open","optimum","part","pattern","placeholder","playsinline","popover","popovertarget","popovertargetaction","poster","preload","pubdate","radiogroup","readonly","rel","required","rev","reversed","role","rows","rowspan","spellcheck","scope","selected","shape","size","sizes","slot","span","srclang","start","src","srcset","step","style","summary","tabindex","title","translate","type","usemap","valign","value","width","wrap","xmlns"]),
10
+ svgAttributes: Object.freeze(["accent-height","accumulate","additive","alignment-baseline","amplitude","ascent","attributename","attributetype","azimuth","basefrequency","baseline-shift","begin","bias","by","class","clip","clippathunits","clip-path","clip-rule","color","color-interpolation","color-interpolation-filters","color-profile","color-rendering","cx","cy","d","dx","dy","diffuseconstant","direction","display","divisor","dominant-baseline","dur","edgemode","elevation","end","exponent","fill","fill-opacity","fill-rule","filter","filterunits","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","fx","fy","g1","g2","glyph-name","glyphref","gradientunits","gradienttransform","height","href","id","image-rendering","in","in2","intercept","k","k1","k2","k3","k4","kerning","keypoints","keysplines","keytimes","lang","lengthadjust","letter-spacing","kernelmatrix","kernelunitlength","lighting-color","local","marker-end","marker-mid","marker-start","markerheight","markerunits","markerwidth","maskcontentunits","maskunits","max","mask","mask-type","media","method","mode","min","name","numoctaves","offset","operator","opacity","order","orient","orientation","origin","overflow","paint-order","path","pathlength","patterncontentunits","patterntransform","patternunits","pointer-events","points","preservealpha","preserveaspectratio","primitiveunits","r","rx","ry","radius","refx","refy","repeatcount","repeatdur","restart","result","rotate","scale","seed","shape-rendering","slope","specularconstant","specularexponent","spreadmethod","startoffset","stddeviation","stitchtiles","stop-color","stop-opacity","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke","stroke-width","style","surfacescale","systemlanguage","tabindex","tablevalues","targetx","targety","transform","transform-origin","text-anchor","text-decoration","text-orientation","text-rendering","textlength","type","u1","u2","unicode","values","vector-effect","viewbox","visibility","version","vert-adv-y","vert-origin-x","vert-origin-y","width","word-spacing","wrap","writing-mode","xchannelselector","ychannelselector","x","x1","x2","xmlns","y","y1","y2","z","zoomandpan"]),
11
+ mathAttributes: Object.freeze(["accent","accentunder","align","bevelled","close","columnalign","columnlines","columnspacing","columnspan","denomalign","depth","dir","display","displaystyle","encoding","fence","frame","height","href","id","largeop","length","linethickness","lquote","lspace","mathbackground","mathcolor","mathsize","mathvariant","maxsize","minsize","movablelimits","notation","numalign","open","rowalign","rowlines","rowspacing","rowspan","rspace","rquote","scriptlevel","scriptminsize","scriptsizemultiplier","selection","separator","separators","stretchy","subscriptshift","supscriptshift","symmetric","voffset","width","xmlns"]),
12
+ xmlAttributes: Object.freeze(["xlink:href","xml:id","xlink:title","xml:space","xmlns:xlink"]),
13
+ });
14
+ const RAPIER_SANITIZE_DROP_CONTENTS = Object.freeze('annotation-xml.audio.colgroup.desc.foreignobject.head.iframe.math.mi.mn.mo.ms.mtext.noembed.noframes.noscript.plaintext.script.selectedcontent.style.svg.template.thead.title.video.xmp'.split('.'));
15
+ function _rapierSanitizeProfile(...kinds) {
16
+ return {ALLOWED_TAGS: Object.freeze(['#text', ...new Set(kinds.flatMap(kind => RAPIER_SANITIZE_PROFILES[kind + 'Tags']))]),
17
+ ALLOWED_ATTR: Object.freeze([...new Set(kinds.flatMap(kind => RAPIER_SANITIZE_PROFILES[kind + 'Attributes']).concat(kinds.some(kind => kind !== 'html') ? RAPIER_SANITIZE_PROFILES.xmlAttributes : []))]),
18
+ FORBID_CONTENTS: RAPIER_SANITIZE_DROP_CONTENTS};
19
+ }
4
20
  const RAPIER_SANITIZE_OPTIONS = Object.freeze({
5
21
  render: Object.freeze({
6
- USE_PROFILES: { html: true, svg: true, mathMl: true },
22
+ ..._rapierSanitizeProfile('html', 'svg', 'math'),
7
23
  FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
8
24
  FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
9
25
  }),
10
26
  paste: Object.freeze({
11
- USE_PROFILES: { html: true },
27
+ ..._rapierSanitizeProfile('html'),
12
28
  FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
13
29
  FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
14
30
  }),
15
31
  snippet: Object.freeze({
16
- USE_PROFILES: { html: true },
32
+ ..._rapierSanitizeProfile('html'),
17
33
  FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
18
34
  FORBID_ATTR: Object.freeze([...'style']),
19
35
  }),
20
36
  diagram: Object.freeze({
21
- USE_PROFILES: { html: true, svg: true },
37
+ ..._rapierSanitizeProfile('html', 'svg'),
22
38
  FORBID_TAGS: Object.freeze(['form', 'dialog', 'template', 'iframe', 'script', 'foreignobject']),
23
39
  FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
24
40
  }),
@@ -66,9 +82,98 @@ function prepareMathSource(input, DOMParser) {
66
82
  return sourceRoot ? root.outerHTML : root;
67
83
  }
68
84
 
85
+ // An inert parser and a string sanitizer use the same profile as DOMPurify. Neither port may
86
+ // execute document scripts or fetch resources. HTML5 parsing precedes filtering so foreign
87
+ // content, entities and malformed table markup cross the same boundary as a browser's parser.
88
+ function createInertHtmlSanitizer({parseFragment, serialize, sanitizeHtml, safeRasterDataUrl}) {
89
+ if (![parseFragment, serialize, sanitizeHtml, safeRasterDataUrl].every(port => typeof port === 'function'))
90
+ throw new TypeError('The inert HTML sanitizer requires every parser and filtering port');
91
+ const uri = /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
92
+ const uriSafe = new Set(['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']);
93
+ const schemes = ['http', 'https', 'ftp', 'ftps', 'mailto', 'tel', 'callto', 'sms', 'cid', 'xmpp', 'matrix'];
94
+ const htmlNamespace = 'http://www.w3.org/1999/xhtml', svgNamespace = 'http://www.w3.org/2000/svg', mathNamespace = 'http://www.w3.org/1998/Math/MathML';
95
+ const svgTags = new Set(RAPIER_SANITIZE_PROFILES.svgTags), mathTags = new Set(RAPIER_SANITIZE_PROFILES.mathTags);
96
+ const mathIntegration = new Set(['mi', 'mo', 'mn', 'ms', 'mtext']), htmlIntegration = new Set(['annotation-xml']);
97
+ const sharedHtmlSvg = new Set(['title', 'style', 'font', 'a', 'script']);
98
+ // DOMPurify's default namespace admission applies in addition to its tag profile. In
99
+ // particular a MathML name parsed as HTML, or HTML inside SVG desc, is not admitted.
100
+ const validNamespace = (node, parent) => {
101
+ const tag = node.tagName.toLowerCase(), parentTag = (parent.tagName || 'template').toLowerCase();
102
+ const namespace = node.namespaceURI, parentNamespace = parent.namespaceURI || htmlNamespace;
103
+ if (namespace === svgNamespace) return parentNamespace === htmlNamespace ? tag === 'svg'
104
+ : parentNamespace === mathNamespace ? tag === 'svg' && (parentTag === 'annotation-xml' || mathIntegration.has(parentTag)) : svgTags.has(tag);
105
+ if (namespace === mathNamespace) return parentNamespace === htmlNamespace ? tag === 'math'
106
+ : parentNamespace === svgNamespace ? tag === 'math' && htmlIntegration.has(parentTag) : mathTags.has(tag);
107
+ return namespace === htmlNamespace && !(parentNamespace === svgNamespace && !htmlIntegration.has(parentTag))
108
+ && !(parentNamespace === mathNamespace && !mathIntegration.has(parentTag))
109
+ && !mathTags.has(tag) && (sharedHtmlSvg.has(tag) || !svgTags.has(tag));
110
+ };
111
+ return function sanitize(value, context = 'render') {
112
+ const selected = context === 'raw' || context === 'export' || context === 'source' || RAPIER_SANITIZE_OPTIONS[context] ? context : 'render';
113
+ const options = RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
114
+ const forbiddenTags = new Set(options.FORBID_TAGS || []), forbiddenAttributes = new Set(options.FORBID_ATTR || []);
115
+ const tags = new Set(options.ALLOWED_TAGS.filter(tag => tag !== '#text' && !forbiddenTags.has(tag)));
116
+ const attributes = new Set(options.ALLOWED_ATTR);
117
+ const tree = parseFragment(String(value == null ? '' : value));
118
+ const dropContents = new Set(RAPIER_SANITIZE_DROP_CONTENTS);
119
+ const admitTree = parent => {
120
+ for (let index = 0; index < (parent.childNodes || []).length;) {
121
+ const node = parent.childNodes[index], tag = node.tagName?.toLowerCase();
122
+ if (tag && !tags.has(tag)) {
123
+ const children = dropContents.has(tag) ? [] : node.childNodes || [];
124
+ for (const child of children) child.parentNode = parent;
125
+ parent.childNodes.splice(index, 1, ...children);
126
+ continue;
127
+ }
128
+ if (tag && !validNamespace(node, parent) || node.nodeName === '#comment') {
129
+ parent.childNodes.splice(index, 1); continue;
130
+ }
131
+ admitTree(node); index++;
132
+ }
133
+ };
134
+ admitTree(tree);
135
+ const admitNames = node => {
136
+ if (node.tagName && tags.has(node.tagName.toLowerCase())) tags.add(node.tagName);
137
+ for (const child of node.childNodes || []) admitNames(child);
138
+ };
139
+ admitNames(tree);
140
+ const cleaned = sanitizeHtml(serialize(tree), {
141
+ allowedTags: [...tags], allowedAttributes: false,
142
+ disallowedTagsMode: 'discard', nonTextTags: RAPIER_SANITIZE_DROP_CONTENTS,
143
+ allowedSchemes: schemes, allowedSchemesByTag: {img: [...schemes, 'data']},
144
+ allowedSchemesAppliedToAttributes: ['href', 'src', 'xlink:href', 'cite', 'background', 'poster'],
145
+ parseStyleAttributes: false,
146
+ parser: {lowerCaseTags: false, lowerCaseAttributeNames: false, decodeEntities: true},
147
+ transformTags: {'*': (tagName, input) => {
148
+ const attribs = Object.create(null), tag = tagName.toLowerCase();
149
+ for (const [name, inputValue] of Object.entries(input)) {
150
+ const key = name.toLowerCase();
151
+ const value = name === 'value' ? inputValue : inputValue.trim();
152
+ if (forbiddenAttributes.has(key) || key.startsWith('on')) continue;
153
+ if (/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i.test(value)
154
+ || key === 'attributename' && value.includes('href')) continue;
155
+ const data = /^data-[\-\w.\u00b7-\uffff]+$/.test(key), aria = /^aria-[\-\w]+$/.test(key);
156
+ const pageBreak = key === 'contenteditable' && value === 'false' && tag === 'div' && input['data-md-break'] === 'page';
157
+ if (!(data && selected !== 'raw') && !aria && !pageBreak && !attributes.has(key)) continue;
158
+ const compact = value.replace(/[\u0000-\u0020\u00a0\u1680\u180e\u2000-\u2029\u205f\u3000]/g, '');
159
+ if (!data && !aria && !pageBreak && !uriSafe.has(key) && compact) {
160
+ if (/^data:/i.test(compact)) {
161
+ if (key !== 'src' || tag !== 'img' || !safeRasterDataUrl(compact)) continue;
162
+ } else if (!uri.test(compact)) continue;
163
+ }
164
+ attribs[name] = value;
165
+ }
166
+ if ((tag === 'a' || tag === 'area') && attribs.target?.trim().toLowerCase() === '_blank') attribs.rel = 'noopener noreferrer';
167
+ return {tagName, attribs};
168
+ }},
169
+ });
170
+ return serialize(parseFragment(cleaned));
171
+ };
172
+ }
173
+
69
174
  // The document's renderer. DOM, codecs and host state are explicit inputs.
70
175
  function createRenderSanitizer(runtime) {
71
- const {CSSStyleSheet, DOMPurify, RAPIER_RASTER_DATA_URL_RE, URL, _rapierCssDeclaration, _rapierDropRemoteDeclarations, _rapierRemoteContent, _rapierRuleDescriptorIsRemote, _rapierSanitizeRuntime, _rapierVerifyRasterBytes, document, globalThis, location} = runtime;
176
+ const {CSSStyleSheet, DOMPurify, RAPIER_RASTER_DATA_URL_RE, URL, _rapierCssDeclaration, _rapierDropRemoteDeclarations, _rapierRemoteContent, _rapierRuleDescriptorIsRemote, _rapierSanitizeRuntime, document, globalThis, location} = runtime;
72
177
 
73
178
  // Only the diagram reader and its styled export admit static foreignObject labels. The
74
179
  // ordinary SVG reader still rejects HTML subdocuments. The export class selects scope;
@@ -257,7 +362,10 @@ function sanitizeRapierHtml(value, context = 'render') {
257
362
  let options = selected === 'raw' ? { ...RAPIER_SANITIZE_OPTIONS.render, ALLOW_DATA_ATTR: false } : RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
258
363
  const labels = selected === 'diagram' || selected === 'export';
259
364
  if (labels) {
260
- options = {...options, USE_PROFILES: {...options.USE_PROFILES, mathMl: true}, ADD_TAGS: ['foreignobject'],
365
+ // DOMPurify's USE_PROFILES replaces explicit allowlists. Add MathML to the
366
+ // selected profile without discarding its admitted HTML and SVG vocabulary.
367
+ options = {...options, ALLOWED_TAGS: [...new Set([...options.ALLOWED_TAGS, ...RAPIER_SANITIZE_PROFILES.mathTags])],
368
+ ALLOWED_ATTR: [...new Set([...options.ALLOWED_ATTR, ...RAPIER_SANITIZE_PROFILES.mathAttributes])], ADD_TAGS: ['foreignobject'],
261
369
  HTML_INTEGRATION_POINTS: {foreignobject: true}, FORBID_TAGS: options.FORBID_TAGS.filter(tag => tag !== 'foreignobject')};
262
370
  DOMPurify.addHook('uponSanitizeElement', diagramLabelElement);
263
371
  DOMPurify.addHook('uponSanitizeAttribute', diagramLabelAttribute);
@@ -356,6 +464,18 @@ function _rapierDropRemoteRules(rules, owner) {
356
464
  }
357
465
  }
358
466
 
359
- return {_rapierInstallSanitizeHooks, sanitizeRapierHtml, diagramLabelStyle, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
467
+ function _rapierVerifyRasterBytes(bytes, mime) {
468
+ if (mime === 'image/jxl') return globalThis.RapierImageAssets.isJxl(bytes);
469
+
470
+ // declaration, doctype or generator/license comment before <svg itself, of whatever length its
471
+
472
+ if (mime === 'image/svg+xml') return /<svg[\s>]/i.test(new TextDecoder('utf-8', {fatal: false}).decode(bytes));
473
+ if (mime === 'image/png') return bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
474
+ if (mime === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
475
+ if (mime === 'image/webp') return bytes.length >= 12 && String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF' && String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
476
+ return false;
477
+ }
478
+
479
+ return {_rapierVerifyRasterBytes, _rapierInstallSanitizeHooks, sanitizeRapierHtml, diagramLabelStyle, escapeRapierHtmlText, _rapierChromeOwnsId, _rapierSafeRasterDataUrl, _rapierCssPresentationIsRemote, _rapierRemoteSubresourceOrigin, _rapierStyleWithoutRemoteUrls, _rapierStylesheetWithoutRemoteUrls, _rapierDropRemoteRules};
360
480
  }
361
- export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, RAPIER_SANITIZE_OPTIONS as options, prepareMathSource};
481
+ export {RAPIER_SANITIZE_FORBID_TAGS as forbidTags, RAPIER_SANITIZE_FORBID_ATTR as forbidAttributes, createRenderSanitizer, createInertHtmlSanitizer, RAPIER_SANITIZE_OPTIONS as options, prepareMathSource};
@@ -2,12 +2,12 @@
2
2
  // The document's renderer. DOM, codecs and host state are explicit inputs.
3
3
  function createRenderStyles(runtime) {
4
4
  const {_rapierArtifactAccent, _rapierStyleText} = runtime;
5
- function _rapierArtifactThemeCss() {
5
+ function _rapierArtifactThemeCss(houseDefaults = false) {
6
6
  // Geometry and the selected accent belong to this page; palette and type belong to the
7
7
  // MIT reference sheet. Only the optional code lexer still reads the app token names.
8
- const accent = _rapierArtifactAccent();
8
+ const accent = houseDefaults ? '' : ':root,.md-render{--md-color-accent:' + _rapierArtifactAccent() + '}';
9
9
  return `
10
- :root,.md-render{--md-color-accent:${accent}}
10
+ ${accent}
11
11
  html[data-rapier-theme="dark"]{color-scheme:dark}
12
12
  html[data-rapier-theme="light"]{color-scheme:light}
13
13
  html[data-rapier-theme="system"]{color-scheme:light dark}
@@ -71,11 +71,11 @@ function _rapierDocumentPrintCss(hostSelector) {
71
71
  }`;
72
72
  }
73
73
 
74
- function _rapierArtifactStyles(theme, includeFonts, printMode) {
74
+ function _rapierArtifactStyles(theme, includeFonts, printMode, houseDefaults = false) {
75
75
  const parts = [];
76
76
  if (includeFonts) parts.push(_rapierStyleText('rapier-font-style'));
77
77
  parts.push(_rapierStyleText('rapier-content-style'));
78
- parts.push(_rapierArtifactThemeCss());
78
+ parts.push(_rapierArtifactThemeCss(houseDefaults));
79
79
  // Where the faces do not ride (the written page's policy carries no font-src), the reader's own sans is
80
80
  // matched to Geist's metrics: one family per system face, src:local() only (fetched by nothing, so the
81
81
  // policy is untouched), size-adjust from Geist's weighted average advance over the face's own (Geist 467