rapier-markdown-kit 1.2.33 → 1.2.40

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 (45) hide show
  1. package/README.md +15 -9
  2. package/dist/agent/vendor/pretext/NOTICE +2 -2
  3. package/dist/agent/vendor/pretext/SOURCE.json +62 -78
  4. package/dist/agent/vendor/pretext/analysis.js +24 -6
  5. package/dist/agent/vendor/pretext/gecko-line-breaks.js +1 -1
  6. package/dist/agent/vendor/pretext/generated/engine-break-data.js +1 -1
  7. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +1 -1
  8. package/dist/agent/vendor/pretext/graphemes.js +1 -1
  9. package/dist/agent/vendor/pretext/han-kerning.js +1 -1
  10. package/dist/agent/vendor/pretext/layout.js +14 -11
  11. package/dist/agent/vendor/pretext/line-break.js +170 -195
  12. package/dist/agent/vendor/pretext/line-breaks.js +1 -7
  13. package/dist/agent/vendor/pretext/line-text.js +2 -2
  14. package/dist/agent/vendor/pretext/measurement.js +454 -113
  15. package/dist/agent/vendor/pretext/prepare.js +164 -244
  16. package/dist/agent/vendor/pretext/rich-inline.js +57 -37
  17. package/dist/agent/will.mjs +73 -0
  18. package/dist/kit/ledger/data.mjs +2 -2
  19. package/dist/kit/ledger/format.mjs +86 -42
  20. package/dist/kit/ledger/hash.mjs +43 -2
  21. package/dist/kit/ledger/history.mjs +6 -0
  22. package/dist/kit/ledger/inverse-source.mjs +6 -2
  23. package/dist/kit/ledger/journal-records.mjs +4 -2
  24. package/dist/kit/ledger/merge.mjs +3 -17
  25. package/dist/kit/ledger/text-merge.mjs +28 -2
  26. package/dist/kit/rapier-ledger.mjs +2 -2
  27. package/dist/kit/render-markdown.mjs +133 -10
  28. package/dist/kit/render-print.mjs +3 -3
  29. package/dist/kit/render-sanitize.mjs +66 -23
  30. package/dist/kit/render.mjs +10 -10
  31. package/dist/kit/wiki.mjs +34 -0
  32. package/dist/kit/will.d.mts +3 -0
  33. package/dist/layout/hyphenation.mjs +6 -4
  34. package/dist/layout/line-plan.mjs +96 -0
  35. package/dist/layout/model.mjs +2 -1
  36. package/dist/layout/paragraph-fit.mjs +4 -4
  37. package/dist/layout/prepared-paragraph.mjs +19 -2
  38. package/dist/layout/typography.mjs +57 -63
  39. package/dist/spec/document-fonts.mjs +1 -0
  40. package/dist/spec/document-settings.mjs +1 -0
  41. package/dist/spec/html-blocks.mjs +10 -7
  42. package/dist/spec/md-assets.mjs +52 -12
  43. package/package.json +1 -1
  44. package/style.css +25 -23
  45. package/dist/agent/vendor/pretext/entry-geometry.js +0 -183
@@ -212,14 +212,14 @@ function _rapierTemplatePage(canonical, settings, root, darkPaper = false) {
212
212
  const image = paragraph.querySelector('img');
213
213
  const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(paragraph.getAttribute('data-md-layout'));
214
214
  const source = image?.getAttribute('src') || '';
215
- if (isPageBorderLayout(layout) && /^data:image\/(?:png|jpeg|webp|gif|svg\+xml|jxl);base64,[A-Za-z0-9+/=]+$/i.test(source)) { url = source; paragraph.remove(); break; }
215
+ if (isPageBorderLayout(layout) && globalThis.RapierImageAssets.dataImage(source)) { url = source; paragraph.remove(); break; }
216
216
  }
217
217
  if (!url) return null;
218
218
  const width = paper.width / 15, height = paper.height / 15;
219
219
  const margins = ['top', 'right', 'bottom', 'left'].map(side => (settings?.geometry?.[side] ?? 1440) / 15);
220
220
  const padding = margins.map(value => value + 'px').join(' ');
221
221
  const ground = 'background:var(--rapier-template-border) 0 0/100% 100% no-repeat';
222
- const shown = darkPaper && url.startsWith('data:image/svg+xml;base64,') ? _rapierDarkPaperSvgUrl(url, '#fafafa') : url;
222
+ const shown = darkPaper && globalThis.RapierImageAssets.dataImage(url)?.codec === 'image/svg+xml' ? _rapierDarkPaperSvgUrl(url, '#fafafa') : url;
223
223
  const css = ':root{--rapier-template-border:url("' + url + '")}\n'
224
224
  + '@media screen{' + (shown !== url ? ':root{--rapier-template-border:url("' + shown + '")}' : '') + '.rapier-template-border{display:none}body{background:var(--md-color-inset)}.rapier-page{box-sizing:border-box;width:min(100%,' + width + 'px);min-height:' + height + 'px;margin:0 auto;padding:' + padding
225
225
  + ';background:var(--rapier-template-border) 0 0/100% ' + height + 'px repeat-y,var(--md-color-bg);color:var(--md-color-text)}}\n'
