rapier-markdown-kit 1.2.19 → 1.2.21

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.
package/README.md CHANGED
@@ -44,7 +44,7 @@ const edited = replaceLedgerText(base, 'Draft, revised\n', {actor: {kind: 'human
44
44
  console.log(merge(edited, base).text, authorship(edited).runs);
45
45
  ```
46
46
 
47
- TypeScript declarations ship for `markdown-it`, `will`, `rapier-ledger`, `ledger/carried` and `pdf`.
47
+ TypeScript declarations ship for `markdown-it`, `parsed`, `will`, `rapier-ledger`, `ledger/carried` and `pdf`.
48
48
 
49
49
  ```js
50
50
  import {parseLayout} from 'rapier-markdown-kit/layout'; // the md-layout comment a picture carries
@@ -165,6 +165,7 @@ family name share the host's font namespace. Print retains its existing factory
165
165
  | Import | What it is |
166
166
  | --- | --- |
167
167
  | `rapier-markdown-kit/markdown-it` | `md.use(rapierMarkdown)`: the reference style's markup in markdown-it, in one line. |
168
+ | `rapier-markdown-kit/parsed` | A frozen, serializable view of completed Markdown tokens, bound to source, revision and grammar. |
168
169
  | `rapier-markdown-kit/layout` | The `md-layout:v1` comment a picture carries: parse it, validate it, write it back. |
169
170
  | `rapier-markdown-kit/marks` | Unified typography values, span writer and parser installer; preserved color/font comments, ink and page breaks. |
170
171
  | `rapier-markdown-kit/model` | Occupancy profiles and the line planner: the wrapping itself. |
@@ -180,6 +181,51 @@ family name share the host's font namespace. Print retains its existing factory
180
181
  The canonical inline form is ordinary HTML inside Markdown. CSS support varies; the host's font metrics still
181
182
  determine line layout (below).
182
183
 
184
+ ## Read a parsed document
185
+
186
+ `projectMarkdown` copies the tokens from your completed parse into JSON data. Pass the exact source and revision
187
+ captured for that parse, and a grammar identity that includes its parser, plugins and options. The function does not
188
+ parse again. Project before renderer hooks mutate the tokens; the resulting snapshot is detached from those later
189
+ changes. Import this optional subpath only where you need it; the package's main entry does not import it.
190
+
191
+ ```js
192
+ import MarkdownIt from 'markdown-it';
193
+ import {rapierMarkdown} from 'rapier-markdown-kit/markdown-it';
194
+ import {projectMarkdown, projectionMatches} from 'rapier-markdown-kit/parsed';
195
+
196
+ const source = '# Plan\n\nShips **Friday**.\n';
197
+ const revision = 7;
198
+ const options = {html: true};
199
+ const grammar = {id: 'my-app-markdown-1', options, plugins: ['rapierMarkdown']};
200
+ const md = new MarkdownIt(options).use(rapierMarkdown);
201
+ const env = {};
202
+ const tokens = md.parse(source, env); // The same tokens can feed your renderer.
203
+ const parsed = projectMarkdown({source, revision, grammar, tokens, env, complete: true});
204
+ const json = JSON.stringify(parsed);
205
+ console.log(projectionMatches(parsed, {source, revision, grammar})); // true
206
+ ```
207
+
208
+ Change the grammar identity when its implementation changes. Grammar options must be JSON data; describe a custom
209
+ rule or renderer by its identity, not by a function. If your parse excludes opening front matter, pass `bodyOffset`
210
+ from that split and keep `source` as the full original document. Token line maps must remain relative to the parsed body.
211
+
212
+ The source binding uses SHA-256 over its UTF-16LE code units, including BOM, original line endings and isolated
213
+ surrogates. `span` ranges are half-open UTF-16 offsets into that source. A span with `scope: 'lines'` covers the token's
214
+ source lines, including container prefixes and the final line ending. Inline delimiters and generated tokens without
215
+ recorded ranges have `span: null`; `coverage` and `omissions` report these missing ranges. Never infer an exact inline
216
+ edit boundary from a containing line span.
217
+
218
+ Recorded `meta.mdLayoutSource` positions are translated from the parser's normalized body into the same original
219
+ UTF-16 source coordinates and labeled `unit: 'utf16'`. Their marker text and any reason for an unmapped target remain
220
+ with the layout record. Other JSON metadata retains the grammar's values; internal probe identities are not exported.
221
+
222
+ The projection includes canonical token content, attributes, JSON metadata and reference, abbreviation and footnote
223
+ definitions. It omits original source gaps, interpreted front matter, rendered HTML and geometry, decoded pictures,
224
+ final heading IDs, and evaluated Will permissions. Non-JSON token metadata becomes `null` with a counted omission.
225
+ Token strings remain document data for your existing renderer and sanitizer. `projectionMatches` checks currentness;
226
+ it does not authenticate a projection received from another party or authorize an edit. Source transactions, Will,
227
+ Undo and Save stay with their existing owners.
228
+
183
229
  ## Refined paragraphs
184
230
 
185
231
  The existing comment expresses the paragraph's intent without storing font-dependent line breaks:
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  export function createMediaPlayback(win, inspectGIF) {
3
+ const FRAMES = 3600, LOOP_MS = 3600000;
3
4
  const active = new Map(), encode = bytes => bytes.toBase64();
4
5
  const bytes = async url => {
5
6
  const text = url.slice(url.indexOf(',') + 1), slice = 1 << 20;
@@ -14,7 +15,7 @@ export function createMediaPlayback(win, inspectGIF) {
14
15
  function raster(url, canvas, changed = () => {}, dimensions = null) {
15
16
  if (url.length > 24 * 1024 * 1024 || !/^data:image\/(gif|jxl);base64,/i.test(url) || (url.length - url.indexOf(',') - 1) % 4) throw new Error('Unsupported animation source');
16
17
  const type = url.slice(5, url.indexOf(';')).toLowerCase();
17
- let data = null, info = null, size = null, decoder = null, timer = 0, closed = false, paused = true, ticket = 0, next = 0, loops = 0, duration = 0, single = false, held = false;
18
+ let data = null, info = null, size = null, decoder = null, timer = 0, closed = false, paused = true, ticket = 0, next = 0, loops = 0, duration = 0, single = false, held = false, due = 0;
18
19
  const state = {animated: true, status: 'loading', first: '', frames: 0};
19
20
  const admitted = (async () => {
20
21
  const buffer = await bytes(url); info = type === 'image/gif' ? inspectGIF(buffer) : null;
@@ -47,11 +48,12 @@ export function createMediaPlayback(win, inspectGIF) {
47
48
  state.status = 'playback-budget'; paused = true; changed(state); return;
48
49
  }
49
50
  active.set(canvas, size.width * size.height);
51
+ next = 0; duration = 0; due = 0;
50
52
  decoder = new win.ImageDecoder({data, type, preferAnimation: true});
51
53
  const current = decoder; await settle(current.tracks.ready);
52
54
  if (closed || paused || stamp !== ticket || decoder !== current) return;
53
55
  const track = current.tracks.selectedTrack;
54
- if (!track || track.frameCount < 1 || track.frameCount > 240 || info && track.frameCount !== info.frames) throw new Error('Animation frame budget or container mismatch');
56
+ if (!track || track.frameCount < 1 || track.frameCount > FRAMES || info && track.frameCount !== info.frames) throw new Error('Animation frame budget or container mismatch');
55
57
  state.frames = track.frameCount; state.animated = track.animated;
56
58
  }
57
59
  const current = decoder, decoding = current.decode({frameIndex: next, completeFramesOnly: true});
@@ -62,13 +64,13 @@ export function createMediaPlayback(win, inspectGIF) {
62
64
  try {
63
65
  if (closed || paused || stamp !== ticket || decoder !== current) return;
64
66
  const w = frame.displayWidth, h = frame.displayHeight;
65
- if (!result.complete || w !== size.width || h !== size.height || w * h * state.frames > 96000000) throw new Error('Decoded animation exceeds the image budget');
67
+ if (!result.complete || w !== size.width || h !== size.height) throw new Error('Decoded animation exceeds the image budget');
66
68
  if (canvas.width !== w) canvas.width = w;
67
69
  if (canvas.height !== h) canvas.height = h;
68
70
  const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
69
71
  if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
70
72
  const delay = type !== 'image/gif' ? Math.max(8, frame.duration / 1000) : frame.duration > 10000 ? Math.max(20, frame.duration / 1000) : 100;
71
- duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
73
+ duration += delay; if (duration > LOOP_MS) throw new Error('Animation exceeds the duration budget');
72
74
  state.status = 'ready'; state.frame = next; changed(state);
73
75
  if (single) {paused = true; closeDecoder(); return;}
74
76
  next++;
@@ -76,7 +78,8 @@ export function createMediaPlayback(win, inspectGIF) {
76
78
  next = 0; duration = 0; loops++;
77
79
  if (!state.animated || Number.isFinite(current.tracks.selectedTrack.repetitionCount) && loops > current.tracks.selectedTrack.repetitionCount) {paused = true; closeDecoder(); return;}
78
80
  }
79
- timer = win.setTimeout(step, delay);
81
+ const now = win.performance.now(); due = due && now - due < 1000 ? due + delay : now + delay;
82
+ timer = win.setTimeout(step, Math.max(0, due - now));
80
83
  } finally {frame.close();}
81
84
  } catch (error) {
82
85
  if (closed || stamp !== ticket) return;
@@ -85,7 +88,7 @@ export function createMediaPlayback(win, inspectGIF) {
85
88
  }
86
89
  return {state, play() {if (!closed && paused && !['decode-failed', 'admission-failed'].includes(state.status)) {paused = false; void step();}},
87
90
  async first() {if (!state.first && !closed) {single = true; next = 0; paused = false; try {await step();} finally {single = false; paused = true; closeDecoder(); if (held) {held = false; this.suspend();}}} return state.first;},
88
- pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0;},
91
+ pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0; due = 0;},
89
92
  suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; state.status = 'suspended'; changed(state);},
90
93
  close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;},
91
94
  get paused() {return paused;}};
@@ -2,12 +2,14 @@
2
2
  import {parseLayout, imageStyle} from './layout.mjs';
3
3
  import {typography, isPageBreakBlock} from './marks.mjs';
4
4
  import {installMarkdownImages} from './assets.mjs';
5
+ import {tuneMarkdownEngine} from '../spec/md-engine.mjs';
5
6
 
6
7
  const PAGE_BREAK = '<div class="rapier-page-break" data-md-break="page" role="separator" aria-label="Page break"></div>\n';
7
8
 
8
9
  export function rapierMarkdown(md) {
9
10
  if (md.__rapierMarkdownInstalled) return md;
10
11
  md.__rapierMarkdownInstalled = true;
12
+ tuneMarkdownEngine(md);
11
13
  typography.install(installMarkdownImages(md));
12
14
  md.renderer.rules.table_open = (tokens, index, options, env, renderer) =>
13
15
  '<div class="table-scroll-wrap">\n' + renderer.renderToken(tokens, index, options);
@@ -15,10 +17,13 @@ export function rapierMarkdown(md) {
15
17
  renderer.renderToken(tokens, index, options) + '</div>\n';
16
18
  md.core.ruler.after('inline', 'rapier-layout', state => {
17
19
  for (let i = 1; i < state.tokens.length; i++) {
18
- const inline = state.tokens[i], owner = state.tokens[i - 1];
19
- if (inline.type !== 'inline' || !['paragraph_open', 'heading_open'].includes(owner.type)) continue;
20
- const children = inline.children, marker = children.at(-1);
21
- const layout = marker?.type === 'html_inline' ? parseLayout(marker.content) : null;
20
+ const inline = state.tokens[i];
21
+ if (inline.type !== 'inline') continue;
22
+ const children = inline.children, marker = children[children.length - 1];
23
+ if (marker?.type !== 'html_inline') continue;
24
+ const owner = state.tokens[i - 1];
25
+ if (owner.type !== 'paragraph_open' && owner.type !== 'heading_open') continue;
26
+ const layout = parseLayout(marker.content);
22
27
  if (!layout) continue;
23
28
  children.pop();
24
29
  if (layout.align) owner.attrSet('data-md-align', layout.align);
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export type Json = null | string | number | boolean | readonly Json[] | {readonly [key: string]: Json};
3
+ export type Revision = string | number;
4
+ export interface GrammarIdentity {
5
+ readonly id: string;
6
+ readonly options: {readonly [key: string]: Json};
7
+ readonly [key: string]: Json;
8
+ }
9
+ export interface Utf16Span { readonly start: number; readonly end: number; readonly unit: 'utf16'; }
10
+ export interface TokenSpan extends Utf16Span { readonly scope: 'lines'; }
11
+ export interface MarkdownTokenInput {
12
+ readonly type: string;
13
+ readonly tag?: string;
14
+ readonly nesting: -1 | 0 | 1;
15
+ readonly level?: number;
16
+ readonly block?: boolean;
17
+ readonly hidden?: boolean;
18
+ readonly content?: string;
19
+ readonly markup?: string;
20
+ readonly info?: string;
21
+ readonly attrs?: unknown;
22
+ readonly meta?: unknown;
23
+ readonly map?: readonly [number, number] | null;
24
+ readonly children?: readonly MarkdownTokenInput[] | null;
25
+ }
26
+ export interface ParsedMarkdown {
27
+ /** The exact full document, before normalization or front matter removal. */
28
+ readonly source: string;
29
+ readonly revision: Revision;
30
+ /** Includes every option or plugin setting that can change this parse's meaning. */
31
+ readonly grammar: GrammarIdentity;
32
+ readonly complete: true;
33
+ /** The shared front matter owner's body offset; zero if the parser read the full source.
34
+ * Token maps and recorded layout target offsets must be relative to that parsed body. */
35
+ readonly bodyOffset?: number;
36
+ readonly tokens: readonly MarkdownTokenInput[];
37
+ readonly env?: {
38
+ readonly references?: unknown;
39
+ readonly abbreviations?: unknown;
40
+ readonly footnotes?: {readonly list?: readonly {readonly label?: string; readonly count?: number}[]};
41
+ };
42
+ }
43
+ export interface ProjectedToken {
44
+ readonly type: string;
45
+ readonly tag: string;
46
+ readonly nesting: -1 | 0 | 1;
47
+ readonly level: number | null;
48
+ readonly block: boolean;
49
+ readonly hidden: boolean;
50
+ readonly content: string;
51
+ readonly markup: string;
52
+ readonly info: string;
53
+ readonly attrs: Json;
54
+ readonly meta: Json;
55
+ readonly span: TokenSpan | null;
56
+ readonly children: readonly ProjectedToken[] | null;
57
+ }
58
+ export interface MarkdownProjection {
59
+ readonly format: 'rapier-parsed/1';
60
+ readonly source: {
61
+ readonly revision: Revision;
62
+ readonly chars: number;
63
+ readonly hash: {readonly algorithm: 'sha256'; readonly encoding: 'utf16le'; readonly value: string};
64
+ };
65
+ readonly grammar: GrammarIdentity;
66
+ readonly body: Utf16Span;
67
+ readonly frontmatter: Utf16Span | null;
68
+ readonly tokens: readonly ProjectedToken[];
69
+ readonly definitions: Json;
70
+ readonly coverage: {readonly tokens: 'complete'; readonly tokenCount: number; readonly spans: 'complete' | 'partial'};
71
+ readonly omissions: readonly {readonly domain: string; readonly reason: string; readonly count?: number}[];
72
+ }
73
+ /** Project one completed parse. Does not parse again, normalize source, render or admit edits. */
74
+ export function projectMarkdown(parsed: ParsedMarkdown): MarkdownProjection;
75
+ /** Checks source, revision and grammar binding; does not authenticate an externally supplied projection. */
76
+ export function projectionMatches(projection: MarkdownProjection, current: {
77
+ readonly source: string; readonly revision: Revision; readonly grammar: GrammarIdentity;
78
+ }): boolean;
@@ -0,0 +1,157 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {sha256State} from './ledger/hash.mjs';
3
+ import {splitOpeningFrontmatter} from '../spec/frontmatter.mjs';
4
+
5
+ export function projectMarkdown(parsed) {
6
+ const {source, revision, grammar, tokens, env = {}, bodyOffset = 0, complete} = parsed || {};
7
+ requireSource(source, revision);
8
+ if (complete !== true || !Array.isArray(tokens)) throw new TypeError('A completed Markdown parse is required');
9
+ const front = splitOpeningFrontmatter(source);
10
+ if (!Number.isSafeInteger(bodyOffset) || bodyOffset < 0 || bodyOffset > source.length ||
11
+ bodyOffset !== 0 && bodyOffset !== front.bodyOffset) throw new TypeError('The parse must cover the document body');
12
+ const grammarCopy = copyGrammar(grammar), counts = new Map();
13
+ const omitted = (domain, reason) => {
14
+ const key = domain + ':' + reason;
15
+ const row = counts.get(key) || {domain, reason, count: 0};
16
+ row.count++; counts.set(key, row);
17
+ };
18
+ const lines = [bodyOffset], normalizedLines = [0];
19
+ let collapsed = 0;
20
+ for (const match of source.slice(bodyOffset).matchAll(/\r\n?|\n/g)) {
21
+ const end = match.index + match[0].length;
22
+ collapsed += match[0].length - 1;
23
+ lines.push(bodyOffset + end); normalizedLines.push(end - collapsed);
24
+ }
25
+ const originalOffset = offset => {
26
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > source.length - bodyOffset - collapsed)
27
+ throw new TypeError('A Markdown layout target has an invalid source offset');
28
+ let low = 0, high = normalizedLines.length;
29
+ while (low + 1 < high) {
30
+ const middle = (low + high) >>> 1;
31
+ if (normalizedLines[middle] <= offset) low = middle; else high = middle;
32
+ }
33
+ return lines[low] + offset - normalizedLines[low];
34
+ };
35
+ const metadata = token => {
36
+ const meta = copyJSON(token.meta ?? null, () => omitted('metadata', 'not-json'));
37
+ const target = meta?.mdLayoutSource;
38
+ if (!target) return meta;
39
+ const marker = target.marker ? Object.freeze({...target.marker, start: originalOffset(target.marker.start),
40
+ end: originalOffset(target.marker.end), unit: 'utf16'}) : null;
41
+ return Object.freeze({...meta, mdLayoutSource: Object.freeze({...target, start: originalOffset(target.start),
42
+ end: originalOffset(target.end), insert: target.insert == null ? null : originalOffset(target.insert), marker, unit: 'utf16'})});
43
+ };
44
+ const span = token => {
45
+ const map = token.map;
46
+ if (map == null) {
47
+ omitted('spans', token.block ? 'block-range-not-retained' : 'inline-range-not-retained');
48
+ return null;
49
+ }
50
+ if (!Array.isArray(map) || map.length !== 2 || map.some(at => !Number.isSafeInteger(at) || at < 0) ||
51
+ map[0] > map[1] || map[0] >= lines.length || map[1] > lines.length)
52
+ throw new TypeError('A Markdown token has an invalid line range');
53
+ if (map[0] === map[1]) { omitted('spans', token.block ? 'block-range-not-retained' : 'inline-range-not-retained'); return null; }
54
+ return Object.freeze({start: lines[map[0]], end: lines[map[1]] ?? source.length, unit: 'utf16', scope: 'lines'});
55
+ };
56
+ let count = 0;
57
+ const copyTokens = input => Object.freeze(input.map(token => {
58
+ if (!token || typeof token.type !== 'string' || ![-1, 0, 1].includes(token.nesting) ||
59
+ token.children != null && !Array.isArray(token.children)) throw new TypeError('Invalid Markdown token');
60
+ count++;
61
+ const scalar = key => {
62
+ if (token[key] == null) return '';
63
+ if (typeof token[key] !== 'string') throw new TypeError('Invalid Markdown token ' + key);
64
+ return token[key];
65
+ };
66
+ return Object.freeze({
67
+ type: token.type, tag: scalar('tag'), nesting: token.nesting,
68
+ level: Number.isSafeInteger(token.level) && token.level >= 0 ? token.level : null,
69
+ block: token.block === true, hidden: token.hidden === true,
70
+ content: scalar('content'), markup: scalar('markup'), info: scalar('info'),
71
+ attrs: copyJSON(token.attrs ?? null, () => omitted('attributes', 'not-json')),
72
+ meta: metadata(token),
73
+ span: span(token), children: token.children ? copyTokens(token.children) : null,
74
+ });
75
+ }));
76
+ const projected = copyTokens(tokens);
77
+ const definitions = copyJSON({
78
+ references: env.references || {}, abbreviations: env.abbreviations || {},
79
+ footnotes: (env.footnotes?.list || []).map(note => ({label: note.label ?? null, count: note.count ?? 0})),
80
+ }, () => omitted('definitions', 'not-json'));
81
+ const omissions = [
82
+ ...counts.values(),
83
+ {domain: 'source', reason: 'original-text-and-unrepresented-gaps-are-not-included'},
84
+ {domain: 'frontmatter', reason: 'metadata-is-not-interpreted'},
85
+ {domain: 'rendering', reason: 'html-dom-heading-ids-layout-and-decoded-assets-are-not-included'},
86
+ {domain: 'authority', reason: 'will-permissions-and-edit-admission-are-not-evaluated'},
87
+ {domain: 'environment', reason: 'only-reference-abbreviation-and-footnote-definitions-are-included'},
88
+ ];
89
+ return Object.freeze({
90
+ format: 'rapier-parsed/1',
91
+ source: Object.freeze({revision, chars: source.length,
92
+ hash: Object.freeze({algorithm: 'sha256', encoding: 'utf16le', value: sourceHash(source)})}),
93
+ grammar: grammarCopy,
94
+ body: Object.freeze({start: bodyOffset, end: source.length, unit: 'utf16'}),
95
+ frontmatter: bodyOffset ? Object.freeze({start: 0, end: bodyOffset, unit: 'utf16'}) : null,
96
+ tokens: projected, definitions,
97
+ coverage: Object.freeze({tokens: 'complete', tokenCount: count,
98
+ spans: counts.has('spans:block-range-not-retained') || counts.has('spans:inline-range-not-retained') ? 'partial' : 'complete'}),
99
+ omissions: Object.freeze(omissions.map(Object.freeze)),
100
+ });
101
+ }
102
+
103
+ export function projectionMatches(projection, {source, revision, grammar} = {}) {
104
+ requireSource(source, revision);
105
+ const expected = copyGrammar(grammar);
106
+ if (!(projection?.format === 'rapier-parsed/1' && projection.source?.revision === revision &&
107
+ projection.source.chars === source.length && projection.source.hash?.algorithm === 'sha256' &&
108
+ projection.source.hash.encoding === 'utf16le' && projection.source.hash.value === sourceHash(source))) return false;
109
+ try { return JSON.stringify(copyGrammar(projection.grammar)) === JSON.stringify(expected); }
110
+ catch (_) { return false; }
111
+ }
112
+
113
+ function requireSource(source, revision) {
114
+ if (typeof source !== 'string' || !(typeof revision === 'string' && revision.length ||
115
+ Number.isSafeInteger(revision) && revision >= 0)) throw new TypeError('Exact source and its revision are required');
116
+ }
117
+
118
+ function copyGrammar(grammar) {
119
+ const copy = copyJSON(grammar, () => { throw new TypeError('Grammar identity and options must be JSON data'); });
120
+ if (!copy || typeof copy.id !== 'string' || !copy.id || !copy.options ||
121
+ typeof copy.options !== 'object' || Array.isArray(copy.options))
122
+ throw new TypeError('A grammar identity and its options are required');
123
+ return copy;
124
+ }
125
+
126
+ function copyJSON(value, omit, ancestors = new Set()) {
127
+ if (typeof value === 'number' && Number.isFinite(value)) return value === 0 ? 0 : value;
128
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
129
+ if (!value || typeof value !== 'object' || ancestors.has(value) ||
130
+ !Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { omit(); return null; }
131
+ ancestors.add(value);
132
+ let result;
133
+ if (Array.isArray(value)) result = Array.from({length: value.length}, (_, index) => {
134
+ const property = Object.getOwnPropertyDescriptor(value, index);
135
+ if (!property || !Object.hasOwn(property, 'value')) { omit(); return null; }
136
+ return copyJSON(property.value, omit, ancestors);
137
+ });
138
+ else result = Object.fromEntries(Object.keys(value).sort().map(key => {
139
+ const property = Object.getOwnPropertyDescriptor(value, key);
140
+ if (!Object.hasOwn(property, 'value')) { omit(); return [key, null]; }
141
+ return [key, copyJSON(property.value, omit, ancestors)];
142
+ }));
143
+ ancestors.delete(value);
144
+ return Object.freeze(result);
145
+ }
146
+
147
+ function sourceHash(source) {
148
+ const hash = sha256State(), bytes = new Uint8Array(32768);
149
+ for (let at = 0; at < source.length;) {
150
+ let used = 0;
151
+ while (at < source.length && used < bytes.length) {
152
+ const code = source.charCodeAt(at++); bytes[used++] = code & 255; bytes[used++] = code >>> 8;
153
+ }
154
+ hash.update(bytes.subarray(0, used));
155
+ }
156
+ return hash.finish();
157
+ }
@@ -90,9 +90,11 @@ function initMarkdownIt() {
90
90
  md.renderer.rules.softbreak = () =>
91
91
  sourceToken('\n', '\u2060', 'rapier-source-token--softbreak');
92
92
  md.core.ruler.before('text_join', 'rapier_escape_token', state => {
93
- for (const token of state.tokens) {
93
+ for (let at = 0; at < state.tokens.length; at++) {
94
+ const token = state.tokens[at];
94
95
  if (token.type !== 'inline' || !token.children) continue;
95
- for (const child of token.children) {
96
+ for (let next = 0; next < token.children.length; next++) {
97
+ const child = token.children[next];
96
98
  if (child.type === 'text_special' && child.info === 'escape' && child.content.length === 1 && !/[\\*`[\]_]/.test(child.content)) child.type = 'rapier_escape';
97
99
  }
98
100
  }
@@ -251,7 +253,8 @@ function initMarkdownIt() {
251
253
 
252
254
  md.core.ruler.push('rapier_setext_rule', function (state) {
253
255
  let lines = null;
254
- for (const token of state.tokens) {
256
+ for (let at = 0; at < state.tokens.length; at++) {
257
+ const token = state.tokens[at];
255
258
  if (token.type !== 'heading_open') continue;
256
259
  if (token.markup !== '=' && token.markup !== '-') continue;
257
260
  if (!token.map) continue;
@@ -489,14 +492,14 @@ function _rapierInstallSemanticProbe(instance) {
489
492
  const nextId = (targetEnv.__rapierInlineProbeSeq || 0) + 1;
490
493
  targetEnv.__rapierInlineProbeSeq = nextId;
491
494
  targetEnv.__rapierInlineProbeId = nextId;
492
- try {
493
- Object.defineProperty(outTokens, '_rapierInlineProbeId', { value: nextId, configurable: true });
494
- } catch (_) { outTokens._rapierInlineProbeId = nextId; }
495
+ const candidateStart = targetEnv.__rapierInlineCandidates?.length || 0;
495
496
  try {
496
497
  const result = originalParse.call(this, source, parser, targetEnv, outTokens);
497
- for (const token of outTokens) {
498
- try { Object.defineProperty(token, '_rapierInlineProbeId', { value: nextId, configurable: true }); }
499
- catch (_) { token._rapierInlineProbeId = nextId; }
498
+ if ((targetEnv.__rapierInlineCandidates?.length || 0) > candidateStart) {
499
+ Object.defineProperty(outTokens, '_rapierInlineProbeId', { value: nextId, configurable: true });
500
+ for (const token of outTokens) {
501
+ Object.defineProperty(token, '_rapierInlineProbeId', { value: nextId, configurable: true });
502
+ }
500
503
  }
501
504
  return result;
502
505
  } finally { targetEnv.__rapierInlineProbeId = priorId; }
@@ -504,6 +507,8 @@ function _rapierInstallSemanticProbe(instance) {
504
507
 
505
508
  instance.inline.ruler.before('escape', 'rapier_semantic_probe', function (state, silent) {
506
509
  if (silent || !state || !state.env) return false;
510
+ const opener = state.src.charCodeAt(state.pos);
511
+ if (opener !== 0x5b && opener !== 0x21) return false;
507
512
  const inlineId = Number(state.env.__rapierInlineProbeId || 0);
508
513
  if (!inlineId) return false;
509
514
  const src = state.src;
@@ -561,6 +566,8 @@ function _rapierInstallSemanticProbe(instance) {
561
566
  if (src.charCodeAt(after) === 0x28) {
562
567
  let destinationPos = after + 1;
563
568
  while (destinationPos < max && /[ \t\n]/.test(src.charAt(destinationPos))) destinationPos++;
569
+ const first = src.charCodeAt(destinationPos);
570
+ if (first !== 0x23 && first !== 0x3c && first !== 0x5c && first !== 0x26) return false;
564
571
  const destination = state.md.helpers.parseLinkDestination(src, destinationPos, max);
565
572
  if (!destination || !destination.ok || !String(destination.str || '').startsWith('#')) return false;
566
573
  const rawDestination = String(destination.str || '');
@@ -184,7 +184,8 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
184
184
  [side, settings?.geometry ? settings.geometry[side] / 20 : (side === 'top' ? 12 : 14) * 72 / 25.4]));
185
185
  const bodyWidth = Math.floor((width - margins.left - margins.right) * 4 / 3);
186
186
  const bodyHeight = Math.floor((height - margins.top - margins.bottom) * 4 / 3);
187
- if (bodyWidth < 1 || bodyHeight < 1 || bodyWidth * bodyHeight > 4 * 1024 * 1024) refuse('pdf_page_geometry_invalid');
187
+ if (bodyWidth < 1 || bodyHeight < 1) refuse('pdf_page_geometry_invalid');
188
+ const tiled = bodyWidth > CAPTURE_EDGE || bodyHeight > CAPTURE_EDGE || bodyWidth * bodyHeight > CAPTURE_PIXELS;
188
189
  const frame = document.createElement('iframe');
189
190
  let vectors, backdrop = null, nativeGlyphs, filmSession;
190
191
  let rejectAbort;
@@ -319,7 +320,21 @@ async function _rapierWritePrintPdf({root, css, fontCss = '', canonical, bom = f
319
320
  if (imageBytes > maxBytes) refuse('export_too_large');
320
321
  pages.push({rgb: packed});
321
322
  };
322
- for (let first = 0; first < count;) {
323
+ const tileWidth = Math.min(bodyWidth, CAPTURE_EDGE), tileHeight = Math.min(bodyHeight, CAPTURE_EDGE, Math.floor(CAPTURE_PIXELS / tileWidth));
324
+ for (let index = 0; tiled && index < count; index++) {
325
+ const tiles = [];
326
+ for (let y = 0; y < bodyHeight; y += tileHeight) for (let x = 0; x < bodyWidth; x += tileWidth) {
327
+ check();
328
+ const width = Math.min(tileWidth, bodyWidth - x), height = Math.min(tileHeight, bodyHeight - y);
329
+ const shot = await step(filmSession.raster({x: index * bodyWidth + x, y, width, height}));
330
+ if (shot?.width !== width || shot?.height !== height || !(shot.rgb instanceof Uint8Array)) refuse('pdf_render_unavailable');
331
+ await pack(shot.rgb);
332
+ shot.rgb = null;
333
+ tiles.push({...pages.pop(), x, y, width, height});
334
+ }
335
+ pages.push({tiles});
336
+ }
337
+ for (let first = 0; !tiled && first < count;) {
323
338
  check();
324
339
  const span = Math.min(share, count - first);
325
340
  try {
@@ -698,9 +713,10 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
698
713
  const marked = [];
699
714
  parentTree.push(marked);
700
715
  const held = pages[index], flate = !!held?.rgb;
701
- const picture = stream('/Type /XObject /Subtype /Image /Width ' + pixelWidth + ' /Height ' + pixelHeight + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /' + (flate ? 'FlateDecode' : 'DCTDecode'), flate ? held.rgb : held);
716
+ const tiles = held?.tiles || [{...(flate ? held : {jpeg: held}), x: 0, y: 0, width: pixelWidth, height: pixelHeight}];
717
+ const pictures = tiles.map((tile, at) => '/Im0_' + at + ' ' + stream('/Type /XObject /Subtype /Image /Width ' + tile.width + ' /Height ' + tile.height +
718
+ ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /' + (tile.rgb ? 'FlateDecode' : 'DCTDecode'), tile.rgb || tile.jpeg) + ' 0 R'), states = [];
702
719
  pages[index] = null;
703
- const pictures = ['/Im0 ' + picture + ' 0 R'], states = [];
704
720
  const emit = (drawing, left, top, clipWidth, clipHeight) => {
705
721
  let out = `q\n.75 0 0 -.75 ${decimal(left)} ${decimal(top)} cm\n0 0 ${clipWidth} ${clipHeight} re W n\n1 0 0 1 ${decimal(drawing.x)} 0 cm\n`;
706
722
  out += [drawing.clip.x, drawing.clip.y, drawing.clip.width, drawing.clip.height].map(decimal).join(' ') + ' re W n\n';
@@ -728,7 +744,8 @@ async function writeRasterPdf({pages, textPages, linkPages = [], vectorPages = [
728
744
  };
729
745
  let commands = '';
730
746
  for (const drawing of backdrop || []) commands += emit(drawing, 0, height, Math.round(width * 4 / 3), Math.round(height * 4 / 3));
731
- commands += '/Artifact BMC\nq\n' + [decimal(pixelWidth * .75), 0, 0, decimal(pixelHeight * .75), decimal(margins.left), decimal(height - margins.top - pixelHeight * .75)].join(' ') + ' cm\n/Im0 Do\nQ\nEMC\n';
747
+ commands += '/Artifact BMC\n' + tiles.map((tile, at) => 'q\n' + [decimal(tile.width * .75), 0, 0, decimal(tile.height * .75), decimal(margins.left + tile.x * .75),
748
+ decimal(height - margins.top - (tile.y + tile.height) * .75)].join(' ') + ' cm\n/Im0_' + at + ' Do\nQ\n').join('') + 'EMC\n';
732
749
  for (const drawing of vectorPages[index] || []) commands += emit(drawing, margins.left, height - margins.top, pixelWidth, pixelHeight);
733
750
  const textAlphas = new Map();
734
751
  for (const run of textPages[index]) {
@@ -119,7 +119,16 @@ const PAPERS = Object.freeze({
119
119
  a4: Object.freeze({name: 'a4', width: 11906, height: 16838, css: 'A4'}),
120
120
  a5: Object.freeze({name: 'a5', width: 8391, height: 11906, css: 'A5'}),
121
121
  legal: Object.freeze({name: 'legal', width: 12240, height: 20160, css: 'legal'}),
122
+ tabloid: Object.freeze({name: 'tabloid', width: 15840, height: 24480, css: '11in 17in'}),
123
+ a3: Object.freeze({name: 'a3', width: 16838, height: 23811, css: 'A3'}),
122
124
  });
125
+ function paperOf(value) {
126
+ const name = String(value || '').toLowerCase(), named = Object.hasOwn(PAPERS, name) ? PAPERS[name] : null;
127
+ if (named) return named;
128
+ const pixels = /^(\d{3,5})x(\d{3,5})px$/.exec(name), width = Number(pixels?.[1]), height = Number(pixels?.[2]);
129
+ return pixels && width <= 10000 && height <= 10000 ? Object.freeze({name, width: width * 15, height: height * 15, css: width + 'px ' + height + 'px'}) : null;
130
+ }
131
+ export const PAGE_SIZES = Object.freeze(['letter', 'legal', 'tabloid', 'a3', 'a4', 'a5', '1080x1080px', '1080x1350px', '1920x1080px', '1200x630px']);
123
132
  const EMPTY = Object.freeze({
124
133
  title: null, subtitle: null, lang: null, fontsize: null, mainfont: null, sansfont: null, monofont: null, defaultfont: null, linestretch: null,
125
134
  papersize: null, geometry: null, pagestyle: null, spacing: null,
@@ -334,7 +343,7 @@ export function readDocumentSettings(markdown) {
334
343
  const monofont = documentFont(take('monofont'));
335
344
  const defaultfont = documentFont(take('defaultfont'));
336
345
  const linestretch = leading(take('linestretch'));
337
- const papersize = PAPERS[String(take('papersize') || '').toLowerCase()] || null;
346
+ const papersize = paperOf(take('papersize'));
338
347
  const geometry = take('geometry') != null ? marginOf(take('geometry')) : null;
339
348
  const page = String(take('pagestyle') || '').toLowerCase();
340
349
  return Object.freeze({
@@ -359,14 +368,14 @@ export function updateDocumentSettings(markdown, patch) {
359
368
  text: (source[field.start - 1] === ':' ? ' ' : '') + text + (source[field.end] === '#' ? ' ' : '')});
360
369
  for (const [key, supplied] of Object.entries(patch || {})) {
361
370
  const font = ['mainfont', 'sansfont', 'monofont', 'defaultfont'].includes(key);
362
- if (!font && !['fontsize', 'linestretch', 'spacing', 'geometry'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
371
+ if (!font && !['fontsize', 'linestretch', 'spacing', 'geometry', 'papersize'].includes(key)) throw new TypeError('Unknown document setting: ' + key);
363
372
  const matches = fields.filter(item => item.key === key);
364
373
  if (matches.length > 1) throw new RangeError('Duplicate document setting: ' + key);
365
374
  const field = matches[0];
366
- const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : key === 'spacing' ? normalizeDocumentSpacing(supplied) : key === 'geometry' ? marginOf(supplied) : leading(supplied);
375
+ const value = supplied === null ? null : font ? documentFont(supplied) : key === 'fontsize' ? fontSize(supplied) : key === 'spacing' ? normalizeDocumentSpacing(supplied) : key === 'geometry' ? marginOf(supplied) : key === 'papersize' ? paperOf(supplied) : leading(supplied);
367
376
  if (supplied !== null && !value) throw new TypeError('Invalid document setting: ' + key);
368
- const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : key === 'spacing' ? JSON.stringify(value) : key === 'geometry' ? unquote(supplied).trim() : String(value.factor);
369
- if (value !== null && before[key] && (font ? before[key].family === value.family : key === 'fontsize' ? same(before.fontsize.unit, value.unit) : key === 'spacing' ? JSON.stringify(before.spacing) === encoded : key === 'geometry' ? ['top', 'right', 'bottom', 'left'].every(side => before.geometry[side] === value[side]) : before.linestretch.factor === value.factor)) continue;
377
+ const encoded = value === null ? '' : font ? JSON.stringify(value.family) : key === 'fontsize' ? value.step : key === 'spacing' ? JSON.stringify(value) : key === 'geometry' ? unquote(supplied).trim() : key === 'papersize' ? value.name : String(value.factor);
378
+ if (value !== null && before[key] && (font ? before[key].family === value.family : key === 'fontsize' ? same(before.fontsize.unit, value.unit) : key === 'spacing' ? JSON.stringify(before.spacing) === encoded : key === 'geometry' ? ['top', 'right', 'bottom', 'left'].every(side => before.geometry[side] === value[side]) : key === 'papersize' ? before.papersize.name === value.name : before.linestretch.factor === value.factor)) continue;
370
379
  if (key === 'fontsize' && value !== null && fontSize(encoded)?.unit !== value.unit) refuse(key);
371
380
  if (!field) { if (value !== null) append.push(key + ': ' + encoded + eol); continue; }
372
381
  if (field.opaque || field.invalid || field.multiline || field.block) refuse(key);
@@ -23,6 +23,7 @@ export function installMarkdownImages(md) {
23
23
  const reference = md.block.ruler.__rules__.find(row => row.name === 'reference').fn;
24
24
  md.block.ruler.at('reference', (state, start, end, silent) => {
25
25
  if (silent) return reference(state, start, end, silent);
26
+ if (state.src.charCodeAt(state.bMarks[start] + state.tShift[start]) !== 0x5B) return false;
26
27
  const saved = state.env.references, local = Object.create(null);
27
28
  state.env.references = local;
28
29
  let accepted;
@@ -40,12 +41,12 @@ export function installMarkdownImages(md) {
40
41
  md.inline.ruler.at('image', (state, silent) => {
41
42
  const start = state.pos;
42
43
  if (state.src[start] !== '!' || state.src[start + 1] !== '[') return false;
43
- const close = state.md.helpers.parseLinkLabel(state, start + 1, false);
44
44
  const before = state.tokens.length;
45
45
  if (!image(state, silent)) return false;
46
46
  if (!silent && state.tokens.length > before) {
47
47
  const token = state.tokens[state.tokens.length - 1], source = state.src.slice(start, state.pos);
48
48
  if (token.type === 'image') {
49
+ const close = state.md.helpers.parseLinkLabel(state, start + 1, false);
49
50
  let reference = null;
50
51
  if (close >= 0 && state.src[close + 1] !== '(') {
51
52
  const explicit = state.src[close + 1] === '[' ? state.src.slice(close + 2, state.pos - 1) : '';
@@ -0,0 +1,90 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ const NEWLINES = /\r\n?/g, NULLS = /\0/g;
4
+
5
+ function lineScanner(Base, Token) {
6
+ function StateBlock(src, md, env, tokens) {
7
+ this.bMarks = []; this.eMarks = []; this.tShift = []; this.sCount = []; this.bsCount = [];
8
+ this.blkIndent = 0; this.line = 0; this.lineMax = 0; this.tight = false; this.listIndent = -1;
9
+ this.parentType = 'root'; this.level = 0; this.Token = Token;
10
+ this.src = src; this.md = md; this.env = env; this.tokens = tokens;
11
+ const length = src.length;
12
+ for (let start = 0; start < length;) {
13
+ let at = start, indent = 0, offset = 0;
14
+ for (; at < length; at++) {
15
+ const code = src.charCodeAt(at);
16
+ if (code === 0x20) offset++;
17
+ else if (code === 0x09) offset += 4 - offset % 4;
18
+ else break;
19
+ indent++;
20
+ }
21
+ if (at === length) break;
22
+ let end = src.indexOf('\n', at);
23
+ if (end < 0) end = length;
24
+ this.bMarks.push(start); this.eMarks.push(end); this.tShift.push(indent); this.sCount.push(offset); this.bsCount.push(0);
25
+ start = end + 1;
26
+ }
27
+ this.bMarks.push(length); this.eMarks.push(length); this.tShift.push(0); this.sCount.push(0); this.bsCount.push(0);
28
+ this.lineMax = this.bMarks.length - 1;
29
+ }
30
+ StateBlock.prototype = Base.prototype;
31
+ return StateBlock;
32
+ }
33
+
34
+ function backticks(state, silent) {
35
+ const start = state.pos, src = state.src;
36
+ if (src.charCodeAt(start) !== 0x60) return false;
37
+ const max = state.posMax;
38
+ let pos = start + 1;
39
+ while (pos < max && src.charCodeAt(pos) === 0x60) pos++;
40
+ const marker = src.slice(start, pos), length = marker.length, runs = state.backticks;
41
+ const scanned = state.backticksScanned;
42
+ if (!scanned || pos < scanned || (runs[length] ?? -1) >= pos) {
43
+ let matchStart, matchEnd = pos;
44
+ while ((matchStart = src.indexOf('`', matchEnd)) !== -1 && matchStart < max) {
45
+ matchEnd = matchStart + 1;
46
+ while (src.charCodeAt(matchEnd) === 0x60) matchEnd++;
47
+ if (matchEnd > max) break;
48
+ const run = matchEnd - matchStart;
49
+ if (run === length) {
50
+ if (!silent) {
51
+ const token = state.push('code_inline', 'code', 0);
52
+ token.markup = marker;
53
+ let content = src.slice(pos, matchStart).replace(/\n/g, ' ');
54
+ if (content.startsWith(' ') && content.endsWith(' ') && /[^ ]/.test(content)) content = content.slice(1, -1);
55
+ token.content = content;
56
+ }
57
+ state.pos = matchEnd;
58
+ return true;
59
+ }
60
+ if (!(runs[run] >= matchStart)) runs[run] = matchStart;
61
+ }
62
+ if (matchStart === -1 && (!scanned || pos < scanned)) state.backticksScanned = pos;
63
+ }
64
+ if (!silent) state.pending += marker;
65
+ state.pos = pos;
66
+ return true;
67
+ }
68
+
69
+ const CLEAN_WEB_LINK = /^https?:\/\/([a-z0-9-]{1,63}(?:\.[a-z0-9-]{1,63})*)(?:\/[A-Za-z0-9\-._~/]*)?$/;
70
+ function cleanWebLink(url) {
71
+ const match = CLEAN_WEB_LINK.exec(url);
72
+ return match !== null && match[1].length <= 255;
73
+ }
74
+
75
+ export function tuneMarkdownEngine(md) {
76
+ if (md.__rapierEngineTuned) return md;
77
+ md.__rapierEngineTuned = true;
78
+ md.core.ruler.at('normalize', state => {
79
+ let src = state.src;
80
+ if (src.indexOf('\r') >= 0) src = src.replace(NEWLINES, '\n');
81
+ if (src.indexOf('\0') >= 0) src = src.replace(NULLS, '�');
82
+ state.src = src;
83
+ });
84
+ const BaseBlock = md.block.State;
85
+ md.block.State = lineScanner(BaseBlock, new BaseBlock('', md, {}, []).Token);
86
+ md.inline.ruler.at('backticks', backticks);
87
+ const normalizeLink = md.normalizeLink;
88
+ md.normalizeLink = url => cleanWebLink(url) ? url : normalizeLink.call(md, url);
89
+ return md;
90
+ }
@@ -251,8 +251,9 @@ export function createTypography({normalizeFontFamily, fontFamilyCss, FONT_STACK
251
251
  if (md.__rapierTypography) return md;
252
252
  md.__rapierTypography = true;
253
253
  md.core.ruler.after('linkify', 'rapier-typography', state => {
254
- for (const block of state.tokens) {
255
- if (block.type !== 'inline' || !block.children) continue;
254
+ for (let at = 0; at < state.tokens.length; at++) {
255
+ const block = state.tokens[at];
256
+ if (block.type !== 'inline' || !block.children || block.content.indexOf('<') < 0) continue;
256
257
  const stack = []; let depth = 0, overflow = 0;
257
258
  for (const token of block.children) {
258
259
  if (token.type !== 'html_inline') { depth += token.nesting || 0; continue; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rapier-markdown-kit",
3
- "version": "1.2.19",
3
+ "version": "1.2.21",
4
4
  "description": "Read, render and write Self-contained Markdown: one .md file that carries its pictures, drawings and layout, and that any Markdown app can read. HTML renderer, stylesheet, text layout, Will/1 agent permissions, and an edit history with authorship and offline merge. MIT, no dependencies.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/kit/markdown-it.d.mts",
45
45
  "default": "./dist/kit/markdown-it.mjs"
46
46
  },
47
+ "./parsed": {
48
+ "types": "./dist/kit/parsed.d.mts",
49
+ "default": "./dist/kit/parsed.mjs"
50
+ },
47
51
  "./layout": "./dist/kit/layout.mjs",
48
52
  "./marks": "./dist/kit/marks.mjs",
49
53
  "./model": "./dist/kit/model.mjs",