@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.
Files changed (107) hide show
  1. package/dist/ai-edits/headless.d.ts +37 -1
  2. package/dist/ai-edits/headless.js +47 -16
  3. package/dist/ai-edits/word-diff.js +134 -1
  4. package/dist/compare/content-alignment.js +220 -5
  5. package/dist/compare/content.d.ts +1 -0
  6. package/dist/compare/content.js +51 -20
  7. package/dist/display-list/dom/renderDisplayListToDom.d.ts +10 -1
  8. package/dist/display-list/dom/renderDisplayListToDom.js +8 -7
  9. package/dist/display-list/html/renderDisplayListToHtml.d.ts +21 -0
  10. package/dist/display-list/html/renderDisplayListToHtml.js +126 -0
  11. package/dist/display-list/selectDisplayPages.d.ts +6 -0
  12. package/dist/display-list/selectDisplayPages.js +44 -0
  13. package/dist/docx/selectiveSave.d.ts +1 -1
  14. package/dist/docx/selectiveSave.js +16 -6
  15. package/dist/docx/structuralXmlPatch.d.ts +14 -0
  16. package/dist/docx/structuralXmlPatch.js +209 -0
  17. package/dist/export-pdf.d.ts +35 -2
  18. package/dist/export-pdf.js +51 -20
  19. package/dist/fonts/fontsourceFaces.d.ts +47 -0
  20. package/dist/fonts/fontsourceFaces.js +230 -0
  21. package/dist/layout-bridge/convert/flowBlockPostprocessing.d.ts +8 -0
  22. package/dist/layout-bridge/convert/flowBlockPostprocessing.js +106 -0
  23. package/dist/layout-bridge/convert/flowBorders.d.ts +17 -0
  24. package/dist/layout-bridge/convert/flowBorders.js +31 -0
  25. package/dist/layout-bridge/convert/flowConversionShared.d.ts +111 -0
  26. package/dist/layout-bridge/convert/flowConversionShared.js +23 -0
  27. package/dist/layout-bridge/convert/footnoteLayout.d.ts +2 -1
  28. package/dist/layout-bridge/convert/footnoteLayout.js +2 -1
  29. package/dist/layout-bridge/convert/headerFooterLayout.d.ts +2 -1
  30. package/dist/layout-bridge/convert/imageConversion.d.ts +33 -0
  31. package/dist/layout-bridge/convert/imageConversion.js +118 -0
  32. package/dist/layout-bridge/convert/listMarkers.d.ts +28 -0
  33. package/dist/layout-bridge/convert/listMarkers.js +98 -0
  34. package/dist/layout-bridge/convert/noteReferences.d.ts +14 -0
  35. package/dist/layout-bridge/convert/noteReferences.js +29 -0
  36. package/dist/layout-bridge/convert/pageBreakSplitting.d.ts +16 -0
  37. package/dist/layout-bridge/convert/pageBreakSplitting.js +140 -0
  38. package/dist/layout-bridge/convert/paragraphAttrs.d.ts +20 -0
  39. package/dist/layout-bridge/convert/paragraphAttrs.js +234 -0
  40. package/dist/layout-bridge/convert/paragraphConversion.d.ts +12 -0
  41. package/dist/layout-bridge/convert/paragraphConversion.js +112 -0
  42. package/dist/layout-bridge/convert/paragraphRuns.d.ts +15 -0
  43. package/dist/layout-bridge/convert/paragraphRuns.js +281 -0
  44. package/dist/layout-bridge/convert/runFormattingMerge.d.ts +19 -0
  45. package/dist/layout-bridge/convert/runFormattingMerge.js +167 -0
  46. package/dist/layout-bridge/convert/runMarkFormatting.d.ts +11 -0
  47. package/dist/layout-bridge/convert/runMarkFormatting.js +291 -0
  48. package/dist/layout-bridge/convert/sectionBoundaries.d.ts +35 -0
  49. package/dist/layout-bridge/convert/sectionBoundaries.js +114 -0
  50. package/dist/layout-bridge/convert/tableConversion.d.ts +12 -0
  51. package/dist/layout-bridge/convert/tableConversion.js +303 -0
  52. package/dist/layout-bridge/convert/textBoxFill.d.ts +20 -0
  53. package/dist/layout-bridge/convert/textBoxFill.js +42 -0
  54. package/dist/layout-bridge/convert/textFormattingConversion.d.ts +22 -0
  55. package/dist/layout-bridge/convert/textFormattingConversion.js +120 -0
  56. package/dist/layout-bridge/convert/toFlowBlocks.d.ts +5 -113
  57. package/dist/layout-bridge/convert/toFlowBlocks.js +11 -2082
  58. package/dist/layout-bridge/engine/hitTest.js +1 -1
  59. package/dist/layout-bridge/engine/measuring/index.d.ts +2 -1
  60. package/dist/layout-bridge/engine/measuring/index.js +2 -1
  61. package/dist/layout-bridge/engine/selectionRects.js +1 -1
  62. package/dist/layout-engine/imageLayout.d.ts +9 -0
  63. package/dist/layout-engine/imageLayout.js +48 -0
  64. package/dist/layout-engine/index.d.ts +3 -18
  65. package/dist/layout-engine/index.js +12 -937
  66. package/dist/layout-engine/layoutFlowShared.d.ts +6 -0
  67. package/dist/layout-engine/layoutFlowShared.js +8 -0
  68. package/dist/layout-engine/measure/crossRunWords.d.ts +45 -0
  69. package/dist/layout-engine/measure/crossRunWords.js +172 -0
  70. package/dist/layout-engine/measure/floatingLineClearance.d.ts +24 -0
  71. package/dist/layout-engine/measure/floatingLineClearance.js +44 -0
  72. package/dist/layout-engine/measure/inlineObjectMetrics.d.ts +6 -0
  73. package/dist/layout-engine/measure/inlineObjectMetrics.js +52 -0
  74. package/dist/layout-engine/measure/justification.d.ts +32 -0
  75. package/dist/layout-engine/measure/justification.js +83 -0
  76. package/dist/layout-engine/measure/lineFitting.d.ts +48 -0
  77. package/dist/layout-engine/measure/lineFitting.js +145 -0
  78. package/dist/layout-engine/measure/lineTypography.d.ts +44 -0
  79. package/dist/layout-engine/measure/lineTypography.js +124 -0
  80. package/dist/layout-engine/measure/measureBlocks.js +2 -1
  81. package/dist/layout-engine/measure/measureParagraph.d.ts +1 -20
  82. package/dist/layout-engine/measure/measureParagraph.js +32 -734
  83. package/dist/layout-engine/measure/paragraphMeasureShared.d.ts +109 -0
  84. package/dist/layout-engine/measure/paragraphMeasureShared.js +82 -0
  85. package/dist/layout-engine/measure/paragraphTabs.d.ts +18 -0
  86. package/dist/layout-engine/measure/paragraphTabs.js +78 -0
  87. package/dist/layout-engine/paragraphLayout.d.ts +23 -0
  88. package/dist/layout-engine/paragraphLayout.js +204 -0
  89. package/dist/layout-engine/sectionLayout.d.ts +45 -0
  90. package/dist/layout-engine/sectionLayout.js +216 -0
  91. package/dist/layout-engine/tableLayout.d.ts +17 -0
  92. package/dist/layout-engine/tableLayout.js +369 -0
  93. package/dist/layout-engine/textBoxLayout.d.ts +15 -0
  94. package/dist/layout-engine/textBoxLayout.js +137 -0
  95. package/dist/layout-painter/renderPage.js +1 -0
  96. package/dist/managers/editorShortcuts.d.ts +16 -3
  97. package/dist/managers/editorShortcuts.js +6 -4
  98. package/dist/paged-layout/sectionBlockWidths.d.ts +2 -1
  99. package/dist/paged-layout/sectionBlockWidths.js +1 -1
  100. package/dist/prosemirror/extensions/StarterKit.d.ts +3 -0
  101. package/dist/prosemirror/extensions/StarterKit.js +2 -1
  102. package/dist/prosemirror/extensions/core/HistoryExtension.d.ts +9 -1
  103. package/dist/prosemirror/extensions/core/HistoryExtension.js +4 -3
  104. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.d.ts +12 -5
  105. package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +91 -33
  106. package/dist/server.d.ts +3 -2
  107. 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(this.nextCommentId(), text, this.author);
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: structuralChange || untrackedChanges ? { type: "full-repack" } : {
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 selective;
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 buffer;
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
- return toSegments(orderDeletionsFirst(marked));
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) => pairByUniqueExactText({
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 (baseBlock.block.text !== revisedBlock.block.text && crossed.base.has(baseBlock.index) && crossed.revised.has(revisedBlock.index) || !canPair(baseBlock, revisedBlock)) {
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
- emitPositionalGap(baseCursor, anchor.baseIndex, revisedCursor, anchor.revisedIndex);
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
- emitPositionalGap(baseCursor, prepared.base.length, revisedCursor, prepared.revised.length);
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;