@@ -235,7 +235,7 @@ function _rapierDarkPaperSvgUrl(url, paperInk) {
235
235
  const ink = globalThis.RapierImageInspection?.inkForPaper;
236
236
  if (typeof ink !== 'function') return url;
237
237
  let text;
238
- try { text = new TextDecoder('utf-8', {fatal: true}).decode(Uint8Array.from(atob(url.slice(26)), c => c.charCodeAt(0))); }
238
+ try { text = new TextDecoder('utf-8', {fatal: true}).decode(globalThis.RapierImageAssets.decodeDataImageBytes(url).bytes); }
239
239
  catch (_) { return url; }
240
240
  const inked = ink(text, {dark: true, paperInk});
241
241
  if (inked === text) return url;
@@ -274,7 +274,7 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
274
274
  for (const token of root.querySelectorAll('.rapier-source-token--softbreak')) { yield;
275
275
  if (token.getAttribute('data-rapier-source') !== '%0A') token.replaceWith(...token.childNodes);
276
276
  else if (token.previousSibling?.classList?.contains('callout__label')) token.remove();
277
- else token.replaceWith(root.ownerDocument.createTextNode(' '));
277
+ else token.replaceWith(root.ownerDocument.createTextNode(token.closest('pre') ? '\n' : ' '));
278
278
  }
279
279
  const metadata = opts.metadata || {};
280
280
 
@@ -532,10 +532,10 @@ function _rapierAnnotateExportBoxPolygons(root) {
532
532
  for (const image of root.querySelectorAll('img[data-rapier-image-layout]')) {
533
533
  const layout = globalThis.RapierMarkdownLayout.parseLayoutAttribute(image.getAttribute('data-rapier-image-layout'));
534
534
  if (layout?.wrap !== 'box' && layout?.wrap !== 'around') continue;
535
- const match = /^data:image\/svg\+xml;base64,([A-Za-z0-9+/]+=*)$/.exec(image.getAttribute('src') || '');
536
- if (!match) continue;
535
+ const url = image.getAttribute('src') || '';
536
+ if (globalThis.RapierImageAssets.dataImage(url)?.codec !== 'image/svg+xml') continue;
537
537
  let text;
538
- try { text = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(match[1]), c => c.charCodeAt(0))); }
538
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(globalThis.RapierImageAssets.decodeDataImageBytes(url).bytes); }
539
539
  catch (_) { continue; }
540
540
  let recipe;
541
541
  try { recipe = _rapierDrawReadSVGRecipe(text); } catch (_) { continue; }
@@ -634,7 +634,7 @@ function _rapierSharedResolve(text, ids, images, definitions) {
634
634
  let broken = false;
635
635
  const src = value => {
636
636
  const found = images.get(value);
637
- if (typeof found !== 'string' || !/^data:image\/(?:png|jpeg|webp|gif|svg\+xml|jxl);base64,[A-Za-z0-9+/]+={0,2}$/i.test(found)) { broken = true; return null; }
637
+ if (!globalThis.RapierImageAssets.dataImage(found)) { broken = true; return null; }
638
638
  return found;
639
639
  };
640
640
  const inline = new RegExp('(\\]\\([ \\t\\r\\n]*(?:<)?)#' + id + '(?=>?[ \\t\\r\\n]*\\)|>?[ \\t\\r\\n]+["\'(])', 'g');
@@ -896,7 +896,7 @@ async function _rapierRequireOfflinePageImages(root) {
896
896
  }
897
897
  if (!missing.length) return;
898
898
  throw Object.assign(new Error('Web page not written; embed these pictures and try again. ' + missing.join('; ')),
899
- {code: 'offline_image_unavailable'});
899
+ {code: 'offline_image_unavailable', missing});
900
900
  }
901
901
 
