rapier-markdown-kit 1.1.101 → 1.1.104

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 (60) hide show
  1. package/LICENSE +2 -2
  2. package/README.md +213 -53
  3. package/dist/agent/vendor/hyphen-en-us/LICENSE +14 -0
  4. package/dist/agent/vendor/hyphen-en-us/SOURCE.json +33 -0
  5. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.hyp.txt +14 -0
  6. package/dist/agent/vendor/hyphen-en-us/hyph-en-us.pat.txt +4938 -0
  7. package/dist/agent/vendor/hyphen-en-us/patterns.js +25 -0
  8. package/dist/agent/vendor/pretext/LICENSE +208 -0
  9. package/dist/agent/vendor/pretext/NOTICE +26 -13
  10. package/dist/agent/vendor/pretext/SOURCE.json +213 -45
  11. package/dist/agent/vendor/pretext/analysis.js +675 -1224
  12. package/dist/agent/vendor/pretext/entry-geometry.js +183 -0
  13. package/dist/agent/vendor/pretext/gecko-line-breaks.js +688 -0
  14. package/dist/agent/vendor/pretext/generated/engine-break-data.js +57 -0
  15. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +80 -0
  16. package/dist/agent/vendor/pretext/graphemes.js +98 -0
  17. package/dist/agent/vendor/pretext/han-kerning.js +253 -0
  18. package/dist/agent/vendor/pretext/layout.js +352 -290
  19. package/dist/agent/vendor/pretext/line-break.js +1154 -588
  20. package/dist/agent/vendor/pretext/line-breaks.js +892 -0
  21. package/dist/agent/vendor/pretext/line-text.js +81 -39
  22. package/dist/agent/vendor/pretext/measurement.js +1105 -176
  23. package/dist/agent/vendor/pretext/prepare.js +807 -0
  24. package/dist/agent/vendor/pretext/rich-inline.js +1242 -273
  25. package/dist/agent/will.mjs +2 -0
  26. package/dist/kit/assets.mjs +5 -1
  27. package/dist/kit/conformance/run.mjs +4 -4
  28. package/dist/kit/export-fonts.mjs +26 -11
  29. package/dist/kit/index.mjs +1 -0
  30. package/dist/kit/ledger/carried.mjs +9 -7
  31. package/dist/kit/ledger/data.mjs +73 -13
  32. package/dist/kit/ledger/format.mjs +166 -53
  33. package/dist/kit/ledger/hash.mjs +75 -18
  34. package/dist/kit/ledger/history.mjs +143 -115
  35. package/dist/kit/ledger/inverse-source.mjs +158 -19
  36. package/dist/kit/ledger/journal-records.mjs +292 -67
  37. package/dist/kit/model.mjs +1 -1
  38. package/dist/kit/pdf-glyphs.mjs +4 -4
  39. package/dist/kit/render-markdown.mjs +259 -122
  40. package/dist/kit/render-sanitize.mjs +24 -4
  41. package/dist/kit/render-work.mjs +99 -20
  42. package/dist/kit/render.mjs +101 -65
  43. package/dist/kit/typography.mjs +2 -0
  44. package/dist/layout/hyphenation.mjs +127 -0
  45. package/dist/layout/line-plan.mjs +88 -96
  46. package/dist/layout/model.mjs +61 -80
  47. package/dist/layout/paragraph-fit.mjs +191 -0
  48. package/dist/layout/preparation.mjs +373 -0
  49. package/dist/layout/prepared-paragraph.mjs +223 -0
  50. package/dist/layout/typography.mjs +345 -111
  51. package/dist/packages/rapier-diff/index.mjs +288 -196
  52. package/dist/spec/document-fonts.mjs +13 -4
  53. package/dist/spec/md-assets.mjs +149 -30
  54. package/dist/spec/md-layout.mjs +5 -2
  55. package/dist/spec/md-marks.mjs +218 -3
  56. package/dist/spec/md-source.mjs +122 -0
  57. package/package.json +2 -1
  58. package/style.css +9 -0
  59. package/dist/agent/vendor/pretext/bidi.js +0 -151
  60. package/dist/agent/vendor/pretext/generated/bidi-data.js +0 -3831
@@ -3,7 +3,7 @@
3
3
  import {markdownParser, installMarkdownImages, normalizeLabel} from './md-assets.mjs';
4
4
  import {splitOpeningFrontmatter} from './frontmatter.mjs';
5
5
  import {readDocumentSettings, documentFont} from './document-settings.mjs';
6
- import {normalizeFontFamily, fontFamilyCss, FONT_STACKS} from './md-marks.mjs';
6
+ import {normalizeFontFamily, fontFamilyCss, FONT_STACKS, typography} from './md-marks.mjs';
7
7
 
8
8
  // Unquoted CSS generic families require no portable asset. Quoted names remain custom.
9
9
  export const CSS_GENERIC_FONT_FAMILIES = Object.freeze(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
@@ -188,6 +188,13 @@ function htmlFontFamilies(html, md) {
188
188
  for (const tag of html.matchAll(/<[A-Za-z][A-Za-z0-9:-]*(?:"[^"]*"|'[^']*'|[^'">])*>/g)) {
189
189
  for (const attr of tag[0].matchAll(/\s(data-md-font|style)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) {
190
190
  const value = decode(attr[2] ?? attr[3] ?? attr[4]), names = [];
191
+ if (/^<span(?=[\s>])/i.test(tag[0])) {
192
+ // Raw or unpaired spans use the same admission as paired typography. A CSS
193
+ // resource expression or authored data-* hook cannot request a font asset.
194
+ const family = attr[1].toLowerCase() === 'style' ? typography.parseStyle(value).family : null;
195
+ if (family?.custom) families.add(family.name);
196
+ continue;
197
+ }
191
198
  if (attr[1].toLowerCase() === 'data-md-font') names.push(value);
192
199
  else for (const declared of value.matchAll(/(?:^|;)\s*font-family\s*:\s*((?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^;])*)/gi)) {
193
200
  const match = /^\s*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^,!]+))/.exec(declared[1]);
