rapier-markdown-kit 1.1.0

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 (55) hide show
  1. package/LICENSE +40 -0
  2. package/README.md +102 -0
  3. package/dist/agent/vendor/pretext/LICENSE +21 -0
  4. package/dist/agent/vendor/pretext/NOTICE +15 -0
  5. package/dist/agent/vendor/pretext/SOURCE.json +85 -0
  6. package/dist/agent/vendor/pretext/analysis.js +1234 -0
  7. package/dist/agent/vendor/pretext/bidi.js +151 -0
  8. package/dist/agent/vendor/pretext/generated/bidi-data.js +3831 -0
  9. package/dist/agent/vendor/pretext/layout.js +308 -0
  10. package/dist/agent/vendor/pretext/line-break.js +636 -0
  11. package/dist/agent/vendor/pretext/line-text.js +44 -0
  12. package/dist/agent/vendor/pretext/measurement.js +189 -0
  13. package/dist/agent/vendor/pretext/package.json +3 -0
  14. package/dist/agent/vendor/pretext/rich-inline.js +291 -0
  15. package/dist/agent/will.mjs +167 -0
  16. package/dist/kit/assets.mjs +3 -0
  17. package/dist/kit/conformance/01-inline.expected.json +82 -0
  18. package/dist/kit/conformance/01-inline.md +10 -0
  19. package/dist/kit/conformance/02-width-x-align.expected.json +83 -0
  20. package/dist/kit/conformance/02-width-x-align.md +12 -0
  21. package/dist/kit/conformance/03-wrap-around-silhouette.expected.json +177 -0
  22. package/dist/kit/conformance/03-wrap-around-silhouette.md +12 -0
  23. package/dist/kit/conformance/04-wrap-box.expected.json +173 -0
  24. package/dist/kit/conformance/04-wrap-box.md +12 -0
  25. package/dist/kit/conformance/05-behind.expected.json +122 -0
  26. package/dist/kit/conformance/05-behind.md +12 -0
  27. package/dist/kit/conformance/06-front.expected.json +122 -0
  28. package/dist/kit/conformance/06-front.md +12 -0
  29. package/dist/kit/conformance/07-rotate-raster.expected.json +179 -0
  30. package/dist/kit/conformance/07-rotate-raster.md +12 -0
  31. package/dist/kit/conformance/08-drawing-rotation.expected.json +188 -0
  32. package/dist/kit/conformance/08-drawing-rotation.md +12 -0
  33. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +229 -0
  34. package/dist/kit/conformance/09-drawing-ring-interior.md +12 -0
  35. package/dist/kit/conformance/10-both-sides.expected.json +221 -0
  36. package/dist/kit/conformance/10-both-sides.md +10 -0
  37. package/dist/kit/conformance/11-neighbour-skips-image.expected.json +160 -0
  38. package/dist/kit/conformance/11-neighbour-skips-image.md +13 -0
  39. package/dist/kit/conformance/12-heading-barrier.expected.json +164 -0
  40. package/dist/kit/conformance/12-heading-barrier.md +12 -0
  41. package/dist/kit/conformance/13-rtl-text.expected.json +179 -0
  42. package/dist/kit/conformance/13-rtl-text.md +8 -0
  43. package/dist/kit/conformance/README.md +99 -0
  44. package/dist/kit/conformance/measurer.mjs +0 -0
  45. package/dist/kit/conformance/run.mjs +123 -0
  46. package/dist/kit/index.mjs +6 -0
  47. package/dist/kit/layout.mjs +2 -0
  48. package/dist/kit/marks.mjs +2 -0
  49. package/dist/kit/model.mjs +3 -0
  50. package/dist/kit/will.mjs +2 -0
  51. package/dist/layout/model.mjs +364 -0
  52. package/dist/spec/md-assets.mjs +323 -0
  53. package/dist/spec/md-layout.mjs +117 -0
  54. package/dist/spec/md-marks.mjs +78 -0
  55. package/package.json +33 -0
