rapier-markdown-kit 1.1.99 → 1.1.102

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.
@@ -1,4 +1,10 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ import {collectPdfVectors} from './pdf-vectors.mjs';
3
+ import {collectPdfGlyphs} from './pdf-glyphs.mjs';
4
+ import {fontRequests, waitForFonts} from './export-fonts.mjs';
5
+ import {apply as applyTypography} from '../layout/typography.mjs';
6
+ import {documentLanguage} from '../spec/document-settings.mjs';
7
+ import {applyPrintCapitals} from './print-capitals.mjs';
2
8
  const RAPIER_WILL_SENTINEL_MARK = '⁣';
3
9
  // SPDX-License-Identifier: MIT
4
10
  // The document's renderer. DOM, codecs and host state are explicit inputs.
@@ -64,7 +70,10 @@ function _rapierPrintWillFont(markers) {
64
70
  const cmap = narrow.length <= 0xffff
65
71
  ? cat(u16(0), u16(2), u16(3), u16(1), u32(20), u16(3), u16(10), u32(20 + narrow.length), narrow, full)
66
72
  : cat(u16(0), u16(1), u16(3), u16(10), u32(12), full);
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]);
73
+ // The collinear contour has zero area and enough bounds for native print at 2px.
74
+ // Keep it below the baseline so a carrier at a page break does not straddle both page clips.
75
+ const bounds = [0, -400, 10, 0];
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]);
68
77
  const utf16 = text => Array.from(text, ch => u16(ch.charCodeAt(0)));
69
78
  const names = [[1, 'RapierWill'], [2, 'Regular'], [4, 'RapierWill Regular'], [6, 'RapierWill-Regular']];
70
79
  let offset = 0;
@@ -75,7 +84,7 @@ function _rapierPrintWillFont(markers) {
75
84
  u16(500), u16(500), u32(1), u32(0), u16(500), u16(700), u16(0), u16(32), u16(0)),
76
85
  cmap,
77
86
  glyf: Array.from({ length: count }, () => outline).flat(),
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),
87
+ head: cat(u32(0x00010000), u32(0x00010000), u32(0), u32(0x5f0f3cf5), u16(1), u16(1000), new Array(16).fill(0), bounds.map(u16),
79
88
  u16(0), u16(8), u16(2), u16(1), u16(0)),
80
89
  hhea: cat(u32(0x00010000), u16(500), u16(-500), u16(0), u16(advance), u16(0), u16(advance - 10), u16(10), u16(1), u16(0), u16(0), new Array(8).fill(0), u16(0), u16(count)),
81
90
  hmtx: Array.from({ length: count }, () => cat(u16(advance), u16(0))).flat(),
@@ -96,7 +105,7 @@ function _rapierPrintWillFont(markers) {
96
105
  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])));
97
106
  const headAt = 12 + tags.length * 16 + tags.slice(0, tags.indexOf('head')).reduce((total, tag) => total + padded(tables[tag]).length, 0);
98
107
  file.splice(headAt + 8, 4, ...u32((0xb1b0afba - sum(file)) >>> 0));
99
- return { family: 'RapierWillCarrier', bytes: Uint8Array.from(file), size, codes: order };
108
+ return { family: 'RapierWillCarrier', bytes: Uint8Array.from(file), size, codes: order, bounds };
100
109
  }
101
110
 
