rapier-markdown-kit 1.2.45 → 1.2.48

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 (52) hide show
  1. package/LICENSE +12 -5
  2. package/README.md +134 -11
  3. package/dist/agent/vendor/BROWSER-ENTITIES.json +36 -0
  4. package/dist/agent/vendor/MARKDOWN-RUNTIME.json +136 -0
  5. package/dist/agent/vendor/MARKDOWNIT.json +582 -0
  6. package/dist/agent/vendor/NOTICE.txt +60 -0
  7. package/dist/agent/vendor/markdown-licenses/entities-LICENSE.txt +11 -0
  8. package/dist/agent/vendor/markdown-licenses/linkify-it-LICENSE.txt +22 -0
  9. package/dist/agent/vendor/markdown-licenses/markdown-it-LICENSE.txt +22 -0
  10. package/dist/agent/vendor/markdown-licenses/mdurl-LICENSE.txt +45 -0
  11. package/dist/agent/vendor/markdown-licenses/punycode.js-LICENSE-MIT.txt +20 -0
  12. package/dist/agent/vendor/markdown-licenses/uc.micro-LICENSE.txt +20 -0
  13. package/dist/agent/vendor/markdown-plugins.json +46 -0
  14. package/dist/agent/vendor/markdown-plugins.mjs +27 -0
  15. package/dist/agent/vendor/markdown-runtime.mjs +13 -0
  16. package/dist/kit/artifact-modules.mjs +19 -0
  17. package/dist/kit/assets.d.mts +106 -0
  18. package/dist/kit/export-fonts.mjs +28 -19
  19. package/dist/kit/index.d.mts +151 -0
  20. package/dist/kit/layout.d.mts +61 -0
  21. package/dist/kit/ledger/carried.d.mts +12 -6
  22. package/dist/kit/ledger/carried.mjs +17 -8
  23. package/dist/kit/ledger/format.mjs +5 -3
  24. package/dist/kit/ledger/source.mjs +31 -2
  25. package/dist/kit/ledger/text-merge.mjs +33 -0
  26. package/dist/kit/ledger/transport.mjs +61 -2
  27. package/dist/kit/ledger.d.mts +137 -31
  28. package/dist/kit/markdown-it.d.mts +2 -2
  29. package/dist/kit/marks.d.mts +241 -0
  30. package/dist/kit/marks.mjs +39 -1
  31. package/dist/kit/model.d.mts +191 -0
  32. package/dist/kit/pdf-source-digest.mjs +10 -0
  33. package/dist/kit/pdf-source.mjs +4 -1
  34. package/dist/kit/pdf-vectors.mjs +3 -2
  35. package/dist/kit/render-entry.d.mts +654 -0
  36. package/dist/kit/render-markdown.mjs +14 -8
  37. package/dist/kit/render-print.mjs +36 -7
  38. package/dist/kit/render.mjs +16 -14
  39. package/dist/kit/typography.d.mts +42 -0
  40. package/dist/kit/will.d.mts +26 -3
  41. package/dist/layout/initial-range.mjs +18 -0
  42. package/dist/layout/typography.mjs +2 -18
  43. package/dist/math/delimiters.mjs +38 -0
  44. package/dist/math/markdown-rules.mjs +103 -0
  45. package/dist/packages/rapier-diff/concerns.mjs +2 -2
  46. package/dist/spec/document-fonts.mjs +62 -16
  47. package/dist/spec/md-assets.mjs +1 -0
  48. package/dist/spec/md-comments.mjs +384 -0
  49. package/dist/spec/md-marks.mjs +9 -0
  50. package/dist/spec/md-parser-config.mjs +463 -0
  51. package/dist/spec/md-parser-runtime.mjs +23 -0
  52. package/package.json +30 -8
@@ -2,20 +2,12 @@
2
2
  import {markdownParser, installMarkdownImages, normalizeLabel} from './md-assets.mjs';
3
3
  import {splitOpeningFrontmatter} from './frontmatter.mjs';
4
4
  import {readDocumentSettings, documentFont} from './document-settings.mjs';
5
- import {normalizeFontFamily, fontFamilyCss, FONT_STACKS, typography} from './md-marks.mjs';
5
+ import {normalizeFontFamily, fontFamilyCss, FONT_STACKS, typography, fontDataUrl} from './md-marks.mjs';
6
+ export {fontDataUrl};
6
7
 
7
8
  export const CSS_GENERIC_FONT_FAMILIES = Object.freeze(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
8
9
  'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded', 'emoji', 'math', 'fangsong', 'initial', 'revert', 'revert-layer']);
9
10
 