@@ -230,9 +237,11 @@ export function documentFontUsage(markdown, parser, defaultFont) {
230
237
  for (const child of token.children || []) {
231
238
  if (child.type === 'html_inline') html += child.content;
232
239
  if (['text', 'text_special', 'emoji', 'image', 'code_inline'].includes(child.type)) text += child.content;
233
- const marked = child.type === 'rapier_font_open' ? normalizeFontFamily(child.attrGet('data-md-font')) : null;
234
- if (marked) { stack.push(marked); continue; }
235
- if (child.type === 'rapier_font_close') { if (stack.length > 1) stack.pop(); continue; }
240
+ const typed = child.meta?.typography;
241
+ if (typed && child.nesting === 1) {
242
+ stack.push(typed.style.family ? typed.style.family.custom ? typed.style.family.name : null : stack.at(-1)); continue;
243
+ }
244
+ if (typed && child.nesting === -1) { if (stack.length > 1) stack.pop(); continue; }
236
245
  if (child.type === 'code_inline') add(mono, child.content);
237
246
  else if (['text', 'text_special', 'emoji', 'image'].includes(child.type)) add(stack.at(-1), child.content);
238
247
  }
@@ -73,6 +73,128 @@ function parserFor(factory = configured || globalThis.markdownit) {
73
73
  export {parserFor as markdownParser};
74
74
  export const markdownBodyOffset = source => splitSource ? splitSource(source).bodyOffset : 0;
75
75
 
76
+ // markdown-it's block state builds five tables with one entry per line of its whole input, and its rules index
77
+ // them by line. A long body gets the parser's one block pass with tables that hold a run of lines instead: a read
78
+ // past the lines they hold fills them further from the parser's own line scan, and they are emptied between
79
+ // top-level blocks. No line is parsed twice, so every token and `env` are the ones a whole-input state yields.
80
+ const BLOCK_WINDOW = 1024, TABLE_STEP = 128, WINDOW_END = Symbol('block window end');
81
+ const LINE_TABLES = ['bMarks', 'eMarks', 'tShift', 'sCount', 'bsCount'];
82
+ const lineIndex = key => typeof key === 'string' && /^(?:0|[1-9]\d*)$/.test(key);
83
+ // An array reaches its prototype only for an index it does not hold. One prototype serves every pass, so the
84
+ // parser's table reads see one more array shape, not one per pass.
85
+ let unheldLine = null;
86
+ const unheldLines = new Proxy(Array.prototype, {
87
+ get: (target, key, table) => unheldLine && lineIndex(key) ? unheldLine(table, Number(key)) : Reflect.get(target, key, table),
88
+ set: (target, key, value, table) => unheldLine && lineIndex(key) ? unheldLine(table, Number(key), true, value) : Reflect.set(target, key, value, table),
89
+ });
90
+
91
+ // The parser's block pass over source.slice(bodyOffset). `visit` receives top-level blocks as they finish:
92
+ // `tokens` with line numbers counted from `firstLine`, and lineStart(line), the source offset where one of those
93
+ // lines begins (undefined when the source ends before it). `env` is the whole pass's. The tables hold at most
94
+ // the lines between two visits: BLOCK_WINDOW of them, or as many as one block's rules read.
95
+ export function eachBlockWindow(source, bodyOffset, parser, env, visit, keepNul = false) {
96
+ const lineEnds = source.slice(bodyOffset).replace(/\r\n?/g, '\n'), body = keepNul ? lineEnds : lineEnds.replace(/\0/g, '\ufffd');
97
+ // The body is the source with every CRLF one unit shorter: count the pairs before a body offset, from the last
98
+ // offset asked.
99
+ let pair = source.indexOf('\r\n', bodyOffset), shift = bodyOffset;
100
+ const sourceOffset = at => {
101
+ while (pair >= 0 && at + shift > pair) { shift++; pair = source.indexOf('\r\n', pair + 2); }
102
+ for (let last; shift > bodyOffset && at + shift - 1 <= (last = source.lastIndexOf('\r\n', (pair < 0 ? source.length : pair) - 1)); pair = last) shift--;
103
+ return at + shift;
104
+ };
105
+ const tokens = [];
106
+ let origin = 0, first = 0, state;
107
+ // A line begins after the newline that ends the line before it. No rule writes eMarks.
108
+ const report = () => visit({tokens, firstLine: first, lineStart: line => {
109
+ if (!line) return sourceOffset(origin);
110
+ const end = Object.hasOwn(state.eMarks, line - 1) ? state.eMarks[line - 1] : body.length;
111
+ return end < body.length ? sourceOffset(end + 1) : undefined;
112
+ }});
113
+ let lines = 0;
114
+ for (let at = 0; at < body.length; at++) if (body.charCodeAt(at) === 10) lines++;
115
+ if (lines <= BLOCK_WINDOW) {
116
+ state = new parser.block.State(body, parser, env, tokens);
117
+ parser.block.tokenize(state, state.line, state.lineMax);
118
+ return report();
119
+ }
120
+ // The parser's own count of a last line that no newline ends: it does not count one of only spaces.
121
+ state = new parser.block.State(body.slice(body.lastIndexOf('\n') + 1), parser, env, tokens);
122
+ lines += state.lineMax;
123
+ state.src = body;
124
+ const tables = new WeakMap();
125
+ for (const name of LINE_TABLES) { state[name].length = 0; tables.set(Object.setPrototypeOf(state[name], unheldLines), name); }
126
+ let frontier = 0, ended = false, betweenBlocks = false;
127
+ // A rule may be reading a table when it grows, so the five arrays are replaced whole; an array a rule still
128
+ // holds is left empty, and `answer` reads and writes the state's current one in its place.
129
+ const fill = line => {
130
+ while (!ended && line >= state.bMarks.length) {
131
+ const held = state.bMarks.length;
132
+ let until = frontier;
133
+ for (let more = held ? Math.max(TABLE_STEP, held - BLOCK_WINDOW) : BLOCK_WINDOW; more && until < body.length; more--) {
134
+ const end = body.indexOf('\n', until);
135
+ until = end < 0 ? body.length : end + 1;
136
+ }
137
+ const scanned = new parser.block.State(body.slice(frontier, until), parser, env, []);
138
+ ended = until === body.length;
139
+ // The scan's last entry stands for the end of its input, which is the body's end only for the last scan.
140
+ const added = ended ? scanned.lineMax + 1 : scanned.lineMax;
141
+ for (const name of LINE_TABLES) {
142
+ const old = state[name], next = scanned[name], offset = name === 'bMarks' || name === 'eMarks' ? frontier : 0, table = [];
143
+ for (let at = 0; at < held; at++) table.push(old[at]);
144
+ for (let at = 0; at < added; at++) table.push(next[at] + offset);
145
+ old.length = 0;
146
+ tables.set(table, name);
147
+ state[name] = Object.setPrototypeOf(table, unheldLines);
148
+ }
149
+ frontier = until;
150
+ }
151
+ };
152
+ const answer = (table, line, write, value) => {
153
+ const name = tables.get(table);
154
+ if (line >= state[name].length) {
155
+ if (betweenBlocks) throw WINDOW_END;
156
+ fill(line);
157
+ }
158
+ const current = state[name], held = Object.hasOwn(current, line);
159
+ if (!write) return held ? current[line] : undefined;
160
+ if (held) current[line] = value;
161
+ else Object.defineProperty(current, line, {value, writable: true, enumerable: true, configurable: true});
162
+ return true;
163
+ };
164
+ // The top-level loop calls this between two blocks with the line it stands on. There every token is a finished
165
+ // block and no rule is running, so the pass can leave the tokenizer and enter it again at that line.
166
+ const skipEmptyLines = state.skipEmptyLines;
167
+ state.skipEmptyLines = function (line) {
168
+ if (this.level || line !== this.line || !line) return skipEmptyLines.call(this, line);
169
+ if (line >= BLOCK_WINDOW) throw WINDOW_END;
170
+ betweenBlocks = true;
171
+ line = skipEmptyLines.call(this, line);
172
+ betweenBlocks = false;
173
+ return line;
174
+ };
175
+ for (;;) {
176
+ // Between top-level blocks the parser's loop only steps over empty lines, so a run of them is never tabled.
177
+ for (let at = origin; at < body.length; at++) {
178
+ const code = body.charCodeAt(at);
179
+ if (code === 10) { origin = at + 1; first++; }
180
+ else if (!parser.utils.isSpace(code)) break;
181
+ }
182
+ frontier = origin; ended = false; betweenBlocks = false;
183
+ state.line = 0; state.lineMax = lines - first;
184
+ const outer = unheldLine;
185
+ unheldLine = answer;
186
+ let finished = true;
187
+ try { parser.block.tokenize(state, 0, state.lineMax); }
188
+ catch (error) { if (error !== WINDOW_END) throw error; finished = false; }
189
+ finally { unheldLine = outer; }
190
+ report();
191
+ if (finished) return;
192
+ tokens.length = 0;
193
+ origin = state.eMarks[state.line - 1] + 1; first += state.line;
194
+ for (const name of LINE_TABLES) state[name].length = 0;
195
+ }
196
+ }
197
+
76
198
  export function dataImage(url) {
77
199
  if (typeof url !== 'string' || url.length > IMAGE_LIMITS.sourceChars) return null;
78
200
  const prepared = preparedDefinitions.find(row => row.image.url === url)?.image;
@@ -94,36 +216,33 @@ export function parseAssets(source, factory = configured || globalThis.markdowni
94
216
  if (prior) return prior.index;
95
217
  const definition = !bodyOffset && preparedDefinitions.find(row => row.source === source && row.factory === factory);
96
218
  if (definition) return definition.index;
97
- const parser = parserFor(factory), env = {}, tokens = [];
98
- const starts = [bodyOffset];
99
- for (let at = bodyOffset; at < source.length; at++) {
100
- if (source[at] === '\r') { if (source[at + 1] === '\n') at++; starts.push(at + 1); }
101
- else if (source[at] === '\n') starts.push(at + 1);
102
- }
103
- parser.block.parse(source.slice(bodyOffset).replace(/\r\n?/g, '\n').replace(/\0/g, '\ufffd'), parser, env, tokens);
104
- const references = env.references || Object.create(null), assets = new Map(), blocks = [], seen = new Set(), duplicateLabels = new Set();
105
- for (const token of tokens) {
106
- if (token.type !== 'reference_definition' || !token.map) continue;
107
- const id = token.meta.label, definition = token.meta.mdImageDefinition, first = !seen.has(id);
108
- if (!first) duplicateLabels.add(id);
109
- seen.add(id);
110
- const info = dataImage(definition?.href);
111
- if (!info) continue;
112
- const start = starts[token.map[0]], stop = starts[token.map[1]] ?? source.length;
113
- let end = stop;
114
- if (source[end - 1] === '\n') end--;
115
- if (source[end - 1] === '\r') end--;
116
- // Repeated definitions are source, not additional image owners.
117
- const raw = source.slice(start, end), found = raw.indexOf(definition.href);
118
- const repeated = found >= 0 ? raw.indexOf(definition.href, found + 1) : -1;
119
- const labelContainsUrl = definition.href.length <= id.length && id.includes(parser.utils.normalizeReference(definition.href));
120
- const urlStart = found >= 0 && repeated < 0 && !labelContainsUrl ? start + found : null;
121
- const row = {id, label: id, source: raw, url: definition.href, title: definition.title || '', start, end,
122
- ...info, urlStart, urlEnd: urlStart == null ? null : urlStart + definition.href.length,
123
- payloadStart: urlStart == null ? null : urlStart + info.payloadStart, payloadEnd: urlStart == null ? null : urlStart + definition.href.length, status: 'unverified', active: first, topLevel: token.level === 0};
124
- blocks.push(row);
125
- if (first) assets.set(id, row);
126
- }
219
+ const parser = parserFor(factory), env = {};
220
+ const assets = new Map(), blocks = [], seen = new Set(), duplicateLabels = new Set();
221
+ eachBlockWindow(source, bodyOffset, parser, env, ({tokens, lineStart}) => {
222
+ for (const token of tokens) {
223
+ if (token.type !== 'reference_definition' || !token.map) continue;
224
+ const id = token.meta.label, definition = token.meta.mdImageDefinition, first = !seen.has(id);
225
+ if (!first) duplicateLabels.add(id);
226
+ seen.add(id);
227
+ const info = dataImage(definition?.href);
228
+ if (!info) continue;
229
+ const start = lineStart(token.map[0]), stop = lineStart(token.map[1]) ?? source.length;
230
+ let end = stop;
231
+ if (source[end - 1] === '\n') end--;
232
+ if (source[end - 1] === '\r') end--;
233
+ // Repeated definitions are source, not additional image owners.
234
+ const raw = source.slice(start, end), found = raw.indexOf(definition.href);
235
+ const repeated = found >= 0 ? raw.indexOf(definition.href, found + 1) : -1;
236
+ const labelContainsUrl = definition.href.length <= id.length && id.includes(parser.utils.normalizeReference(definition.href));
237
+ const urlStart = found >= 0 && repeated < 0 && !labelContainsUrl ? start + found : null;
238
+ const row = {id, label: id, source: raw, url: definition.href, title: definition.title || '', start, end,
239
+ ...info, urlStart, urlEnd: urlStart == null ? null : urlStart + definition.href.length,
240
+ payloadStart: urlStart == null ? null : urlStart + info.payloadStart, payloadEnd: urlStart == null ? null : urlStart + definition.href.length, status: 'unverified', active: first, topLevel: token.level === 0};
241
+ blocks.push(row);
242
+ if (first) assets.set(id, row);
243
+ }
244
+ });
245
+ const references = env.references || Object.create(null);
127
246
  if (assets.size > IMAGE_LIMITS.assets) for (const asset of assets.values()) asset.status = 'asset_count_limit';
128
247
  let appendixStart = source.length;
129
248
  for (let index = blocks.length - 1; index >= 0; index--) {
@@ -103,14 +103,16 @@ export const parseLayoutAttribute = value => parseLayout(decodeLayoutAttribute(v
103
103
  export function textStyle(value, {portable = false, pointSize, indentOffset = 0} = {}) {
104
104
  if (!validLayout(value)) return '';
105
105
  const property = {first: 'text-indent', indent: 'margin-inline-start'};
106
- return (portable ? ['first', 'indent'] : ['first', 'indent', 'marker'])
106
+ const css = (portable ? ['first', 'indent'] : ['first', 'indent', 'marker'])
107
107
  .filter(key => has(value, key) || key === 'indent' && indentOffset).map(key => {
108
108
  const level = (value[key] || 0) + (key === 'indent' ? indentOffset : 0);
109
109
  const measure = decimal(Number((level * 2).toFixed(6)));
110
110
  return (portable ? property[key] : '--md-' + key) + ':' + (portable ?
111
111
  pointSize ? decimal(Math.round(level * pointSize * 40) / 20) + 'pt' : measure + 'em' :
112
112
  'calc(' + measure + ' * var(--md-text-body))');
113
- }).join(';');
113
+ });
114
+ if (portable && value.hyphens === 'auto') css.push('hyphens:auto');
115
+ return css.join(';');
114
116
  }
115
117
 
116
118
  // Raw HTML tables carry ordinary paragraph CSS. Read the browser's parsed declarations on the same
@@ -127,6 +129,7 @@ export function textLayout(style, {listMarker = false, pointSize, offset = 0} =
127
129
  // rather than each intermediate declaration, is the measurement the writer must keep.
128
130
  if (Number.isFinite(level) && level) value[key] = level;
129
131
  }
132
+ if (!listMarker && style?.getPropertyValue('hyphens').trim() === 'auto') value.hyphens = 'auto';
130
133
  return value;
131
134
  }
132
135
 
@@ -1,5 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // The one owner of colour, font, ink and page-break markers.
2
+ // The one owner of inline typography, authored colour/font comments, ink and page breaks.
3
+ import {markdownSourcePositions} from './md-source.mjs';
3
4
 
4
5
  // The exported page retains this reader alone. The editor uses the same closure, then adds its writers and scans.
5
6
  const inkReader = () => {
@@ -84,7 +85,7 @@ export function formatColorOpen(hex) {
84
85
  }
85
86
 
86
87
  export function formatColorRun(hex, content) {
87
- return formatColorOpen(hex) + content + COLOR_CLOSE;
88
+ return typography.wrap({color: hex}, content);
88
89
  }
89
90
 
90
91
  // Relocated into the parse Worker by .toString() (engine.js _buildParseWorkerSource): read only parameters, TEXT_COLOR_NAMES and COLOR_CLOSE.
@@ -165,7 +166,7 @@ export function formatFontOpen(family) {
165
166
  }
166
167
 
167
168
  export function formatFontRun(family, content) {
168
- return formatFontOpen(family) + content + FONT_CLOSE;
169
+ return typography.wrap({family}, content);
169
170
  }
170
171
 
171
172
  export function matchFontOpen(text) {
@@ -209,6 +210,220 @@ export function hasFontMarker(source) {
209
210
  return scanFontMarkers(source).length > 0;
210
211
  }
211
212
 
213
+ // One typed interpretation for source, DOM and native-file adapters. The worker carries this
214
+ // exact factory and its explicit grammar inputs; no DOM, CSS evaluator or resource loader.
215
+ export function createTypography({normalizeFontFamily, fontFamilyCss, parseColorOpen, parseFontOpen, FONT_STACKS}) {
216
+ const LIMIT = 4096, DEPTH = 64;
217
+ const generics = {serif: 'serif', 'sans-serif': 'sans', monospace: 'mono', cursive: 'hand',
218
+ fantasy: 'fantasy', 'system-ui': 'system', 'ui-serif': 'ui-serif', 'ui-sans-serif': 'ui-sans-serif',
219
+ 'ui-monospace': 'ui-monospace', 'ui-rounded': 'ui-rounded', emoji: 'emoji', math: 'math', fangsong: 'fangsong'};
220
+ const escape = value => String(value).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
221
+ function size(value) {
222
+ if (typeof value !== 'string' || value.length > 48) return null;
223
+ const match = /^(\d+(?:\.\d{0,12})?|\.\d{1,12})\s*(pt|px)$/i.exec(value.trim());
224
+ if (!match) return null;
225
+ const number = Number(match[1]), unit = match[2].toLowerCase(), points = number * (unit === 'px' ? .75 : 1);
226
+ if (!(points > 0 && points <= 1638)) return null;
227
+ const [integer, fraction = ''] = match[1].split('.'), tail = fraction.replace(/0+$/, '');
228
+ return Object.freeze({css: (integer.replace(/^0+/, '') || '0') + (tail ? '.' + tail : '') + unit,
229
+ unit, value: number, points, halfPoints: points * 2});
230
+ }
231
+ function color(value) {
232
+ if (typeof value !== 'string') return null;
233
+ if (/^#[0-9a-f]{6}$/i.test(value.trim())) return value.trim().toLowerCase();
234
+ // CSSOM serializes admitted hex to opaque rgb(). Native HTML import uses this same boundary.
235
+ const rgb = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*1\s*)?\)$/i.exec(value.trim());
236
+ return rgb && rgb.slice(1).every(channel => Number(channel) <= 255)
237
+ ? '#' + rgb.slice(1).map(channel => Number(channel).toString(16).padStart(2, '0')).join('') : null;
238
+ }
239
+ function familyCss(value) {
240
+ if (typeof value !== 'string' || value.length > 2048) return null;
241
+ const css = value.trim();
242
+ const stackKey = text => text.replace(/'/g, '"').replace(/"([\w-]+)"/g, '$1').replace(/\s*,\s*/g, ',');
243
+ for (const [name] of Object.entries(FONT_STACKS)) if ((!/^["']/.test(css) || css.includes(',')) && stackKey(css) === stackKey(fontFamilyCss(name)))
244
+ return Object.freeze({name, css: fontFamilyCss(name), custom: false});
245
+ if (Object.hasOwn(generics, css.toLowerCase())) return Object.freeze({name: generics[css.toLowerCase()], css: css.toLowerCase(), custom: false});
246
+ let name = css;
247
+ if (css[0] === '"' || css[0] === "'") {
248
+ if (css.at(-1) !== css[0]) return null;
249
+ name = '';
250
+ for (let at = 1; at < css.length - 1; at++) {
251
+ if (css[at] === css[0] || /[\r\n\f]/.test(css[at])) return null;
252
+ if (css[at] !== '\\') { name += css[at]; continue; }
253
+ const escaped = /^\\(?:([0-9a-f]{1,6})(?:\r\n|[ \t\r\n\f])?|([^\r\n\f]))/i.exec(css.slice(at));
254
+ if (!escaped || at + escaped[0].length > css.length - 1) return null;
255
+ const code = escaped[1] ? parseInt(escaped[1], 16) : 0;
256
+ name += escaped[1] ? code && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff) ? String.fromCodePoint(code) : '\ufffd' : escaped[2];
257
+ at += escaped[0].length - 1;
258
+ }
259
+ } else if (!/^[\p{L}_-][\p{L}\p{N}_ -]*$/u.test(css) || /^(?:inherit|initial|unset|revert|revert-layer)$/i.test(css)) return null;
260
+ name = normalizeFontFamily(name);
261
+ // These five names are the existing family-choice aliases, not custom font identities.
262
+ if (name && Object.hasOwn(FONT_STACKS, name.toLowerCase())) return null;
263
+ return name ? Object.freeze({name, css: '"' + name.replace(/[\\"]/g, '\\$&') + '"', custom: true}) : null;
264
+ }
265
+ function family(value) {
266
+ if (value && typeof value === 'object') {
267
+ const parsed = familyCss(value.css);
268
+ return parsed && parsed.name === value.name && parsed.custom === value.custom ? parsed : null;
269
+ }
270
+ const name = normalizeFontFamily(value);
271
+ return name ? familyCss(fontFamilyCss(name)) : null;
272
+ }
273
+ function normalize(value, removals = false) {
274
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('Typography requires size, color or family.');
275
+ const result = {};
276
+ for (const key of Object.keys(value)) {
277
+ if (!['size', 'color', 'family'].includes(key)) throw new TypeError('Unknown typography property: ' + key);
278
+ if (value[key] === null && removals) { result[key] = null; continue; }
279
+ const parsed = key === 'size' ? size(value[key])?.css : key === 'color' ? color(value[key]) : family(value[key]);
280
+ if (!parsed) throw new TypeError('Invalid typography ' + key + '.');
281
+ result[key] = parsed;
282
+ }
283
+ return Object.freeze(result);
284
+ }
285
+ // Rendering drops unsupported declarations; rich editing admits only wholly owned
286
+ // spans so unrelated HTML and rejected attributes keep their exact-source editor.
287
+ function parseStyle(value, editable = false) {
288
+ if (typeof value !== 'string' || value.length > LIMIT) return editable ? null : Object.freeze({});
289
+ const parts = []; let quote = '', start = 0;
290
+ for (let at = 0; at <= value.length; at++) {
291
+ const ch = value[at];
292
+ if (quote) { if (ch === '\\') at++; else if (ch === quote) quote = ''; }
293
+ else if (ch === '"' || ch === "'") quote = ch;
294
+ else if (ch === ';' || at === value.length) { parts.push(value.slice(start, at)); start = at + 1; }
295
+ }
296
+ if (quote || parts.length > 32) return editable ? null : Object.freeze({});
297
+ const result = {};
298
+ for (const part of parts) {
299
+ if (!part.trim()) continue;
300
+ const colon = part.indexOf(':'); if (colon < 0) { if (editable) return null; continue; }
301
+ const key = part.slice(0, colon).trim().toLowerCase(), raw = part.slice(colon + 1).trim();
302
+ const parsed = key === 'font-size' ? size(raw)?.css : key === 'color' ? color(raw) : key === 'font-family' ? familyCss(raw) : null;
303
+ if (parsed) result[key === 'font-size' ? 'size' : key === 'font-family' ? 'family' : 'color'] = parsed;
304
+ else if (editable) return null;
305
+ }
306
+ return Object.freeze(result);
307
+ }
308
+ function style(value) {
309
+ const held = normalize(value), parts = [];
310
+ if (held.size) parts.push('font-size:' + held.size);
311
+ if (held.color) parts.push('color:' + held.color);
312
+ if (held.family) parts.push('font-family:' + held.family.css);
313
+ return parts.join(';');
314
+ }
315
+ const open = value => { const css = style(value); return css ? '<span style="' + escape(css) + '">' : ''; };
316
+ const wrap = (value, content) => {
317
+ const start = open(value);
318
+ if (!start || !content) return content;
319
+ // Importers can supply a styled break by itself, or before/after words. Keep
320
+ // those exact line-edge bytes outside the new span so its opener has words.
321
+ const prefix = /^(?:[ \t]*(?:\r\n|\r(?!\n)|\n))+/.exec(content)?.[0] || '';
322
+ let body = content.slice(prefix.length);
323
+ const suffix = /(?:[ \t]*(?:\r\n|\r(?!\n)|\n))+[ \t]*$/.exec(body)?.[0] || '';
324
+ body = body.slice(0, body.length - suffix.length);
325
+ if (!body) return content;
326
+ // Soft/hard line breaks can belong to one Markdown paragraph. A blank line or
327
+ // an opener on a line of its own would instead change the block grammar.
328
+ if (/^\s*\n|\n[ \t]*\n/.test(body.replace(/\r\n?/g, '\n')))
329
+ throw new TypeError('Typography wraps one inline paragraph at a time.');
330
+ return prefix + start + body + '</span>' + suffix;
331
+ };
332
+ function parseOpen(source, decode = value => value, editable = false) {
333
+ if (typeof source !== 'string' || source.length > LIMIT + 1024 || !/^<span(?=[\s>])/i.test(source) || !source.endsWith('>') || /\/\s*>$/.test(source)) return null;
334
+ const attrs = /\s+([^\s=<>/"']+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s=<>`"']+)))?/y;
335
+ let at = 5, css = null;
336
+ while (at < source.length - 1) {
337
+ if (/^\s*>$/.test(source.slice(at))) break;
338
+ attrs.lastIndex = at; const match = attrs.exec(source);
339
+ if (!match) return null;
340
+ at = attrs.lastIndex;
341
+ if (match[1].toLowerCase() !== 'style') { if (editable) return null; continue; }
342
+ if (css !== null) return null;
343
+ css = decode(match[2] ?? match[3] ?? match[4] ?? '');
344
+ }
345
+ const facts = parseStyle(css, editable);
346
+ return facts && Object.keys(facts).length ? facts : null;
347
+ }
348
+ function nodeStyle(node) {
349
+ const result = {...parseStyle(node?.getAttribute?.('style') || '')};
350
+ const authoredSize = size(node?.getAttribute?.('data-md-size'));
351
+ const authoredColor = color(node?.getAttribute?.('data-md-color'));
352
+ const authoredFamily = normalizeFontFamily(node?.getAttribute?.('data-md-font'));
353
+ if (authoredSize) result.size = authoredSize.css;
354
+ if (authoredColor) result.color = authoredColor;
355
+ if (authoredFamily && !result.family) result.family = family(authoredFamily);
356
+ return Object.freeze(result);
357
+ }
358
+ function runs(root) {
359
+ let text = ''; const paint = [], leaves = [];
360
+ const visit = (node, inherited, blocked) => {
361
+ if (node.nodeType === 3) {
362
+ const value = String(node.nodeValue || '').replace(/\u2060/g, '\n'), start = text.length;
363
+ text += value;
364
+ for (let index = 0; index < value.length; index++) paint.push(inherited);
365
+ if (value) leaves.push({start, end: text.length, blocked});
366
+ return;
367
+ }
368
+ if (node.nodeType !== 1 && node !== root) return;
369
+ const own = nodeStyle(node), next = {...inherited, ...own};
370
+ const literal = blocked || /^(?:CODE|PRE|KBD|SAMP|MATH)$/.test(node.tagName) ||
371
+ node.classList?.contains('math-rendered') || node.classList?.contains('math-placeholder');
372
+ for (const child of node.childNodes || []) visit(child, next, !!literal);
373
+ };
374
+ visit(root, {}, false);
375
+ return {text, paint, leaves};
376
+ }
377
+ function install(md) {
378
+ if (md.__rapierTypography) return md;
379
+ md.__rapierTypography = true;
380
+ md.core.ruler.after('linkify', 'rapier-typography', state => {
381
+ for (const block of state.tokens) {
382
+ if (block.type !== 'inline' || !block.children) continue;
383
+ const stack = []; let depth = 0, overflow = 0;
384
+ for (const token of block.children) {
385
+ if (token.type !== 'html_inline') { depth += token.nesting || 0; continue; }
386
+ const source = token.content, htmlClose = /^<\/span\s*>$/i.test(source);
387
+ const kind = htmlClose || /^<span(?=[\s>])/i.test(source) ? 'span'
388
+ : source === '<!--/c-->' || parseColorOpen(source) ? 'color'
389
+ : source === '<!--/f-->' || parseFontOpen(source) ? 'font' : '';
390
+ if (!kind) continue;
391
+ const close = htmlClose || source === '<!--/c-->' || source === '<!--/f-->';
392
+ if (overflow) { overflow += close ? -1 : 1; continue; }
393
+ if (!close) {
394
+ // Authored comment marks retain their original first-opener rule. Ordinary
395
+ // canonical spans nest; a repeated old opener remains an inert stray.
396
+ if (kind !== 'span' && stack.some(held => held.kind === kind)) continue;
397
+ if (stack.length === DEPTH) { overflow = 1; continue; }
398
+ const facts = kind === 'span' ? parseOpen(source, value => md.utils.unescapeAll(value.replace(/\\/g, '\\\\')))
399
+ : kind === 'color' ? normalize({color: parseColorOpen(source)}) : normalize({family: parseFontOpen(source)});
400
+ stack.push({token, kind, facts, depth, source}); continue;
401
+ }
402
+ const held = stack.at(-1);
403
+ if (!held || held.kind !== kind) continue;
404
+ stack.pop();
405
+ if (!held.facts || depth !== held.depth) continue;
406
+ const type = kind === 'span' ? 'typography' : kind, opener = held.token;
407
+ opener.type = 'rapier_' + type + '_open'; opener.tag = 'span'; opener.nesting = 1; opener.content = '';
408
+ (opener.meta ||= {}).typography = Object.freeze({style: held.facts, open: held.source, close: source});
409
+ opener.attrSet('style', style(held.facts));
410
+ if (held.facts.color) opener.attrSet('data-md-color', held.facts.color);
411
+ if (held.facts.size) opener.attrSet('data-md-size', held.facts.size);
412
+ if (held.facts.family) opener.attrSet('data-md-font', held.facts.family.name);
413
+ if (kind === 'font') { opener.attrSet('data-md-font-open', encodeURIComponent(held.source)); opener.meta.mdFont = {source: held.source, family: held.facts.family.name}; }
414
+ token.type = 'rapier_' + type + '_close'; token.tag = 'span'; token.nesting = -1; token.content = '';
415
+ (token.meta ||= {}).typography = opener.meta.typography;
416
+ }
417
+ }
418
+ });
419
+ return md;
420
+ }
421
+ return Object.freeze({size, color, family, familyCss, normalize, parseStyle, style, open, wrap, parseOpen, nodeStyle, runs, install});
422
+ }
423
+
424
+ export const typography = createTypography({normalizeFontFamily, fontFamilyCss, parseColorOpen, parseFontOpen, FONT_STACKS});
425
+ export function typographySourceRanges(source, parser) { return markdownSourcePositions(source, parser).typography; }
426
+
212
427
  const INK_CLOSE = '<!--/ink-->';
213
428
  export { INK_KINDS, INK_CLOSE, INK_PATH_MAX, INK_INT_MAX, INK_OPEN_PATTERN };
214
429
 
@@ -0,0 +1,122 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {splitOpeningFrontmatter} from './frontmatter.mjs';
3
+
4
+ // The standard parser owns literal regions and comment boundaries. Offsets are original UTF-16,
5
+ // including BOM/CRLF and list/quote prefixes. A private parser view observes rules; it changes no
6
+ // live rule or token. Call once per source, then ask isText(position) or isComment(start, end).
7
+ export function markdownSourcePositions(source, parser) {
8
+ if (typeof source !== 'string' || !parser) throw new TypeError('Source positions require text and its Markdown parser');
9
+ const {body, bodyOffset} = splitOpeningFrontmatter(source), lines = [bodyOffset];
10
+ for (const row of body.matchAll(/\r\n?|\n/g)) lines.push(bodyOffset + row.index + row[0].length);
11
+ const lineAt = line => lines[line] ?? source.length;
12
+ const literal = [], comments = new Map(), observed = new WeakMap(), typography = [], openedStyles = new Map();
13
+ const add = (start, end) => { if (end > start) literal.push({start, end}); };
14
+ if (bodyOffset) add(0, bodyOffset);
15
+ const owner = Object.create(parser), inline = Object.create(parser.inline), ruler = Object.create(parser.inline.ruler);
16
+ owner.inline = inline; inline.ruler = ruler;
17
+ ruler.getRules = chain => parser.inline.ruler.getRules(chain).map(rule => (state, silent) => {
18
+ const start = state.pos, first = state.tokens.length;
19
+ const matched = rule(state, silent), token = state.tokens.at(-1);
20
+ // A pending text token may flush before this token. Never overwrite a nested rule's span
21
+ // with its enclosing link's range.
22
+ if (matched && !silent && state.tokens.length > first && !observed.has(token) &&
23
+ ['code_inline', 'html_inline', 'image', 'footnote_ref'].includes(token?.type)) {
24
+ observed.set(token, {start, end: state.pos, source: state.src,
25
+ comment: token.type === 'html_inline' && token.content.startsWith('<!--'),
26
+ footnote: token.type === 'footnote_ref' && state.src.startsWith('^[', start)
27
+ ? state.env.footnotes?.list?.[token.meta.id] : null});
28
+ }
29
+ return matched;
30
+ });
31
+ const tokens = owner.parse(body, {});
32
+ const readInline = (children, content, rawOffset) => {
33
+ for (const child of children || []) {
34
+ const span = observed.get(child);
35
+ if (!span || span.source !== content) continue;
36
+ // Inline footnotes are moved to a generated footer without line maps. Their parser-owned
37
+ // child tokens still stand inside the original ^[...] carrier, not in that footer.
38
+ if (span.footnote?.tokens) {
39
+ readInline(span.footnote.tokens, span.footnote.content, pos => rawOffset(span.start + 2 + pos));
40
+ } else if (child.type !== 'footnote_ref') {
41
+ const start = rawOffset(span.start), end = rawOffset(span.end);
42
+ const style = child.meta?.typography;
43
+ if (style && child.nesting === 1) openedStyles.set(style, {start, innerStart: end, style: style.style});
44
+ else if (style && child.nesting === -1 && openedStyles.has(style)) {
45
+ typography.push({...openedStyles.get(style), innerEnd: start, end});
46
+ openedStyles.delete(style);
47
+ }
48
+ if (span.comment) comments.set(start, end);
49
+ else add(start, end);
50
+ }
51
+ }
52
+ };
53
+ let table = null;
54
+ for (const token of tokens) {
55
+ if (token.type === 'tr_open' && token.map) {
56
+ // GFM unescapes a pipe before parsing a cell. Preserve that one-character projection and
57
+ // consume cells in order, so repeated cells and backticks across a separator never alias.
58
+ const start = lineAt(token.map[0]), end = lineAt(token.map[0] + 1), offsets = [];
59
+ let text = '';
60
+ for (let at = start; at < end; at++) {
61
+ if (source[at] === '\\' && source[at + 1] === '|') at++;
62
+ offsets.push(at); text += source[at] === '\0' ? '\ufffd' : source[at];
63
+ }
64
+ offsets.push(end); table = {text, offsets, at: 0};
65
+ }
66
+ if (token.type === 'tr_close') table = null;
67
+ if (token.map && ['fence', 'code_block', 'html_block', 'reference_definition'].includes(token.type)) {
68
+ const start = lineAt(token.map[0]), end = lineAt(token.map[1]);
69
+ add(start, end);
70
+ // A comment-carrier block can contain several adjacent comments. Keep each carrier,
71
+ // including a closer surviving damage to its opener. Never promote nested comments or
72
+ // comment-shaped text in a raw tagged HTML block (pre, script, div, ...).
73
+ if (token.type === 'html_block' && /^\s*<!--/.test(token.content)) {
74
+ let at = source.indexOf('<!--', start);
75
+ while (at >= start && at < end) {
76
+ const close = source.indexOf('-->', at + 4);
77
+ const stop = close >= 0 && close + 3 <= end ? close + 3 : end;
78
+ comments.set(at, stop);
79
+ at = source.indexOf('<!--', stop);
80
+ }
81
+ }
82
+ continue;
83
+ }
84
+ if (token.type !== 'inline' || !token.content) continue;
85
+ let rawOffset;
86
+ if (token.map) {
87
+ const offsets = [], starts = [], rows = token.content.split('\n');
88
+ let local = 0;
89
+ for (let i = 0; i < rows.length; i++) {
90
+ const start = lineAt(token.map[0] + i), end = lineAt(token.map[0] + i + 1);
91
+ const at = source.slice(start, end).replace(/\0/g, '\ufffd').indexOf(rows[i]);
92
+ if (at < 0) throw new Error('Markdown inline source mapping failed');
93
+ starts.push(local); offsets.push(start + at); local += rows[i].length + 1;
94
+ }
95
+ rawOffset = pos => {
96
+ let row = starts.length - 1;
97
+ while (row > 0 && starts[row] > pos) row--;
98
+ return offsets[row] + pos - starts[row];
99
+ };
100
+ } else if (table) {
101
+ const at = table.text.indexOf(token.content, table.at);
102
+ if (at < 0) throw new Error('Markdown table source mapping failed');
103
+ table.at = at + token.content.length;
104
+ rawOffset = pos => table.offsets[at + pos];
105
+ } else continue;
106
+ readInline(token.children, token.content, rawOffset);
107
+ }
108
+ literal.sort((a, b) => a.start - b.start || b.end - a.end);
109
+ const merged = [];
110
+ for (const range of literal) {
111
+ const last = merged.at(-1);
112
+ if (last && range.start <= last.end) last.end = Math.max(last.end, range.end);
113
+ else merged.push({...range});
114
+ }
115
+ const isText = position => {
116
+ if (comments.has(position)) return false;
117
+ let low = 0, high = merged.length;
118
+ while (low < high) { const mid = (low + high) >> 1; if (merged[mid].end <= position) low = mid + 1; else high = mid; }
119
+ return low < merged.length && merged[low].start <= position;
120
+ };
121
+ return {isText, isComment: (start, end) => comments.get(start) === end, typography: typography.sort((a, b) => a.start - b.start)};
122
+ }