902
902
  function _rapierCssValueUrls(value) {
@@ -1741,7 +1741,7 @@ function _rapierArtifactLayoutScript(root, nonce = '', {pictures = true} = {}) {
1741
1741
  throw new Error('Offline typography requires the complete retained MIT layout factories');
1742
1742
  const modules = _rapierArtifactFactoryModules(dependencies, 'layout');
1743
1743
  const script = '/* Rapier export layout: SPDX-License-Identifier: MIT */\n' +
1744
- '/* Pretext bac1774c0f8b7a92e44b9da43b6d5443d55d0d9b (Unreleased)\n' + dependencies.license + '\n*/\n' +
1744
+ '/* Pretext 1.0.0 + main 0d102a5cd933611e86a7e60bda7b4baa5995c6b0\n' + dependencies.license + '\n*/\n' +
1745
1745
  '(() => {\nconst modules = Object.create(null);\n' + modules +
1746
1746
  (wrapping ? '\n(' + _rapierProjectArtifactLayout.toString() +
1747
1747
  ')(document.querySelector("main.rapier-page"), modules["spec/md-layout.mjs"], modules["layout/model.mjs"], modules["layout/preparation.mjs"]);\n' : '') +
@@ -0,0 +1,34 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export const WIKI_PREFIX = '?rapier-wiki=';
3
+ export function wikiAddress(target) {
4
+ const at = target.indexOf('#'), path = at < 0 ? target : target.slice(0, at);
5
+ return WIKI_PREFIX + encodeURIComponent(path) + (at < 0 ? '' : '#' + encodeURIComponent(target.slice(at + 1)));
6
+ }
7
+ export function wikiTarget(address) {
8
+ if (typeof address !== 'string' || !address.startsWith(WIKI_PREFIX)) return null;
9
+ try { return decodeURIComponent(address.slice(WIKI_PREFIX.length).split('#')[0]); }
10
+ catch { return null; }
11
+ }
12
+ export function installWikiParser(md, render) {
13
+ md.inline.ruler.before('image', 'rapier_wiki', (state, silent) => {
14
+ if (!state.env?.rapierWikiLinks) return false;
15
+ const start = state.pos, embed = state.src[start] === '!';
16
+ const open = start + (embed ? 1 : 0);
17
+ if (state.linkLevel || state.src.slice(open, open + 2) !== '[[') return false;
18
+ const end = state.src.indexOf(']]', open + 2);
19
+ if (end < 0 || end + 2 > state.posMax || end - start > 4096) return false;
20
+ const body = state.src.slice(open + 2, end);
21
+ if (!body || /[\[\]\n\r\0]/.test(body)) return false;
22
+ const pipe = body.indexOf('|'), target = (pipe < 0 ? body : body.slice(0, pipe)).trim();
23
+ if (!target || /^(?:[a-z][a-z0-9+.-]*:|\/|\\)/i.test(target)) return false;
24
+ if (!silent) {
25
+ const token = state.push('rapier_wiki', '', 0);
26
+ token.content = state.src.slice(start, end + 2);
27
+ token.meta = {target, label: pipe < 0 ? target : body.slice(pipe + 1), embed};
28
+ }
29
+ state.pos = end + 2;
30
+ return true;
31
+ });
32
+ md.renderer.rules.rapier_wiki = (tokens, at, options, env) => render(tokens[at], env);
33
+ }
34
+
@@ -40,3 +40,6 @@ export function willMarkerOf(line: string): WillMarker | null;
40
40
  export function terminatorWidth(last: number, prior: number): number;
41
41
  /** The text without one trailing line terminator. */
42
42
  export function stripOneTerminator(text: string): string;
43
+ /** Judges sequential UTF-16 source splices, including the resulting marker identities and protected text. */
44
+ export function evaluateWill(beforeText: string, splices: {pos: number; removed: string; inserted: string}[]):
45
+ {outcome: 'applied'} | {outcome: 'invalid' | 'refused'; reason: string; rule: string; law?: WillLaw; region?: number};
@@ -1,9 +1,11 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {next, weight, exceptions, maximum} from '../agent/vendor/hyphen-en-us/patterns.js';
3
3
 
4
+ export const hyphenMinimums = {left: 2, right: 3, word: 5};
5
+
4
6
  export function englishCuts(word) {
5
7
  if (!/^[A-Za-z]+$/.test(word)) return null;
6
- if (word.length < 6) return [];
8
+ if (word.length < hyphenMinimums.word) return [];
7
9
  const cuts = [];
8
10
  let offset = 0, before = 0;
9
11
  for (const part of word.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+/g)) {
@@ -11,11 +13,11 @@ export function englishCuts(word) {
11
13
  for (const at of partCuts(part)) cuts.push(offset+at);
12
14
  offset += part.length; before = part.length;
13
15
  }
14
- return cuts.filter(at => at >= 3 && at <= word.length-3).sort((a, b) => a-b).filter((at, index, all) => at !== all[index-1]);
16
+ return cuts.filter(at => at >= hyphenMinimums.left && at <= word.length-hyphenMinimums.right).sort((a, b) => a-b).filter((at, index, all) => at !== all[index-1]);
15
17
  }
16
18
 
17
19
  function partCuts(word) {
18
- if (word.length < 6) return [];
20
+ if (word.length < hyphenMinimums.word) return [];
19
21
  const lower = word.toLowerCase(), known = exceptions[lower];
20
22
  if (Array.isArray(known)) return known;
21
23
  const source = '.'+lower+'.', weights = new Uint8Array(source.length+1);
@@ -30,7 +32,7 @@ function partCuts(word) {
30
32
  }
31
33
  }
32
34
  const cuts = [];
33
- for (let at = 3; at <= word.length-3; at++) if (weights[at+1]&1) cuts.push(at);
35
+ for (let at = hyphenMinimums.left; at <= word.length-hyphenMinimums.right; at++) if (weights[at+1]&1) cuts.push(at);
34
36
  return cuts;
35
37
  }
36
38
 
@@ -55,9 +55,105 @@ function laterWholeWord(flow, cursor, line, items, bands, slot, next, materializ
55
55
  return false;
56
56
  }
57
57
 
58
+ export function slotSequence(width, top, obstacles, lineHeight, minWidth, slotsForBand, direction = 'ltr', order = 'columns') {
59
+ const bottom = obstacles.length ? Math.max(...obstacles.map(item => item.y + item.height)) - top : 0, rows = [];
60
+ let y = 0;
61
+ while (y < bottom && rows.length < 768) {
62
+ const bands = slotsForBand(width, obstacles, top + y, lineHeight, Math.min(minWidth, width));
63
+ if (!bands) return null;
64
+ if (bands.length) { rows.push({y, bands}); y += lineHeight; continue; }
65
+ let next = Infinity;
66
+ for (const obstacle of obstacles) if (obstacle.x < width && obstacle.x + obstacle.width > 0 &&
67
+ obstacle.y < top + y + lineHeight && obstacle.y + obstacle.height > top + y) next = Math.min(next, obstacle.y + obstacle.height - top);
68
+ if (!Number.isFinite(next) || next <= y) return null;
69
+ y = next;
70
+ }
71
+ const multiple = rows.some(row => row.bands.length > 1);
72
+ if (!multiple) return {slots: rows.flatMap(row => row.bands.map(band => ({...band, y: row.y}))), end: y, multiple};
73
+ const free = row => row.bands.length === 1 && row.bands[0].x < .5 && row.bands[0].width > width - .5;
74
+ const slots = [];
75
+ let block = 0;
76
+ for (let at = 0; at < rows.length;) {
77
+ if (free(rows[at])) { slots.push({...rows[at].bands[0], y: rows[at].y}); at++; continue; }
78
+ let stop = at;
79
+ while (stop < rows.length && !free(rows[stop])) stop++;
80
+ block++;
81
+ const channels = [];
82
+ for (let index = at; index < stop; index++) for (const band of rows[index].bands) {
83
+ const owner = channels.find(channel => channel.row === index - 1 &&
84
+ Math.min(channel.last.x + channel.last.width, band.x + band.width) - Math.max(channel.last.x, band.x) > 0);
85
+ const slot = {...band, y: rows[index].y};
86
+ if (owner) { owner.slots.push(slot); owner.row = index; owner.last = slot; }
87
+ else channels.push({slots: [slot], row: index, last: slot, x: band.x});
88
+ }
89
+ channels.sort((a, b) => direction === 'rtl' ? b.x - a.x : a.x - b.x);
90
+ const gaps = rows.slice(at, stop).filter(row => row.bands.length > 1).map(row => row.bands[1].x - row.bands[0].x - row.bands[0].width).sort((a, b) => a - b);
91
+ const thin = gaps[gaps.length >> 1] < .15 * width;
92
+ if (!thin) channels.forEach((channel, index) => { for (const slot of channel.slots) { slot.block = block; slot.channel = index; } });
93
+ if (order === 'rows' || thin) slots.push(...channels.flatMap(channel => channel.slots).sort((a, b) => a.y - b.y || (direction === 'rtl' ? b.x - a.x : a.x - b.x)));
94
+ else for (const channel of channels) slots.push(...channel.slots);
95
+ at = stop;
96
+ }
97
+ return {slots, end: y, multiple};
98
+ }
99
+
100
+ function channelPlan(flow, width, lineHeight, sequence, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items) {
101
+ const lines = [], slots = sequence.slots;
102
+ let cursor, previous = null;
103
+ const place = (band, later) => {
104
+ let range = layoutNextRichInlineLineRange(flow, band.width, cursor);
105
+ if (!range) return 'end';
106
+ if (!Number.isFinite(range.width) || range.width > width + .5) return 'fail';
107
+ if (range.width > band.width + .5) return 'skip';
108
+ let line = materializeRichInlineLineRange(flow, range);
109
+ if (!afterSource(lastSource(line), previous)) return 'fail';
110
+ if (laterWholeWord(flow, cursor, line, items, [band, ...later], 0, layoutNextRichInlineLineRange, materializeRichInlineLineRange)) return 'skip';
111
+ if (later.some(other => other.y === band.y) && insideChip(items, line)) {
112
+ const whole = beforeChip(flow, range, line, cursor, items, layoutNextRichInlineLineRange, materializeRichInlineLineRange);
113
+ if (whole) { range = whole.range; line = whole.line; }
114
+ }
115
+ if (!afterSource(lastSource(line), previous) || lines.length === 4096) return 'fail';
116
+ lines.push({...line, ...band});
117
+ previous = lastSource(line); cursor = range.end;
118
+ return 'placed';
119
+ };
120
+ const done = () => lines.length ? {lines, height: Math.max(lineHeight, ...lines.map(line => line.y + lineHeight))} : null;
121
+ for (let index = 0; index < slots.length; index++) {
122
+ const result = place(slots[index], [...slots.slice(index + 1, index + 9), {x: 0, width, y: sequence.end}]);
123
+ if (result === 'end') return done();
124
+ if (result === 'fail') return null;
125
+ }
126
+ for (let row = 0; row < 768; row++) {
127
+ const result = place({x: 0, width, y: sequence.end + row * lineHeight}, []);
128
+ if (result === 'end') return done();
129
+ if (result !== 'placed') return null;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ export function columnsFilled(slots, placed) {
135
+ const total = new Map(), used = new Map(), reached = new Set();
136
+ for (const slot of slots) if (slot.block) total.set(slot.block + ':' + slot.channel, (total.get(slot.block + ':' + slot.channel) || 0) + 1);
137
+ for (const slot of placed) if (slot.block) { used.set(slot.block + ':' + slot.channel, (used.get(slot.block + ':' + slot.channel) || 0) + 1); reached.add(slot.block); }
138
+ for (const count of total.values()) if (count < 4) return false;
139
+ for (const block of reached) {
140
+ let last = -1;
141
+ for (const slot of slots) if (slot.block === block && slot.channel > last) last = slot.channel;
142
+ if ((used.get(block + ':' + last) || 0) < .6 * total.get(block + ':' + last)) return false;
143
+ }
144
+ return true;
145
+ }
146
+
58
147
  export function linePlan(flow, width, top, obstacles, lineHeight, minWidth, direction, balance,
59
148
  slotsForBand, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items = null) {
60
149
  if (!flow || balance) return null;
150
+ const sequence = obstacles.length ? slotSequence(width, top, obstacles, lineHeight, minWidth, slotsForBand, direction) : null;
151
+ if (sequence?.multiple) {
152
+ const columns = channelPlan(flow, width, lineHeight, sequence, layoutNextRichInlineLineRange, materializeRichInlineLineRange, items);
153
+ if (!columns || columnsFilled(sequence.slots, columns.lines)) return columns;
154
+ return channelPlan(flow, width, lineHeight, slotSequence(width, top, obstacles, lineHeight, minWidth, slotsForBand, direction, 'rows'),
155
+ layoutNextRichInlineLineRange, materializeRichInlineLineRange, items);
156
+ }
61
157
  const lines = [];
62
158
  let cursor, previous = null, y = 0, bottom = 0, rows = 0;
63
159
  for (let step = 0; step < 4096 && rows < 768; step++) {
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {layoutNextRichInlineLineRange, materializeRichInlineLineRange} from '../agent/vendor/pretext/rich-inline.js';
3
3
  import {validLayout} from '../spec/md-layout.mjs';
4
- import {linePlan} from './line-plan.mjs';
4
+ import {linePlan, slotSequence, columnsFilled} from './line-plan.mjs';
5
5
  import {captureFontAuthority, createPreparationScope, fontReady, invalidateRichMetrics, paragraphItems,
6
6
  prepareRichParagraph, supportsRichStyle} from './preparation.mjs';
7
7
  export {captureFontAuthority, createPreparationScope, invalidateRichMetrics, prepareRichParagraph, supportsRichStyle};
@@ -309,6 +309,7 @@ export function pictureSlices(profile, x, y, width, height, scale = 1) {
309
309
  return slices;
310
310
  }
311
311
 
312
+ export {slotSequence, columnsFilled};
312
313
  export function slotsForBand(width, obstacles, top, height, minWidth = 40) {
313
314
  if (!finite(width) || width <= 0 || !finite(top) || !finite(height) || height <= 0 ||
314
315
  !finite(top + height) || !finite(minWidth) || minWidth <= 0 || !Array.isArray(obstacles)) return null;
@@ -22,7 +22,7 @@ export function createParagraphFitter() {
22
22
  return handle;
23
23
  }
24
24
 
25
- function* solveSteps(handle, width, {indent = 0, left = 0, right = 0, widths = null, shrink = .4, stretch = .8} = {}, known) {
25
+ function* solveSteps(handle, width, {indent = 0, left = 0, right = 0, widths = null, shrink = .5, stretch = 1.2, hyphenCost = 8} = {}, known) {
26
26
  const prepared = indexes.get(handle);
27
27
  if (!prepared || ![width, indent, left, right].every(Number.isFinite) || width <= 0) return null;
28
28
  const measures = Array.isArray(widths) && widths.every(value => Number.isFinite(value) && value > 0) ? widths : [];
@@ -102,7 +102,7 @@ export function createParagraphFitter() {
102
102
  if (!Number.isFinite(natural)) continue;
103
103
  const count = lastWord - start.word, gapWidth = gaps[lastWord] - gaps[start.word];
104
104
  const opening = start.cut ? 0 : Math.min(Math.max(0, left), words[start.word].opening);
105
- const closing = end.cut ? 0 : Math.min(Math.max(0, right), words[lastWord].closing);
105
+ const closing = end.cut ? Math.min(Math.max(0, right), end.cut.protrude || 0) : Math.min(Math.max(0, right), words[lastWord].closing);
106
106
  const priorDiscretionary = start.kind === 'soft' || start.kind === 'auto';
107
107
  if (final && priorDiscretionary) continue;
108
108
  for (let lineClass = 0; lineClass < classes; lineClass++) {
@@ -120,7 +120,7 @@ export function createParagraphFitter() {
120
120
  let local = 1 + 100 * Math.max(0, overflow / target) ** 2;
121
121
  if (final && startIndex) local += 100 * Math.max(0, .45 - natural / target) ** 2 + (count === 0 ? 25 : 0);
122
122
  else if (!final) local += (ratio < 0 ? 100 : 12.5) * ratio ** 2 + 2400 * (residual / target) ** 2;
123
- if (discretionary) local += 12 + (priorDiscretionary ? 12 : 0);
123
+ if (discretionary) local += hyphenCost + (priorDiscretionary ? hyphenCost : 0);
124
124
  const set = !final && count && residual > 0 ? remaining / count : delta;
125
125
  const nextClass = Math.min(lineClass + 1, classes - 1);
126
126
  for (let active = available; active; active &= active - 1) {
@@ -153,7 +153,7 @@ export function createParagraphFitter() {
153
153
  const start = points[startIndex], end = points[endIndex], lastWord = end.cut ? end.word : end.word - 1;
154
154
  chosen.push({start: start.next, end: end.end, next: end.next, kind: end.kind, glyph: end.cut?.glyph || '',
155
155
  wordSpacing: spacing[best], opening: start.cut ? 0 : Math.min(Math.max(0, left), words[start.word].opening),
156
- closing: end.cut ? 0 : Math.min(Math.max(0, right), words[lastWord].closing),
156
+ closing: end.cut ? Math.min(Math.max(0, right), end.cut.protrude || 0) : Math.min(Math.max(0, right), words[lastWord].closing),
157
157
  firstWord: start.word, lastWord, segment: segmentIndex});
158
158
  best = priorIndex; endIndex = startIndex;
159
159
  }
@@ -2,9 +2,25 @@
2
2
  import {paragraphHyphens} from './hyphenation.mjs';
3
3
  import {prepareRichInlineSegments} from '../agent/vendor/pretext/rich-inline.js';
4
4
  import {getEngineProfile, getPreparationLanguage, getFontMeasurement, parseFontSize,
5
- getTextWidth, getEmojiCorrection, textMayContainEmoji, measureWithLetterSpacing} from '../agent/vendor/pretext/measurement.js';
5
+ getTextWidth, getEmojiCorrection, textMayContainEmoji, getSegmentMetrics, getContext} from '../agent/vendor/pretext/measurement.js';
6
6
  import {KIND_BITS, TEXT, SPACE, SOFT_HYPHEN, ZERO_WIDTH_BREAK, ZERO_WIDTH_GLUE, OBJECT, SPACED, UNBROKEN} from '../agent/vendor/pretext/analysis.js';
7
7
 
8
+ function measureWithLetterSpacing(text, letterSpacing, emojiCorrection, measurement) {
9
+ if (!measurement.state.takesLetterSpacing) return null;
10
+ const base = getTextWidth(text, measurement, emojiCorrection);
11
+ if (letterSpacing === 0) return base;
12
+ const corrected = getSegmentMetrics(text, measurement).width - base;
13
+ const context = getContext(measurement), previous = context.letterSpacing;
14
+ try {
15
+ context.letterSpacing = `${letterSpacing}px`;
16
+ if (Number.parseFloat(context.letterSpacing) !== letterSpacing) return null;
17
+ const width = context.measureText(text).width - corrected;
18
+ return Number.isFinite(width) ? width : null;
19
+ } finally {
20
+ context.letterSpacing = previous;
21
+ }
22
+ }
23
+
8
24
  const opening = /^[\p{Ps}\p{Pi}\p{Pf}"']+/u;
9
25
  const closing = /[\p{Pe}\p{Pi}\p{Pf}"'.,;:!?]+$/u;
10
26
  const empty = values => values == null || !values.some(value => value !== null && value !== 0);
@@ -141,7 +157,7 @@ export function* prepareParagraphCandidateSteps(items, options) {
141
157
  if (joined) {
142
158
  for (let at = word.first; at <= word.last; at++) {
143
159
  const unit = units[at];
144
- if (unit.kind !== TEXT || /^\p{M}+$/u.test(text.slice(unit.start, unit.end))) continue;
160
+ if (unit.kind !== TEXT) continue;
145
161
  const delta = unit.width - measure(unit.start, unit.end);
146
162
  if (delta !== 0) contexts.push(Object.freeze({start: unit.start, end: unit.end, delta}));
147
163
  if (++measuredEdges % 8 === 0) yield;
@@ -152,6 +168,7 @@ export function* prepareParagraphCandidateSteps(items, options) {
152
168
  for (const cut of word.breaks) {
153
169
  if (cut.kind === 'soft' || cut.kind === 'auto') {
154
170
  cut.glyph = '-';
171
+ if (options.hanging && !word.protected) cut.protrude = parseFontSize(items[units[word.first].itemIndex].font) * .25;
155
172
  }
156
173
  let before = cut.at, after = cut.at + 1;
157
174
  if (cut.kind === 'auto') {
@@ -464,6 +464,7 @@ export function createTypography() {
464
464
  function updateProjection(projection, plan) {
465
465
  for (const {node,property,line,sign} of projection.bindings) {
466
466
  const value = sign * plan.lines[line][property] + 'px';
467
+ if (node.hasAttribute(boundary)) { if (node.style.getPropertyValue('--md-fit-protrude') !== value) node.style.setProperty('--md-fit-protrude', value); continue; }
467
468
  const name = property === 'opening' ? 'marginInlineStart' : property === 'closing' ? 'marginInlineEnd' : 'wordSpacing';
468
469
  if (node.style[name] !== value) node.style[name] = value;
469
470
  }
@@ -487,7 +488,7 @@ export function createTypography() {
487
488
  add(line.end);
488
489
  }
489
490
  if (line.kind === 'space') operations.push({start:line.end,end:line.next,boundary:true});
490
- else if (line.kind !== 'end') inserts.push({offset:line.end,glyph:line.glyph});
491
+ else if (line.kind !== 'end') inserts.push({offset:line.end,glyph:line.glyph,line:lineIndex,protrudes:line.closing>0});
491
492
  const first = words[line.firstWord], last = words[line.lastWord];
492
493
  if (first.opening && line.start === first.start) operations.push({start:first.start,end:first.openEnd,opening:true,line:lineIndex});
493
494
  if (last.closing && line.end === last.end) operations.push({start:last.closeStart,end:last.end,closing:true,line:lineIndex});
@@ -538,6 +539,7 @@ export function createTypography() {
538
539
  const at=ends.get(insert.offset);
539
540
  const marker=paragraph.ownerDocument.createElement('span');marker.setAttribute(boundary,insert.glyph?'hyphen':'');
540
541
  if (insert.glyph) marker.style.setProperty('--md-fit-hyphen',JSON.stringify(insert.glyph));
542
+ if (insert.protrudes) bindings.push({node:marker,property:'closing',line:insert.line,sign:-1});
541
543
  at.after(marker);
542
544
  }
543
545
  const projection = {bindings,topology:topology(plan)};
@@ -689,77 +691,69 @@ export function createTypography() {
689
691
  slotPlans.set(paragraph, {key, plan});
690
692
  return plan;
691
693
  }
692
- function slotLines(paragraph, items, options, {width, top, obstacles, lineHeight, minWidth, slotsForBand, justify, hyphenate}) {
694
+ function slotLines(paragraph, items, options, {width, top, obstacles, lineHeight, minWidth, slotsForBand, slotSequence, columnsFilled, justify, hyphenate}) {
693
695
  if (!obstacles.length) return null;
694
696
  const doc = paragraph.ownerDocument, words = scope(doc);
695
697
  const source = items.map(item => ({...item, hyphenate}));
696
698
  const geometry = words.prepare(paragraph, source, {policy: 'paragraph', language: options.language, whiteSpace: 'normal',
697
699
  wordBreak: options.wordBreak, hanging: false, paragraphBreaks: [0, source.length]});
698
700
  if (!geometry) return null;
699
- const sequence = [], bottom = Math.max(...obstacles.map(item => item.y + item.height)) - top;
700
- let y = 0, rows = 0;
701
- while (y < bottom && rows < 512 && sequence.length < 1024) {
702
- const bands = slotsForBand(width, obstacles, top + y, lineHeight, Math.min(minWidth, width));
703
- if (!bands) return null;
704
- if (bands.length) {
705
- for (const band of bands) sequence.push({x: band.x, y, width: band.width});
706
- y += lineHeight; rows++; continue;
701
+ const build = order => {
702
+ const found = slotSequence(width, top, obstacles, lineHeight, minWidth, slotsForBand, 'ltr', order);
703
+ if (!found?.multiple) return null;
704
+ const sequence = found.slots, y = found.end;
705
+ const prepared = fitter.prepare(geometry);
706
+ let slots = sequence, plan = null;
707
+ for (let pass = 0; prepared && pass < 8; pass++) {
708
+ const measures = slots.map(slot => Math.floor(slot.width * 64) / 64);
709
+ if (!measures.length) return null;
710
+ plan = words.fit(paragraph, geometry, known => fitter.solveSteps(prepared, width, {widths: measures, shrink: justify ? undefined : 0}, known));
711
+ if (!plan) return null;
712
+ const wide = new Set();
713
+ plan.lines.forEach((line, index) => {
714
+ if (index >= slots.length || line.firstWord !== line.lastWord || line.kind === 'soft' || line.kind === 'auto') return;
715
+ const word = geometry.segments[line.segment].words[line.firstWord];
716
+ if (line.start === word.start && line.end === word.end && word.width > measures[index] + .5) wide.add(slots[index]);
717
+ });
718
+ if (!wide.size) break;
719
+ slots = slots.filter(slot => !wide.has(slot)); plan = null;
707
720
  }
708
- let next = Infinity;
709
- for (const obstacle of obstacles) if (obstacle.x < width && obstacle.x + obstacle.width > 0 &&
710
- obstacle.y < top + y + lineHeight && obstacle.y + obstacle.height > top + y) next = Math.min(next, obstacle.y + obstacle.height - top);
711
- if (!Number.isFinite(next) || next <= y) return null;
712
- y = next;
713
- }
714
- const prepared = fitter.prepare(geometry);
715
- let slots = sequence, plan = null;
716
- for (let pass = 0; prepared && pass < 8; pass++) {
717
- const measures = slots.map(slot => Math.floor(slot.width * 64) / 64);
718
- if (!measures.length) return null;
719
- plan = words.fit(paragraph, geometry, known => fitter.solveSteps(prepared, width, {widths: measures, shrink: justify ? undefined : 0}, known));
720
- if (!plan) return null;
721
- const wide = new Set();
722
- plan.lines.forEach((line, index) => {
723
- if (index >= slots.length || line.firstWord !== line.lastWord || line.kind === 'soft' || line.kind === 'auto') return;
724
- const word = geometry.segments[line.segment].words[line.firstWord];
725
- if (line.start === word.start && line.end === word.end && word.width > measures[index] + .5) wide.add(slots[index]);
726
- });
727
- if (!wide.size) break;
728
- slots = slots.filter(slot => !wide.has(slot)); plan = null;
729
- }
730
- const perRow = new Map();
731
- for (const slot of slots) perRow.set(slot.y, (perRow.get(slot.y) || 0) + 1);
732
- if (!plan || ![...perRow.values()].some(count => count > 1)) return null;
733
- const lines = [], text = geometry.text, offsets = geometry.offsets, space = /^[\t\n\f\r ]*$/;
734
- for (let index = 0; index < plan.lines.length; index++) {
735
- const line = plan.lines[index], slot = sequence[index] || {x: 0, y: y + (index - sequence.length) * lineHeight, width};
736
- const spacing = justify ? line.wordSpacing : 0, fragments = [];
737
- const segment = geometry.segments[line.segment].words.slice(line.firstWord, line.lastWord + 1);
738
- let at = 0, previous = null;
739
- while (at < source.length - 1 && offsets[at + 1] <= line.start) at++;
740
- for (; at < source.length && offsets[at] < line.end; at++) {
741
- let from = Math.max(line.start, offsets[at]), to = Math.min(line.end, offsets[at + 1]);
742
- if (previous) while (from < to && /[\t\n\f\r ]/.test(text[from])) from++;
743
- if (offsets[at + 1] < line.end) while (to > from && /[\t\n\f\r ]/.test(text[to - 1])) to--;
744
- if (to <= from) continue;
745
- const fragment = {itemIndex: at, sourceStart: from - offsets[at], sourceEnd: to - offsets[at], derive: true, occupiedWidth: 0, gapItemIndex: at, gapBefore: 0};
746
- if (previous) {
747
- fragment.gapItemIndex = previous.itemIndex;
748
- if (previous.end < from) {
749
- if (!space.test(text.slice(previous.end, from))) return null;
750
- const before = segment.find(word => word.end === previous.end);
751
- if (!before?.gap) return null;
752
- fragment.gapBefore = before.gap.width + spacing;
753
- } else fragment.gapBefore = 0;
721
+ const perRow = new Map();
722
+ for (const slot of slots) perRow.set(slot.y, (perRow.get(slot.y) || 0) + 1);
723
+ if (!plan || ![...perRow.values()].some(count => count > 1)) return null;
724
+ const lines = [], text = geometry.text, offsets = geometry.offsets, space = /^[\t\n\f\r ]*$/;
725
+ for (let index = 0; index < plan.lines.length; index++) {
726
+ const line = plan.lines[index], slot = sequence[index] || {x: 0, y: y + (index - sequence.length) * lineHeight, width};
727
+ const spacing = justify ? line.wordSpacing : 0, fragments = [];
728
+ const segment = geometry.segments[line.segment].words.slice(line.firstWord, line.lastWord + 1);
729
+ let at = 0, previous = null;
730
+ while (at < source.length - 1 && offsets[at + 1] <= line.start) at++;
731
+ for (; at < source.length && offsets[at] < line.end; at++) {
732
+ let from = Math.max(line.start, offsets[at]), to = Math.min(line.end, offsets[at + 1]);
733
+ if (previous) while (from < to && /[\t\n\f\r ]/.test(text[from])) from++;
734
+ if (offsets[at + 1] < line.end) while (to > from && /[\t\n\f\r ]/.test(text[to - 1])) to--;
735
+ if (to <= from) continue;
736
+ const fragment = {itemIndex: at, sourceStart: from - offsets[at], sourceEnd: to - offsets[at], derive: true, occupiedWidth: 0, gapItemIndex: at, gapBefore: 0};
737
+ if (previous) {
738
+ fragment.gapItemIndex = previous.itemIndex;
739
+ if (previous.end < from) {
740
+ if (!space.test(text.slice(previous.end, from))) return null;
741
+ const before = segment.find(word => word.end === previous.end);
742
+ if (!before?.gap) return null;
743
+ fragment.gapBefore = before.gap.width + spacing;
744
+ } else fragment.gapBefore = 0;
745
+ }
746
+ fragments.push(fragment); previous = {itemIndex: at, end: to};
754
747
  }
755
- fragments.push(fragment); previous = {itemIndex: at, end: to};
748
+ if (!fragments.length) return null;
749
+ if (line.kind === 'soft' || line.kind === 'auto') fragments.at(-1).glyph = line.glyph || '-';
750
+ lines.push({x: slot.x, y: slot.y, width: slot.width, fragments, wordSpacing: spacing});
756
751
  }
757
- if (!fragments.length) return null;
758
- if (line.kind === 'soft' || line.kind === 'auto') fragments.at(-1).glyph = line.glyph || '-';
759
- lines.push({x: slot.x, y: slot.y, width: slot.width, fragments, wordSpacing: spacing});
760
- }
761
- const last = lines.at(-1);
762
- return {lines, height: last ? Math.max(lineHeight, last.y + lineHeight) : lineHeight};
752
+ return {plan: {lines, height: Math.max(lineHeight, ...lines.map(line => line.y + lineHeight))}, filled: columnsFilled(slots, slots.slice(0, plan.lines.length))};
753
+ };
754
+ const columns = build('columns');
755
+ if (!columns || columns.filled) return columns?.plan || null;
756
+ return build('rows')?.plan || columns.plan;
763
757
  }
764
758
 
765
759
  function retire(root, {documentChanged=false}={}) {
@@ -211,6 +211,7 @@ export function documentFontUsage(markdown, parser, defaultFont) {
211
211
  const inherited = defaultFont === undefined ? settings.defaultfont : documentFont(defaultFont);
212
212
  const main = family(settings.mainfont || inherited || settings.sansfont), sans = family(settings.sansfont || settings.mainfont || inherited), mono = family(settings.monofont);
213
213
  const add = (name, text) => { if (name && !Object.hasOwn(FONT_STACKS, name.toLowerCase()) && text) use.set(name, (use.get(name) || '') + text + '\n'); };
214
+ if (!main && !sans && !mono && !source.includes('<')) return use;
214
215
  const plain = html => md.utils.unescapeAll(String(html).replace(/<!--[^]*?-->/g, '').replace(/<(script|style)\b[^>]*>[^]*?<\/\1\s*>/gi, '').replace(/<[^>]*>/g, '').replace(/\\/g, '\\\\'));
215
216
  let blockFamily = main;
216
217
  for (const token of md.parse(splitOpeningFrontmatter(source).body, {})) {
@@ -129,6 +129,7 @@ function paperOf(value) {
129
129
  return pixels && width <= 10000 && height <= 10000 ? Object.freeze({name, width: width * 15, height: height * 15, css: width + 'px ' + height + 'px'}) : null;
130
130
  }
131
131
  export const pageSizeOf = settings => settings?.papersize || PAPERS.letter;
132
+ export const paperForLanguage = language => /(?:^|[-_])(?:US|CA|MX|PH|CL|CO|VE|CR|GT)$/i.test(String(language || '')) ? PAPERS.letter : PAPERS.a4;
132
133
  export const PAGE_SIZES = Object.freeze(['letter', 'legal', 'tabloid', 'a3', 'a4', 'a5', '1080x1080px', '1080x1350px', '1920x1080px', '1200x630px']);
133
134
  const EMPTY = Object.freeze({
134
135
  title: null, subtitle: null, lang: null, fontsize: null, mainfont: null, sansfont: null, monofont: null, defaultfont: null, linestretch: null,
@@ -425,7 +425,7 @@ export function mountHtmlBlocks(doc, options = {}) {
425
425
  const name = attr.name.toLowerCase(), value = attr.value;
426
426
  if (name.startsWith('on') || name.startsWith('data-rapier-') || ['nonce','srcdoc','is','autofocus','form','formaction','action','target','download','ping','xmlns:xlink'].includes(name)) el.removeAttribute(attr.name);
427
427
  if (['src','srcset','href'].includes(attr.localName.toLowerCase()) && !(name.endsWith('href') && /^#[A-Za-z][\w.-]*$/.test(value) ||
428
- (name === 'src' && tag === 'img' || name.endsWith('href') && tag === 'image') && /^data:image\/(?:png|jpeg|webp|gif|jxl|svg\+xml);/i.test(value))) el.removeAttribute(attr.name);
428
+ (name === 'src' && tag === 'img' || name.endsWith('href') && tag === 'image') && /^data:image\/(?:(?:png|jpeg|webp|gif|jxl|svg\+xml);|svg\+xml,)/i.test(value))) el.removeAttribute(attr.name);
429
429
  }
430
430
  if (tag.startsWith('animate') || tag === 'set') {
431
431
  const attribute = (el.getAttribute('attributeName') || '').trim().toLowerCase().split(':').pop();
@@ -437,13 +437,16 @@ export function mountHtmlBlocks(doc, options = {}) {
437
437
  const name = tag === 'img' ? 'src' : el.hasAttribute('href') ? 'href' : 'xlink:href', original = el.getAttribute(name);
438
438
  if (original) {
439
439
  const match = /^data:(image\/(?:png|jpeg|webp|gif|jxl|svg\+xml));(?:charset=[^;,]+;)?base64,([A-Za-z0-9+/]+={0,2})$/i.exec(original);
440
- if (!match || original.length > 24 * 1024 * 1024) throw new Error('Image requires a bounded base64 data URL');
441
- const url = 'data:' + match[1].toLowerCase() + ';base64,' + match[2];
442
- const bytes = Uint8Array.from(win.atob(match[2]), c => c.charCodeAt(0));
443
- const info = match[1].toLowerCase() === 'image/gif' ? media.inspectGIF(bytes) : imageInfo(url) || admitted[url];
444
- if (!info && match[1].toLowerCase() !== 'image/svg+xml') {el.removeAttribute(name); continue;}
440
+ const percent = /^data:image\/svg\+xml,/i.test(original);
441
+ if ((!match && !percent) || original.length > 24 * 1024 * 1024) throw new Error('Image requires a bounded base64 data URL');
442
+ const mime = percent ? 'image/svg+xml' : match[1].toLowerCase();
443
+ const url = percent ? original : 'data:' + mime + ';base64,' + match[2];
444
+ const bytes = percent ? new win.TextEncoder().encode(decodeURIComponent(original.slice(19)))
445
+ : Uint8Array.from(win.atob(match[2]), c => c.charCodeAt(0));
446
+ const info = mime === 'image/gif' ? media.inspectGIF(bytes) : imageInfo(url) || admitted[url];
447
+ if (!info && mime !== 'image/svg+xml') {el.removeAttribute(name); continue;}
445
448
  if (info) {pixels += info.width * info.height; if (pixels > 24000000) throw new Error('HTML image pixel budget'); admitted[url] = {width: info.width, height: info.height, animated: !!info.animated};}
446
- if (match[1].toLowerCase() === 'image/svg+xml') {
449
+ if (mime === 'image/svg+xml') {
447
450
  const svgText = new win.TextDecoder('utf-8', {fatal: true}).decode(bytes);
448
451
  if (/<!\s*(?:DOCTYPE|ENTITY)\b/i.test(svgText)) throw new Error('SVG declarations are unsupported');
449
452
  const parsed = new win.DOMParser().parseFromString(svgText, 'image/svg+xml');