rapier-markdown-kit 1.1.104 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -4
- package/README.md +50 -7
- package/dist/images/gif.mjs +49 -0
- package/dist/images/playback.mjs +103 -0
- package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
- package/dist/kit/conformance/04-wrap-box.md +1 -1
- package/dist/kit/conformance/05-behind.md +1 -1
- package/dist/kit/conformance/06-front.md +1 -1
- package/dist/kit/conformance/07-rotate-raster.md +1 -1
- package/dist/kit/conformance/08-drawing-rotation.md +1 -1
- package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
- package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
- package/dist/kit/conformance/10-both-sides.md +1 -1
- package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
- package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
- package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
- package/dist/kit/conformance/README.md +19 -28
- package/dist/kit/conformance/run.mjs +10 -8
- package/dist/kit/ledger/authored-origin.mjs +49 -0
- package/dist/kit/ledger/authorship.mjs +18 -9
- package/dist/kit/ledger/carried.mjs +10 -5
- package/dist/kit/ledger/data.mjs +7 -4
- package/dist/kit/ledger/format.mjs +37 -11
- package/dist/kit/ledger/hash.mjs +31 -15
- package/dist/kit/ledger/history.mjs +48 -10
- package/dist/kit/ledger/inverse-source.mjs +111 -49
- package/dist/kit/ledger/journal-records.mjs +24 -29
- package/dist/kit/ledger/merge.mjs +51 -15
- package/dist/kit/ledger/text-merge.mjs +75 -29
- package/dist/kit/ledger/transport.mjs +30 -45
- package/dist/kit/pdf-vectors.mjs +6 -2
- package/dist/kit/print-capitals.mjs +0 -1
- package/dist/kit/rapier-ledger.mjs +4 -2
- package/dist/kit/render-ink.mjs +1 -1
- package/dist/kit/render-markdown.mjs +155 -31
- package/dist/kit/render-print.mjs +173 -61
- package/dist/kit/render-sanitize.mjs +25 -15
- package/dist/kit/render-styles.mjs +3 -3
- package/dist/kit/render.mjs +37 -115
- package/dist/layout/model.mjs +10 -8
- package/dist/layout/paragraph-fit.mjs +1 -15
- package/dist/layout/typography.mjs +2 -1
- package/dist/packages/rapier-diff/concerns.mjs +163 -0
- package/dist/packages/rapier-diff/diff.mjs +68 -0
- package/dist/packages/rapier-diff/index.mjs +4 -701
- package/dist/packages/rapier-diff/matching.mjs +157 -0
- package/dist/packages/rapier-diff/merge.mjs +226 -0
- package/dist/packages/rapier-diff/moves.mjs +130 -0
- package/dist/packages/rapier-diff/structure.mjs +50 -0
- package/dist/packages/rapier-diff/tokens.mjs +72 -0
- package/dist/packages/rapier-diff/walk.mjs +219 -0
- package/dist/spec/document-fonts.mjs +1 -1
- package/dist/spec/document-settings.mjs +11 -164
- package/dist/spec/html-blocks.mjs +618 -79
- package/dist/spec/md-assets.mjs +29 -6
- package/dist/spec/md-layout.mjs +9 -1
- package/dist/spec/md-marks.mjs +25 -147
- package/dist/spec/md-source.mjs +69 -26
- package/package.json +1 -1
- package/style.css +7 -51
package/dist/kit/render.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
import {htmlImageRecord,
|
|
2
|
+
import {htmlImageRecord, htmlRuntimeSource} from '../spec/html-blocks.mjs';
|
|
3
3
|
import {finish, finishAsync, cloneTree, cleanTree, serializeTree, encodeUtf8Steps, pause} from './render-work.mjs';
|
|
4
4
|
import {fontFamilyCss} from '../spec/md-marks.mjs';
|
|
5
5
|
import {readFontAssets, documentFontsCss} from '../spec/document-fonts.mjs';
|
|
@@ -8,43 +8,6 @@ import {documentLanguage, documentSettingsFonts} from '../spec/document-settings
|
|
|
8
8
|
import {createLineMeasurer, linesHeight} from '../layout/model.mjs';
|
|
9
9
|
import {markPrintCapitals, createPrintCapitals, followingPrintProse} from './print-capitals.mjs';
|
|
10
10
|
import {freezePublishedInk} from './render-ink.mjs';
|
|
11
|
-
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
12
|
-
// Rapier generates attribute selectors for dark paper. Project those owned rules onto their
|
|
13
|
-
// SVG elements before export removes stylesheets; the inert host and the editor share this pass.
|
|
14
|
-
export function presentNativeDiagram(drawing) {
|
|
15
|
-
const winners=new Map();
|
|
16
|
-
for(const style of drawing.querySelectorAll('style')){
|
|
17
|
-
const media=/^\s*@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{([\s\S]*)\}\s*$/.exec(style.textContent);
|
|
18
|
-
if(media)for(const rule of media[1].matchAll(/([^{}]+)\{([^{}]*)\}/g)){
|
|
19
|
-
// The browser's CSS sanitizer serializes owned hex colours as rgb()/rgba().
|
|
20
|
-
const properties=[...rule[2].matchAll(/(?:^|;)\s*(fill|stroke|color|stop-color)\s*:\s*(#[\da-f]{3,8}|rgba?\([\d.,%/\s]+\)|none)\s*(?:!important)?(?=;|$)/gi)];
|
|
21
|
-
for(const selector of rule[1].split(',')){
|
|
22
|
-
// Draw's selectors contain only attributes, descendants and immediate children.
|
|
23
|
-
if(!/^\s*\[[^\[\]{};]+\](?:\s*(?:>\s*)?\[[^\[\]{};]+\])*\s*$/.test(selector))continue;
|
|
24
|
-
const rank=(selector.match(/\[/g)||[]).length;
|
|
25
|
-
for(const element of [...(drawing.matches(selector)?[drawing]:[]),...drawing.querySelectorAll(selector)]){
|
|
26
|
-
let paints=winners.get(element);if(!paints)winners.set(element,paints=new Map());
|
|
27
|
-
for(const [,property,dark] of properties){const light=element.getAttribute(property);
|
|
28
|
-
if(light&&(paints.get(property)?.rank??-1)<=rank)paints.set(property,{light,dark,rank});}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
style.remove();
|
|
33
|
-
}
|
|
34
|
-
for(const [element,paints] of winners)for(const [property,{light,dark}] of paints)
|
|
35
|
-
element.style.setProperty(property,'light-dark('+light+', '+dark+')','important');
|
|
36
|
-
drawing.classList.add('rapier-native-flowchart');drawing.setAttribute('role','img');
|
|
37
|
-
drawing.setAttribute('aria-roledescription',drawing.getAttribute('data-rapier-diagram-kind')||'flowchart');
|
|
38
|
-
const width=Number(drawing.getAttribute('width')),
|
|
39
|
-
sizes=[...drawing.querySelectorAll('text[font-size]')].map(text=>Number(text.getAttribute('font-size'))).filter(size=>Number.isFinite(size)&&size>0),
|
|
40
|
-
minimum=sizes.reduce((smallest,size)=>Math.min(smallest,size),Infinity);
|
|
41
|
-
if(Number.isFinite(width)&&width>0){
|
|
42
|
-
const readable=Math.ceil(width*14/(Number.isFinite(minimum)?minimum:14)*1000)/1000;
|
|
43
|
-
drawing.style.width='clamp('+readable+'px, 100%, '+width+'px)';
|
|
44
|
-
}
|
|
45
|
-
drawing.style.maxWidth='none';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
11
|
// Set the first view once. Subsequent rendering keeps the person's horizontal position.
|
|
49
12
|
export function positionNativeDiagram(drawing) {
|
|
50
13
|
const surface=drawing?.closest?.('.diagram-block,.diagram-live-preview');
|
|
@@ -67,8 +30,9 @@ export function positionNativeDiagram(drawing) {
|
|
|
67
30
|
surface.scrollLeft=Math.max(0,Math.min(surface.scrollWidth-surface.clientWidth,left));
|
|
68
31
|
}
|
|
69
32
|
|
|
33
|
+
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
70
34
|
function createRenderer(runtime) {
|
|
71
|
-
const {
|
|
35
|
+
const {RapierLedgerCarried, RapierPageReturnAddress, RapierTextCodec, _rapierArtifactHighlight, _rapierArtifactPreference, _rapierArtifactStyles, _rapierBlobDataUrl, _rapierBuildInterchangeContext, _rapierDocumentNameIsAdmissible, _rapierLedgerParts, _rapierDrawReadSVGRecipe, _rapierDrawShapeProfileFor, _rapierExportFontCss, _rapierFillDiagram, _rapierFinalizeExportCode, _rapierLanguageClass, _rapierLineStartOffsets, _rapierMarkdownEnvironment, _rapierPortableHtml, _rapierPrepareInterchangeContext, _rapierProjectPortableRoot, _rapierProviders, _rapierSourceCharEscaped, _rapierSourceLineSpan, crypto, document, escapeRapierHtmlText, globalThis, sanitizeRapierHtml} = runtime;
|
|
72
36
|
let md = runtime.md;
|
|
73
37
|
async function render(source, options = {}) {
|
|
74
38
|
if (typeof source !== 'string') throw new TypeError('Document source must be a string');
|
|
@@ -157,14 +121,16 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
157
121
|
styledRoot.setAttribute('lang', language);
|
|
158
122
|
if (completePage && typeof _rapierFinalizeExportCode === 'function') await _rapierFinalizeExportCode(styledRoot);
|
|
159
123
|
// _rapierProjectStyledRoot already returns the sanitized projection.
|
|
160
|
-
//
|
|
161
|
-
// picture-shape annotations have no consumer in that artifact.
|
|
124
|
+
// Picture-shape annotations have no consumer in a printed artifact.
|
|
162
125
|
if (!opts.print) _rapierAnnotateExportBoxPolygons(styledRoot);
|
|
126
|
+
// Paper keeps each picture's wrap through the same static float layout a page uses.
|
|
127
|
+
if (completePage || opts.print) {
|
|
128
|
+
if (work) await finishAsync(_rapierSharedPageLayoutSteps(styledRoot), work);
|
|
129
|
+
else _rapierSharedPageLayout(styledRoot);
|
|
130
|
+
}
|
|
163
131
|
if (completePage && !opts.print) {
|
|
164
|
-
if (work)
|
|
165
|
-
|
|
166
|
-
await finishAsync(_rapierShareCapLongCodeBlocksSteps(styledRoot), work);
|
|
167
|
-
} else { _rapierSharedPageLayout(styledRoot); _rapierShareCapLongCodeBlocks(styledRoot); }
|
|
132
|
+
if (work) await finishAsync(_rapierShareCapLongCodeBlocksSteps(styledRoot), work);
|
|
133
|
+
else _rapierShareCapLongCodeBlocks(styledRoot);
|
|
168
134
|
}
|
|
169
135
|
if (typeof opts.afterRoot === 'function') await opts.afterRoot(styledRoot);
|
|
170
136
|
markPrintCapitals(styledRoot);
|
|
@@ -185,8 +151,6 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
185
151
|
// rules of its own -- Share, for the no-script float it alone writes -- appends them.
|
|
186
152
|
// Print retains its existing factory resources. HTML adds only the optional small Sans subset below.
|
|
187
153
|
const includeFonts = !!opts.print;
|
|
188
|
-
const zenFontCss = opts.print && styledRoot.querySelector('svg[aria-roledescription="zenuml"]')
|
|
189
|
-
? (_rapierProviders?.mermaid?.exportFontCss?.() || '') : '';
|
|
190
154
|
const willFont = opts.print && opts.willFont ? opts.willFont : null;
|
|
191
155
|
const settingsCss = _rapierDocumentSettingsCss(settings);
|
|
192
156
|
const refined = styledRoot.querySelector('p[data-md-hyphens="auto"],p[data-md-lines],p[data-md-caps="small"],p[data-md-hanging="on"],img[data-rapier-print-lines]');
|
|
@@ -227,7 +191,7 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
227
191
|
// nonce out of it and be admitted.
|
|
228
192
|
const layoutScript = opts.scripts !== false ? _rapierArtifactLayoutScript(styledRoot, nonce, {pictures:!opts.print}) : '';
|
|
229
193
|
const inkScript = !opts.print && opts.scripts !== false ? _rapierArtifactInkScript(styledRoot, nonce) : '';
|
|
230
|
-
const htmlScript = opts.scripts !== false && styledRoot.querySelector('[data-rapier-html]') ? '<script nonce="' + nonce + '">
|
|
194
|
+
const htmlScript = !opts.print && opts.scripts !== false && styledRoot.querySelector('[data-rapier-html],[data-rapier-animation]') ? '<script nonce="' + nonce + '">' + htmlRuntimeSource().replace(/<\/script/gi, '<\\/script') + '</script>' : '';
|
|
231
195
|
const page = '<!DOCTYPE html>\n'
|
|
232
196
|
+ '<html lang="' + escapeRapierHtmlText(language) + '" data-rapier-theme="' + theme + '" data-highlights="' + highlights + '" data-invert-image-alpha="' + (invertImageAlpha ? 'on' : 'off') + '">\n'
|
|
233
197
|
+ '<head>\n<meta charset="UTF-8">\n' + (carrier ? RapierPageReturnAddress.PAGE_SEED + '\n' : '')
|
|
@@ -243,7 +207,7 @@ async function _rapierBuildArtifact(options, providedContext) {
|
|
|
243
207
|
+ '<meta name="generator" content="Rapier ' + escapeRapierHtmlText(version) + '">\n'
|
|
244
208
|
+ '<title>' + escapeRapierHtmlText(pageTitle) + '</title>\n'
|
|
245
209
|
+ '<style>' + css + '</style>\n</head>\n'
|
|
246
|
-
+ '<body class="' + bodyClass + '"><main class="' + docClass + '" data-md-theme="' + theme + '"
|
|
210
|
+
+ '<body class="' + bodyClass + '"><main class="' + docClass + '" data-md-theme="' + theme + '">'
|
|
247
211
|
+ bodyHtml + '</main>' + carrier + layoutScript + inkScript + htmlScript + '</body>\n</html>';
|
|
248
212
|
|
|
249
213
|
return {
|
|
@@ -301,49 +265,6 @@ function* _rapierProjectStyledRootSteps(semanticRoot, options) {
|
|
|
301
265
|
if (lang) code.classList.add('language-' + String(lang).replace(/[^a-z0-9_-]/gi, ''));
|
|
302
266
|
}
|
|
303
267
|
} else {
|
|
304
|
-
if (root.hasAttribute('data-md-space')) {
|
|
305
|
-
// One raw HTML token may have several siblings. Its authored heights stay inside one line-sized box.
|
|
306
|
-
for (let element = root.firstElementChild; element;) {
|
|
307
|
-
const group = element.getAttribute('data-md-html-block');
|
|
308
|
-
if (group === null) { element = element.nextElementSibling; continue; }
|
|
309
|
-
let first = element;
|
|
310
|
-
// Sanitizing a container or repairing a table can leave its text before the first surviving tag.
|
|
311
|
-
while (first.previousSibling && first.previousSibling.nodeType !== 1) { first = first.previousSibling; yield; }
|
|
312
|
-
const wrap = document.createElement('div');
|
|
313
|
-
wrap.className = 'md-space-block';
|
|
314
|
-
root.insertBefore(wrap, first);
|
|
315
|
-
let node = first;
|
|
316
|
-
while (node && (node.nodeType !== 1 || node.getAttribute('data-md-html-block') === group)) {
|
|
317
|
-
const next = node.nextSibling;
|
|
318
|
-
wrap.appendChild(node);
|
|
319
|
-
node = next;
|
|
320
|
-
yield;
|
|
321
|
-
}
|
|
322
|
-
element = wrap.nextElementSibling;
|
|
323
|
-
}
|
|
324
|
-
// A removed raw container can leave only text. Reserve that surviving content in this projection.
|
|
325
|
-
for (let first = root.firstChild; first;) {
|
|
326
|
-
if (first.nodeType === 1) { first = first.nextSibling; continue; }
|
|
327
|
-
let end = first, hasText = false;
|
|
328
|
-
while (end && end.nodeType !== 1) {
|
|
329
|
-
if (end.nodeType === 3 && end.nodeValue.trim()) hasText = true;
|
|
330
|
-
end = end.nextSibling;
|
|
331
|
-
yield;
|
|
332
|
-
}
|
|
333
|
-
if (hasText) {
|
|
334
|
-
const wrap = document.createElement('div');
|
|
335
|
-
wrap.className = 'md-space-block';
|
|
336
|
-
root.insertBefore(wrap, first);
|
|
337
|
-
for (let node = first; node !== end;) {
|
|
338
|
-
const next = node.nextSibling;
|
|
339
|
-
wrap.appendChild(node);
|
|
340
|
-
node = next;
|
|
341
|
-
yield;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
first = end;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
268
|
for (const code of root.querySelectorAll('pre > code')) { yield;
|
|
348
269
|
if (code.parentElement?.classList.contains('diagram-source') &&
|
|
349
270
|
code.closest('.diagram-block')?.getAttribute('data-diagram-state') === 'ready') continue;
|
|
@@ -465,11 +386,15 @@ function _rapierPaintCode(el, html) {
|
|
|
465
386
|
|
|
466
387
|
function _rapierPrefixPortableAnchors(root, baseName) { return finish(_rapierPrefixPortableAnchorsSteps(root, baseName)); }
|
|
467
388
|
|
|
468
|
-
|
|
389
|
+
// The id an anchor carries on a portable page: the document's name, then the anchor with what an id cannot hold made a hyphen.
|
|
390
|
+
function _rapierPortableAnchorId(baseName, id) {
|
|
469
391
|
const slug = String(baseName || 'document').toLowerCase()
|
|
470
392
|
.replace(/[^a-z0-9]+/g, '-')
|
|
471
393
|
.replace(/^-+|-+$/g, '') || 'document';
|
|
472
|
-
|
|
394
|
+
return 'rapier-' + slug + '-' + (String(id).replace(/[^a-z0-9_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'anchor');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function* _rapierPrefixPortableAnchorsSteps(root, baseName) {
|
|
473
398
|
const ids = new Map(), svgIds = new Map();
|
|
474
399
|
const used = new Set();
|
|
475
400
|
const scopeOf = element => {
|
|
@@ -486,11 +411,10 @@ function* _rapierPrefixPortableAnchorsSteps(root, baseName) {
|
|
|
486
411
|
for (const element of root.querySelectorAll('[id]')) { yield;
|
|
487
412
|
const oldId = element.id;
|
|
488
413
|
if (!oldId) continue;
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
let nextId = prefix + base;
|
|
414
|
+
const first = _rapierPortableAnchorId(baseName, oldId);
|
|
415
|
+
let nextId = first;
|
|
492
416
|
let suffix = 2;
|
|
493
|
-
while (used.has(nextId)) nextId =
|
|
417
|
+
while (used.has(nextId)) nextId = first + '-' + suffix++;
|
|
494
418
|
used.add(nextId);
|
|
495
419
|
// A document link resolves the first matching anchor, as it did before renaming.
|
|
496
420
|
// SVG references resolve within their own drawing, never the last repeated drawing.
|
|
@@ -693,16 +617,18 @@ function _rapierSharedResolve(text, ids, images, definitions) {
|
|
|
693
617
|
let broken = false;
|
|
694
618
|
const src = value => {
|
|
695
619
|
const found = images.get(value);
|
|
696
|
-
if (typeof found !== 'string' || !/^data:image\/(?:png|jpeg|webp|svg\+xml|jxl);base64,[A-Za-z0-9+/]+={0,2}$/i.test(found)) { broken = true; return null; }
|
|
620
|
+
if (typeof found !== 'string' || !/^data:image\/(?:png|jpeg|webp|gif|svg\+xml|jxl);base64,[A-Za-z0-9+/]+={0,2}$/i.test(found)) { broken = true; return null; }
|
|
697
621
|
return found;
|
|
698
622
|
};
|
|
699
623
|
// Authored # characters were encoded. A raw id after ]( can only be a structural
|
|
700
624
|
// substitution; matching its destination lets nested alt brackets survive unchanged.
|
|
701
625
|
const inline = new RegExp('(\\]\\([ \\t\\r\\n]*(?:<)?)#' + id + '(?=>?[ \\t\\r\\n]*\\)|>?[ \\t\\r\\n]+["\'(])', 'g');
|
|
702
|
-
|
|
626
|
+
// A definition may sit in a quote or a list item, as the writer found it: its line starts with their markers.
|
|
627
|
+
const definition = new RegExp('(^[ \\t>]*(?:(?:[-+*]|\\d{1,9}[.)])[ \\t]+[ \\t>]*)*\\[((?:\\\\.|[^\\]\\\\])+)\\]:[ \\t]*(?:(?:\\r\\n|\\r|\\n)[ \\t>]*)?(?:<)?)#' + id + '(?=>?[ \\t]*$|>?[ \\t]+["\'(])', 'gm');
|
|
703
628
|
const swapInline = (match, lead, value) => { const found = src(value); return found == null ? match : lead + found; };
|
|
704
629
|
const swapDefinition = (match, lead, label, value) => {
|
|
705
|
-
|
|
630
|
+
const decoded = _rapierSharedDecode(label), quoted = decoded.replace(/\r?\n[ \t]*(?:>[ \t]*)+/g, ' ');
|
|
631
|
+
if (!definitions.has(md.utils.normalizeReference(decoded)) && !definitions.has(md.utils.normalizeReference(quoted))) return match;
|
|
706
632
|
const found = src(value);
|
|
707
633
|
return found == null ? match : lead + found;
|
|
708
634
|
};
|
|
@@ -877,7 +803,7 @@ function* _rapierSharedPageLayoutSteps(root) {
|
|
|
877
803
|
// `layout.y` already owns `shape-outside` for its own inset reservation (below); the two are not
|
|
878
804
|
// combinable (a float has exactly one `shape-outside` value), so a `y`-offset `around` picture
|
|
879
805
|
// keeps that inset instead of silhouette wrapping -- a documented, narrow degradation.
|
|
880
|
-
if (layout.wrap === 'around' && !layout.rotate && !layout.y) {
|
|
806
|
+
if (layout.wrap === 'around' && !layout.rotate && !layout.y && !image.dataset.rapierContour?.endsWith('-box')) {
|
|
881
807
|
const src = image.getAttribute('src');
|
|
882
808
|
if (src) {
|
|
883
809
|
paragraph.style.setProperty('shape-outside', 'url("' + src + '")');
|
|
@@ -905,16 +831,12 @@ function* _rapierSharedPageLayoutSteps(root) {
|
|
|
905
831
|
for (const {image, paragraph, layout} of positionedAssignments) {
|
|
906
832
|
yield;
|
|
907
833
|
paragraph.style.cssText = 'position:relative;height:0;margin:0;padding:0;line-height:0';
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
image.style.left = 'calc(' + x + '% - ' + (naturalWidth / 2) + 'px)';
|
|
915
|
-
} else {
|
|
916
|
-
image.style.left = x + '%';
|
|
917
|
-
}
|
|
834
|
+
// Without x the picture starts at the left edge, as everywhere else (layout/model.mjs imageBox).
|
|
835
|
+
const x = layout.x, naturalWidth = Number(image.getAttribute('width')) || 0;
|
|
836
|
+
if (layout.width != null) image.style.width = layout.width + '%';
|
|
837
|
+
else if (naturalWidth > 0) image.style.width = naturalWidth + 'px';
|
|
838
|
+
image.style.left = x == null ? '0' : layout.width != null ? (x - layout.width / 2) + '%'
|
|
839
|
+
: naturalWidth > 0 ? 'calc(' + x + '% - ' + (naturalWidth / 2) + 'px)' : x + '%';
|
|
918
840
|
image.style.position = 'absolute';
|
|
919
841
|
image.style.top = (layout.y || 0) + 'em';
|
|
920
842
|
image.style.margin = '0';
|
|
@@ -1567,6 +1489,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
|
|
|
1567
1489
|
// wrap=around reads data-rapier-occupancy, else alpha. Cache key: src, dimensions, layout.wrap, rotate angle.
|
|
1568
1490
|
// A turned raster's profile is recomputed here as pictureProfile does, never shipped.
|
|
1569
1491
|
function profile(image, layout) {
|
|
1492
|
+
if (layout.wrap === 'around' && image.dataset?.rapierContour?.endsWith('-box')) return null;
|
|
1570
1493
|
const src = image.currentSrc || image.getAttribute('src') || '', rotateRad = (layout.rotate || 0) * Math.PI / 180;
|
|
1571
1494
|
const cached = profiles.get(image);
|
|
1572
1495
|
if (cached?.src === src && cached.width === image.naturalWidth && cached.height === image.naturalHeight &&
|
|
@@ -1780,8 +1703,7 @@ function _rapierProjectArtifactLayout(root, metadata, geometry, preparation) {
|
|
|
1780
1703
|
if (image.hasAttribute('data-rapier-html')) {
|
|
1781
1704
|
const attrs = JSON.parse(decodeURIComponent(image.getAttribute('data-rapier-html'))).attrs;
|
|
1782
1705
|
const line = parseFloat(computed.lineHeight) || (parseFloat(computed.fontSize) || 16) * 1.6;
|
|
1783
|
-
unrotated.height =
|
|
1784
|
-
? Number(image.dataset.rapierNaturalHeight) || box(image).height : Number(attrs.height) * line;
|
|
1706
|
+
unrotated.height = metadata.objectHeight(unrotated.width, Number(image.dataset.rapierNaturalHeight) || box(image).height, attrs, line);
|
|
1785
1707
|
}
|
|
1786
1708
|
// rasterReserved (layout/model.mjs).
|
|
1787
1709
|
const rasterRad = (layout.rotate || 0) * Math.PI / 180;
|
|
@@ -1936,6 +1858,6 @@ function _rapierArtifactTypographyScript(root) {
|
|
|
1936
1858
|
'})();';
|
|
1937
1859
|
}
|
|
1938
1860
|
|
|
1939
|
-
return {_rapierDataImageDestinationsAsync, _rapierSharedSourceFormsAsync, _rapierPrefixPortableAnchorsSteps, _rapierProjectStyledRootAsync, _rapierBuildArtifact, _rapierBuildPublishedPage, _rapierProjectStyledRoot, _rapierDocxRenderStyles, _rapierPaintCode, _rapierPrefixPortableAnchors, _rapierDocumentSettingsOf, _rapierDocumentSettingsCss, _rapierExportedPageCsp, _rapierArtifactNonce, _rapierAnnotateExportBoxPolygons, _rapierSharedSourceHash, _rapierSharedSourceForms, _rapierSharedSourceCarrier, _rapierSharedResolve, _rapierReadSharedDocument, _rapierSharedPageFallbackCss, _rapierShareWrapKind, _rapierSharePictureParagraph, _rapierSharedPageLayout, _rapierShareCapLongCodeBlocks, _rapierRequireOfflinePageImages, _rapierCssValueUrls, _rapierBuildSharedPage, _rapierImageSources, _rapierImageDestinations, _rapierScanMarkdownImages, _rapierParseMarkdownImageDestination, _rapierImageOpenRunsList, _rapierArtifactFactoryModules, _rapierProjectArtifactLayout, _rapierArtifactLayoutScript, _rapierArtifactInkScript , render};
|
|
1861
|
+
return {_rapierDataImageDestinationsAsync, _rapierSharedSourceFormsAsync, _rapierPrefixPortableAnchorsSteps, _rapierPortableAnchorId, _rapierProjectStyledRootAsync, _rapierBuildArtifact, _rapierBuildPublishedPage, _rapierProjectStyledRoot, _rapierDocxRenderStyles, _rapierPaintCode, _rapierPrefixPortableAnchors, _rapierDocumentSettingsOf, _rapierDocumentSettingsCss, _rapierExportedPageCsp, _rapierArtifactNonce, _rapierAnnotateExportBoxPolygons, _rapierSharedSourceHash, _rapierSharedSourceForms, _rapierSharedSourceCarrier, _rapierSharedResolve, _rapierReadSharedDocument, _rapierSharedPageFallbackCss, _rapierShareWrapKind, _rapierSharePictureParagraph, _rapierSharedPageLayout, _rapierShareCapLongCodeBlocks, _rapierRequireOfflinePageImages, _rapierCssValueUrls, _rapierBuildSharedPage, _rapierImageSources, _rapierImageDestinations, _rapierScanMarkdownImages, _rapierParseMarkdownImageDestination, _rapierImageOpenRunsList, _rapierArtifactFactoryModules, _rapierProjectArtifactLayout, _rapierArtifactLayoutScript, _rapierArtifactInkScript , render};
|
|
1940
1862
|
}
|
|
1941
1863
|
export {createRenderer};
|
package/dist/layout/model.mjs
CHANGED
|
@@ -251,13 +251,11 @@ export function bandsProfile(bands) {
|
|
|
251
251
|
export function serializeProfile(profile, count = shapeGrid) {
|
|
252
252
|
if (!profile) return '';
|
|
253
253
|
const bands = [];
|
|
254
|
-
let any = false;
|
|
255
254
|
for (let index = 0; index < count; index++) {
|
|
256
255
|
const t = (index + .5) / count;
|
|
257
256
|
let runs = profile.runsAt ? profile.runsAt(t) : null;
|
|
258
257
|
if (!runs) { const span = profile.spanAt ? profile.spanAt(t) : null; runs = span ? [span] : []; }
|
|
259
258
|
const clean = (runs || []).map(([a, b]) => [Math.max(0, Math.min(1, +a)), Math.max(0, Math.min(1, +b))]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && b > a);
|
|
260
|
-
if (clean.length) any = true;
|
|
261
259
|
bands.push(clean.map(([a, b]) => {
|
|
262
260
|
let left = Math.round(a * 1e4), right = Math.round(b * 1e4);
|
|
263
261
|
// A narrow occupied run must not round to an invalid zero-width descriptor.
|
|
@@ -265,13 +263,13 @@ export function serializeProfile(profile, count = shapeGrid) {
|
|
|
265
263
|
return (left / 1e4) + '-' + (right / 1e4);
|
|
266
264
|
}).join(','));
|
|
267
265
|
}
|
|
268
|
-
return
|
|
266
|
+
return bands.join('|');
|
|
269
267
|
}
|
|
270
268
|
|
|
271
269
|
export function parseProfile(text) {
|
|
272
|
-
if (typeof text !== 'string' || !text) return null;
|
|
270
|
+
if (typeof text !== 'string' || !text || text.length > 262144) return null;
|
|
273
271
|
const bands = text.split('|').map(band => band ? band.split(',').map(run => run.split('-').map(Number)) : []);
|
|
274
|
-
if (bands.length < 2 || bands.some(band => band.some(run => run.length !== 2 || !run.every(Number.isFinite) || run[0] < 0 || run[1] > 1 || run[1] <= run[0]))) return null;
|
|
272
|
+
if (bands.length < 2 || bands.length > 192 || bands.some(band => band.length > 192 || band.some(run => run.length !== 2 || !run.every(Number.isFinite) || run[0] < 0 || run[1] > 1 || run[1] <= run[0]))) return null;
|
|
275
273
|
return bandsProfile(bands);
|
|
276
274
|
}
|
|
277
275
|
|
|
@@ -309,9 +307,13 @@ export function pictureSlices(profile, x, y, width, height, scale = 1) {
|
|
|
309
307
|
return span ? [span] : [];
|
|
310
308
|
}).filter(run => Number.isFinite(run[0]) && Number.isFinite(run[1]) && run[1] > run[0])
|
|
311
309
|
.sort((a, b) => a[0] - b[0]);
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
const
|
|
310
|
+
// Preserve transparent holes and gaps; only overlapping runs share an obstacle.
|
|
311
|
+
const merged = [];
|
|
312
|
+
for (const run of runs) {
|
|
313
|
+
const tail = merged.at(-1);
|
|
314
|
+
if (tail && run[0] <= tail[1]) tail[1] = Math.max(tail[1], run[1]);
|
|
315
|
+
else merged.push([...run]);
|
|
316
|
+
}
|
|
315
317
|
const current = new Map();
|
|
316
318
|
for (const [left, right] of merged) {
|
|
317
319
|
const key = left + ':' + right;
|
|
@@ -172,20 +172,6 @@ export function createParagraphFitter() {
|
|
|
172
172
|
return {lines, work, cost: totalCost, approximate: false};
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
const work = solveSteps(prepared, width, options);
|
|
177
|
-
try {
|
|
178
|
-
let step = work.next();
|
|
179
|
-
while (!step.done) {
|
|
180
|
-
if (step.value && !observe) return null;
|
|
181
|
-
step = work.next(step.value ? observe(step.value) : undefined);
|
|
182
|
-
}
|
|
183
|
-
return step.value;
|
|
184
|
-
} finally { work.return(); }
|
|
185
|
-
}
|
|
186
|
-
return {prepare, solve, solveSteps};
|
|
175
|
+
return {prepare, solveSteps};
|
|
187
176
|
}
|
|
188
177
|
|
|
189
|
-
const fitter = createParagraphFitter();
|
|
190
|
-
export const prepareParagraph = fitter.prepare;
|
|
191
|
-
export const fitParagraph = fitter.solve;
|
|
@@ -3,7 +3,8 @@ import {createPreparationScope, supportsRichStyle, captureFontAuthority} from '.
|
|
|
3
3
|
import {createParagraphFitter} from './paragraph-fit.mjs';
|
|
4
4
|
|
|
5
5
|
// Layout projections keep the original text in place. The factory also runs in an exported page.
|
|
6
|
-
export function createTypography(
|
|
6
|
+
export function createTypography() {
|
|
7
|
+
const fitter = createParagraphFitter();
|
|
7
8
|
const mark = 'data-rapier-typography', boundary = mark + '-break', hang = mark + '-hang';
|
|
8
9
|
const protectedInitial = mark + '-protected-initial';
|
|
9
10
|
const gapMark = mark + '-gap', fitMark = mark + '-fit';
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {letter, within, prev, next} from './tokens.mjs';
|
|
3
|
+
|
|
4
|
+
// Separator-only pieces join the edit beside them; adjacent blocks each carry their own line break.
|
|
5
|
+
export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
6
|
+
const {before, after, safe, raw, spend} = c;
|
|
7
|
+
// A separator-only piece beside another edit joins it; equal separators at its ends stay unchanged.
|
|
8
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
9
|
+
const p = c.result[i - 1], q = c.result[i], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1, text = side ? after : before;
|
|
10
|
+
if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
|
|
11
|
+
let n = 0;
|
|
12
|
+
while (p[0][1] + n < q[0][0] && text[p[side][0] + n] === text[p[side][1] + n] && spend()) n++;
|
|
13
|
+
if (p[0][1] + n === q[0][0] && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + n) && safe(k ? after : before, span[1] + n)))
|
|
14
|
+
for (const span of p.slice(0, 2)) { span[0] += n; span[1] += n; }
|
|
15
|
+
}
|
|
16
|
+
if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
|
|
17
|
+
![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
|
|
18
|
+
const x = [p[0][0], q[0][1]], y = [p[1][0], q[1][1]], keep = /\S/.test(before.slice(...q[0]) + after.slice(...q[1])) ? q : p;
|
|
19
|
+
// CRLF steps as one separator.
|
|
20
|
+
for (let k; x[0] < x[1] && y[0] < y[1] && /\s/.test(before[x[1] - 1]) && before[x[1] - 1] === after[y[1] - 1] &&
|
|
21
|
+
(k = before.endsWith('\r\n', x[1]) && after.endsWith('\r\n', y[1]) ? 2 : 1) <= Math.min(x[1] - x[0], y[1] - y[0]) &&
|
|
22
|
+
safe(before, x[1] - k) && safe(after, y[1] - k);) { x[1] -= k; y[1] -= k; }
|
|
23
|
+
keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
|
|
24
|
+
c.result.splice(i - 1, 2, [x, y, false, keep[3]]);
|
|
25
|
+
}
|
|
26
|
+
// Repeated closing lines can attach to the wrong block. Rotate an exact insertion/deletion to the
|
|
27
|
+
// next blank-line boundary, without crossing another edit or splitting a character.
|
|
28
|
+
if (!raw) for (let i = 0; i < c.result.length; i++) {
|
|
29
|
+
const p = c.result[i], q = c.result[i + 1], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1;
|
|
30
|
+
if (side < 0 || p[2]) continue;
|
|
31
|
+
const text = side ? after : before;
|
|
32
|
+
const room = Math.min((q?.[0][0] ?? endA) - p[0][1], (q?.[1][0] ?? endB) - p[1][1]);
|
|
33
|
+
let n = 0, shift = 0;
|
|
34
|
+
while (n < room && text[p[side][0] + n] === text[p[side][1] + n] && spend()) {
|
|
35
|
+
n++;
|
|
36
|
+
if (text[p[side][0] + n - 1] === '\n' && /^\r?\n/.test(text.slice(p[side][0] + n))) { shift = n; break; }
|
|
37
|
+
}
|
|
38
|
+
if (shift && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + shift) && safe(k ? after : before, span[1] + shift)))
|
|
39
|
+
for (const span of p.slice(0, 2)) { span[0] += shift; span[1] += shift; }
|
|
40
|
+
}
|
|
41
|
+
// Adjacent blocks deleted or inserted together: when the first carries the line break before it, each carries
|
|
42
|
+
// its own, so any one restores apart from its neighbours.
|
|
43
|
+
if (!raw) for (let i = 1; i < c.result.length; i++) {
|
|
44
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
45
|
+
if (kind % 2 || q[3][0] !== kind || p[2] || q[2] || p[side][1] !== q[side][0] || p[1 - side][0] !== q[1 - side][0]) continue;
|
|
46
|
+
const blank = /^\r?\n/.test(text.slice(...p[side])) && !/^\r?\n/.test(text.slice(...q[side])) && /(\r?\n)(?:[ \t]*\r?\n)+$/.exec(text.slice(...p[side]));
|
|
47
|
+
if (blank && blank.index > 0) p[side][1] = q[side][0] -= blank[0].length - blank[1].length;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Refine the outer walk and each move's inner walk before assigning transport ownership.
|
|
52
|
+
export function phrases(c) {
|
|
53
|
+
const {before, after, raw, code} = c;
|
|
54
|
+
// Whole-word replacements separated only by spaces read and restore as one phrase, and one number stays one number.
|
|
55
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
56
|
+
const p = c.result[i - 1], q = c.result[i], gap = before.slice(p[0][1], q[0][0]);
|
|
57
|
+
if (p[3][0] !== 1 || q[3][0] !== 1 || p[3][3] || q[3][3] || p[2] || q[2] || gap !== after.slice(p[1][1], q[1][0])) continue;
|
|
58
|
+
// A time, a date, a version or a decimal is one number.
|
|
59
|
+
const number = /^(?:[.,:/-]\d*)+$/.test(gap) && /\d/.test(before[p[0][1] - 1] + after[p[1][1] - 1]) && /\d/.test(before[q[0][0]] + after[q[1][0]]);
|
|
60
|
+
if (!number && (code || !/^[^\S\r\n]+$/.test(gap) || within(before, p[0][0]) || within(after, p[1][0]) ||
|
|
61
|
+
within(before, q[0][1]) || within(after, q[1][1]))) continue;
|
|
62
|
+
p[0][1] = q[0][1]; p[1][1] = q[1][1]; c.result.splice(i, 1);
|
|
63
|
+
}
|
|
64
|
+
// Paired inline markers added or removed around unchanged text on one line are one concern.
|
|
65
|
+
if (!raw && !code) for (let i = 1; i < c.result.length; i++) {
|
|
66
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
67
|
+
if (kind % 2 || kind !== q[3][0] || p[2] || q[2] || p[3][3] || q[3][3]) continue;
|
|
68
|
+
const open = text.slice(...p[side]), close = text.slice(...q[side]), inner = text.slice(p[side][1], q[side][0]);
|
|
69
|
+
if (!inner.trim() || inner.includes('\n') || !(open === close && /^(?:[*_]{1,3}|~~|==|`+)$/.test(open) ||
|
|
70
|
+
open === '[' && /^\]\([^\s()]*\)$/.test(close))) continue;
|
|
71
|
+
p[3] = q[3] = [kind, 'word', p[3][2], [[p[0][0], q[0][1]], [p[1][0], q[1][1]]]];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Readable concerns: phrases, numbers and paired markers, then the public edits and changes with context.
|
|
76
|
+
export function concerns(c) {
|
|
77
|
+
phrases(c);
|
|
78
|
+
const {before, after, spend, safe, budget, raw, code, evidence, structures, initial, options, pa, pb} = c, lb = c.lb;
|
|
79
|
+
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
80
|
+
// Siblings are ordered and disjoint: the parts holding a range are found by descent, outermost first.
|
|
81
|
+
const containing = (parts, from, to, out = []) => {
|
|
82
|
+
let lo = 0, hi = parts.length;
|
|
83
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (parts[mid].end < to) lo = mid + 1; else hi = mid; }
|
|
84
|
+
for (let k = lo; k < parts.length && parts[k].start <= from && spend(); k++)
|
|
85
|
+
if (parts[k].end >= to) { out.push(parts[k]); containing(parts[k].children, from, to, out); }
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
// Edits are ordered and disjoint on the after side.
|
|
89
|
+
const touched = (edits, range) => {
|
|
90
|
+
let lo = 0, hi = edits.length;
|
|
91
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (edits[mid].after[1] <= range[0]) lo = mid + 1; else hi = mid; }
|
|
92
|
+
return lo < edits.length && overlaps(range, edits[lo].after);
|
|
93
|
+
};
|
|
94
|
+
const changes = [], edits = [];
|
|
95
|
+
let normalized;
|
|
96
|
+
const meaning = structures?.[0]?.meaning;
|
|
97
|
+
const format = typeof meaning === 'string' && meaning === structures[1].meaning;
|
|
98
|
+
for (let i = 0; i < c.result.length; i++) {
|
|
99
|
+
const item = c.result[i];
|
|
100
|
+
if (raw === 'char' && !evidence) {
|
|
101
|
+
const a = item[0], b = item[1], next = c.result[i + 1];
|
|
102
|
+
if (item[3][0] !== 1) {
|
|
103
|
+
const room = Math.min((next?.[0][0] ?? before.length) - a[1], (next?.[1][0] ?? after.length) - b[1]);
|
|
104
|
+
let n = 0;
|
|
105
|
+
while (n < room && before[a[0] + n] === after[b[0] + n] && spend()) n++;
|
|
106
|
+
while (n && (!safe(before, a[0] + n) || !safe(before, a[1] + n) || !safe(after, b[0] + n) || !safe(after, b[1] + n))) n--;
|
|
107
|
+
a[0] += n; a[1] += n; b[0] += n; b[1] += n;
|
|
108
|
+
}
|
|
109
|
+
const p = normalized;
|
|
110
|
+
if (p && p[0][0] === p[0][1] && item[3][0] === 2 && p[0][0] === a[0] && p[1][1] === b[0]) {
|
|
111
|
+
p[1][1] = b[1]; if (item[2]) edits.at(-1).coarse = true; continue;
|
|
112
|
+
}
|
|
113
|
+
normalized = item;
|
|
114
|
+
}
|
|
115
|
+
const group = item[3];
|
|
116
|
+
if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
|
|
117
|
+
if (!group[4]) {
|
|
118
|
+
const context = group[3];
|
|
119
|
+
// Context completes a word only where an edge falls inside it.
|
|
120
|
+
if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
|
|
121
|
+
const start = within(text, span[0]), end = within(text, span[1]);
|
|
122
|
+
for (let c; start && letter(c = prev(text, span[0])) && spend() && safe(text, span[0] - c.length);) span[0] -= c.length;
|
|
123
|
+
for (let c; end && letter(c = next(text, span[1])) && spend() && safe(text, span[1] + c.length);) span[1] += c.length;
|
|
124
|
+
}
|
|
125
|
+
group[4] = []; changes.push(group);
|
|
126
|
+
if (format) group[0] = 5;
|
|
127
|
+
}
|
|
128
|
+
group[4].push(edits.length);
|
|
129
|
+
edits.push({before: item[0], after: item[1], ...(item[2] ? {coarse: true} : {})});
|
|
130
|
+
}
|
|
131
|
+
if (structures && !raw && budget.left >= 0) {
|
|
132
|
+
const oldNames = new Set(pa.filter(p => p.nameAt).map(p => p.name)), newNames = new Set(pb.filter(p => p.nameAt).map(p => p.name));
|
|
133
|
+
const linksTo = new Map();
|
|
134
|
+
if (spend(pa.length + pb.length + lb.length)) lb.forEach((link, k) => linksTo.set(link.to, [...linksTo.get(link.to) || [], k]));
|
|
135
|
+
for (const change of changes) {
|
|
136
|
+
if (change[0] === 5) continue;
|
|
137
|
+
if (budget.left < 0) break;
|
|
138
|
+
const range = change[3];
|
|
139
|
+
const older = containing(structures[0].parts, ...range[0]), newer = containing(structures[1].parts, ...range[1]);
|
|
140
|
+
const part = (change[0] === 0 ? older : newer).at(-1) || older.at(-1);
|
|
141
|
+
if (!change[2] && part?.name) { change[2] = part.name; change[1] = part.kind; }
|
|
142
|
+
const names = new Set([change[2], ...older.map(p => p.name), ...newer.map(p => p.name)]), breaks = [], affects = [];
|
|
143
|
+
for (const k of [...names].flatMap(name => linksTo.get(name) || []).sort((a, b) => a - b)) {
|
|
144
|
+
const {at, to} = lb[k];
|
|
145
|
+
if (!newNames.has(to)) { if (oldNames.has(to)) breaks.push({at, to}); continue; }
|
|
146
|
+
if (!spend() || touched(edits, at)) continue;
|
|
147
|
+
const p = containing(structures[1].parts, ...at).filter(p => /^(paragraph|call|item|task|cell|declaration)$/.test(p.kind)).at(-1);
|
|
148
|
+
affects.push(p && !touched(edits, [p.start, p.end]) ? [p.start, p.end] : at);
|
|
149
|
+
}
|
|
150
|
+
if (breaks.length) change[6] = breaks;
|
|
151
|
+
if (affects.length) change[5] = affects;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (options._stats) Object.assign(options._stats, {work: Math.max(0, initial - Math.max(0, budget.left)), coarse: edits.some(c => c.coarse)});
|
|
155
|
+
// Concern tuples: kind, unit, name, context, edits, affects, breaks. Wording inside a move shares its context.
|
|
156
|
+
const moves = new Map(changes.map((change, i) => change[0] === 3 && [change[3], i + 1]).filter(Boolean));
|
|
157
|
+
return {edits, changes: changes.map(([kind, unit, name, context, owned, affects, breaks], i) => ({
|
|
158
|
+
id: i + 1, kind: ['delete', 'replace', 'insert', 'move', 'rename', 'format'][kind], unit, edits: owned,
|
|
159
|
+
...(name ? {name} : {}), ...(context ? {context: {before: context[0], after: context[1]}} : {}),
|
|
160
|
+
...(kind !== 3 && moves.has(context) ? {move: moves.get(context)} : {}),
|
|
161
|
+
...(affects ? {affects} : {}), ...(breaks ? {breaks} : {})
|
|
162
|
+
}))};
|
|
163
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// One comparison: observe structure, walk the source into exact records, find moves and renames, then read them as
|
|
3
|
+
// concerns. Each stage lives in its own module and shares one comparison context.
|
|
4
|
+
import {observe} from './structure.mjs';
|
|
5
|
+
import {boundaries, tokenizer, wordSegmenter} from './tokens.mjs';
|
|
6
|
+
import {matcher} from './matching.mjs';
|
|
7
|
+
import {walker} from './walk.mjs';
|
|
8
|
+
import {moves} from './moves.mjs';
|
|
9
|
+
import {separate, concerns} from './concerns.mjs';
|
|
10
|
+
|
|
11
|
+
export function diff(before, after, options = {}) {
|
|
12
|
+
if (before === after) return {edits: [], changes: []};
|
|
13
|
+
const budget = options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7};
|
|
14
|
+
const spend = (n = 1) => (budget.left -= n) >= 0, safe = boundaries(), raw = options._unit;
|
|
15
|
+
// Internal records: [before range, after range, coarse, concern, transported fragments].
|
|
16
|
+
const c = {before, after, options, budget, spend, safe, raw, chars: options.unit === 'char', initial: budget.left,
|
|
17
|
+
segmenter: wordSegmenter(), result: [], code: options.as === 'code'};
|
|
18
|
+
// A Markdown fence holds code inside prose; only code outside fences makes the text code.
|
|
19
|
+
if (options.as !== 'prose' && options.as !== 'code') c.code = /(?:^|\n)\s*(?:[{}]|(?:function|class|const|let|var|import|export|def|fn|func|package)\s+\w[^]{0,80}[({=:;])/
|
|
20
|
+
.test((before.slice(0, 4096) + '\n' + after.slice(0, 4096)).replace(/^ {0,3}(`{3,}|~{3,})[^]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![^]))/gm, ''));
|
|
21
|
+
let fallback;
|
|
22
|
+
if (options.structure && (!raw || options._structure)) {
|
|
23
|
+
let structures;
|
|
24
|
+
try {
|
|
25
|
+
structures = options._observed || [observe(before, options.structure, spend, safe), observe(after, options.structure, spend, safe)];
|
|
26
|
+
if (structures.some(value => !value || value.invalid)) throw null;
|
|
27
|
+
const [pa, pb] = structures.map(value => value.flat), aliases = new Map();
|
|
28
|
+
const oldByKey = new Map(pa.filter(p => p.key != null).map(p => [p.key, p]));
|
|
29
|
+
const oldNames = new Set(pa.map(p => p.name)), newNames = new Set(pb.map(p => p.name));
|
|
30
|
+
for (const p of pb) {
|
|
31
|
+
const old = oldByKey.get(p.key);
|
|
32
|
+
if (old?.nameAt && p.nameAt && old.kind === p.kind && !oldNames.has(p.name) && !newNames.has(old.name)) aliases.set(p, old);
|
|
33
|
+
}
|
|
34
|
+
Object.assign(c, {structures, pa, pb, la: structures[0].links, lb: structures[1].links, aliases});
|
|
35
|
+
} catch {
|
|
36
|
+
// A failed reader preserves an explicit prose choice and reports why structure was not used.
|
|
37
|
+
const failed = structures?.find(value => !value || value.invalid);
|
|
38
|
+
fallback = {structure: failed?.threw ? 'threw' : budget.left < 0 ? 'limit' : 'invalid', ...(failed?.threw ? {error: failed.error} : {})};
|
|
39
|
+
c.code = options.as !== 'prose';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
c.tokens = tokenizer(c); c.matches = matcher(spend, budget);
|
|
43
|
+
const {walk, operations} = Object.assign(c, walker(c)), {structures, pa, pb} = c;
|
|
44
|
+
const evidence = c.evidence = options.from && operations(options.from);
|
|
45
|
+
if (options.from && !evidence) (fallback ||= {}).from = budget.left < 0 ? 'limit' : 'invalid';
|
|
46
|
+
if (evidence) for (const span of evidence) walk(...span, structures ? 0 : raw === 'char' ? 3 : 1, pa, pb);
|
|
47
|
+
else walk(0, before.length, 0, after.length, structures ? 0 : raw === 'word' ? 2 : 1,
|
|
48
|
+
structures?.[0]?.parts, structures?.[1]?.parts);
|
|
49
|
+
separate(c);
|
|
50
|
+
moves(c);
|
|
51
|
+
// A heading edit can re-record its children. Compare both walks after transport is known: unchanged moved
|
|
52
|
+
// fragments cost nothing, so a smaller plain walk cannot discard a move just by aligning some of its lines.
|
|
53
|
+
if (structures && !evidence && !raw && c.result.some(r => r[0][0] === r[0][1]) && c.result.some(r => r[1][0] === r[1][1]) &&
|
|
54
|
+
budget.left >= c.initial - budget.left) {
|
|
55
|
+
const size = (rows, transport = false) => rows.reduce((n, r) => n + (!transport && r[3][0] === 3 ? 0 : r[0][1] - r[0][0] + r[1][1] - r[1][0]), 0);
|
|
56
|
+
const walked = c.result, links = c.lb, cost = size(walked);
|
|
57
|
+
if (cost) {
|
|
58
|
+
c.result = []; c.lb = structures[1].links;
|
|
59
|
+
walk(0, before.length, 0, after.length, 1);
|
|
60
|
+
separate(c); moves(c);
|
|
61
|
+
const plain = size(c.result);
|
|
62
|
+
if (budget.left < 0 || c.result.some(r => r[2]) || plain > cost ||
|
|
63
|
+
plain === cost && size(c.result, true) >= size(walked, true)) { c.result = walked; c.lb = links; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const {edits, changes} = concerns(c);
|
|
67
|
+
return {edits, ...(fallback && !raw ? {fallback} : {}), changes};
|
|
68
|
+
}
|