10
- export function fontDataUrl(url) {
11
- if (typeof url !== 'string') return null;
12
- const match = /^data:font\/(woff2|woff|ttf|otf);base64,([A-Za-z0-9+/]+={0,2})$/i.exec(url);
13
- if (!match || match[2].length % 4) return null;
14
- const padding = match[2].endsWith('==') ? 2 : match[2].endsWith('=') ? 1 : 0;
15
- const byteLength = match[2].length / 4 * 3 - padding;
16
- return byteLength >= 12 ? {format: match[1].toLowerCase(), byteLength, data: match[2]} : null;
17
- }
18
-
19
11
  function parserFor(parser) {
20
12
  const md = installMarkdownImages(markdownParser(parser));
21
13
  if (!md.__rapierFontsInstalled) {
@@ -26,12 +18,19 @@ function parserFor(parser) {
26
18
  return md;
27
19
  }
28
20
 
21
+ const sourceMetadataFields = ['license', 'origin'];
22
+ function sourceMetadata(value) {
23
+ return Object.fromEntries(sourceMetadataFields.filter(field => value[field] != null).map(field => [field, value[field]]));
24
+ }
25
+
29
26
  function faceOf(value) {
30
27
  const family = normalizeFontFamily(value?.family), style = value?.style || 'normal';
31
28
  const weight = value?.weight ?? 400, stretch = value?.stretch ?? 100;
32
29
  if (!family || !['normal', 'italic', 'oblique'].includes(style) || !Number.isFinite(weight) || weight < 1 || weight > 1000 ||
33
30
  !Number.isFinite(stretch) || stretch <= 0 || stretch > 1000) return null;
34
31
  const result = {family, style, weight, stretch};
32
+ if (value.subsetted != null && typeof value.subsetted !== 'boolean') return null;
33
+ if (value.subsetted === true || value.subset?.subset === true) result.subsetted = true;
35
34
  if (value.weightRange != null) {
36
35
  const range = value.weightRange;
37
36
  if (!Array.isArray(range) || range.length !== 2 || range.some(number => !Number.isFinite(number) || number < 1 || number > 1000) || range[0] > range[1]) return null;
@@ -52,6 +51,25 @@ function faceOf(value) {
52
51
  if (!/^f[0-9a-f]{24}$/.test(value.sourceId)) return null;
53
52
  result.sourceId = value.sourceId;
54
53
  }
54
+ if (value.license != null) {
55
+ const license = value.license;
56
+ if (!license || !['OFL-1.0', 'OFL-1.1', 'Apache-2.0', 'Ubuntu-font-1.0'].includes(license.id) ||
57
+ typeof license.text !== 'string' || !license.text.trim() || new TextEncoder().encode(license.text).length > 128 * 1024 ||
58
+ typeof license.url !== 'string' || typeof license.sri !== 'string' || !/^sha384-[A-Za-z0-9+/]{64}$/.test(license.sri)) return null;
59
+ let url;
60
+ try { url = new URL(license.url); } catch (_) { return null; }
61
+ if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return null;
62
+ result.license = {id: license.id, text: license.text, url: license.url, sri: license.sri};
63
+ }
64
+ if (value.origin != null) {
65
+ const origin = value.origin;
66
+ if (!result.license || result.subsetted || !origin || origin.catalog !== 'google-fonts' ||
67
+ typeof origin.family !== 'string' || !/^[a-z0-9][a-z0-9-]{0,95}$/.test(origin.family) ||
68
+ typeof origin.version !== 'string' || !origin.version || origin.version.length > 128 || /[\u0000-\u001f\u007f]/u.test(origin.version) ||
69
+ typeof origin.file !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\.woff2$/.test(origin.file) ||
70
+ typeof origin.sri !== 'string' || !/^sha384-[A-Za-z0-9+/]{64}$/.test(origin.sri)) return null;
71
+ result.origin = {catalog: origin.catalog, family: origin.family, version: origin.version, file: origin.file, sri: origin.sri};
72
+ }
55
73
  return result;
56
74
  }
57
75
 
@@ -71,7 +89,7 @@ export function readFontAssets(markdown, parser) {
71
89
  else if (source[at] === '\n') starts.push(at + 1);
72
90
  }
73
91
  md.block.parse(frame.body.replace(/\r\n?/g, '\n').replace(/\0/g, '\ufffd'), md, env, tokens);
74
- const labels = new Set(), faces = new Set();
92
+ const labels = new Set(), faces = new Set(), metadata = new Map(), faceMetadata = new Map(), subsetUrls = new Set();
75
93
  for (const token of tokens) {
76
94
  if (token.type !== 'reference_definition' || !token.map) continue;
77
95
  const id = token.meta.label, first = !labels.has(id);
@@ -87,18 +105,33 @@ export function readFontAssets(markdown, parser) {
87
105
  if (!match) continue;
88
106
  const label = md.utils.unescapeAll(match[1]);
89
107
  if (!data) { refused.push({name: label, reason: 'The font reference must contain an embedded font.'}); continue; }
90
- let metadata = {family: label.slice(5)};
108
+ let definitionMetadata = {family: label.slice(5)};
91
109
  if (definition.title) {
92
- try { metadata = JSON.parse(definition.title); }
110
+ try { definitionMetadata = JSON.parse(definition.title); }
93
111
  catch (_) { refused.push({name: label, reason: 'The font reference has invalid metadata.'}); continue; }
94
112
  }
95
- const face = faceOf(metadata);
113
+ const face = faceOf(definitionMetadata);
96
114
  if (!face) { refused.push({name: label, reason: 'The font reference has invalid metadata.'}); continue; }
97
115
  const key = fontAssetKey(face), active = first && !faces.has(key);
116
+ const conflict = sourceMetadataFields.find(field => face[field] && [metadata.get(url)?.[field], faceMetadata.get(key)?.[field]]
117
+ .some(prior => prior && JSON.stringify(prior) !== JSON.stringify(face[field])));
118
+ if (conflict) { refused.push({name: label, reason: 'The font references have conflicting ' + conflict + ' metadata.'}); continue; }
119
+ if (face.subsetted && metadata.get(url)?.origin || face.origin && subsetUrls.has(url)) {
120
+ refused.push({name: label, reason: 'A font subset cannot claim an original catalog file.'}); continue;
121
+ }
122
+ metadata.set(url, {...metadata.get(url), ...sourceMetadata(face)});
123
+ faceMetadata.set(key, {...faceMetadata.get(key), ...sourceMetadata(face)});
124
+ if (face.subsetted) subsetUrls.add(url);
98
125
  const row = {...face, ...data, url, label, id, key, source: raw, start, end, next, active, topLevel: token.level === 0};
99
126
  blocks.push(row);
100
127
  if (active) { assets.push(row); faces.add(key); }
101
128
  }
129
+ for (let index = 0; index < assets.length; index++) {
130
+ const asset = assets[index], declared = metadata.get(asset.url);
131
+ for (const field of sourceMetadataFields) if (!asset[field] && declared?.[field])
132
+ assets[index] = {...assets[index], [field]: declared[field]};
133
+ if (subsetUrls.has(asset.url) && !asset.subsetted) assets[index] = {...assets[index], subsetted: true};
134
+ }
102
135
  return {assets, blocks, refused, references: env.references || Object.create(null)};
103
136
  }
104
137
 
@@ -121,13 +154,26 @@ function escapeFontLabel(label) {
121
154
  }
122
155
 
123
156
  export function writeFontAssets(markdown, assets, parser) {
124
- const source = String(markdown ?? ''), md = parserFor(parser), index = readFontAssets(source, md), wanted = new Map();
157
+ const source = String(markdown ?? ''), md = parserFor(parser), index = readFontAssets(source, md), wanted = new Map(), metadata = new Map(), subsetUrls = new Set();
125
158
  for (const asset of assets || []) {
126
- const key = fontAssetKey(asset);
159
+ const key = fontAssetKey(asset), face = faceOf(asset);
127
160
  if (!key || !fontDataUrl(asset?.url)) throw new TypeError('font: invalid embedded asset');
128
161
  if (wanted.has(key) && wanted.get(key).url !== asset.url) throw new TypeError('font: one file owns each family face');
162
+ const conflict = sourceMetadataFields.find(field => face[field] && metadata.get(asset.url)?.[field] &&
163
+ JSON.stringify(metadata.get(asset.url)[field]) !== JSON.stringify(face[field]));
164
+ if (conflict) throw new TypeError('font: conflicting ' + conflict + ' metadata');
165
+ if (face.subsetted && metadata.get(asset.url)?.origin || face.origin && subsetUrls.has(asset.url))
166
+ throw new TypeError('font: a subset cannot claim an original catalog file');
167
+ metadata.set(asset.url, {...metadata.get(asset.url), ...sourceMetadata(face)});
168
+ if (face.subsetted) subsetUrls.add(asset.url);
129
169
  wanted.set(key, asset);
130
170
  }
171
+ for (const [key, asset] of wanted) {
172
+ const declared = metadata.get(asset.url);
173
+ for (const field of sourceMetadataFields) if (!asset[field] && declared?.[field])
174
+ wanted.set(key, {...wanted.get(key), [field]: declared[field]});
175
+ if (subsetUrls.has(asset.url) && !asset.subsetted) wanted.set(key, {...wanted.get(key), subsetted: true});
176
+ }
131
177
  const edits = [], consumed = new Set(), labels = new Set(Object.keys(index.references));
132
178
  const retired = index.assets.filter(row => !wanted.has(row.key));
133
179
  for (const row of index.blocks) {
@@ -9,6 +9,7 @@ const cached = [];
9
9
  let preparedDefinitions = [];
10
10
  let configured = null, splitSource = null;
11
11
  export function configureParser(parser, split) { configured = parser; splitSource = split; cached.length = 0; preparedDefinitions = []; }
12
+ export const configuredMarkdownParser = () => configured;
12
13
  function fail(reason) { throw new Error(reason); }
13
14
  export const normalizeLabel = label => String(label).trim().replace(/\s+/g, ' ').toLowerCase().toUpperCase();
14
15
  export const assetTitle = value => String(value || '').replace(/[\r\n]/g, ' ').replace(/\0/g, '\ufffd');
@@ -0,0 +1,384 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Jack Skipworth. See LICENSE-MIT.
3
+ import {ensureMarkdownParser} from './md-parser-runtime.mjs';
4
+ import {markdownParser, markdownBodyOffset, documentAssets, parseAssets, projectImageDefinitions, eachBlockWindow} from './md-assets.mjs';
5
+ import {_rapierTransformSplices as transformSplices} from '../kit/ledger/journal-records.mjs';
6
+
7
+ export const MARKER = '<!-- md-comments:v1 ';
8
+ const int = value => Number.isSafeInteger(value) && value >= 0;
9
+ const plain = value => !!value && typeof value === 'object' && !Array.isArray(value);
10
+ const exactKeys = (value, required, optional = []) => plain(value) && required.every(key => Object.hasOwn(value, key)) &&
11
+ Object.keys(value).every(key => required.includes(key) || optional.includes(key));
12
+ const id = value => typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9_-]+$/.test(value);
13
+ const text = value => typeof value === 'string' && !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\uD800-\uDFFF]/u.test(value);
14
+
15
+ const FIRST = 16777619, SECOND = 2246822519;
16
+ export const emptyHash = () => ({length: 0, first: 0, second: 0, firstPower: 1, secondPower: 1});
17
+
18
+ export function hashText(text) {
19
+ let first = 0, second = 0, firstPower = 1, secondPower = 1;
20
+ for (let at = 0; at < text.length; at++) {
21
+ const code = text.charCodeAt(at) + 1;
22
+ first = (Math.imul(first, FIRST) + code) >>> 0;
23
+ second = (Math.imul(second, SECOND) + code) >>> 0;
24
+ firstPower = Math.imul(firstPower, FIRST) >>> 0;
25
+ secondPower = Math.imul(secondPower, SECOND) >>> 0;
26
+ }
27
+ return {length: text.length, first, second, firstPower, secondPower};
28
+ }
29
+
30
+ export function foldHash(left, right) {
31
+ return {length: left.length + right.length,
32
+ first: (Math.imul(left.first, right.firstPower) + right.first) >>> 0,
33
+ second: (Math.imul(left.second, right.secondPower) + right.second) >>> 0,
34
+ firstPower: Math.imul(left.firstPower, right.firstPower) >>> 0,
35
+ secondPower: Math.imul(left.secondPower, right.secondPower) >>> 0};
36
+ }
37
+
38
+ export const digest = hash => 'p1:' + hash.length + ':' + hash.first + ':' + hash.second;
39
+ export const commentBodyDigest = text => digest(hashText(text));
40
+ export function validCommentTimestamp(value) {
41
+ return value === null || Number.isSafeInteger(value) && value >= -62167219200000 && value <= 253402300799999;
42
+ }
43
+
44
+ export function validCommentDate(value) {
45
+ if (typeof value !== 'string') return false;
46
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))?$/.exec(value);
47
+ if (!match) return false;
48
+ const [, year, month, day, hour, minute, second, zoneHour, zoneMinute] = match.map((part, index) => index ? Number(part) : part);
49
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
50
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
51
+ return month >= 1 && month <= 12 && day >= 1 && day <= days[month - 1] && hour <= 23 && minute <= 59 && second <= 59 &&
52
+ (match[7] === undefined || zoneHour <= 14 && zoneMinute <= 59 && (zoneHour !== 14 || zoneMinute === 0));
53
+ }
54
+
55
+ export function commentDigest(value) {
56
+ let fnv = 0x811c9dc5, a = 1, b = 0;
57
+ for (let offset = 0; offset < value.length; offset += 4096) {
58
+ const end = Math.min(value.length, offset + 4096);
59
+ for (let index = offset; index < end; index++) {
60
+ const code = value.charCodeAt(index);
61
+ fnv = Math.imul(fnv ^ code, 16777619); a += code; b += a;
62
+ }
63
+ a %= 65521; b %= 65521;
64
+ }
65
+ return `${value.length}:${fnv >>> 0}:${((b << 16) | a) >>> 0}`;
66
+ }
67
+
68
+ export function validAnchor(anchor) {
69
+ if (!exactKeys(anchor, ['kind', 'status'], ['start', 'end', 'exact', 'quote', 'objectId', 'reason'])) return false;
70
+ if (!['document', 'text', 'image', 'drawing'].includes(anchor.kind) || !['attached', 'stale'].includes(anchor.status)) return false;
71
+ if (anchor.kind !== 'document' && (!int(anchor.start) || !int(anchor.end) || anchor.end < anchor.start || !text(anchor.exact) || !text(anchor.quote))) return false;
72
+ return (anchor.objectId === undefined || anchor.kind === 'drawing' && id(anchor.objectId)) &&
73
+ (anchor.reason === undefined || text(anchor.reason));
74
+ }
75
+
76
+ export function validData(data) {
77
+ if (!exactKeys(data, ['body', 'threads']) || !text(data.body) || !Array.isArray(data.threads)) return false;
78
+ const threadIds = new Set(), messageIds = new Set();
79
+ return data.threads.every(thread => {
80
+ if (!exactKeys(thread, ['id', 'anchor', 'resolved', 'messages']) || !id(thread.id) || threadIds.has(thread.id) ||
81
+ !validAnchor(thread.anchor) || typeof thread.resolved !== 'boolean' || !Array.isArray(thread.messages) || !thread.messages.length) return false;
82
+ threadIds.add(thread.id);
83
+ const localMessageIds = new Set(thread.messages.map(message => message?.id));
84
+ const validMessages = thread.messages.every((message, index) => {
85
+ if (!exactKeys(message, ['id', 'text', 'author', 'createdAt'], ['recipient', 'date', 'replyTo']) || !id(message.id) || messageIds.has(message.id) ||
86
+ !text(message.text) || !validCommentTimestamp(message.createdAt) ||
87
+ message.date !== undefined && !validCommentDate(message.date) ||
88
+ !exactKeys(message.author, ['kind', 'name']) ||
89
+ !['human', 'agent', 'system'].includes(message.author.kind) || !text(message.author.name) ||
90
+ (message.recipient !== undefined && !text(message.recipient)) || (message.replyTo !== undefined &&
91
+ (!index || !id(message.replyTo) || message.replyTo === message.id || !localMessageIds.has(message.replyTo)))) return false;
92
+ messageIds.add(message.id); return true;
93
+ });
94
+ if (!validMessages) return false;
95
+ const parents = new Map(thread.messages.map(message => [message.id, message.replyTo])), finished = new Set();
96
+ for (const message of thread.messages) {
97
+ const seen = new Set(); let at = message.id;
98
+ while (at !== undefined && !finished.has(at)) {
99
+ if (seen.has(at)) return false;
100
+ seen.add(at); at = parents.get(at);
101
+ }
102
+ for (const id of seen) finished.add(id);
103
+ }
104
+ return true;
105
+ });
106
+ }
107
+
108
+ export function commentProjectionParts(raw) {
109
+ const opening = /^(<!-- md-comments:v1 [^\r\n]*? -->)(?=\r|\n|$)/.exec(raw)?.[1];
110
+ if (!opening) return null;
111
+ let data;
112
+ try { data = JSON.parse(opening.slice(MARKER.length, -4)); } catch { return null; }
113
+ if (!validData(data)) return null;
114
+ if (opening !== raw) return null;
115
+ return {parts: [opening]};
116
+ }
117
+
118
+ export function commentSourceRange(anchor, parsed) {
119
+ if (anchor.kind === 'document' || anchor.status !== 'attached') return null;
120
+ const at = (value, start) => parsed.record && (value > parsed.record.start || start && value === parsed.record.start) ? value + parsed.record.raw.length : value;
121
+ return {start: at(anchor.start, true), end: at(anchor.end, false)};
122
+ }
123
+
124
+ export function commentAnchor(kind, start, end, source, parsed, objectId) {
125
+ if (kind === 'document') return {kind, status: 'attached'};
126
+ const record = parsed.record;
127
+ if (!int(start) || !int(end) || end <= start || end > source.length ||
128
+ record && start < record.end && record.start < end) throw new TypeError('comment_anchor_invalid');
129
+ const at = value => record && value >= record.end ? value - record.raw.length : value;
130
+ const selected = source.slice(start, end);
131
+ return {kind, status: 'attached', start: at(start), end: at(end), exact: commentDigest(selected),
132
+ quote: Array.from(selected).slice(0, 256).join(''), ...(objectId ? {objectId} : {})};
133
+ }
134
+
135
+ export function serializeComments(threads, body) {
136
+ return encodeCommentRecord(threads, commentBodyDigest(body));
137
+ }
138
+
139
+ export function transport(anchor, row) {
140
+ if (anchor.kind === 'document' || anchor.status !== 'attached' &&
141
+ !(anchor.kind === 'drawing' && anchor.reason === 'object_missing')) return anchor;
142
+ const end = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length;
143
+ if (end <= anchor.start) return {...anchor, start: anchor.start + delta, end: anchor.end + delta};
144
+ if (row.pos >= anchor.end) return anchor;
145
+ if (anchor.kind === 'drawing' && row.pos === anchor.start && end === anchor.end && row.inserted) {
146
+ return {...anchor, end: anchor.start + row.inserted.length, exact: commentDigest(row.inserted),
147
+ quote: Array.from(row.inserted).slice(0, 256).join('')};
148
+ }
149
+ return {...anchor, status: 'stale', reason: row.inserted ? 'target_changed' : 'target_deleted'};
150
+ }
151
+
152
+ function carryDefinitions(source, blocks, splices) {
153
+ return blocks.flatMap(block => {
154
+ let {start, end} = block;
155
+ for (const row of splices) {
156
+ const stop = row.pos + row.removed.length;
157
+ if (row.pos <= end && stop >= start) return [];
158
+ if (stop < start) { start += row.inserted.length - row.removed.length; end += row.inserted.length - row.removed.length; }
159
+ }
160
+ if (source.slice(start, end) !== block.source) return [];
161
+ const moved = {...block, start, end};
162
+ for (const key of ['urlStart', 'urlEnd', 'payloadStart', 'payloadEnd']) if (block[key] != null) moved[key] += start - block.start;
163
+ return [moved];
164
+ });
165
+ }
166
+
167
+ export function encodeCommentValue(data) {
168
+ return JSON.stringify(data).replace(/"(?:[^"\\]|\\.)*"/g, value => value.replace(/[<>&-]/g,
169
+ character => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0')));
170
+ }
171
+
172
+ export function encodeCommentRecord(threads, digest) {
173
+ const data = {body: digest, threads};
174
+ if (!validData(data)) throw new TypeError('comments_data_invalid');
175
+ return MARKER + encodeCommentValue(data) + ' -->';
176
+ }
177
+
178
+ export function freezeCommentRecord(parsed) {
179
+ for (const thread of parsed.threads) {
180
+ Object.freeze(thread.anchor);
181
+ for (const message of thread.messages) { Object.freeze(message.author); Object.freeze(message); }
182
+ Object.freeze(thread.messages); Object.freeze(thread);
183
+ }
184
+ Object.freeze(parsed.threads);
185
+ if (parsed.record) Object.freeze(parsed.record);
186
+ return Object.freeze(parsed);
187
+ }
188
+
189
+ export function createCommentSourceOwner({readDrawing} = {}) {
190
+ const memos = [], MEMOS = 3;
191
+ const remembered = source => memos.find(row => row.source === source)?.parsed;
192
+ const grammar = new WeakMap();
193
+
194
+ function scanComments(source, verifyBody = true, definitions, scopeOffset) {
195
+ const empty = {record: null, threads: [], body: source, current: true};
196
+ if (scopeOffset === undefined && !source.includes('md-comments:')) return empty;
197
+ ensureMarkdownParser();
198
+ if (!definitions) {
199
+ try { definitions = documentAssets(source).blocks; } catch (_) { definitions = []; }
200
+ }
201
+ const offset = scopeOffset ?? markdownBodyOffset(source);
202
+ definitions = definitions.filter(row => row.start >= offset);
203
+ const projected = projectImageDefinitions(source, {blocks: definitions, references: {}});
204
+ const payloadEnds = [], payloadUnits = [];
205
+ for (const row of projected.records) { payloadEnds.push(row.end); payloadUnits.push((payloadUnits.at(-1) || 0) + row.payload.length - 4); }
206
+ const sourceOffset = at => {
207
+ let low = 0, high = payloadEnds.length;
208
+ while (low < high) { const mid = (low + high) >>> 1; if (payloadEnds[mid] <= at) low = mid + 1; else high = mid; }
209
+ return low ? at + payloadUnits[low - 1] : at;
210
+ };
211
+ const parser = markdownParser(), env = {};
212
+ const records = [], blocks = [], referenceRows = [], regions = [], firstDefinitions = [];
213
+ const known = new Map(definitions.map(row => [row.start, row]));
214
+ eachBlockWindow(projected.source, offset, parser, env, ({tokens, lineStart}) => {
215
+ const lineOffset = line => { const at = lineStart(line); return at === undefined ? source.length : sourceOffset(at); };
216
+ for (const token of tokens) {
217
+ const isDefinition = token.type === 'reference_definition';
218
+ if (!token.map || !isDefinition && token.level !== 0) continue;
219
+ const start = lineOffset(token.map[0]), stop = lineOffset(token.map[1]);
220
+ if (isDefinition) {
221
+ let end = stop;
222
+ if (source[end - 1] === '\n') end--;
223
+ if (source[end - 1] === '\r') end--;
224
+ const previous = known.get(start), definition = token.meta.mdImageDefinition;
225
+ let exactReference = definition;
226
+ const projectedURL = previous?.payloadStart != null
227
+ ? previous.url.slice(0, previous.payloadStart - previous.urlStart) + 'AA==' : previous?.url;
228
+ if (previous?.end === end && previous.id === token.meta.label && definition?.href === projectedURL) {
229
+ blocks.push({...previous, topLevel: token.level === 0});
230
+ exactReference = {...definition, href: previous.url};
231
+ if (env.references?.[previous.id] === definition) firstDefinitions.push([previous.id, exactReference]);
232
+ } else {
233
+ for (const row of parseAssets(source.slice(start, end)).blocks) {
234
+ const moved = {...row, start: row.start + start, end: row.end + start};
235
+ for (const key of ['urlStart', 'urlEnd', 'payloadStart', 'payloadEnd']) if (row[key] != null) moved[key] += start;
236
+ blocks.push(moved);
237
+ }
238
+ }
239
+ referenceRows.push({start, label: token.meta.label, definition: exactReference});
240
+ }
241
+ if (token.level !== 0) continue;
242
+ if (token.map[1] > token.map[0]) regions.push({start, stop, type: token.type});
243
+ const match = token.type === 'html_block' && /^(<!-- md-comments:[^\r\n]*? -->)(?:\r\n|\n|\r)?$/.exec(source.slice(start, stop));
244
+ if (match) records.push({start, end: start + match[1].length, raw: match[1]});
245
+ }
246
+ }, true);
247
+ const references = Object.assign(Object.create(null), env.references);
248
+ for (const [label, definition] of firstDefinitions) references[label] = {...definition};
249
+ if (!regions.length || regions[0].start > 0) regions.unshift({start: 0, stop: offset, type: 'trivia'});
250
+ let recordAt = 0, rowAt = 0;
251
+ const scopes = regions.map((region, index) => {
252
+ const next = regions[index + 1]?.start ?? source.length, defined = [];
253
+ while (recordAt < records.length && records[recordAt].start < region.start) recordAt++;
254
+ for (; rowAt < referenceRows.length && referenceRows[rowAt].start < next; rowAt++)
255
+ if (referenceRows[rowAt].start >= region.start) defined.push([referenceRows[rowAt].label, referenceRows[rowAt].definition]);
256
+ return {length: next - region.start, parsedLength: region.stop - region.start, type: region.type,
257
+ record: records[recordAt]?.start === region.start ? records[recordAt].raw : null, references: defined};
258
+ });
259
+ const result = value => { grammar.set(value, {blocks, references, scopes, offset}); return value; };
260
+ if (!records.length) return result(empty);
261
+ if (records.length !== 1) return result({...empty, reason: 'comments_ambiguous'});
262
+ const record = records[0];
263
+ if (!record.raw.startsWith(MARKER)) return result({...empty, reason: 'comments_format_unknown'});
264
+ let data;
265
+ try { data = JSON.parse(record.raw.slice(MARKER.length, -4)); } catch { return result({...empty, reason: 'comments_malformed'}); }
266
+ if (!validData(data)) return result({...empty, reason: 'comments_malformed'});
267
+ const body = source.slice(0, record.start) + source.slice(record.end);
268
+ return result({record, threads: data.threads, body, current: verifyBody && data.body === commentBodyDigest(body)});
269
+ }
270
+
271
+ function remember(source, parsed, context = grammar.get(parsed)) {
272
+ freezeCommentRecord(parsed);
273
+ if (context) grammar.set(parsed, context);
274
+ const stale = memos.findIndex(row => row.source === source);
275
+ if (stale >= 0) memos.splice(stale, 1);
276
+ memos.unshift({source, parsed});
277
+ if (memos.length > MEMOS) memos.pop();
278
+ return parsed;
279
+ }
280
+
281
+ function parseComments(source) {
282
+ return remembered(source) || remember(source, scanComments(source));
283
+ }
284
+
285
+ function objectPresent(source, anchor, parsed, context) {
286
+ if (anchor.kind !== 'drawing' && anchor.kind !== 'image') return true;
287
+ const range = commentSourceRange(anchor, parsed), occurrence = source.slice(range.start, range.end);
288
+ const parser = markdownParser(), assets = context || grammar.get(parsed) || grammar.get(remembered(source)) || documentAssets(source), tokens = [];
289
+ parser.inline.parse(occurrence, parser, {references: assets.references}, tokens);
290
+ if (tokens.length !== 1 || tokens[0].type !== 'image' || tokens[0].meta?.mdImage?.source !== occurrence) return false;
291
+ if (anchor.kind === 'image') return true;
292
+ const url = tokens[0].attrGet('src');
293
+ if (!/^data:image\/svg\+xml(?:;base64)?,/i.test(url || '')) return false;
294
+ let recipe;
295
+ try { recipe = readDrawing?.(url); } catch { return false; }
296
+ return !!recipe && (!anchor.objectId || recipe.shapes.some(shape => shape.id === anchor.objectId));
297
+ }
298
+
299
+ function commentThreads(source, parsed = parseComments(source)) {
300
+ return parsed.threads.map(thread => {
301
+ let anchor = {...thread.anchor};
302
+ if (anchor.kind !== 'document' && anchor.status === 'attached') {
303
+ const reason = !parsed.current ? 'source_changed_without_history' :
304
+ commentDigest(parsed.body.slice(anchor.start, anchor.end)) !== anchor.exact ? 'target_changed' :
305
+ !objectPresent(source, anchor, parsed) ? 'object_missing' : '';
306
+ if (reason) anchor = {...anchor, status: 'stale', reason};
307
+ }
308
+ return {...thread, anchor, messages: thread.messages.slice()};
309
+ });
310
+ }
311
+
312
+ function writeComments(source, threads, parsed = parseComments(source)) {
313
+ if (parsed.reason) throw new TypeError(parsed.reason);
314
+ if (parsed.record) return {pos: parsed.record.start, removed: parsed.record.raw, inserted: serializeComments(threads, parsed.body)};
315
+ const eol = /\r\n|\n|\r/.exec(source)?.[0] || '\n';
316
+ const prefix = !source || /(?:\r\n|\n|\r){2}$/.test(source) ? '' : /[\r\n]$/.test(source) ? eol : eol + eol;
317
+ const body = source + prefix + eol;
318
+ const inserted = prefix + serializeComments(threads, body) + eol;
319
+ const next = parseComments(source + inserted);
320
+ if (!next.record || next.reason) throw new TypeError('comments_appendix_unavailable');
321
+ return {pos: source.length, removed: '', inserted};
322
+ }
323
+
324
+ function commentSplices(before, splices) {
325
+ if (!before.includes(MARKER)) return [];
326
+ const parsed = parseComments(before);
327
+ if (!parsed.record || parsed.reason) return [];
328
+ if (!readDrawing && parsed.threads.some(thread => thread.anchor.kind === 'drawing')) return [];
329
+ let position = parsed.record.start, end = parsed.record.end;
330
+ const bodyRows = [];
331
+ for (const row of splices) {
332
+ const stop = row.pos + row.removed.length;
333
+ if (row.pos < end && stop > position || row.pos > position && row.pos < end) return [];
334
+ bodyRows.push({...row, pos: row.pos >= end ? row.pos - parsed.record.raw.length : row.pos});
335
+ if (stop <= position) { position += row.inserted.length - row.removed.length; end += row.inserted.length - row.removed.length; }
336
+ }
337
+ const after = transformSplices(before, splices);
338
+ if (after == null) return [];
339
+ const next = scanComments(after, false, carryDefinitions(after, grammar.get(parsed)?.blocks || [], splices));
340
+ if (!next.record || next.reason || next.record.start !== position || next.record.raw !== parsed.record.raw) return [];
341
+ const threads = commentThreads(before, parsed).map(thread => ({...thread,
342
+ anchor: bodyRows.reduce(transport, thread.anchor)}));
343
+ for (const thread of threads) if (thread.anchor.status === 'attached' && !objectPresent(after, thread.anchor, next)) {
344
+ thread.anchor = {...thread.anchor, status: 'stale', reason: 'object_missing'};
345
+ }
346
+ const row = writeComments(after, threads, next);
347
+ if (row.inserted !== row.removed) {
348
+ const final = after.slice(0, row.pos) + row.inserted + after.slice(row.pos + row.removed.length);
349
+ const context = grammar.get(next);
350
+ remember(final, {...next, current: true, threads,
351
+ record: {...next.record, raw: row.inserted, end: row.pos + row.inserted.length}},
352
+ context && {...context, blocks: carryDefinitions(final, context.blocks, [row])});
353
+ }
354
+ return row.inserted === row.removed ? [] : [row];
355
+ }
356
+
357
+ function derivedCommentEdits(base, edits) {
358
+ const parsed = parseComments(base), submitted = transformSplices(base, edits);
359
+ if (parsed.reason || submitted === null) return null;
360
+ if (!parsed.record) return parseComments(submitted).record ? null : {edits, restore: []};
361
+ let start = parsed.record.start, end = parsed.record.end;
362
+ const body = [];
363
+ for (const row of edits) {
364
+ const stop = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length;
365
+ if (row.pos < end && stop > start || row.pos > start && row.pos < end) {
366
+ if (row.pos < start || stop > end) return null;
367
+ end += delta;
368
+ } else {
369
+ body.push({...row, pos: row.pos >= end ? row.pos - (end - start) + parsed.record.raw.length : row.pos});
370
+ if (stop <= start) { start += delta; end += delta; }
371
+ }
372
+ }
373
+ const clean = transformSplices(base, body), derived = commentSplices(base, body);
374
+ if (clean === null || transformSplices(clean, derived) !== submitted) return null;
375
+ const restore = derived.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
376
+ return {edits: body, restore};
377
+ }
378
+
379
+ return Object.freeze({scanComments, parseComments, commentThreads, writeComments, commentSplices,
380
+ derivedCommentEdits, objectPresent, context: parsed => grammar.get(parsed)});
381
+ }
382
+
383
+ const portable = createCommentSourceOwner();
384
+ export const {parseComments, commentSplices, derivedCommentEdits} = portable;
@@ -1,6 +1,15 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {markdownSourcePositions} from './md-source.mjs';
3
3
 
4
+ export function fontDataUrl(url) {
5
+ if (typeof url !== 'string') return null;
6
+ const match = /^data:font\/(woff2|woff|ttf|otf);base64,([A-Za-z0-9+/]+={0,2})$/i.exec(url);
7
+ if (!match || match[2].length % 4) return null;
8
+ const padding = match[2].endsWith('==') ? 2 : match[2].endsWith('=') ? 1 : 0;
9
+ const byteLength = match[2].length / 4 * 3 - padding;
10
+ return byteLength >= 12 ? {format: match[1].toLowerCase(), byteLength, data: match[2]} : null;
11
+ }
12
+
4
13
  const inkReader = () => {
5
14
  // SPDX-License-Identifier: MIT
6
15
  const TEXT_COLOR_NAMES = Object.freeze({ green: '#1c7547', red: '#b32034', blue: '#175dc4', gold: '#865818', purple: '#7924d7' });