@@ -0,0 +1,323 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Pure text and markdown-it tokens: no DOM, no byte decoding, no globalThis.Rapier* (MIT kit; decoders stay in images/assets.mjs).
3
+
4
+ export const IMAGE_LIMITS = Object.freeze({bytes: 16 * 1024 * 1024, sourceChars: 25 * 1024 * 1024, dimension: 16384, pixels: 24000000, assets: 1024});
5
+ const parsers = new WeakMap();
6
+ const cached = [];
7
+ let configured = null, splitSource = null;
8
+ export function configureParser(parser, split) { configured = parser; splitSource = split; cached.length = 0; }
9
+ function fail(reason) { throw new Error(reason); }
10
+ export const normalizeLabel = label => String(label).trim().replace(/\s+/g, ' ').toLowerCase().toUpperCase();
11
+ // New titles have one identity before hashing, serializing or matching a repeated append.
12
+ // Existing document bytes are never rewritten; CommonMark entity decoding must not change a title.
13
+ export const assetTitle = value => String(value || '').replace(/[\r\n]/g, ' ').replace(/\0/g, '\ufffd');
14
+
15
+ // The same hook runs in the editor, its parse worker and the agent's parser.
16
+ export function installMarkdownImages(md) {
17
+ if (md.__rapierImagesInstalled) return md;
18
+ md.__rapierImagesInstalled = true;
19
+ const validate = md.validateLink;
20
+ md.validateLink = url => /^data:image\/(?:jxl|svg\+xml);base64,[A-Za-z0-9+/]+={0,2}$/i.test(url) || validate(url);
21
+ const reference = md.block.ruler.__rules__.find(row => row.name === 'reference').fn;
22
+ md.block.ruler.at('reference', (state, start, end, silent) => {
23
+ if (silent) return reference(state, start, end, silent);
24
+ const saved = state.env.references, local = Object.create(null);
25
+ state.env.references = local;
26
+ let accepted;
27
+ try { accepted = reference(state, start, end, false); }
28
+ finally { state.env.references = saved; }
29
+ if (accepted) {
30
+ const token = state.tokens[state.tokens.length - 1], label = token.meta.label;
31
+ token.meta.mdImageDefinition = local[label];
32
+ const refs = state.env.references ||= Object.create(null);
33
+ if (!Object.hasOwn(refs, label)) refs[label] = local[label];
34
+ }
35
+ return accepted;
36
+ });
37
+ const image = md.inline.ruler.__rules__.find(row => row.name === 'image').fn;
38
+ md.inline.ruler.at('image', (state, silent) => {
39
+ const start = state.pos;
40
+ if (state.src[start] !== '!' || state.src[start + 1] !== '[') return false;
41
+ const close = state.md.helpers.parseLinkLabel(state, start + 1, false);
42
+ const before = state.tokens.length;
43
+ if (!image(state, silent)) return false;
44
+ if (!silent && state.tokens.length > before) {
45
+ const token = state.tokens[state.tokens.length - 1], source = state.src.slice(start, state.pos);
46
+ if (token.type === 'image') {
47
+ let reference = null;
48
+ if (close >= 0 && state.src[close + 1] !== '(') {
49
+ const explicit = state.src[close + 1] === '[' ? state.src.slice(close + 2, state.pos - 1) : '';
50
+ reference = md.utils.normalizeReference(explicit || state.src.slice(start + 2, close));
51
+ }
52
+ (token.meta ||= {}).mdImage = {source, reference};
53
+ }
54
+ }
55
+ return true;
56
+ });
57
+ return md;
58
+ }
59
+
60
+ function parserFor(factory = configured || globalThis.markdownit) {
61
+ if (factory?.block && factory?.utils) return factory;
62
+ if (configured && factory === globalThis.markdownit) return configured;
63
+ if (typeof factory !== 'function') return fail('markdown_parser_unavailable');
64
+ let parser = parsers.get(factory);
65
+ if (!parser) { parser = installMarkdownImages(factory({html: true, maxNesting: 20})); parsers.set(factory, parser); }
66
+ return parser;
67
+ }
68
+ export {parserFor as markdownParser};
69
+ export const markdownBodyOffset = source => splitSource ? splitSource(source).bodyOffset : 0;
70
+
71
+ export function dataImage(url) {
72
+ if (typeof url !== 'string' || url.length > IMAGE_LIMITS.sourceChars) return null;
73
+ const match = /^data:(image\/(?:jxl|png|jpeg|webp|svg\+xml));base64,([A-Za-z0-9+/]+={0,2})$/i.exec(url);
74
+ if (!match || match[2].length % 4) return null;
75
+ const padding = match[2].endsWith('==') ? 2 : match[2].endsWith('=') ? 1 : 0;
76
+ const byteLength = match[2].length / 4 * 3 - padding;
77
+ if (!byteLength || byteLength > IMAGE_LIMITS.bytes) return null;
78
+ return {codec: match[1].toLowerCase(), byteLength, payloadStart: match[0].length - match[2].length};
79
+ }
80
+
81
+ // Markdown-it owns recognition, nesting, duplicate precedence and escapes; block parse only.
82
+ export function parseAssets(source, factory = configured || globalThis.markdownit, bodyOffset = 0) {
83
+ if (typeof source !== 'string') return fail('image_source_invalid');
84
+ if (source.length > IMAGE_LIMITS.sourceChars) return fail('image_source_limit');
85
+ if (!source.includes(']:')) return {assets: new Map(), blocks: [], duplicateLabels: new Set(), appendixStart: source.length, references: Object.create(null)};
86
+ const prior = cached.find(row => row.source === source && row.factory === factory && row.bodyOffset === bodyOffset);
87
+ if (prior) return prior.index;
88
+ const parser = parserFor(factory), env = {}, tokens = [];
89
+ const starts = [bodyOffset];
90
+ for (let at = bodyOffset; at < source.length; at++) {
91
+ if (source[at] === '\r') { if (source[at + 1] === '\n') at++; starts.push(at + 1); }
92
+ else if (source[at] === '\n') starts.push(at + 1);
93
+ }
94
+ parser.block.parse(source.slice(bodyOffset).replace(/\r\n?/g, '\n').replace(/\0/g, '\ufffd'), parser, env, tokens);
95
+ const references = env.references || Object.create(null), assets = new Map(), blocks = [], seen = new Set(), duplicateLabels = new Set();
96
+ for (const token of tokens) {
97
+ if (token.type !== 'reference_definition' || !token.map) continue;
98
+ const id = token.meta.label, definition = token.meta.mdImageDefinition, first = !seen.has(id);
99
+ if (!first) duplicateLabels.add(id);
100
+ seen.add(id);
101
+ const info = dataImage(definition?.href);
102
+ if (!info) continue;
103
+ const start = starts[token.map[0]], stop = starts[token.map[1]] ?? source.length;
104
+ let end = stop;
105
+ if (source[end - 1] === '\n') end--;
106
+ if (source[end - 1] === '\r') end--;
107
+ // Repeated definitions are source, not additional image owners.
108
+ const raw = source.slice(start, end), found = raw.indexOf(definition.href);
109
+ const repeated = found >= 0 ? raw.indexOf(definition.href, found + 1) : -1;
110
+ const labelContainsUrl = definition.href.length <= id.length && id.includes(parser.utils.normalizeReference(definition.href));
111
+ const urlStart = found >= 0 && repeated < 0 && !labelContainsUrl ? start + found : null;
112
+ const row = {id, label: id, source: raw, url: definition.href, title: definition.title || '', start, end,
113
+ ...info, urlStart, urlEnd: urlStart == null ? null : urlStart + definition.href.length,
114
+ payloadStart: urlStart == null ? null : urlStart + info.payloadStart, payloadEnd: urlStart == null ? null : urlStart + definition.href.length, status: 'unverified', active: first, topLevel: token.level === 0};
115
+ blocks.push(row);
116
+ if (first) assets.set(id, row);
117
+ }
118
+ if (assets.size > IMAGE_LIMITS.assets) for (const asset of assets.values()) asset.status = 'asset_count_limit';
119
+ let appendixStart = source.length;
120
+ for (let index = blocks.length - 1; index >= 0; index--) {
121
+ const block = blocks[index];
122
+ if (!block.active || !block.topLevel || !/^[ \t\r\n]*$/.test(source.slice(block.end, appendixStart))) break;
123
+ appendixStart = block.start;
124
+ }
125
+ const index = {assets, blocks, duplicateLabels, appendixStart, references};
126
+ cached.unshift({source, factory, bodyOffset, index});
127
+ if (cached.length > 3) cached.pop();
128
+ return index;
129
+ }
130
+ export function documentAssets(source, factory) { return parseAssets(source, factory, markdownBodyOffset(source)); }
131
+ export function imageEnvironment(source, factory) { return {references: Object.assign(Object.create(null), documentAssets(source, factory).references)}; }
132
+
133
+ function imageUses(source, factory, env = {}) {
134
+ const parser = parserFor(factory), offset = markdownBodyOffset(source), starts = [offset], normalized = [0];
135
+ let position = 0;
136
+ for (let at = offset; at < source.length; at++, position++) {
137
+ if (source[at] === '\r') { if (source[at + 1] === '\n') at++; }
138
+ else if (source[at] !== '\n') continue;
139
+ starts.push(at + 1); normalized.push(position + 1);
140
+ }
141
+ const originalOffset = at => {
142
+ let low = 0, high = normalized.length;
143
+ while (low + 1 < high) {
144
+ const mid = (low + high) >>> 1;
145
+ if (normalized[mid] <= at) low = mid; else high = mid;
146
+ }
147
+ return starts[low] + at - normalized[low];
148
+ };
149
+ const body = source.slice(offset).replace(/\r\n?/g, '\n').replace(/\0/g, '\ufffd');
150
+ const tokens = parser.parse(body, env), urls = new Set(), images = [], html = [];
151
+ const visit = rows => {
152
+ for (const token of rows || []) {
153
+ if (token.type === 'image') urls.add(token.attrGet('src'));
154
+ if (token.type === 'link_open') urls.add(token.attrGet('href'));
155
+ if (token.type === 'html_inline' || token.type === 'html_block') html.push(parser.utils.unescapeAll(token.content));
156
+ if (token.children) visit(token.children);
157
+ }
158
+ };
159
+ visit(tokens);
160
+ for (let index = 0; index < tokens.length; index++) {
161
+ const token = tokens[index], map = token.map || tokens[index - 1]?.map;
162
+ if (token.type !== 'inline' || !map) continue;
163
+ const groups = new Map();
164
+ for (const image of token.children || []) {
165
+ const meta = image.type === 'image' && image.meta?.mdImage;
166
+ if (!meta?.source) continue;
167
+ if (!groups.has(meta.source)) groups.set(meta.source, []);
168
+ groups.get(meta.source).push({id: meta.reference, url: image.attrGet('src')});
169
+ }
170
+ const start = normalized[map[0]], end = normalized[map[1]] ?? body.length;
171
+ if (!Number.isSafeInteger(start) || end <= start) continue;
172
+ for (const [raw, rows] of groups) {
173
+ const found = [];
174
+ for (let at = body.indexOf(raw, start); at >= 0 && at + raw.length <= end; at = body.indexOf(raw, at + raw.length)) found.push(at);
175
+ // Repeated text in code or an unmapped container is not deletion proof.
176
+ if (found.length !== rows.length) continue;
177
+ found.forEach((at, index) => images.push({...rows[index], start: originalOffset(at), end: originalOffset(at + raw.length)}));
178
+ }
179
+ }
180
+ const simple = tokens.length === 3 && tokens[1].type === 'inline' && tokens[1].level === 1 &&
181
+ images.length === (tokens[1].children || []).filter(row => row.type === 'image').length;
182
+ return {urls, images, simple, html: html.join('\n').toUpperCase()};
183
+ }
184
+
185
+ // A parse failure answers true: uncertainty keeps the definition.
186
+ export function referenceOccurs(source, id, factory) {
187
+ try { return imageUses(source, factory).images.some(image => image.id === id); }
188
+ catch (_) { return true; }
189
+ }
190
+
191
+ function occurrenceChange(image, splices) {
192
+ let {start, end} = image, changed = false;
193
+ for (const row of splices) {
194
+ const stop = row.pos + row.removed.length;
195
+ if (row.pos <= start && stop >= end && stop > row.pos) return true;
196
+ const shift = row.inserted.length - row.removed.length;
197
+ if (stop <= start) { start += shift; end += shift; continue; }
198
+ if (row.pos > end || row.pos === end && row.removed.length) continue;
199
+ if (row.pos < start || stop > end) return null;
200
+ changed = true; end += shift;
201
+ }
202
+ return changed ? {start, end} : null;
203
+ }
204
+
205
+ // A permissive gate keeps ordinary typing out of full-document image parsing.
206
+ // Recognition and retirement proof still belong to Markdown-it below.
207
+ export function mayRetireImageDefinitions(source, splices, factory) {
208
+ if (!/data:image\//i.test(source)) return false;
209
+ const original = (at, right, count) => {
210
+ for (let index = count - 1; index >= 0; index--) {
211
+ const row = splices[index], end = row.pos + row.inserted.length;
212
+ if (at >= end) at += row.removed.length - row.inserted.length;
213
+ else if (at > row.pos) at = row.pos + (right ? row.removed.length : 0);
214
+ }
215
+ return at;
216
+ };
217
+ const possible = splices.some((row, index) => {
218
+ if (!row.removed && !row.inserted) return false;
219
+ const start = original(row.pos, false, index), end = original(row.pos + row.removed.length, true, index);
220
+ const image = source.lastIndexOf('![', end);
221
+ return image >= 0 && (image >= start || Math.max(source.lastIndexOf('\n\n', start),
222
+ source.lastIndexOf('\r\n\r\n', start), source.lastIndexOf('\r\r', start)) < image);
223
+ });
224
+ if (!possible || splices.length !== 1) return possible;
225
+ const row = splices[0];
226
+ if (row.removed.includes('![') || /[\r\n]/.test(row.removed) || /[\r\n]/.test(row.inserted)) return true;
227
+ const start = row.pos ? Math.max(source.lastIndexOf('\n', row.pos - 1), source.lastIndexOf('\r', row.pos - 1)) + 1 : 0;
228
+ let end = source.length;
229
+ for (const eol of ['\r', '\n']) { const at = source.indexOf(eol, row.pos); if (at >= 0) end = Math.min(end, at); }
230
+ if (end - start > 8192 || row.pos + row.removed.length > end ||
231
+ start !== markdownBodyOffset(source) && !/(?:\n\n|\r\n\r\n|\r\r)$/.test(source.slice(Math.max(0, start - 4), start)) ||
232
+ end !== source.length && !/^(?:\n\n|\r\n\r\n|\r\r)/.test(source.slice(end, end + 4))) return true;
233
+ try {
234
+ const local = imageUses(source.slice(start, end), factory, imageEnvironment(source, factory));
235
+ return !local.simple || local.images.some(image => occurrenceChange({...image,
236
+ start: start + image.start, end: start + image.end}, splices));
237
+ } catch (_) { return true; }
238
+ }
239
+
240
+ // Whole deletion or a valid replacement at the rebased occurrence may retire
241
+ // bytes. Unfinished source edits and pre-existing unused definitions do not.
242
+ export function retireDeletedImageDefinitions(before, after, splices, factory) {
243
+ if (before === after || !mayRetireImageDefinitions(before, splices, factory)) return [];
244
+ try {
245
+ const prior = documentAssets(before, factory), current = documentAssets(after, factory);
246
+ if (!prior.assets.size || !current.assets.size) return [];
247
+ const changes = imageUses(before, factory).images.filter(image => image.id)
248
+ .map(image => ({image, change: occurrenceChange(image, splices)})).filter(row => row.change);
249
+ if (!changes.length) return [];
250
+ const now = imageUses(after, factory);
251
+ const deleted = new Set(changes.filter(({image, change}) => change === true || now.images.some(row =>
252
+ row.start === change.start && row.end === change.end && row.url !== image.url)).map(row => row.image.id));
253
+ if (!deleted.size) return [];
254
+ let remaining = '', at = 0;
255
+ for (const row of current.blocks) { remaining += after.slice(at, row.start); at = row.end; }
256
+ remaining = normalizeLabel(remaining + after.slice(at)).replace(/\[\s+/g, '[');
257
+ return current.blocks.filter(row => {
258
+ const old = prior.assets.get(row.id);
259
+ return deleted.has(row.id) && row.active && row.topLevel && old?.topLevel &&
260
+ row.status === 'unverified' && old.source === row.source && !now.urls.has(row.url) &&
261
+ !prior.duplicateLabels.has(row.id) && !current.duplicateLabels.has(row.id) &&
262
+ !remaining.includes('[' + row.id) && !now.html.includes(row.id) && !now.html.includes(row.url.toUpperCase());
263
+ }).sort((a, b) => b.start - a.start).map(row => ({pos: row.start, removed: row.source, inserted: ''}));
264
+ } catch (_) { return []; }
265
+ }
266
+
267
+ // Disclosure stays conservative even for code examples and malformed definitions.
268
+ // Keep labels and readable prose; omit only the binary destination.
269
+ export function assetOmissions(source) {
270
+ if (typeof source !== 'string') return [];
271
+ const pattern = /data:(image\/[a-z0-9.+-]+);base64,[A-Za-z0-9+/=\\;&%#._~-]*/ig, spans = [];
272
+ for (let match; (match = pattern.exec(source));) {
273
+ const value = match[0], payload = value.slice(value.indexOf(',') + 1);
274
+ const bytes = payload.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(payload)
275
+ ? payload.length / 4 * 3 - (payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0) : null;
276
+ spans.push({start: match.index, end: pattern.lastIndex, chars: value.length,
277
+ domain: 'image_bytes', reason: 'embedded_asset', profile: 'embedded', type: match[1].toLowerCase(), bytes});
278
+ }
279
+ return spans;
280
+ }
281
+ export function isAssetBlock(raw) {
282
+ if (typeof raw !== 'string' || !/data:image\//i.test(raw)) return false;
283
+ const parsed = parseAssets(raw);
284
+ if (!parsed.blocks.length) return false;
285
+ let at = 0;
286
+ for (const block of parsed.blocks) {
287
+ if (!block.active || !block.topLevel || !/^[ \t\r\n]*$/.test(raw.slice(at, block.start))) return false;
288
+ at = block.end;
289
+ }
290
+ return /^[ \t\r\n]*$/.test(raw.slice(at));
291
+ }
292
+
293
+ // Mirrors engine.js _rapierEscapeImageAlt without importing the editor bundle.
294
+ export function escapeImageAlt(value) {
295
+ return String(value ?? '').replace(/[\r\n]+/g, ' ')
296
+ .replace(/[\\[\]*_`~^+$=<&]/g, '\\$&').replace(/\|(?=[1-9]\d{1,3}$)/, '\\|');
297
+ }
298
+ export function serializeAsset(asset) {
299
+ if (!asset || !/^[a-z0-9-]+$/i.test(asset.label) || !dataImage(asset.url)) return fail('image_metadata_invalid');
300
+ const value = assetTitle(asset.title), title = value ? ' "' + value.replace(/&/g, '&amp;').replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"' : '';
301
+ return '[' + asset.label + ']: ' + asset.url + title;
302
+ }
303
+ // Pure: re-derived at commit against the moved document; appends only at the text's end (docs/kernel.md).
304
+ export function appendAssetText(source, asset) {
305
+ if (typeof source !== 'string' || !asset || normalizeLabel(asset.label) !== asset.id) return fail('image_source_invalid');
306
+ const parsed = documentAssets(source), title = assetTitle(asset.title);
307
+ const existing = [...parsed.assets.values()].find(row => row.url === asset.url && row.title === title);
308
+ if (existing) return {source, id: existing.id, reference: existing.label, added: false, suffix: ''};
309
+ const previous = parsed.references[asset.id];
310
+ const block = serializeAsset(asset);
311
+ if (previous) {
312
+ if (previous.href !== asset.url || (previous.title || '') !== title) return fail('image_label_conflict');
313
+ return {source, id: asset.id, reference: asset.label, added: false, suffix: ''};
314
+ }
315
+ if (parsed.assets.size >= IMAGE_LIMITS.assets) return fail('image_asset_count_limit');
316
+ const eol = /\r\n|\n|\r/.exec(source)?.[0] || '\n', tail = /(?:\r\n?|\n)[ \t]*$/.exec(source);
317
+ const separator = !source || tail && /[\r\n][ \t]*$/.test(source.slice(0, tail.index)) ? '' : tail ? eol : eol + eol;
318
+ const suffix = separator + block + eol, nextSource = source + suffix;
319
+ if (nextSource.length > IMAGE_LIMITS.sourceChars) return fail('image_source_limit');
320
+ if (documentAssets(nextSource).references[asset.id]?.href !== asset.url) return fail('image_appendix_not_available');
321
+ return {source: nextSource, id: asset.id, reference: asset.label, added: true, suffix};
322
+ }
323
+ export async function appendAsset(source, asset) { return appendAssetText(source, asset); }
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ // `rotate`: degrees in (-180, 180], one decimal, omitted when 0; never written for a Rapier drawing (its turn lives in its SVG).
4
+ export const fields = new Set(['align', 'width', 'wrap', 'x', 'y', 'rotate']);
5
+ export const alignments = new Set(['left', 'center', 'right', 'justify']);
6
+ // `behind`/`front`: out of flow; the paragraph lays out as though the picture were absent.
7
+ export const wraps = new Set(['around', 'box', 'behind', 'front']);
8
+ const has = (value, key) => Object.hasOwn(value, key);
9
+ const percent = value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100;
10
+ const oneDecimalDegrees = value => typeof value === 'number' && Number.isFinite(value) &&
11
+ value > -180 && value <= 180 && Math.abs(value * 10 - Math.round(value * 10)) < 1e-9;
12
+
13
+ /** Validate values independently of their Markdown attachment context. */
14
+ export function validLayout(value) {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
16
+ Object.keys(value).some(key => !fields.has(key))) return false;
17
+ return (!has(value, 'align') || alignments.has(value.align)) &&
18
+ (!has(value, 'width') || percent(value.width) && value.width > 0) &&
19
+ (!has(value, 'wrap') || wraps.has(value.wrap)) &&
20
+ (!has(value, 'x') || percent(value.x) && (has(value, 'wrap') || has(value, 'width'))) &&
21
+ (!has(value, 'y') || typeof value.y === 'number' && Number.isFinite(value.y) && value.y >= -50 && has(value, 'wrap')) &&
22
+ (!has(value, 'rotate') || oneDecimalDegrees(value.rotate)) &&
23
+ !(has(value, 'align') && (has(value, 'wrap') || has(value, 'x')));
24
+ }
25
+
26
+ /** Parse the closed v1 grammar. Geometry values are returned as numbers. */
27
+ export function parseLayout(comment) {
28
+ if (typeof comment !== 'string' || /[^\t\x20-\x7e]/.test(comment)) return null;
29
+ const match = /^<!--md-layout:v1[ \t]+([^\r\n]*?)[ \t]*-->$/.exec(comment);
30
+ if (!match) return null;
31
+ const pairs = match[1].replace(/^[ \t]+|[ \t]+$/g, '').split(/[ \t]+/), value = {};
32
+ if (pairs.length > fields.size) return null;
33
+ for (const pair of pairs) {
34
+ const field = /^([a-z]+)=(.+)$/.exec(pair);
35
+ if (!field || !fields.has(field[1]) || has(value, field[1])) return null;
36
+ const [, key, raw] = field;
37
+ if (key === 'width' || key === 'x') {
38
+ const number = /^(0|[1-9]\d*)(?:\.(\d+))?%$/.exec(raw);
39
+ if (!number || number[1].length > 3 || Number(number[1]) > 100 ||
40
+ number[1] === '100' && /[1-9]/.test(number[2] || '')) return null;
41
+ value[key] = Number(raw.slice(0, -1));
42
+ } else if (key === 'y') {
43
+ // Negative allowed; signed like rotate, never "-0em".
44
+ const number = /^(-)?(0|[1-9]\d*)(?:\.\d+)?em$/.exec(raw);
45
+ if (!number || (number[1] && Number(raw.slice(1, -2)) === 0)) return null;
46
+ value.y = Number(raw.slice(0, -2));
47
+ } else if (key === 'rotate') {
48
+ // No '+', exponent, '°' or "-0deg"; 0 is unsigned.
49
+ const number = /^(-)?(0|[1-9]\d*)(?:\.(\d))?deg$/.exec(raw);
50
+ if (!number) return null;
51
+ const magnitude = Number(number[2] + (number[3] ? '.' + number[3] : ''));
52
+ if (number[1] && magnitude === 0) return null;
53
+ value.rotate = number[1] ? -magnitude : magnitude;
54
+ } else value[key] = raw;
55
+ }
56
+ return validLayout(value) ? value : null;
57
+ }
58
+
59
+ // Comment delimiters are unsafe in sanitized attributes. Markdown stays literal;
60
+ // rendered attributes carry encodeURIComponent(marker), including its '%' signs.
61
+ export function decodeLayoutAttribute(value) {
62
+ try { return decodeURIComponent(value || ''); } catch (_) { return ''; }
63
+ }
64
+
65
+ export const parseLayoutAttribute = value => parseLayout(decodeLayoutAttribute(value));
66
+
67
+ export function imageStyle(value) {
68
+ if (!validLayout(value) || value.width == null) return '';
69
+ const width = value.width;
70
+ const left = value.x == null ? '' : ';display:block;margin-left:' +
71
+ decimal(Number(Math.max(0, Math.min(100 - width, value.x - width / 2)).toFixed(6))) + '%;margin-right:0';
72
+ return 'width:' + decimal(width) + '%;height:auto' + left;
73
+ }
74
+
75
+ // The one wrap-owner rule (layout/browser.js, layout/interchange.js, editor/share.js): prose after, else before, skipping pictures,
76
+ // metadata and empty paragraphs; any other block is a barrier in that direction.
77
+ export function wrapNeighbour(node, classify) {
78
+ for (const step of [n => n && n.nextElementSibling, n => n && n.previousElementSibling]) {
79
+ for (let sibling = step(node); sibling; sibling = step(sibling)) {
80
+ const kind = classify(sibling);
81
+ if (kind && typeof kind === 'object') return kind.stop;
82
+ if (kind === 'metadata') continue;
83
+ if (kind === 'prose') return sibling;
84
+ if (kind !== 'picture') break;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ // Narrower than this, a line breaker splits words letter by letter.
91
+ export function wrapColumnFloor(fontSize) {
92
+ return Math.max(40, 3.5 * (fontSize || 16));
93
+ }
94
+
95
+ function decimal(value) {
96
+ if (value < 0) return '-' + decimal(-value);
97
+ const text = String(value);
98
+ if (!text.includes('e')) return text;
99
+ const [mantissa, exponent] = text.split('e');
100
+ const digits = mantissa.replace('.', '');
101
+ const point = (mantissa.includes('.') ? mantissa.indexOf('.') : mantissa.length) + Number(exponent);
102
+ if (point <= 0) return '0.' + '0'.repeat(-point) + digits;
103
+ if (point >= digits.length) return digits + '0'.repeat(point - digits.length);
104
+ return digits.slice(0, point) + '.' + digits.slice(point);
105
+ }
106
+
107
+ /** Write only the supplied facts; no alignment is implied by absence. */
108
+ export function formatLayout(value) {
109
+ if (!validLayout(value)) throw new TypeError('invalid_markdown_layout');
110
+ const pairs = [];
111
+ for (const key of fields) {
112
+ if (has(value, key) && !(key === 'y' && value.y === 0) && !(key === 'rotate' && value.rotate === 0)) pairs.push(key + '=' +
113
+ (key === 'width' || key === 'x' ? decimal(value[key]) + '%' : key === 'y' ? decimal(value.y) + 'em' :
114
+ key === 'rotate' ? decimal(value.rotate) + 'deg' : value[key]));
115
+ }
116
+ return pairs.length ? '<!--md-layout:v1 ' + pairs.join(' ') + '-->' : '';
117
+ }
@@ -0,0 +1,78 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The one place these literal grammars are written (docs/markdown-standard.md, "Text colour", "Page break").
3
+
4
+ const TEXT_COLOR_NAMES = Object.freeze({ green: '#1c7547', red: '#b32034', blue: '#175dc4', gold: '#865818', purple: '#7924d7' });
5
+ const TEXT_COLOR_HEX_TO_NAME = Object.freeze(
6
+ Object.fromEntries(Object.entries(TEXT_COLOR_NAMES).map(([name, hex]) => [hex, name]))
7
+ );
8
+ const COLOR_NAME_ALT = Object.keys(TEXT_COLOR_NAMES).join('|');
9
+ const COLOR_VALUE = '(?:#[0-9a-f]{6}|' + COLOR_NAME_ALT + ')';
10
+ const COLOR_CLOSE = '<!--/c-->';
11
+ const PAGE_BREAK_MARKER = '<!--md-break:v1 page-->';
12
+
13
+ export { TEXT_COLOR_NAMES, TEXT_COLOR_HEX_TO_NAME, COLOR_CLOSE, PAGE_BREAK_MARKER };
14
+
15
+ export function formatColorOpen(hex) {
16
+ return '<!--c ' + (TEXT_COLOR_HEX_TO_NAME[hex] || hex) + '-->';
17
+ }
18
+
19
+ export function formatColorRun(hex, content) {
20
+ return formatColorOpen(hex) + content + COLOR_CLOSE;
21
+ }
22
+
23
+ // Relocated into the parse Worker by .toString() (engine.js _buildParseWorkerSource): read only parameters, TEXT_COLOR_NAMES and COLOR_CLOSE.
24
+
25
+ // Returns {hex, length in UTF-16 units} or null; not end-anchored.
26
+ export function matchColorOpen(text) {
27
+ if (typeof text !== 'string') return null;
28
+ const match = new RegExp('^<!--c (#[0-9a-f]{6}|' + Object.keys(TEXT_COLOR_NAMES).join('|') + ')-->').exec(text);
29
+ if (!match) return null;
30
+ const value = match[1];
31
+ return { hex: value.charCodeAt(0) === 0x23 /* '#' */ ? value : TEXT_COLOR_NAMES[value], length: match[0].length };
32
+ }
33
+
34
+ export function parseColorOpen(comment) {
35
+ if (typeof comment !== 'string') return null;
36
+ const match = new RegExp('^<!--c (#[0-9a-f]{6}|' + Object.keys(TEXT_COLOR_NAMES).join('|') + ')-->$').exec(comment);
37
+ if (!match) return null;
38
+ const value = match[1];
39
+ return value.charCodeAt(0) === 0x23 /* '#' */ ? value : TEXT_COLOR_NAMES[value];
40
+ }
41
+
42
+ export function isColorClose(comment) {
43
+ return comment === COLOR_CLOSE;
44
+ }
45
+
46
+ export function scanColorMarkers(source) {
47
+ const text = String(source == null ? '' : source);
48
+ const scan = new RegExp('<!--c (?:(#[0-9a-f]{6})|(' + COLOR_NAME_ALT + '))-->|<!--/c-->', 'g');
49
+ const markers = [];
50
+ let match;
51
+ while ((match = scan.exec(text))) {
52
+ const hex = match[1] || (match[2] ? TEXT_COLOR_NAMES[match[2]] : null) || null;
53
+ markers.push({ start: match.index, end: match.index + match[0].length, hex });
54
+ }
55
+ return markers;
56
+ }
57
+
58
+ export function stripColorMarkers(text) {
59
+ return String(text == null ? '' : text).replace(new RegExp('<!--(?:c ' + COLOR_VALUE + '|/c)-->', 'g'), '');
60
+ }
61
+
62
+ export function hasColorMarker(text) {
63
+ return new RegExp('<!--(?:c ' + COLOR_VALUE + '|/c)-->').test(String(text == null ? '' : text));
64
+ }
65
+
66
+ // The only spelling.
67
+ export function formatPageBreak() {
68
+ return PAGE_BREAK_MARKER;
69
+ }
70
+
71
+ export function isPageBreakLine(text) {
72
+ return typeof text === 'string' && /^<!--md-break:v1 page-->\r?$/.test(text);
73
+ }
74
+
75
+ // Also relocated by .toString(); reads only its own regex.
76
+ export function isPageBreakBlock(text) {
77
+ return typeof text === 'string' && /^<!--md-break:v1 page-->\r?\n?$/.test(text);
78
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "rapier-markdown-kit",
3
+ "version": "1.1.0",
4
+ "description": "The Rapier Markdown standard's layout grammar, occupancy model, line planner, Will grammar and picture appendix -- MIT, no Rapier required.",
5
+ "author": "Jack Skipworth",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
11
+ "sideEffects": false,
12
+ "dependencies": {},
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "main": "./dist/kit/index.mjs",
19
+ "exports": {
20
+ ".": "./dist/kit/index.mjs",
21
+ "./layout": "./dist/kit/layout.mjs",
22
+ "./marks": "./dist/kit/marks.mjs",
23
+ "./model": "./dist/kit/model.mjs",
24
+ "./will": "./dist/kit/will.mjs",
25
+ "./assets": "./dist/kit/assets.mjs",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/jackskip22/rapier-plugins.git",
31
+ "directory": "npm/rapier-markdown-kit"
32
+ }
33
+ }