102
111
  function _rapierPrintWillMetrics(markers) {
@@ -125,7 +134,7 @@ function _rapierPrintWillPlace(root, plan) {
125
134
  else runs.push([row]);
126
135
  }
127
136
  const line = 'display:block;position:static;float:none;box-sizing:content-box;width:auto;height:0;min-height:0;margin:0;padding:0;border:0;overflow:visible;'
128
- + 'white-space:pre;text-align:left;text-indent:0;text-transform:none;text-shadow:none;letter-spacing:0;word-spacing:0;hyphens:none;'
137
+ + 'white-space:pre;text-align:left;text-indent:0;text-transform:none;text-shadow:none;text-rendering:geometricPrecision;letter-spacing:0;word-spacing:0;hyphens:none;'
129
138
  + 'font:' + measure.size + 'px/0 RapierWillCarrier,sans-serif;font-variant-ligatures:none;font-kerning:none;font-feature-settings:normal;'
130
139
  + 'direction:ltr;unicode-bidi:normal;color:rgba(0,0,0,.0039);break-after:avoid;break-inside:avoid;user-select:none;pointer-events:none;';
131
140
  for (const run of runs) {
@@ -166,7 +175,7 @@ function _rapierPrintWillPageCss() {
166
175
  // The capture's own limits (agent/visual.mjs VISUAL_LIMITS): the longest edge and the most pixels one capture returns.
167
176
  const CAPTURE_EDGE = 4096, CAPTURE_PIXELS = 4 * 1024 * 1024;
168
177
 
169
- async function _rapierWritePrintPdf({root, css, canonical, 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} = {}) {
170
179
  const refuse = code => { throw Object.assign(new Error(code), {code}); };
171
180
  const check = () => {
172
181
  if (signal?.aborted) refuse('cancelled');
@@ -184,6 +193,7 @@ async function _rapierWritePrintPdf({root, css, canonical, filename, title, sett
184
193
  const bodyHeight = Math.floor((height - margins.top - margins.bottom) * 4 / 3);
185
194
  if (bodyWidth < 1 || bodyHeight < 1 || bodyWidth * bodyHeight > 4 * 1024 * 1024) refuse('pdf_page_geometry_invalid');
186
195
  const frame = document.createElement('iframe');
196
+ let vectors, nativeGlyphs;
187
197
  let rejectAbort;
188
198
  const aborted = new Promise((_, reject) => { rejectAbort = reject; });
189
199
  aborted.catch(() => {});
@@ -206,7 +216,11 @@ async function _rapierWritePrintPdf({root, css, canonical, filename, title, sett
206
216
  check();
207
217
  const into = frame.contentDocument;
208
218
  if (!into?.body) refuse('pdf_render_unavailable');
219
+ // The capture and native printer use standards layout. An initial blank
220
+ // iframe otherwise measures preformatted inline struts in quirks mode.
221
+ into.open(); into.write('<!doctype html><html><head></head><body></body></html>'); into.close();
209
222
  into.documentElement.setAttribute('data-rapier-theme', 'light');
223
+ into.documentElement.setAttribute('lang', documentLanguage(settings, document.defaultView?.navigator?.language));
210
224
  into.documentElement.setAttribute('data-highlights', document.documentElement.getAttribute('data-highlights') || 'standard');
211
225
  into.body.className = 'light';
212
226
  const sheet = into.createElement('style'); sheet.textContent = String(css || ''); into.head.append(sheet);
@@ -231,6 +245,7 @@ async function _rapierWritePrintPdf({root, css, canonical, filename, title, sett
231
245
  main.setAttribute('data-md-theme', 'light');
232
246
  for (const details of main.querySelectorAll('details')) details.open = true;
233
247
  main.getBoundingClientRect();
248
+ if (fontCss) await step(waitForFonts(main, {fontCss, css, settings}));
234
249
  await step(into.fonts.ready);
235
250
  for (const image of main.querySelectorAll('img')) { image.loading = 'eager'; await step(image.decode()); }
236
251
  check();
@@ -238,6 +253,14 @@ async function _rapierWritePrintPdf({root, css, canonical, filename, title, sett
238
253
  // pages without a print dialog; its break constraints become column constraints here.
239
254
  for (const node of [main, ...main.querySelectorAll('*')]) {
240
255
  const style = into.defaultView.getComputedStyle(node);
256
+ // Clipped, out-of-flow source has no page ink. Leaving it in a multicolumn
257
+ // container can still create blank columns and expose its text to PDF search.
258
+ if (!node.closest('.rapier-will-carrier') && ['absolute', 'fixed'].includes(style.position) && /^rect\(/.test(style.clip)) {
259
+ const box = style.clip.slice(5, -1).trim().split(/[,\s]+/).map(Number.parseFloat);
260
+ if (box.length === 4 && box.every(Number.isFinite) && (box[1] <= box[3] || box[2] <= box[0])) {
261
+ node.style.setProperty('display', 'none', 'important'); continue;
262
+ }
263
+ }
241
264
  for (const name of ['break-before', 'break-after', 'break-inside']) {
242
265
  const value = style.getPropertyValue(name);
243
266
  if (value.includes('page')) node.style.setProperty(name, value.replace('page', 'column'), 'important');
@@ -248,9 +271,23 @@ async function _rapierWritePrintPdf({root, css, canonical, filename, title, sett
248
271
  'column-width': bodyWidth + 'px', 'column-gap': '0', 'column-count': 'auto', 'column-fill': 'auto'}))
249
272
  main.style.setProperty(name, value, 'important');
250
273
  globalThis.RapierMath?.fit(main);
274
+ applyPrintCapitals(main);
275
+ applyTypography(main, {all: true});
251
276
  const count = Math.max(1, Math.ceil((main.scrollWidth - 1) / bodyWidth));
252
277
  if (!Number.isSafeInteger(count)) refuse('pdf_page_geometry_invalid');
253
- const textPages = await _rapierPrintTextPages(main, bodyWidth, count, check);
278
+ // Captured document font bytes own a same-name family; factory glyphs must
279
+ // never replace their page paint. This uses the artifact, not a live catalog.
280
+ const customFamilies = new Set(fontCss ? fontRequests(main, {fontCss, css, settings}).map(face => face.family.toLowerCase()) : []);
281
+ nativeGlyphs = await collectPdfGlyphs(main, {width: bodyWidth, check,
282
+ faces: globalThis.RapierDocxFonts?.DOCX_FONTS?.filter(face => !customFamilies.has(face.family.toLowerCase()))});
283
+ const textPages = await _rapierPrintTextPages(main, bodyWidth, count, check, undefined, nativeGlyphs.nodes);
284
+ const linkPages = _rapierPrintLinkPages(main, bodyWidth, bodyHeight, count);
285
+ vectors = await collectPdfVectors(main, {width: bodyWidth, height: bodyHeight, count, capture, signal, current, check, maxBytes,
286
+ sanitizeSvg: globalThis.RapierImageAssets?.sanitizeSvgText});
287
+ for (const root of vectors.textRoots) {
288
+ const held = await _rapierPrintTextPages(root, bodyWidth, count, check, main.getBoundingClientRect());
289
+ for (const [page, runs] of held.entries()) textPages[page].push(...runs);
290
+ }
254
291
  const text = textPages.flatMap(page => page.map(run => run.text));
255
292
  const glyphs = new Set();
256
293
  for (const line of text) for (const character of line) {
@@ -289,80 +326,92 @@ async function _rapierWritePrintPdf({root, css, canonical, filename, title, sett
289
326
  if (!context) refuse('pdf_render_unavailable');
290
327
  context.fillStyle = '#fff'; context.fillRect(0, 0, bodyWidth, bodyHeight);
291
328
  context.drawImage(image, offset * bodyWidth, 0, bodyWidth, bodyHeight, 0, 0, bodyWidth, bodyHeight);
292
- const blob = await step(new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.95)));
293
- if (!blob) refuse('pdf_render_unavailable');
294
- imageBytes += blob.size;
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;
332
+ 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;
295
336
  if (imageBytes > maxBytes) refuse('export_too_large');
296
- pages.push(new Uint8Array(await blob.arrayBuffer()));
337
+ pages.push({rgb: packed});
297
338
  } finally { canvas.width = canvas.height = 0; }
298
339
  }
299
340
  } finally { image.src = ''; }
300
341
  first += span;
301
342
  }
302
343
  check();
303
- const bytes = await writeRasterPdf({pages, textPages, canonical, filename, title, font, width, height, margins,
344
+ const bytes = await writeRasterPdf({pages, textPages, linkPages, vectorPages: vectors.pages, canonical, bom, filename, title, font, width, height, margins,
304
345
  pixelWidth: bodyWidth, pixelHeight: bodyHeight, pageNumbers: settings?.pagestyle === 'plain', maxBytes, check});
305
346
  check();
306
347
  return {bytes, pages: count};
307
- } finally { signal?.removeEventListener('abort', abort); frame.remove(); }
348
+ } finally { nativeGlyphs?.restore(); vectors?.restore(); signal?.removeEventListener('abort', abort); frame.remove(); }
308
349
  }
309
350
 
310
351
  // Ranges locate the actual text fragments after column layout. Splitting a text node at
311
352
  // its line and page boundaries keeps a PDF search on the page that carries those words.
312
- async function _rapierPrintTextPages(main, pageWidth, count, check) {
313
- const into = main.ownerDocument, origin = main.getBoundingClientRect();
314
- const pages = Array.from({length: count}, () => []);
315
- // The renderer's outlines have no text ranges. Read each equation's exact TeX
316
- // alternative at its SVG box, and reject its subtree so titles cannot duplicate it.
317
- const walker = into.createTreeWalker(main, 5, {acceptNode(node) {
318
- if (node.parentElement?.closest('.math-rendered svg[aria-label]')) return 2;
319
- return node.nodeType === 3 || node.matches('.math-rendered svg[aria-label]') ? 1 : 3;
320
- }});
353
+ async function _rapierPrintTextPages(main, pageWidth, count, check, origin = main.getBoundingClientRect(), nativeNodes = null) {
354
+ const into = main.ownerDocument;
355
+ const pages = Array.from({length: count}, () => []), walker = into.createTreeWalker(main, 5);
356
+ const markerMetrics = _rapierPrintWillMetrics(Array.from(main.querySelectorAll('.rapier-will-carrier'), node => node.textContent));
357
+ const markerLost = () => { throw _rapierPrintWillLost('a marker has no readable position on the page', 'Try another browser'); };
321
358
  const range = into.createRange();
322
- const pageOf = x => Math.max(0, Math.min(count - 1, Math.floor((x - origin.left + .01) / pageWidth)));
323
359
  let nodes = 0, operations = 0;
324
360
  for (let node; (node = walker.nextNode());) {
325
361
  if (++nodes % 128 === 0) { await new Promise(resolve => setTimeout(resolve, 0)); check(); }
326
- const math = node.nodeType === 1;
327
- const style = into.defaultView.getComputedStyle(math ? node : node.parentElement);
328
- if (style.visibility === 'hidden' || style.visibility === 'collapse') continue;
329
- if (math) {
330
- const rect = node.getBoundingClientRect(), text = node.getAttribute('aria-label');
331
- if (rect.width > 0 && rect.height > 0 && text) {
332
- const page = pageOf(rect.left);
333
- pages[page].push({text, x: rect.left - origin.left - page * pageWidth, y: rect.top - origin.top,
334
- width: rect.width, height: rect.height, math: true});
335
- }
336
- continue;
337
- }
362
+ const element = node.nodeType === 1 ? node : node.parentElement;
363
+ const formula = node.nodeType === 1 && node.matches('.math-rendered[data-math-src]') && node.querySelector('svg');
364
+ if (node.nodeType === 1 && !formula || node.nodeType === 3 && element.closest('.math-rendered[data-math-src]')) continue;
365
+ let value;
366
+ if (formula) {
367
+ // The math owner paints SVG paths and retains the equation's exact TeX alternative.
368
+ // Carry that semantic text at the SVG's measured position for search and copy.
369
+ value = formula.getAttribute('aria-label') || '';
370
+ } else value = node.data;
371
+ const style = into.defaultView.getComputedStyle(element);
372
+ const carrier = element.closest('.rapier-will-carrier');
373
+ if (style.visibility === 'hidden' || style.visibility === 'collapse') { if (carrier) markerLost(); continue; }
338
374
  const preserve = /pre|break-spaces/.test(style.whiteSpace);
339
- const block = node.parentElement.closest('p,h1,h2,h3,h4,h5,h6,li,pre,td,th,figcaption,blockquote,.rapier-will-carrier') || node.parentElement;
340
- const carrier = node.parentElement.closest('.rapier-will-carrier');
341
- const value = node.data;
375
+ const block = element.closest('p,h1,h2,h3,h4,h5,h6,li,pre,td,th,figcaption,blockquote,.rapier-will-carrier') || element;
376
+ const native = nativeNodes?.get(node);
377
+ const pageOf = x => Math.max(0, Math.min(count - 1, Math.floor((x - origin.left + .01) / pageWidth)));
342
378
  const visit = (start, end) => {
343
379
  if (++operations % 128 === 0) check();
344
- range.setStart(node, start); range.setEnd(node, end);
345
- const rects = Array.from(range.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0);
346
- if (!rects.length) return;
380
+ if (!formula) { range.setStart(node, start); range.setEnd(node, end); }
381
+ // A tiny carrier font can have its advance rounded to zero by a browser. Its
382
+ // measured baseline still locates the marker; the font's authored metrics keep its text width.
383
+ const rects = Array.from(formula ? [formula.getBoundingClientRect()] : range.getClientRects()).filter(rect => rect.height > 0 && (rect.width > 0 || carrier));
384
+ if (!rects.length) { if (carrier) markerLost(); return; }
347
385
  const first = rects[0], page = pageOf(first.left);
348
- const oneLine = rects.every(rect => pageOf(rect.left) === page && pageOf(rect.right - .02) === page && Math.abs(rect.top - first.top) < .5);
349
- let middle = Math.floor((start + end) / 2);
386
+ 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
+ let middle = formula ? start : Math.floor((start + end) / 2);
350
388
  if (middle > start && /[\uD800-\uDBFF]/.test(value[middle - 1]) && /[\uDC00-\uDFFF]/.test(value[middle])) middle--;
389
+ const cluster = native?.find(item => item.start < middle && item.end > middle);
390
+ if (cluster) middle = cluster.start > start ? cluster.start : cluster.end;
351
391
  if (!oneLine && middle > start && middle < end) { visit(start, middle); visit(middle, end); return; }
352
392
  let text = value.slice(start, end);
353
- text = preserve ? text.replace(/\r\n?|\n/g, '') : text.replace(/[ \t\r\n\f]+/g, ' ');
393
+ if (!formula) text = preserve ? text.replace(/\r\n?|\n/g, '') : text.replace(/[ \t\r\n\f]+/g, ' ');
354
394
  if (!text) return;
355
395
  const x = Math.min(...rects.map(rect => rect.left)) - origin.left - page * pageWidth;
356
396
  const y = Math.min(...rects.map(rect => rect.top)) - origin.top;
357
- const right = Math.max(...rects.map(rect => rect.right)) - origin.left - page * pageWidth;
397
+ let right = Math.max(...rects.map(rect => rect.right)) - origin.left - page * pageWidth;
398
+ if (carrier && right === x) right += Array.from(text).length * markerMetrics.advance * markerMetrics.size / 1000;
358
399
  const bottom = Math.max(...rects.map(rect => rect.bottom)) - origin.top;
400
+ 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}];
359
402
  const last = pages[page].at(-1);
360
- if (last && !last.math && last.block === block && last.carrier === carrier && Math.abs(last.y - y) < Math.max(2, Math.min(last.height, bottom - y) * .2)) {
403
+ 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
+ if (!preserve && last.text.endsWith(' ') && text.startsWith(' ') && parts.length) {
405
+ const first = parts[0];
406
+ if (first.text === ' ') parts.shift();
407
+ else {const advance = first.width / Array.from(first.text).length; first.text = first.text.slice(1); first.x += advance; first.width -= advance;}
408
+ }
361
409
  last.text = preserve ? last.text + text : (last.text + text).replace(/[ \t\r\n\f]+/g, ' ');
410
+ last.parts.push(...parts);
362
411
  last.width = Math.max(last.x + last.width, right) - Math.min(last.x, x);
363
412
  last.height = Math.max(last.y + last.height, bottom) - Math.min(last.y, y);
364
413
  last.x = Math.min(last.x, x); last.y = Math.min(last.y, y);
365
- } else pages[page].push({text, x, y, width: right - x, height: bottom - y, block, carrier});
414
+ } else pages[page].push({text, x, y, width: right - x, height: bottom - y, block, carrier, math: !!formula, parts});
366
415
  };
367
416
  visit(0, value.length);
368
417
  }
@@ -370,12 +419,40 @@ async function _rapierPrintTextPages(main, pageWidth, count, check) {
370
419
  return pages.map(page => page.map(({block, carrier, ...run}) => run));
371
420
  }
372
421
 
422
+ function _rapierPrintLinkPages(main, width, height, count) {
423
+ const origin = main.getBoundingClientRect(), pages = Array.from({length: count}, () => []);
424
+ for (const anchor of main.querySelectorAll('a[href]')) {
425
+ const href = anchor.getAttribute('href');
426
+ if (!href || /^(?:javascript|vbscript|data):/i.test(href)) continue;
427
+ let target;
428
+ if (href.startsWith('#')) {
429
+ let id; try { id = decodeURIComponent(href.slice(1)); } catch (_) { continue; }
430
+ const node = main.ownerDocument.getElementById(id);
431
+ if (!node || !main.contains(node)) continue;
432
+ const box = node.getBoundingClientRect(), page = Math.floor((box.left - origin.left + .01) / width);
433
+ if (page < 0 || page >= count) continue;
434
+ target = {page, x: box.left - origin.left - page * width, y: box.top - origin.top};
435
+ } else target = {uri: encodeURI(href).replace(/%25([a-f0-9]{2})/gi, '%$1')};
436
+ for (const box of anchor.getClientRects()) {
437
+ if (!(box.width > 0 && box.height > 0)) continue;
438
+ const first = Math.max(0, Math.floor((box.left - origin.left + .01) / width));
439
+ const last = Math.min(count - 1, Math.floor((box.right - origin.left - .01) / width));
440
+ for (let page = first; page <= last; page++) {
441
+ const x = Math.max(0, box.left - origin.left - page * width), y = Math.max(0, box.top - origin.top);
442
+ const right = Math.min(width, box.right - origin.left - page * width), bottom = Math.min(height, box.bottom - origin.top);
443
+ if (right > x && bottom > y) pages[page].push({x, y, width: right - x, height: bottom - y, target});
444
+ }
445
+ }
446
+ }
447
+ return pages;
448
+ }
449
+
373
450
  return {_rapierWillSentinelPlan, _rapierPrintWillAdmit, _rapierPrintWillFont, _rapierPrintWillMetrics, _rapierPrintWillPlace, _rapierPrintWillLost, _rapierPrintWillPageCss, _rapierWritePrintPdf};
374
451
  }
375
452
 
376
453
  // A standard PDF page tree, image streams, Unicode text and an associated source file.
377
454
  // It accepts already-rendered pages, so there is no second document grammar or layout engine.
378
- async function writeRasterPdf({pages, textPages, canonical, filename, title, font, width, height, margins, pixelWidth, pixelHeight, pageNumbers, maxBytes, check}) {
455
+ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [], canonical, bom = false, filename, title, font, width, height, margins, pixelWidth, pixelHeight, pageNumbers, maxBytes, check}) {
379
456
  const encoder = new TextEncoder(), encode = value => encoder.encode(value);
380
457
  const fail = code => { throw Object.assign(new Error(code), {code}); };
381
458
  const concat = parts => {
@@ -397,7 +474,7 @@ async function writeRasterPdf({pages, textPages, canonical, filename, title, fon
397
474
  const stream = (dict, bytes) => add(concat([encode('<<' + dict + ' /Length ' + bytes.length + '>>\nstream\n'), bytes, encode('\nendstream')]));
398
475
  const hex = value => Array.from({length: value.length}, (_, index) => value.charCodeAt(index).toString(16).padStart(4, '0')).join('');
399
476
  const literal = value => '(' + String(value).replace(/[\\()\r\n]/g, character => ({'\\': '\\\\', '(': '\\(', ')': '\\)', '\r': '\\r', '\n': '\\n'}[character])) + ')';
400
- const source = encoder.encode(canonical);
477
+ const source = encoder.encode((bom ? '\uFEFF' : '') + canonical);
401
478
  const embedded = stream('/Type /EmbeddedFile /Subtype /text#2Fplain /Params <</Size ' + source.length + '>> /Filter /FlateDecode', await compressed(source));
402
479
  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 >> >>');
403
480
  const codes = new Map(font.codes.map((code, index) => [code, index + 1]));
@@ -407,27 +484,98 @@ async function writeRasterPdf({pages, textPages, canonical, filename, title, fon
407
484
  cmap += 'endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend';
408
485
  const mapping = stream('/Filter /FlateDecode', await compressed(encode(cmap)));
409
486
  const fontFile = stream('/Length1 ' + font.bytes.length + ' /Filter /FlateDecode', await compressed(font.bytes));
410
- 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 >>');
487
+ 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 >>');
411
488
  const descendant = add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /RapierText /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ' + descriptor + ' 0 R /DW 500 /CIDToGIDMap /Identity >>');
412
489
  const type = add('<< /Type /Font /Subtype /Type0 /BaseFont /RapierText /Encoding /Identity-H /DescendantFonts [' + descendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
413
490
  const numberFont = pageNumbers ? add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>') : null;
414
- const kids = [], decimal = value => Number(value.toFixed(4));
491
+ const kids = pages.map(() => { objects.push(null); return objects.length - 1; }), decimal = value => Number(value.toFixed(4));
492
+ const nativeFonts = new Map();
493
+ const glyphKey = part => part.glyph + ':' + part.text;
494
+ for (const page of textPages) for (const run of page) for (const part of run.parts || []) if (part.font) {
495
+ let entry = nativeFonts.get(part.font);
496
+ if (!entry) {entry = {font: part.font, name: '/Fn' + nativeFonts.size, codes: new Map(), rows: []}; nativeFonts.set(part.font, entry);}
497
+ const key = glyphKey(part);
498
+ if (!entry.codes.has(key)) {const code = entry.rows.length + 1; if (code > 65000) fail('pdf_text_glyph_limit'); entry.codes.set(key, code); entry.rows.push({...part, code});}
499
+ }
500
+ const nativeResources = [];
501
+ for (const entry of nativeFonts.values()) {
502
+ check();
503
+ const face = entry.font, name = face.asset.family.replace(/\s/g, '') + '-' + face.asset.style, scale = 1000 / face.units;
504
+ const file = stream('/Length1 ' + face.bytes.length + ' /Filter /FlateDecode', await compressed(face.bytes));
505
+ const descriptor = add('<< /Type /FontDescriptor /FontName /' + name + ' /Flags 32 /FontBBox [' + face.bbox.map(value => decimal(value * scale)).join(' ') + '] /ItalicAngle 0 /Ascent ' + decimal(face.ascent * scale)
506
+ + ' /Descent ' + decimal(face.descent * scale) + ' /CapHeight ' + decimal(face.capHeight * scale) + ' /StemV 80 /FontFile2 ' + file + ' 0 R >>');
507
+ const ids = new Uint8Array((entry.rows.length + 1) * 2);
508
+ for (const row of entry.rows) {ids[row.code * 2] = row.glyph >> 8; ids[row.code * 2 + 1] = row.glyph & 255;}
509
+ const glyphMap = stream('/Filter /FlateDecode', await compressed(ids));
510
+ let unicode = '/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /' + name + 'Unicode def\n/CMapType 2 def\n1 begincodespacerange\n<0000> <ffff>\nendcodespacerange\n';
511
+ for (let at = 0; at < entry.rows.length; at += 100) {const rows = entry.rows.slice(at, at + 100); unicode += rows.length + ' beginbfchar\n' + rows.map(row => '<' + row.code.toString(16).padStart(4, '0') + '> <' + hex(row.text) + '>').join('\n') + '\nendbfchar\n';}
512
+ unicode += 'endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend';
513
+ const mapping = stream('/Filter /FlateDecode', await compressed(encode(unicode)));
514
+ const descendant = add('<< /Type /Font /Subtype /CIDFontType2 /BaseFont /' + name + ' /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ' + descriptor + ' 0 R /CIDToGIDMap ' + glyphMap + ' 0 R /W [1 [' + entry.rows.map(row => decimal(face.advance(row.glyph) * scale)).join(' ') + ']] >>');
515
+ const type = add('<< /Type /Font /Subtype /Type0 /BaseFont /' + name + ' /Encoding /Identity-H /DescendantFonts [' + descendant + ' 0 R] /ToUnicode ' + mapping + ' 0 R >>');
516
+ nativeResources.push(entry.name + ' ' + type + ' 0 R');
517
+ }
415
518
  for (let index = 0; index < pages.length; index++) {
416
519
  check();
417
- const picture = stream('/Type /XObject /Subtype /Image /Width ' + pixelWidth + ' /Height ' + pixelHeight + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode', pages[index]);
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]);
418
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();
523
+ for (const drawing of vectorPages[index] || []) {
524
+ 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
+ commands += [drawing.clip.x, drawing.clip.y, drawing.clip.width, drawing.clip.height].map(decimal).join(' ') + ' re W n\n';
526
+ for (const piece of drawing.sequence) {
527
+ if (piece.image) {
528
+ let name = rasterCache.get(piece.image);
529
+ if (!name) {
530
+ const image = piece.image;
531
+ const mask = stream('/Type /XObject /Subtype /Image /Width ' + image.width + ' /Height ' + image.height + ' /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode', image.alpha);
532
+ 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);
534
+ }
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`;
536
+ } else {
537
+ const state = '/Gs' + states.length;
538
+ states.push(state + ' ' + add('<< /Type /ExtGState /ca ' + decimal(piece.fillAlpha) + ' /CA ' + decimal(piece.strokeAlpha) + ' >>') + ' 0 R');
539
+ commands += 'q\n' + state + ' gs\n' + piece.commands + 'Q\n';
540
+ }
541
+ }
542
+ commands += 'Q\n';
543
+ }
544
+ const textAlphas = new Map();
419
545
  for (const run of textPages[index]) {
420
- const characters = Array.from(run.text);
421
- const value = characters.map(character => codes.get(character.codePointAt(0)).toString(16).padStart(4, '0')).join('');
422
- const size = Math.max(.1, run.height * .75);
423
- const scale = run.width * .75 / (characters.length * size * .5) * 100;
424
- if (run.math) commands += '/Span << /ActualText <feff' + hex(run.text) + '> >> BDC\n';
425
- 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`;
426
- if (run.math) commands += 'EMC\n';
546
+ if (!run.text) continue;
547
+ // Keep one logical Unicode text object, resetting its font before each scalar's direction
548
+ // can change. Readers that ignore ActualText otherwise discard earlier words in mixed RTL/LTR.
549
+ // Express width in the font size: some readers ignore horizontal text scaling when joining.
550
+ commands += `q\n/Span << /ActualText <feff${hex(run.text)}> >> BDC\nBT\n100 Tz\n`;
551
+ for (const part of run.parts || [run]) {
552
+ if (part.font) {
553
+ const entry = nativeFonts.get(part.font), value = entry.codes.get(glyphKey(part)).toString(16).padStart(4, '0'), alpha = decimal(part.rgba[3]);
554
+ let state = textAlphas.get(alpha);
555
+ if (!state) {state = '/Gs' + states.length; states.push(state + ' ' + add('<< /Type /ExtGState /ca ' + alpha + ' >>') + ' 0 R'); textAlphas.set(alpha, state);}
556
+ 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
+ } else {
558
+ 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;
560
+ const baseline = decimal(height - margins.top - (part.y + part.height * .8) * .75);
561
+ for (let letter = 0; letter < characters.length; letter++) {
562
+ 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`;
564
+ }
565
+ }
566
+ }
567
+ commands += 'ET\nEMC\nQ\n';
427
568
  }
428
569
  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`;
429
570
  const content = stream('/Filter /FlateDecode', await compressed(encode(commands)));
430
- 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 >>'));
571
+ const annotations = (linkPages[index] || []).map(link => {
572
+ const rect = [margins.left + link.x * .75, height - margins.top - (link.y + link.height) * .75,
573
+ margins.left + (link.x + link.width) * .75, height - margins.top - link.y * .75].map(decimal);
574
+ const target = link.target.uri ? '/A << /S /URI /URI ' + literal(link.target.uri) + ' >>'
575
+ : '/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
+ return add('<< /Type /Annot /Subtype /Link /Border [0 0 0] /Rect [' + rect.join(' ') + '] ' + target + ' >>');
577
+ });
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(' ') + ']' : '') + ' >>');
431
579
  }
432
580
  put(2, '<< /Type /Pages /Count ' + kids.length + ' /Kids [' + kids.map(id => id + ' 0 R').join(' ') + '] >>');
433
581
  put(1, '<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [<feff' + hex(filename) + '> ' + file + ' 0 R] >> >> /AF [' + file + ' 0 R] >>');
@@ -442,4 +590,4 @@ async function writeRasterPdf({pages, textPages, canonical, filename, title, fon
442
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'));
443
591
  const bytes = concat(parts); if (bytes.length > maxBytes) fail('export_too_large'); return bytes;
444
592
  }
445
- export {createPrintRenderer, RAPIER_WILL_SENTINEL_MARK as sentinel};
593
+ export {createPrintRenderer, applyPrintCapitals, RAPIER_WILL_SENTINEL_MARK as sentinel};
@@ -58,6 +58,11 @@ const DIAGRAM_LABEL_SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
58
58
  const DIAGRAM_LABEL_HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
59
59
  const DIAGRAM_LABEL_MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
60
60
 
61
+ function _rapierImageDataAttributeAllowed(tag, attribute, value, verify) {
62
+ return (tag === 'img' && attribute === 'src' || tag === 'image' && /^(?:href|xlink:href)$/.test(attribute)
63
+ && /^data:image\/(?:png|jpeg|webp|jxl);base64,/i.test(value)) && verify(value);
64
+ }
65
+
61
66
  // Snapshot current math text before a Markdown writer collapses whitespace. Rendered SVG
62
67
  // glyphs have no textContent: put their retained source in this inert copy so the writer's
63
68
  // blank-node rule cannot discard an equation or its containing paragraph/list item.
@@ -140,7 +145,7 @@ function createInertHtmlSanitizer({parseFragment, serialize, sanitizeHtml, safeR
140
145
  const cleaned = sanitizeHtml(serialize(tree), {
141
146
  allowedTags: [...tags], allowedAttributes: false,
142
147
  disallowedTagsMode: 'discard', nonTextTags: RAPIER_SANITIZE_DROP_CONTENTS,
143
- allowedSchemes: schemes, allowedSchemesByTag: {img: [...schemes, 'data']},
148
+ allowedSchemes: schemes, allowedSchemesByTag: {img: [...schemes, 'data'], image: [...schemes, 'data']},
144
149
  allowedSchemesAppliedToAttributes: ['href', 'src', 'xlink:href', 'cite', 'background', 'poster'],
145
150
  parseStyleAttributes: false,
146
151
  parser: {lowerCaseTags: false, lowerCaseAttributeNames: false, decodeEntities: true},
@@ -158,7 +163,7 @@ function createInertHtmlSanitizer({parseFragment, serialize, sanitizeHtml, safeR
158
163
  const compact = value.replace(/[\u0000-\u0020\u00a0\u1680\u180e\u2000-\u2029\u205f\u3000]/g, '');
159
164
  if (!data && !aria && !pageBreak && !uriSafe.has(key) && compact) {
160
165
  if (/^data:/i.test(compact)) {
161
- if (key !== 'src' || tag !== 'img' || !safeRasterDataUrl(compact)) continue;
166
+ if (!_rapierImageDataAttributeAllowed(tag, key, compact, safeRasterDataUrl)) continue;
162
167
  } else if (!uri.test(compact)) continue;
163
168
  }
164
169
  attribs[name] = value;
@@ -264,10 +269,11 @@ function _rapierInstallSanitizeHooks() {
264
269
  if (!/^(?:src|href|xlink:href)$/i.test(data.attrName || '')) return;
265
270
  const compact = String(data.attrValue || '').replace(/[\u0000-\u0020\u00a0]+/g, '');
266
271
  if (!/^data:/i.test(compact)) return;
267
- const rasterAllowed = /^(?:render|export|raw|source)$/.test(_rapierSanitizeRuntime.context)
268
- && data.attrName.toLowerCase() === 'src'
269
- && node && node.nodeName === 'IMG'
270
- && _rapierSafeRasterDataUrl(compact);
272
+ const tag = node?.nodeName === 'IMG' ? 'img'
273
+ : node?.namespaceURI === DIAGRAM_LABEL_SVG_NAMESPACE && node.localName === 'image' ? 'image' : '';
274
+ const rasterAllowed = (/^(?:render|export|raw|source)$/.test(_rapierSanitizeRuntime.context)
275
+ || _rapierSanitizeRuntime.context === 'diagram' && tag === 'image')
276
+ && _rapierImageDataAttributeAllowed(tag, data.attrName.toLowerCase(), compact, _rapierSafeRasterDataUrl);
271
277
  if (!rasterAllowed) data.keepAttr = false;
272
278
  });
273
279
  // Paste, render and export sanitise: a remote <img src> would fetch the instant its markup lands in the page,
@@ -290,6 +296,15 @@ function _rapierInstallSanitizeHooks() {
290
296
  const title = node.getAttribute('title');
291
297
  if (title != null) node.setAttribute('data-rapier-remote-title', title);
292
298
  });
299
+ DOMPurify.addHook('uponSanitizeElement', node => {
300
+ if (_rapierRemoteContent.allowed || !/^(?:diagram|export)$/.test(_rapierSanitizeRuntime.context) ||
301
+ node?.namespaceURI !== DIAGRAM_LABEL_SVG_NAMESPACE || node.localName?.toLowerCase() !== 'image') return;
302
+ const href = node.getAttribute('href') || node.getAttribute('xlink:href') || '';
303
+ if (href && _rapierRemoteSubresourceOrigin(href)) {
304
+ node._rapierHeldBack = true;
305
+ node.setAttribute('data-rapier-remote-src', href);
306
+ }
307
+ });
293
308
  // A list from Word is named by the style of its paragraph and of the span that holds its marker (mso-list,
294
309
  // spec/html-reading.mjs), and the CSS pass below empties a style of every property the browser does not
295
310
  // know: a marker's style is only `mso-list:Ignore`. The paste carries both as data-* first, where they stay,
@@ -362,12 +377,13 @@ function sanitizeRapierHtml(value, context = 'render') {
362
377
  // profile's admission, and the block on remote content stays the reader's (a remote picture's URL is the person's words).
363
378
  const selected = context === 'raw' || context === 'export' || context === 'source' || RAPIER_SANITIZE_OPTIONS[context] ? context : 'render';
364
379
  let options = selected === 'raw' ? { ...RAPIER_SANITIZE_OPTIONS.render, ALLOW_DATA_ATTR: false } : RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
380
+ if (selected === 'source') options = {...options, ADD_DATA_URI_TAGS: ['image']};
365
381
  const labels = selected === 'diagram' || selected === 'export';
366
382
  if (labels) {
367
383
  // DOMPurify's USE_PROFILES replaces explicit allowlists. Add MathML to the
368
384
  // selected profile without discarding its admitted HTML and SVG vocabulary.
369
385
  options = {...options, ALLOWED_TAGS: [...new Set([...options.ALLOWED_TAGS, ...RAPIER_SANITIZE_PROFILES.mathTags])],
370
- ALLOWED_ATTR: [...new Set([...options.ALLOWED_ATTR, ...RAPIER_SANITIZE_PROFILES.mathAttributes])], ADD_TAGS: ['foreignobject'],
386
+ ALLOWED_ATTR: [...new Set([...options.ALLOWED_ATTR, ...RAPIER_SANITIZE_PROFILES.mathAttributes])], ADD_TAGS: ['foreignobject'], ADD_DATA_URI_TAGS: ['image'],
371
387
  HTML_INTEGRATION_POINTS: {foreignobject: true}, FORBID_TAGS: options.FORBID_TAGS.filter(tag => tag !== 'foreignobject')};
372
388
  DOMPurify.addHook('uponSanitizeElement', diagramLabelElement);
373
389
  DOMPurify.addHook('uponSanitizeAttribute', diagramLabelAttribute);
@@ -5,6 +5,8 @@ function createRenderStyles(runtime) {
5
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. Static code highlighting reads the app token names.
8
+ // The live editor resolves System to Light before applying the reference heading tokens;
9
+ // a carried System page keeps following the reader's device, including those light tokens.
8
10
  const accent = houseDefaults ? '' : ':root,.md-render{--md-color-accent:' + _rapierArtifactAccent() + '}';
9
11
  return `
10
12
  ${accent}
@@ -18,12 +20,14 @@ body{
18
20
  --color-text-subtle:var(--md-color-text-subtle);--color-accent-text:var(--md-color-accent-text);
19
21
  margin:0;background:var(--md-color-bg);color:var(--md-color-text);font-family:var(--md-font-sans);-webkit-font-smoothing:antialiased
20
22
  }
21
- .rapier-page{width:min(100%,calc(var(--md-measure,39.6rem) + 48px));margin:0 auto;padding:48px 24px 72px}
23
+ .rapier-page{width:min(100%,calc(var(--md-measure,39.6rem) + 32px));margin:0 auto;padding:48px 16px 72px}
22
24
  .rapier-page [id]{scroll-margin-top:var(--md-space-8)}
23
25
  .artifact-flat{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;tab-size:2}
24
26
  .artifact-flat--text{font:var(--md-fw-regular) 1rem/1.7 var(--md-font-sans)}
25
27
  .artifact-flat--code{font:var(--md-fw-regular) .875rem/1.6 var(--md-font-mono);overflow-x:auto;white-space:pre}
26
- @media (max-width:600px){.rapier-page{padding:32px 18px 56px}}
28
+ @media screen{html{scrollbar-width:none}html::-webkit-scrollbar{width:0;height:0;display:none}}
29
+ @media (max-width:600px){.rapier-page{padding:32px 16px 56px}}
30
+ @media (prefers-color-scheme:light){.rapier-page[data-md-theme="system"]{--md-heading-color:var(--md-color-line);--md-heading-muted-color:var(--md-color-line)}}
27
31
  `;
28
32
  }
29
33
 
@@ -54,6 +58,7 @@ function _rapierDocumentPrintCss(hostSelector) {
54
58
  ${list('.md-render p','.md-render li','.md-render blockquote')}{orphans:3;widows:3}
55
59
  ${p('.md-render img')}{max-width:100%!important;height:auto!important;break-inside:avoid-page}
56
60
  ${list('.md-render pre','.artifact-flat')}{white-space:pre-wrap!important;overflow-wrap:anywhere!important;word-break:break-word!important;overflow:visible!important;max-height:none!important}
61
+ ${p('.diagram-block[data-diagram-state="ready"] .diagram-source')}{display:none!important}
57
62
  ${p('.artifact-flat')}{margin:0;font:400 12pt/1.55 var(--md-font-sans);tab-size:2}
58
63
  ${p('.artifact-flat--code')}{font:400 10pt/1.5 var(--md-font-mono)}
59
64
  ${list('.table-scroll-wrap','.math-display-wrap')}{overflow:visible!important;padding:0!important}
@@ -77,16 +82,7 @@ function _rapierArtifactStyles(theme, includeFonts, printMode, houseDefaults = f
77
82
  else if (includeFonts) parts.push(_rapierStyleText('rapier-font-style'));
78
83
  parts.push(_rapierStyleText('rapier-content-style'));
79
84
  parts.push(_rapierArtifactThemeCss(houseDefaults));
80
- // Where the faces do not ride (the written page's policy carries no font-src), the reader's own sans is
81
- // matched to Geist's metrics: one family per system face, src:local() only (fetched by nothing, so the
82
- // policy is untouched), size-adjust from Geist's weighted average advance over the face's own (Geist 467
83
- // per 1000, measured from repo/shell/fonts; the system faces from their tables), the ascent and descent
84
- // overrides divided by that size-adjust so the used metrics are Geist's 1005/295 per 1000, the line gap
85
- // Geist's zero. Bold weights take the face's own bold; 500 the regular. Proved in Chromium against the
86
- // installed Liberation Sans: widths 1.057 as computed, regular and bold loaded, the stack resolving to it.
87
- // The family list walks the platforms: Roboto (Android), Helvetica Neue (Apple), Segoe UI (Windows),
88
- // Arial, then Linux's Noto Sans, Ubuntu, Liberation Sans, DejaVu Sans; a face that is not installed fails
89
- // to load and the browser moves to the next family. font-size-adjust was tried and rescaled Geist itself.
85
+ // Local faces keep an offline fallback. The HTML writer may append a small factory Sans subset.
90
86
  if (!includeFonts && fontCss === null) parts.push(`@font-face{font-family:'Geist/Roboto';src:local('Roboto');font-weight:400 500;size-adjust:105%;ascent-override:95.7%;descent-override:28.1%;line-gap-override:0%}
91
87
  @font-face{font-family:'Geist/Roboto';src:local('Roboto Bold');font-weight:600 700;size-adjust:105%;ascent-override:95.7%;descent-override:28.1%;line-gap-override:0%}
92
88
  @font-face{font-family:'Geist/Helvetica Neue';src:local('Helvetica Neue');font-weight:400 500;size-adjust:103.8%;ascent-override:96.8%;descent-override:28.4%;line-gap-override:0%}