@stll/folio-core 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-edits/headless.d.ts +37 -1
- package/dist/ai-edits/headless.js +47 -16
- package/dist/ai-edits/word-diff.js +134 -1
- package/dist/compare/content-alignment.js +220 -5
- package/dist/compare/content.d.ts +1 -0
- package/dist/compare/content.js +51 -20
- package/dist/display-list/dom/renderDisplayListToDom.d.ts +10 -1
- package/dist/display-list/dom/renderDisplayListToDom.js +8 -7
- package/dist/display-list/html/renderDisplayListToHtml.d.ts +21 -0
- package/dist/display-list/html/renderDisplayListToHtml.js +126 -0
- package/dist/display-list/selectDisplayPages.d.ts +6 -0
- package/dist/display-list/selectDisplayPages.js +44 -0
- package/dist/docx/selectiveSave.d.ts +1 -1
- package/dist/docx/selectiveSave.js +16 -6
- package/dist/docx/structuralXmlPatch.d.ts +14 -0
- package/dist/docx/structuralXmlPatch.js +209 -0
- package/dist/export-pdf.d.ts +35 -2
- package/dist/export-pdf.js +51 -20
- package/dist/fonts/fontsourceFaces.d.ts +47 -0
- package/dist/fonts/fontsourceFaces.js +230 -0
- package/dist/layout-bridge/convert/flowBlockPostprocessing.d.ts +8 -0
- package/dist/layout-bridge/convert/flowBlockPostprocessing.js +106 -0
- package/dist/layout-bridge/convert/flowBorders.d.ts +17 -0
- package/dist/layout-bridge/convert/flowBorders.js +31 -0
- package/dist/layout-bridge/convert/flowConversionShared.d.ts +111 -0
- package/dist/layout-bridge/convert/flowConversionShared.js +23 -0
- package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -1
- package/dist/layout-bridge/convert/footnoteLayout.js +2 -1
- package/dist/layout-bridge/convert/headerFooterLayout.d.ts +2 -1
- package/dist/layout-bridge/convert/imageConversion.d.ts +33 -0
- package/dist/layout-bridge/convert/imageConversion.js +118 -0
- package/dist/layout-bridge/convert/listMarkers.d.ts +28 -0
- package/dist/layout-bridge/convert/listMarkers.js +98 -0
- package/dist/layout-bridge/convert/noteReferences.d.ts +14 -0
- package/dist/layout-bridge/convert/noteReferences.js +29 -0
- package/dist/layout-bridge/convert/pageBreakSplitting.d.ts +16 -0
- package/dist/layout-bridge/convert/pageBreakSplitting.js +140 -0
- package/dist/layout-bridge/convert/paragraphAttrs.d.ts +20 -0
- package/dist/layout-bridge/convert/paragraphAttrs.js +234 -0
- package/dist/layout-bridge/convert/paragraphConversion.d.ts +12 -0
- package/dist/layout-bridge/convert/paragraphConversion.js +112 -0
- package/dist/layout-bridge/convert/paragraphRuns.d.ts +15 -0
- package/dist/layout-bridge/convert/paragraphRuns.js +281 -0
- package/dist/layout-bridge/convert/runFormattingMerge.d.ts +19 -0
- package/dist/layout-bridge/convert/runFormattingMerge.js +167 -0
- package/dist/layout-bridge/convert/runMarkFormatting.d.ts +11 -0
- package/dist/layout-bridge/convert/runMarkFormatting.js +291 -0
- package/dist/layout-bridge/convert/sectionBoundaries.d.ts +35 -0
- package/dist/layout-bridge/convert/sectionBoundaries.js +114 -0
- package/dist/layout-bridge/convert/tableConversion.d.ts +12 -0
- package/dist/layout-bridge/convert/tableConversion.js +303 -0
- package/dist/layout-bridge/convert/textBoxFill.d.ts +20 -0
- package/dist/layout-bridge/convert/textBoxFill.js +42 -0
- package/dist/layout-bridge/convert/textFormattingConversion.d.ts +22 -0
- package/dist/layout-bridge/convert/textFormattingConversion.js +120 -0
- package/dist/layout-bridge/convert/toFlowBlocks.d.ts +5 -113
- package/dist/layout-bridge/convert/toFlowBlocks.js +11 -2082
- package/dist/layout-bridge/engine/hitTest.js +1 -1
- package/dist/layout-bridge/engine/measuring/index.d.ts +2 -1
- package/dist/layout-bridge/engine/measuring/index.js +2 -1
- package/dist/layout-bridge/engine/selectionRects.js +1 -1
- package/dist/layout-engine/imageLayout.d.ts +9 -0
- package/dist/layout-engine/imageLayout.js +48 -0
- package/dist/layout-engine/index.d.ts +3 -18
- package/dist/layout-engine/index.js +12 -937
- package/dist/layout-engine/layoutFlowShared.d.ts +6 -0
- package/dist/layout-engine/layoutFlowShared.js +8 -0
- package/dist/layout-engine/measure/crossRunWords.d.ts +45 -0
- package/dist/layout-engine/measure/crossRunWords.js +172 -0
- package/dist/layout-engine/measure/floatingLineClearance.d.ts +24 -0
- package/dist/layout-engine/measure/floatingLineClearance.js +44 -0
- package/dist/layout-engine/measure/inlineObjectMetrics.d.ts +6 -0
- package/dist/layout-engine/measure/inlineObjectMetrics.js +52 -0
- package/dist/layout-engine/measure/justification.d.ts +32 -0
- package/dist/layout-engine/measure/justification.js +83 -0
- package/dist/layout-engine/measure/lineFitting.d.ts +48 -0
- package/dist/layout-engine/measure/lineFitting.js +145 -0
- package/dist/layout-engine/measure/lineTypography.d.ts +44 -0
- package/dist/layout-engine/measure/lineTypography.js +124 -0
- package/dist/layout-engine/measure/measureBlocks.js +2 -1
- package/dist/layout-engine/measure/measureParagraph.d.ts +1 -20
- package/dist/layout-engine/measure/measureParagraph.js +32 -734
- package/dist/layout-engine/measure/paragraphMeasureShared.d.ts +109 -0
- package/dist/layout-engine/measure/paragraphMeasureShared.js +82 -0
- package/dist/layout-engine/measure/paragraphTabs.d.ts +18 -0
- package/dist/layout-engine/measure/paragraphTabs.js +78 -0
- package/dist/layout-engine/paragraphLayout.d.ts +23 -0
- package/dist/layout-engine/paragraphLayout.js +204 -0
- package/dist/layout-engine/sectionLayout.d.ts +45 -0
- package/dist/layout-engine/sectionLayout.js +216 -0
- package/dist/layout-engine/tableLayout.d.ts +17 -0
- package/dist/layout-engine/tableLayout.js +369 -0
- package/dist/layout-engine/textBoxLayout.d.ts +15 -0
- package/dist/layout-engine/textBoxLayout.js +137 -0
- package/dist/layout-painter/renderPage.js +1 -0
- package/dist/managers/editorShortcuts.d.ts +16 -3
- package/dist/managers/editorShortcuts.js +6 -4
- package/dist/paged-layout/sectionBlockWidths.d.ts +2 -1
- package/dist/paged-layout/sectionBlockWidths.js +1 -1
- package/dist/prosemirror/extensions/StarterKit.d.ts +3 -0
- package/dist/prosemirror/extensions/StarterKit.js +2 -1
- package/dist/prosemirror/extensions/core/HistoryExtension.d.ts +9 -1
- package/dist/prosemirror/extensions/core/HistoryExtension.js +4 -3
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts +12 -5
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +91 -33
- package/dist/server.d.ts +3 -2
- package/package.json +2 -2
|
@@ -35,6 +35,34 @@ type FolioApplyOperationsOptions = {
|
|
|
35
35
|
};
|
|
36
36
|
/** Options for {@link FolioDocxReviewer.applyDocumentOperations}. */
|
|
37
37
|
type FolioApplyDocumentOperationsOptions = Omit<FolioApplyOperationsOptions, "mode">;
|
|
38
|
+
/**
|
|
39
|
+
* Why a save rewrote the whole package instead of patching the edited
|
|
40
|
+
* paragraphs into the original: `structuralChange` (paragraphs added or
|
|
41
|
+
* removed, or styles, headers, footers or final section properties staged),
|
|
42
|
+
* `untrackedChange` (an edit the change tracker cannot key to a paragraph), or
|
|
43
|
+
* `selectiveDeclined` (the patch could not express the edit safely).
|
|
44
|
+
*/
|
|
45
|
+
type FolioReviewerRepackReason = "structuralChange" | "untrackedChange" | "selectiveDeclined";
|
|
46
|
+
/** Options for {@link FolioDocxReviewer.save}. */
|
|
47
|
+
type FolioReviewerSaveOptions = {
|
|
48
|
+
/**
|
|
49
|
+
* `"allow"` (default) falls back to a full repack when the selective patch
|
|
50
|
+
* does not apply; `"refuse"` reports the reason and writes nothing.
|
|
51
|
+
*/
|
|
52
|
+
repack?: "allow" | "refuse";
|
|
53
|
+
};
|
|
54
|
+
/** Result of {@link FolioDocxReviewer.save}. */
|
|
55
|
+
type FolioReviewerSaveResult = {
|
|
56
|
+
type: "selective";
|
|
57
|
+
buffer: ArrayBuffer;
|
|
58
|
+
} | {
|
|
59
|
+
type: "full-repack";
|
|
60
|
+
reason: FolioReviewerRepackReason;
|
|
61
|
+
buffer: ArrayBuffer;
|
|
62
|
+
} | {
|
|
63
|
+
type: "repackRefused";
|
|
64
|
+
reason: FolioReviewerRepackReason;
|
|
65
|
+
};
|
|
38
66
|
/** Options for {@link FolioDocxReviewer.getContentAsText}. */
|
|
39
67
|
type FolioGetContentAsTextOptions = {
|
|
40
68
|
/**
|
|
@@ -248,6 +276,8 @@ type FolioReviewReplyInput = {
|
|
|
248
276
|
/** Reply author; defaults to the reviewer's author. */
|
|
249
277
|
author?: string;
|
|
250
278
|
initials?: string;
|
|
279
|
+
/** ISO-8601 reply date; defaults to the wall clock. */
|
|
280
|
+
date?: string;
|
|
251
281
|
};
|
|
252
282
|
/** A comment thread discovered in the document. */
|
|
253
283
|
type FolioReviewComment = {
|
|
@@ -506,6 +536,12 @@ declare class FolioDocxReviewer {
|
|
|
506
536
|
* structural edits — the same two-tier path the editor's save uses.
|
|
507
537
|
*/
|
|
508
538
|
toBuffer(): Promise<ArrayBuffer>;
|
|
539
|
+
/**
|
|
540
|
+
* Serialise like {@link toBuffer} and report which path wrote the package.
|
|
541
|
+
* With `repack: "refuse"`, a save the selective patch cannot express
|
|
542
|
+
* returns the reason instead of rewriting every part.
|
|
543
|
+
*/
|
|
544
|
+
save({ repack: repackPolicy }?: FolioReviewerSaveOptions): Promise<FolioReviewerSaveResult>;
|
|
509
545
|
private assertResolvedStoriesSerialized;
|
|
510
546
|
/**
|
|
511
547
|
* Attempt the selective patch, treating a throw the same as a decline. Odd
|
|
@@ -580,4 +616,4 @@ type ApplyFolioAIEditsToBufferResult = FolioAIEditApplyResult & {
|
|
|
580
616
|
*/
|
|
581
617
|
declare const applyFolioAIEditsToBuffer: (buffer: ArrayBuffer, operations: FolioAIEditOperation[], options?: ApplyFolioAIEditsToBufferOptions) => Promise<ApplyFolioAIEditsToBufferResult>;
|
|
582
618
|
//#endregion
|
|
583
|
-
export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioApplyDocumentOperationsOptions, FolioApplyDocumentOperationsToStoryOptions, FolioApplyOperationsOptions, FolioDocumentStory, FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, FolioDocxReviewer, FolioDocxReviewerOptions, FolioEditableDocumentStoryHandle, FolioGetContentAsTextOptions, FolioMatchStoryTableGeometryOptions, FolioNumberingLevel, FolioReadReviewedStoryOptions, FolioResolveReviewedStoryOptions, FolioResolvedReviewedView, type FolioReviewChange, FolioReviewChangeFilter, type FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, FolioReviewedStory, FolioReviewedView, type FolioRevisionStamp, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, getFolioDocxComparisonAccess, isFolioResolvedReviewedView, isFolioReviewedView };
|
|
619
|
+
export { ApplyFolioAIEditsToBufferOptions, ApplyFolioAIEditsToBufferResult, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FolioApplyDocumentOperationsOptions, FolioApplyDocumentOperationsToStoryOptions, FolioApplyOperationsOptions, FolioDocumentStory, FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, FolioDocxReviewer, FolioDocxReviewerOptions, FolioEditableDocumentStoryHandle, FolioGetContentAsTextOptions, FolioMatchStoryTableGeometryOptions, FolioNumberingLevel, FolioReadReviewedStoryOptions, FolioResolveReviewedStoryOptions, FolioResolvedReviewedView, type FolioReviewChange, FolioReviewChangeFilter, type FolioReviewChangeKind, FolioReviewComment, FolioReviewCommentFilter, FolioReviewCommentReply, FolioReviewReplyInput, FolioReviewedStory, FolioReviewedView, FolioReviewerRepackReason, FolioReviewerSaveOptions, FolioReviewerSaveResult, type FolioRevisionStamp, UnsupportedFolioReviewedViewError, applyFolioAIEditsToBuffer, getFolioDocxComparisonAccess, isFolioResolvedReviewedView, isFolioReviewedView };
|
|
@@ -68,16 +68,10 @@ import JSZip from "jszip";
|
|
|
68
68
|
*/
|
|
69
69
|
let commentIdCursor = Date.now();
|
|
70
70
|
let undoHandleCursor = Date.now();
|
|
71
|
-
|
|
72
|
-
* Build the note-free comment thread the apply layer references by id.
|
|
73
|
-
* Mirrors `commentsHelpers.createComment` (a pure object literal there) so a
|
|
74
|
-
* headless `commentOnBlock` / `comment` op serialises the same `comments.xml`
|
|
75
|
-
* shape the editor produces.
|
|
76
|
-
*/
|
|
77
|
-
const createReviewerComment = (id, text, author) => ({
|
|
71
|
+
const createReviewerComment = ({ id, text, author, date }) => ({
|
|
78
72
|
id,
|
|
79
73
|
author,
|
|
80
|
-
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
74
|
+
date: date ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
81
75
|
content: [{
|
|
82
76
|
type: "paragraph",
|
|
83
77
|
formatting: {},
|
|
@@ -848,7 +842,12 @@ var FolioDocxReviewer = class FolioDocxReviewer {
|
|
|
848
842
|
...tableTemplates !== void 0 && { tableTemplates },
|
|
849
843
|
...replacementBackground !== void 0 && { replacementBackground },
|
|
850
844
|
createCommentId: (text) => {
|
|
851
|
-
const comment = createReviewerComment(
|
|
845
|
+
const comment = createReviewerComment({
|
|
846
|
+
id: this.nextCommentId(),
|
|
847
|
+
text,
|
|
848
|
+
author: this.author,
|
|
849
|
+
date: revisionStamp?.date
|
|
850
|
+
});
|
|
852
851
|
this.createdComments.push(comment);
|
|
853
852
|
return comment.id;
|
|
854
853
|
},
|
|
@@ -1080,7 +1079,8 @@ var FolioDocxReviewer = class FolioDocxReviewer {
|
|
|
1080
1079
|
const reply = createReply([...this.baseDocument.package.document.comments ?? [], ...this.createdComments], parentId, {
|
|
1081
1080
|
author: input.author ?? this.author,
|
|
1082
1081
|
text: input.text,
|
|
1083
|
-
...input.initials !== void 0 ? { initials: input.initials } : {}
|
|
1082
|
+
...input.initials !== void 0 ? { initials: input.initials } : {},
|
|
1083
|
+
...input.date !== void 0 && { date: input.date }
|
|
1084
1084
|
});
|
|
1085
1085
|
if (!reply) return null;
|
|
1086
1086
|
this.createdComments.push(reply);
|
|
@@ -1344,12 +1344,21 @@ var FolioDocxReviewer = class FolioDocxReviewer {
|
|
|
1344
1344
|
structuralChange ||= hasStructuralChanges(entry.state);
|
|
1345
1345
|
untrackedChanges ||= hasUntrackedChanges(entry.state);
|
|
1346
1346
|
}
|
|
1347
|
+
let path = {
|
|
1348
|
+
type: "selective-first",
|
|
1349
|
+
changedParaIds
|
|
1350
|
+
};
|
|
1351
|
+
if (structuralChange) path = {
|
|
1352
|
+
type: "full-repack",
|
|
1353
|
+
reason: "structuralChange"
|
|
1354
|
+
};
|
|
1355
|
+
else if (untrackedChanges) path = {
|
|
1356
|
+
type: "full-repack",
|
|
1357
|
+
reason: "untrackedChange"
|
|
1358
|
+
};
|
|
1347
1359
|
return {
|
|
1348
1360
|
document: this.documentFromStateSnapshot(snapshot),
|
|
1349
|
-
path
|
|
1350
|
-
type: "selective-first",
|
|
1351
|
-
changedParaIds
|
|
1352
|
-
},
|
|
1361
|
+
path,
|
|
1353
1362
|
changedNoteParaIds,
|
|
1354
1363
|
sectionReferenceRemovals: snapshot.sectionReferenceRemovals,
|
|
1355
1364
|
sectionEndpointRemoval: getTrackedSectionEndpointRemoval(snapshot.mainState),
|
|
@@ -1363,12 +1372,30 @@ var FolioDocxReviewer = class FolioDocxReviewer {
|
|
|
1363
1372
|
* structural edits — the same two-tier path the editor's save uses.
|
|
1364
1373
|
*/
|
|
1365
1374
|
async toBuffer() {
|
|
1375
|
+
const result = await this.save();
|
|
1376
|
+
if (result.type === "repackRefused") return panic("A save that allows a full repack refused to repack", { result });
|
|
1377
|
+
return result.buffer;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Serialise like {@link toBuffer} and report which path wrote the package.
|
|
1381
|
+
* With `repack: "refuse"`, a save the selective patch cannot express
|
|
1382
|
+
* returns the reason instead of rewriting every part.
|
|
1383
|
+
*/
|
|
1384
|
+
async save({ repack: repackPolicy = "allow" } = {}) {
|
|
1366
1385
|
const save = this.captureSaveSnapshot();
|
|
1367
1386
|
const selective = save.path.type === "selective-first" ? await this.trySelectiveSave(save.document, save.path.changedParaIds) : null;
|
|
1368
1387
|
if (selective) {
|
|
1369
1388
|
await this.assertResolvedStoriesSerialized(selective, save.resolvedStoryExpectations);
|
|
1370
|
-
return
|
|
1389
|
+
return {
|
|
1390
|
+
type: "selective",
|
|
1391
|
+
buffer: selective
|
|
1392
|
+
};
|
|
1371
1393
|
}
|
|
1394
|
+
const reason = save.path.type === "full-repack" ? save.path.reason : "selectiveDeclined";
|
|
1395
|
+
if (repackPolicy === "refuse") return {
|
|
1396
|
+
type: "repackRefused",
|
|
1397
|
+
reason
|
|
1398
|
+
};
|
|
1372
1399
|
const repackDocument = {
|
|
1373
1400
|
...save.document,
|
|
1374
1401
|
originalBuffer: this.originalBuffer
|
|
@@ -1385,7 +1412,11 @@ var FolioDocxReviewer = class FolioDocxReviewer {
|
|
|
1385
1412
|
repack: repackReferences
|
|
1386
1413
|
}) : await repackReferences();
|
|
1387
1414
|
await this.assertResolvedStoriesSerialized(buffer, save.resolvedStoryExpectations);
|
|
1388
|
-
return
|
|
1415
|
+
return {
|
|
1416
|
+
type: "full-repack",
|
|
1417
|
+
reason,
|
|
1418
|
+
buffer
|
|
1419
|
+
};
|
|
1389
1420
|
}
|
|
1390
1421
|
async assertResolvedStoriesSerialized(buffer, expectations) {
|
|
1391
1422
|
if (expectations.length === 0) return;
|
|
@@ -836,6 +836,137 @@ const orderDeletionsFirst = (runs) => {
|
|
|
836
836
|
flush();
|
|
837
837
|
return ordered;
|
|
838
838
|
};
|
|
839
|
+
const WORD_CHARACTER = /^[\p{L}\p{N}\p{M}]$/u;
|
|
840
|
+
const LINE_BREAK = /^[\n\r\p{Zl}\p{Zp}]$/u;
|
|
841
|
+
/**
|
|
842
|
+
* How well a change edge between two code points reads, after
|
|
843
|
+
* diff-match-patch's semantic score: the string's edge (an empty side), then
|
|
844
|
+
* a line break, then the gap after a sentence or clause mark, then any space,
|
|
845
|
+
* then any other mark; inside a word is worst.
|
|
846
|
+
*/
|
|
847
|
+
const boundaryScore = (previous, next) => {
|
|
848
|
+
if (previous.length === 0 || next.length === 0) return 6;
|
|
849
|
+
if (LINE_BREAK.test(previous) || LINE_BREAK.test(next)) return 4;
|
|
850
|
+
const previousIsSpace = WHITESPACE.test(previous);
|
|
851
|
+
const nextIsSpace = WHITESPACE.test(next);
|
|
852
|
+
if (!previousIsSpace && !WORD_CHARACTER.test(previous) && nextIsSpace) return 3;
|
|
853
|
+
if (previousIsSpace || nextIsSpace) return 2;
|
|
854
|
+
return WORD_CHARACTER.test(previous) && WORD_CHARACTER.test(next) ? 0 : 1;
|
|
855
|
+
};
|
|
856
|
+
/** True when `position` falls between the two halves of a surrogate pair. */
|
|
857
|
+
const splitsSurrogatePair = (text, position) => {
|
|
858
|
+
const low = text.charCodeAt(position);
|
|
859
|
+
const high = text.charCodeAt(position - 1);
|
|
860
|
+
return low >= 56320 && low <= 57343 && high >= 55296 && high <= 56319;
|
|
861
|
+
};
|
|
862
|
+
/**
|
|
863
|
+
* Where, among the lossless positions of one change, it reads best. The
|
|
864
|
+
* change may slide by one character whenever the character it gives up
|
|
865
|
+
* equals the one it takes on, which keeps both strings intact. Only the
|
|
866
|
+
* window decides the result, so an insertion and the deletion that undoes it
|
|
867
|
+
* land on the same text; the leftmost of equally good positions wins.
|
|
868
|
+
*/
|
|
869
|
+
const bestSlideStart = (window) => {
|
|
870
|
+
const { text, start, length, minimumStart, maximumEnd } = window;
|
|
871
|
+
const scoreAt = (position) => boundaryScore(position === 0 ? window.outsideBefore : codePointBefore(text, position), position === text.length ? window.outsideAfter : codePointAt(text, position));
|
|
872
|
+
let leftmost = start;
|
|
873
|
+
while (leftmost > minimumStart && text[leftmost - 1] === text[leftmost + length - 1]) leftmost--;
|
|
874
|
+
let best = start;
|
|
875
|
+
let bestScore = -1;
|
|
876
|
+
for (let candidate = leftmost; candidate + length <= maximumEnd; candidate++) {
|
|
877
|
+
const end = candidate + length;
|
|
878
|
+
if (!splitsSurrogatePair(text, candidate) && !splitsSurrogatePair(text, end)) {
|
|
879
|
+
const startScore = scoreAt(candidate);
|
|
880
|
+
const endScore = scoreAt(end);
|
|
881
|
+
if (!(candidate !== start && (startScore === 0 || endScore === 0)) && startScore + endScore > bestScore) {
|
|
882
|
+
best = candidate;
|
|
883
|
+
bestScore = startScore + endScore;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (end === text.length || text[candidate] !== text[end]) break;
|
|
887
|
+
}
|
|
888
|
+
return best;
|
|
889
|
+
};
|
|
890
|
+
/**
|
|
891
|
+
* Whether a change may end up directly beside `neighbour` once the equality
|
|
892
|
+
* between them empties: always beside nothing, an equality or its own kind,
|
|
893
|
+
* and a deletion may precede an insertion; an insertion before a deletion
|
|
894
|
+
* would break deletion-first order.
|
|
895
|
+
*/
|
|
896
|
+
const mayAbut = (first, second) => first === void 0 || second === void 0 || first === "equal" || second === "equal" || first === second || first === "del" && second === "ins";
|
|
897
|
+
/** The code point nearest `index`, walking by `step`, on the side `type` belongs to. */
|
|
898
|
+
const sideCodePoint = (segments, { from, step }, type) => {
|
|
899
|
+
for (let index = from; index >= 0 && index < segments.length; index += step) {
|
|
900
|
+
const segment = segments[index];
|
|
901
|
+
if (segment === void 0 || segment.text.length === 0) continue;
|
|
902
|
+
if (segment.type === "equal" || segment.type === type) return step === 1 ? codePointAt(segment.text, 0) : codePointBefore(segment.text, segment.text.length);
|
|
903
|
+
}
|
|
904
|
+
return "";
|
|
905
|
+
};
|
|
906
|
+
const mergeAdjacentSegments = (segments) => {
|
|
907
|
+
const merged = [];
|
|
908
|
+
for (const segment of segments) {
|
|
909
|
+
const last = merged.at(-1);
|
|
910
|
+
if (segment.text.length === 0) continue;
|
|
911
|
+
if (last?.type === segment.type) {
|
|
912
|
+
last.text += segment.text;
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
merged.push(segment);
|
|
916
|
+
}
|
|
917
|
+
return merged;
|
|
918
|
+
};
|
|
919
|
+
/**
|
|
920
|
+
* Slide every insertion or deletion that sits between equalities to the
|
|
921
|
+
* position that reads best.
|
|
922
|
+
*
|
|
923
|
+
* An LCS places a change arbitrarily among equally long alignments: an
|
|
924
|
+
* appended sentence can be marked as `". New sentence"` before the old
|
|
925
|
+
* sentence's full stop, not `" New sentence."` after it. The redline then
|
|
926
|
+
* marks a stop the author never touched and leaves the new sentence's own
|
|
927
|
+
* unmarked. Sliding moves only where a change's edges fall, so both strings
|
|
928
|
+
* still reconstruct.
|
|
929
|
+
*/
|
|
930
|
+
const slideChangesToReadableBoundaries = (segments) => {
|
|
931
|
+
const slid = [
|
|
932
|
+
{
|
|
933
|
+
type: "equal",
|
|
934
|
+
text: ""
|
|
935
|
+
},
|
|
936
|
+
...segments.map((segment) => ({ ...segment })),
|
|
937
|
+
{
|
|
938
|
+
type: "equal",
|
|
939
|
+
text: ""
|
|
940
|
+
}
|
|
941
|
+
];
|
|
942
|
+
for (let index = 1; index < slid.length - 1; index++) {
|
|
943
|
+
const change = slid[index];
|
|
944
|
+
const previous = slid[index - 1];
|
|
945
|
+
const next = slid[index + 1];
|
|
946
|
+
if (change === void 0 || previous === void 0 || next === void 0 || change.type === "equal" || previous.type !== "equal" || next.type !== "equal") continue;
|
|
947
|
+
const text = previous.text + change.text + next.text;
|
|
948
|
+
const start = bestSlideStart({
|
|
949
|
+
text,
|
|
950
|
+
start: previous.text.length,
|
|
951
|
+
length: change.text.length,
|
|
952
|
+
minimumStart: mayAbut(slid[index - 2]?.type, change.type) ? 0 : 1,
|
|
953
|
+
maximumEnd: mayAbut(change.type, slid[index + 2]?.type) ? text.length : text.length - 1,
|
|
954
|
+
outsideBefore: sideCodePoint(slid, {
|
|
955
|
+
from: index - 2,
|
|
956
|
+
step: -1
|
|
957
|
+
}, change.type),
|
|
958
|
+
outsideAfter: sideCodePoint(slid, {
|
|
959
|
+
from: index + 2,
|
|
960
|
+
step: 1
|
|
961
|
+
}, change.type)
|
|
962
|
+
});
|
|
963
|
+
const end = start + change.text.length;
|
|
964
|
+
previous.text = text.slice(0, start);
|
|
965
|
+
change.text = text.slice(start, end);
|
|
966
|
+
next.text = text.slice(end);
|
|
967
|
+
}
|
|
968
|
+
return mergeAdjacentSegments(slid);
|
|
969
|
+
};
|
|
839
970
|
const wholeStringReplacement = (before, after) => {
|
|
840
971
|
const segments = [];
|
|
841
972
|
if (before.length > 0) segments.push({
|
|
@@ -903,7 +1034,9 @@ const diffWordSegmentsWithBudget = (before, after, options, budget) => {
|
|
|
903
1034
|
const monotoneRuns = alignMonotoneChange(before, after, beforeTokens, afterTokens, normalization, monotoneDirection);
|
|
904
1035
|
marked = whitespaceIsSignificant ? separateWhitespaceChanges(monotoneRuns) : monotoneRuns;
|
|
905
1036
|
}
|
|
906
|
-
|
|
1037
|
+
const segments = toSegments(orderDeletionsFirst(marked));
|
|
1038
|
+
const equalRunsAreExact = requestedNormalization.case !== true && requestedNormalization.whitespace !== true;
|
|
1039
|
+
return granularity === "word" && equalRunsAreExact ? slideChangesToReadableBoundaries(segments) : segments;
|
|
907
1040
|
};
|
|
908
1041
|
/**
|
|
909
1042
|
* One internal comparison/apply scope. Every diff shares the same quadratic
|
|
@@ -260,6 +260,44 @@ const pairByUniqueExactText = ({ base, revised, baseFrom, baseTo, revisedFrom, r
|
|
|
260
260
|
}
|
|
261
261
|
return pairs.toReversed();
|
|
262
262
|
};
|
|
263
|
+
/**
|
|
264
|
+
* Unique exact text anchors, then again inside every sub-gap they leave:
|
|
265
|
+
* wording repeated elsewhere in a document is still identity evidence within
|
|
266
|
+
* the one gap where it is unique, so a section's pairing does not depend on
|
|
267
|
+
* whether a neighbouring section happens to repeat it. Each nested pass is
|
|
268
|
+
* charged to the structural allowance; a refused pass leaves its sub-gap to
|
|
269
|
+
* the similarity pairing.
|
|
270
|
+
*/
|
|
271
|
+
const pairByNestedUniqueExactText = ({ workSession, ...gap }) => {
|
|
272
|
+
const anchors = [];
|
|
273
|
+
const pending = [gap];
|
|
274
|
+
for (let current = pending.pop(); current !== void 0; current = pending.pop()) {
|
|
275
|
+
const found = pairByUniqueExactText(current);
|
|
276
|
+
if (found.length === 0) continue;
|
|
277
|
+
anchors.push(...found);
|
|
278
|
+
let baseFrom = current.baseFrom;
|
|
279
|
+
let revisedFrom = current.revisedFrom;
|
|
280
|
+
for (const anchor of [...found, {
|
|
281
|
+
baseIndex: current.baseTo,
|
|
282
|
+
revisedIndex: current.revisedTo
|
|
283
|
+
}]) {
|
|
284
|
+
const size = anchor.baseIndex - baseFrom + (anchor.revisedIndex - revisedFrom);
|
|
285
|
+
if (anchor.baseIndex > baseFrom && anchor.revisedIndex > revisedFrom && size <= workSession.remainingStructuralTokenLookups) {
|
|
286
|
+
workSession.remainingStructuralTokenLookups -= size;
|
|
287
|
+
pending.push({
|
|
288
|
+
...current,
|
|
289
|
+
baseFrom,
|
|
290
|
+
baseTo: anchor.baseIndex,
|
|
291
|
+
revisedFrom,
|
|
292
|
+
revisedTo: anchor.revisedIndex
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
baseFrom = anchor.baseIndex + 1;
|
|
296
|
+
revisedFrom = anchor.revisedIndex + 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return anchors.toSorted((left, right) => left.baseIndex - right.baseIndex || left.revisedIndex - right.revisedIndex);
|
|
300
|
+
};
|
|
263
301
|
const pairsInAnchorGaps = ({ baseLength, revisedLength, anchors, pairGap }) => {
|
|
264
302
|
const pairs = [];
|
|
265
303
|
let baseFrom = 0;
|
|
@@ -312,9 +350,122 @@ const crossedExactTextBlocks = ({ base, revised, anchors, canPair }) => {
|
|
|
312
350
|
revised: crossedRevised
|
|
313
351
|
};
|
|
314
352
|
};
|
|
353
|
+
/**
|
|
354
|
+
* Minimum multiset Dice similarity for two blocks to pair inside a gap. At
|
|
355
|
+
* 0.5 a paragraph still pairs after gaining up to twice its own length
|
|
356
|
+
* (2n / (n + 3n)); below it, more of the pair would read as changed than
|
|
357
|
+
* kept, which a removal beside an insertion says better.
|
|
358
|
+
*/
|
|
359
|
+
const GAP_PAIR_SIMILARITY_THRESHOLD = .5;
|
|
360
|
+
/**
|
|
361
|
+
* Gap similarities are compared as integers, so a tie is exact rather than an
|
|
362
|
+
* accident of floating-point summation, and a tie-break can sit below the
|
|
363
|
+
* smallest similarity step.
|
|
364
|
+
*/
|
|
365
|
+
const GAP_PAIR_SIMILARITY_SCALE = 1e3;
|
|
366
|
+
/**
|
|
367
|
+
* Words as case-folded runs of letters, marks and digits: punctuation glued to
|
|
368
|
+
* a word ("paragraph." against "paragraph") and a capital at a sentence start
|
|
369
|
+
* would otherwise count a kept word as changed.
|
|
370
|
+
*/
|
|
371
|
+
const gapBlockTokens = (text) => {
|
|
372
|
+
const counts = /* @__PURE__ */ new Map();
|
|
373
|
+
let total = 0;
|
|
374
|
+
for (const match of text.toLowerCase().matchAll(/[\p{L}\p{M}\p{N}]+/gu)) {
|
|
375
|
+
total += 1;
|
|
376
|
+
counts.set(match[0], (counts.get(match[0]) ?? 0) + 1);
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
counts,
|
|
380
|
+
total
|
|
381
|
+
};
|
|
382
|
+
};
|
|
383
|
+
const gapBlockSimilarity = (base, revised, workSession) => {
|
|
384
|
+
if (base.total === 0 && revised.total === 0) return {
|
|
385
|
+
status: "measured",
|
|
386
|
+
value: 1
|
|
387
|
+
};
|
|
388
|
+
if (base.total === 0 || revised.total === 0) return {
|
|
389
|
+
status: "measured",
|
|
390
|
+
value: GAP_PAIR_SIMILARITY_THRESHOLD
|
|
391
|
+
};
|
|
392
|
+
const [tokens, counterparts] = base.counts.size <= revised.counts.size ? [base.counts, revised.counts] : [revised.counts, base.counts];
|
|
393
|
+
if (tokens.size > workSession.remainingStructuralTokenLookups) return { status: "budget-exceeded" };
|
|
394
|
+
workSession.remainingStructuralTokenLookups -= tokens.size;
|
|
395
|
+
let shared = 0;
|
|
396
|
+
for (const [token, count] of tokens) shared += Math.min(count, counterparts.get(token) ?? 0);
|
|
397
|
+
return {
|
|
398
|
+
status: "measured",
|
|
399
|
+
value: 2 * shared / (base.total + revised.total)
|
|
400
|
+
};
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* The order-preserving pairs of one gap that maximise their summed
|
|
404
|
+
* similarity, each at least `GAP_PAIR_SIMILARITY_THRESHOLD`; offsets are into
|
|
405
|
+
* the gap's slices. Pairing by position instead fuses an inserted block with
|
|
406
|
+
* the neighbour it pushed down, and every block after it with the next one's
|
|
407
|
+
* wording. Null when the work budget refuses the gap.
|
|
408
|
+
*/
|
|
409
|
+
const pairGapBySimilarity = ({ base, revised, canPair, workSession }) => {
|
|
410
|
+
const baseCount = base.length;
|
|
411
|
+
const revisedCount = revised.length;
|
|
412
|
+
if (!claimFolioContentAlignmentCells(baseCount, revisedCount, workSession)) return null;
|
|
413
|
+
const revisedTokens = revised.map((block) => gapBlockTokens(block.block.text));
|
|
414
|
+
const similarityStep = Math.min(baseCount, revisedCount) + 1;
|
|
415
|
+
const similarity = new Float64Array(baseCount * revisedCount).fill(-1);
|
|
416
|
+
const candidateBase = /* @__PURE__ */ new Set();
|
|
417
|
+
const candidateRevised = /* @__PURE__ */ new Set();
|
|
418
|
+
for (const [baseOffset, baseBlock] of base.entries()) {
|
|
419
|
+
const baseTokens = gapBlockTokens(baseBlock.block.text);
|
|
420
|
+
for (const [revisedOffset, revisedBlock] of revised.entries()) {
|
|
421
|
+
if (!canPair(baseBlock, revisedBlock)) continue;
|
|
422
|
+
const tokens = revisedTokens[revisedOffset] ?? panic("A gap block has no token profile");
|
|
423
|
+
const measured = baseBlock.block.text === revisedBlock.block.text ? {
|
|
424
|
+
status: "measured",
|
|
425
|
+
value: 1
|
|
426
|
+
} : gapBlockSimilarity(baseTokens, tokens, workSession);
|
|
427
|
+
if (measured.status === "budget-exceeded") return null;
|
|
428
|
+
if (measured.value >= GAP_PAIR_SIMILARITY_THRESHOLD) {
|
|
429
|
+
const sameLabel = baseBlock.block.displayLabel !== void 0 && baseBlock.block.displayLabel === revisedBlock.block.displayLabel;
|
|
430
|
+
similarity[baseOffset * revisedCount + revisedOffset] = Math.round(measured.value * GAP_PAIR_SIMILARITY_SCALE) * similarityStep + (sameLabel ? 1 : 0);
|
|
431
|
+
candidateBase.add(baseOffset);
|
|
432
|
+
candidateRevised.add(revisedOffset);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const width = revisedCount + 1;
|
|
437
|
+
const scores = new Float64Array((baseCount + 1) * width);
|
|
438
|
+
const cellSimilarity = (baseOffset, revisedOffset) => similarity[baseOffset * revisedCount + revisedOffset] ?? -1;
|
|
439
|
+
const score = (row, column) => scores[row * width + column] ?? 0;
|
|
440
|
+
for (let row = baseCount - 1; row >= 0; row--) for (let column = revisedCount - 1; column >= 0; column--) {
|
|
441
|
+
const cell = cellSimilarity(row, column);
|
|
442
|
+
scores[row * width + column] = Math.max(score(row + 1, column), score(row, column + 1), cell < 0 ? 0 : score(row + 1, column + 1) + cell);
|
|
443
|
+
}
|
|
444
|
+
const pairs = [];
|
|
445
|
+
let row = 0;
|
|
446
|
+
let column = 0;
|
|
447
|
+
while (row < baseCount && column < revisedCount) {
|
|
448
|
+
const cell = cellSimilarity(row, column);
|
|
449
|
+
if (cell >= 0 && score(row, column) === score(row + 1, column + 1) + cell) {
|
|
450
|
+
pairs.push({
|
|
451
|
+
baseIndex: row,
|
|
452
|
+
revisedIndex: column
|
|
453
|
+
});
|
|
454
|
+
row += 1;
|
|
455
|
+
column += 1;
|
|
456
|
+
} else if (score(row, column) === score(row + 1, column)) row += 1;
|
|
457
|
+
else column += 1;
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
pairs,
|
|
461
|
+
candidateBase,
|
|
462
|
+
candidateRevised
|
|
463
|
+
};
|
|
464
|
+
};
|
|
315
465
|
const alignFolioContentBlocksInScope = (baseBlocks, revisedBlocks, options) => {
|
|
316
466
|
const stableIdMismatch = options.stableIdMismatch ?? "separate";
|
|
317
467
|
const idStability = options.idStability ?? folioContentIdStability;
|
|
468
|
+
const workSession = options.workSession ?? createFolioContentAlignmentWorkSession();
|
|
318
469
|
const prepared = prepareAlignmentBlocks({
|
|
319
470
|
baseBlocks,
|
|
320
471
|
revisedBlocks,
|
|
@@ -335,14 +486,15 @@ const alignFolioContentBlocksInScope = (baseBlocks, revisedBlocks, options) => {
|
|
|
335
486
|
baseLength: prepared.base.length,
|
|
336
487
|
revisedLength: prepared.revised.length,
|
|
337
488
|
anchors: stableIdAnchors,
|
|
338
|
-
pairGap: (baseFrom, baseTo, revisedFrom, revisedTo) =>
|
|
489
|
+
pairGap: (baseFrom, baseTo, revisedFrom, revisedTo) => pairByNestedUniqueExactText({
|
|
339
490
|
base: prepared.base,
|
|
340
491
|
revised: prepared.revised,
|
|
341
492
|
baseFrom,
|
|
342
493
|
baseTo,
|
|
343
494
|
revisedFrom,
|
|
344
495
|
revisedTo,
|
|
345
|
-
canPair
|
|
496
|
+
canPair,
|
|
497
|
+
workSession
|
|
346
498
|
})
|
|
347
499
|
});
|
|
348
500
|
const exactAndStableAnchors = [...stableIdAnchors, ...exactTextAnchors].toSorted((left, right) => left.baseIndex - right.baseIndex || left.revisedIndex - right.revisedIndex);
|
|
@@ -368,12 +520,13 @@ const alignFolioContentBlocksInScope = (baseBlocks, revisedBlocks, options) => {
|
|
|
368
520
|
canPair
|
|
369
521
|
});
|
|
370
522
|
const events = [];
|
|
523
|
+
const fusesACrossing = (baseBlock, revisedBlock) => baseBlock.block.text !== revisedBlock.block.text && crossed.base.has(baseBlock.index) && crossed.revised.has(revisedBlock.index);
|
|
371
524
|
const emitPositionalGap = (baseFrom, baseTo, revisedFrom, revisedTo) => {
|
|
372
525
|
const pairedCount = Math.min(baseTo - baseFrom, revisedTo - revisedFrom);
|
|
373
526
|
for (let offset = 0; offset < pairedCount; offset++) {
|
|
374
527
|
const baseBlock = prepared.base[baseFrom + offset];
|
|
375
528
|
const revisedBlock = prepared.revised[revisedFrom + offset];
|
|
376
|
-
if (baseBlock && revisedBlock) if (
|
|
529
|
+
if (baseBlock && revisedBlock) if (fusesACrossing(baseBlock, revisedBlock) || !canPair(baseBlock, revisedBlock)) {
|
|
377
530
|
events.push({
|
|
378
531
|
type: "baseOnly",
|
|
379
532
|
block: baseBlock.block
|
|
@@ -403,10 +556,72 @@ const alignFolioContentBlocksInScope = (baseBlocks, revisedBlocks, options) => {
|
|
|
403
556
|
});
|
|
404
557
|
}
|
|
405
558
|
};
|
|
559
|
+
/**
|
|
560
|
+
* A gap's blocks pair by similarity, whether or not its sides are equal in
|
|
561
|
+
* length: an equal gap can hide an insertion beside a deletion, and pairing
|
|
562
|
+
* it by position reads each kept block as a rewrite of its neighbour. The
|
|
563
|
+
* similar pairs anchor the rest; blocks between two anchors pair by position
|
|
564
|
+
* only when neither side offered any candidate and the counts match, which
|
|
565
|
+
* reads a block rewritten beyond recognition as the modification it is.
|
|
566
|
+
*/
|
|
567
|
+
const emitGap = (baseFrom, baseTo, revisedFrom, revisedTo) => {
|
|
568
|
+
const pairing = pairGapBySimilarity({
|
|
569
|
+
base: prepared.base.slice(baseFrom, baseTo),
|
|
570
|
+
revised: prepared.revised.slice(revisedFrom, revisedTo),
|
|
571
|
+
canPair: (baseBlock, revisedBlock) => !fusesACrossing(baseBlock, revisedBlock) && canPair(baseBlock, revisedBlock),
|
|
572
|
+
workSession
|
|
573
|
+
});
|
|
574
|
+
if (pairing === null) {
|
|
575
|
+
emitPositionalGap(baseFrom, baseTo, revisedFrom, revisedTo);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const { pairs, candidateBase, candidateRevised } = pairing;
|
|
579
|
+
let baseCursor = baseFrom;
|
|
580
|
+
let revisedCursor = revisedFrom;
|
|
581
|
+
const hasCandidate = (candidates, from, to) => {
|
|
582
|
+
for (let offset = from; offset < to; offset++) if (candidates.has(offset)) return true;
|
|
583
|
+
return false;
|
|
584
|
+
};
|
|
585
|
+
const emitUnpairedUntil = (baseEnd, revisedEnd) => {
|
|
586
|
+
if (baseEnd - baseCursor === revisedEnd - revisedCursor && !hasCandidate(candidateBase, baseCursor - baseFrom, baseEnd - baseFrom) && !hasCandidate(candidateRevised, revisedCursor - revisedFrom, revisedEnd - revisedFrom)) {
|
|
587
|
+
emitPositionalGap(baseCursor, baseEnd, revisedCursor, revisedEnd);
|
|
588
|
+
baseCursor = baseEnd;
|
|
589
|
+
revisedCursor = revisedEnd;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
for (; baseCursor < baseEnd; baseCursor++) {
|
|
593
|
+
const block = prepared.base[baseCursor]?.block;
|
|
594
|
+
if (block) events.push({
|
|
595
|
+
type: "baseOnly",
|
|
596
|
+
block
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
for (; revisedCursor < revisedEnd; revisedCursor++) {
|
|
600
|
+
const block = prepared.revised[revisedCursor]?.block;
|
|
601
|
+
if (block) events.push({
|
|
602
|
+
type: "revisedOnly",
|
|
603
|
+
block
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
for (const pair of pairs) {
|
|
608
|
+
emitUnpairedUntil(baseFrom + pair.baseIndex, revisedFrom + pair.revisedIndex);
|
|
609
|
+
const baseBlock = prepared.base[baseCursor]?.block;
|
|
610
|
+
const revisedBlock = prepared.revised[revisedCursor]?.block;
|
|
611
|
+
if (baseBlock && revisedBlock) events.push({
|
|
612
|
+
type: "pair",
|
|
613
|
+
baseBlock,
|
|
614
|
+
revisedBlock
|
|
615
|
+
});
|
|
616
|
+
baseCursor += 1;
|
|
617
|
+
revisedCursor += 1;
|
|
618
|
+
}
|
|
619
|
+
emitUnpairedUntil(baseTo, revisedTo);
|
|
620
|
+
};
|
|
406
621
|
let baseCursor = 0;
|
|
407
622
|
let revisedCursor = 0;
|
|
408
623
|
for (const anchor of anchors) {
|
|
409
|
-
|
|
624
|
+
emitGap(baseCursor, anchor.baseIndex, revisedCursor, anchor.revisedIndex);
|
|
410
625
|
const baseBlock = prepared.base[anchor.baseIndex]?.block;
|
|
411
626
|
const revisedBlock = prepared.revised[anchor.revisedIndex]?.block;
|
|
412
627
|
if (baseBlock && revisedBlock) events.push({
|
|
@@ -417,7 +632,7 @@ const alignFolioContentBlocksInScope = (baseBlocks, revisedBlocks, options) => {
|
|
|
417
632
|
baseCursor = anchor.baseIndex + 1;
|
|
418
633
|
revisedCursor = anchor.revisedIndex + 1;
|
|
419
634
|
}
|
|
420
|
-
|
|
635
|
+
emitGap(baseCursor, prepared.base.length, revisedCursor, prepared.revised.length);
|
|
421
636
|
return events;
|
|
422
637
|
};
|
|
423
638
|
const alignFolioContentBlocks = (baseBlocks, revisedBlocks, options = {}) => alignFolioContentBlocksInScope(baseBlocks, revisedBlocks, {
|
|
@@ -186,6 +186,7 @@ type ParagraphMarkPlan<Block extends FolioContentBlock> = {
|
|
|
186
186
|
revisedBlock: Block;
|
|
187
187
|
separator: string;
|
|
188
188
|
};
|
|
189
|
+
/** Plans keyed by their first step; each consumes the step after it. */
|
|
189
190
|
declare const detectFolioContentParagraphMarkPlans: <Block extends FolioContentBlock>(steps: readonly FolioContentAlignmentStep<Block>[]) => ReadonlyMap<number, ParagraphMarkPlan<Block>>;
|
|
190
191
|
type MovePair<Block extends FolioContentBlock> = {
|
|
191
192
|
baseBlock: Block;
|