@stll/folio-core 0.32.2 → 0.33.1
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/__fixtures__/paragraphs.d.ts +17 -0
- package/dist/ai-edits/__fixtures__/paragraphs.js +33 -0
- package/dist/ai-edits/apply.d.ts +49 -3
- package/dist/ai-edits/apply.js +652 -86
- package/dist/ai-edits/blockRange.d.ts +7 -3
- package/dist/ai-edits/blockRange.js +14 -6
- package/dist/ai-edits/headless.d.ts +39 -5
- package/dist/ai-edits/headless.js +76 -17
- package/dist/ai-edits/index.d.ts +6 -6
- package/dist/ai-edits/index.js +3 -3
- package/dist/ai-edits/read.d.ts +4 -0
- package/dist/ai-edits/read.js +30 -6
- package/dist/ai-edits/scoped-reading.js +4 -3
- package/dist/ai-edits/snapshot.d.ts +42 -2
- package/dist/ai-edits/snapshot.js +127 -62
- package/dist/ai-edits/table-row-column-mutations.d.ts +24 -1
- package/dist/ai-edits/table-row-column-mutations.js +98 -6
- package/dist/ai-edits/table-targets.d.ts +12 -3
- package/dist/ai-edits/table-targets.js +15 -5
- package/dist/ai-edits/types.d.ts +219 -15
- package/dist/ai-edits/word-diff.d.ts +54 -11
- package/dist/ai-edits/word-diff.js +221 -61
- package/dist/compare/__fixtures__/body-sequence.d.ts +56 -0
- package/dist/compare/__fixtures__/body-sequence.js +136 -0
- package/dist/compare/__fixtures__/nested-table.d.ts +16 -0
- package/dist/compare/__fixtures__/nested-table.js +50 -0
- package/dist/compare/__fixtures__/numbered-list.d.ts +34 -0
- package/dist/compare/__fixtures__/numbered-list.js +85 -0
- package/dist/compare/column-alignment.d.ts +27 -0
- package/dist/compare/column-alignment.js +162 -0
- package/dist/compare/compare.d.ts +107 -0
- package/dist/compare/compare.js +412 -0
- package/dist/compare/formatting.d.ts +29 -0
- package/dist/compare/formatting.js +84 -0
- package/dist/compare/plan.d.ts +22 -0
- package/dist/compare/plan.js +1141 -0
- package/dist/compare/reproducible-package.d.ts +21 -0
- package/dist/compare/reproducible-package.js +47 -0
- package/dist/compare/scenario.d.ts +77 -0
- package/dist/compare/scenario.js +263 -0
- package/dist/compare/types.d.ts +297 -0
- package/dist/compare/types.js +42 -0
- package/dist/compare/verification.d.ts +83 -0
- package/dist/compare/verification.js +189 -0
- package/dist/compat/eigenpal.d.ts +9 -6
- package/dist/compat/eigenpal.js +6 -3
- package/dist/controller/layoutPipeline.d.ts +13 -0
- package/dist/controller/layoutPipeline.js +15 -1
- package/dist/display-list/build/buildContext.d.ts +39 -0
- package/dist/display-list/build/buildContext.js +31 -0
- package/dist/display-list/build/buildDisplayList.d.ts +79 -0
- package/dist/display-list/build/buildDisplayList.js +283 -0
- package/dist/display-list/build/colors.d.ts +20 -0
- package/dist/display-list/build/colors.js +136 -0
- package/dist/display-list/build/floatingImages.d.ts +24 -0
- package/dist/display-list/build/floatingImages.js +41 -0
- package/dist/display-list/build/fontTable.d.ts +31 -0
- package/dist/display-list/build/fontTable.js +97 -0
- package/dist/display-list/build/furniture.d.ts +86 -0
- package/dist/display-list/build/furniture.js +98 -0
- package/dist/display-list/build/glyphs.d.ts +55 -0
- package/dist/display-list/build/glyphs.js +103 -0
- package/dist/display-list/build/headerFooterPrimitives.d.ts +17 -0
- package/dist/display-list/build/headerFooterPrimitives.js +40 -0
- package/dist/display-list/build/imagePrimitives.d.ts +35 -0
- package/dist/display-list/build/imagePrimitives.js +264 -0
- package/dist/display-list/build/pageBorderPrimitives.d.ts +23 -0
- package/dist/display-list/build/pageBorderPrimitives.js +111 -0
- package/dist/display-list/build/pageFurniture.d.ts +35 -0
- package/dist/display-list/build/pageFurniture.js +141 -0
- package/dist/display-list/build/paragraphPrimitives.d.ts +19 -0
- package/dist/display-list/build/paragraphPrimitives.js +819 -0
- package/dist/display-list/build/regions.d.ts +46 -0
- package/dist/display-list/build/regions.js +56 -0
- package/dist/display-list/build/storyPrimitives.d.ts +29 -0
- package/dist/display-list/build/storyPrimitives.js +279 -0
- package/dist/display-list/build/strokes.d.ts +19 -0
- package/dist/display-list/build/strokes.js +78 -0
- package/dist/display-list/build/tablePrimitives.d.ts +38 -0
- package/dist/display-list/build/tablePrimitives.js +409 -0
- package/dist/display-list/build/textBoxPrimitives.d.ts +14 -0
- package/dist/display-list/build/textBoxPrimitives.js +129 -0
- package/dist/display-list/build/textDecorations.d.ts +29 -0
- package/dist/display-list/build/textDecorations.js +29 -0
- package/dist/display-list/build/unsupported.d.ts +57 -0
- package/dist/display-list/build/unsupported.js +0 -0
- package/dist/display-list/build/watermarkPrimitives.d.ts +15 -0
- package/dist/display-list/build/watermarkPrimitives.js +128 -0
- package/dist/display-list/dom/renderDisplayListToDom.d.ts +26 -0
- package/dist/display-list/dom/renderDisplayListToDom.js +620 -0
- package/dist/display-list/editor/displayListPagePainter.d.ts +22 -0
- package/dist/display-list/editor/displayListPagePainter.js +63 -0
- package/dist/display-list/editor/pageRenderer.d.ts +24 -0
- package/dist/display-list/editor/pageRenderer.js +36 -0
- package/dist/display-list/primitives.d.ts +95 -0
- package/dist/display-list/primitives.js +127 -0
- package/dist/display-list/types.d.ts +472 -0
- package/dist/display-list/types.js +0 -0
- package/dist/document-operations.d.ts +39 -8
- package/dist/document-operations.js +169 -16
- package/dist/document-stories.d.ts +16 -0
- package/dist/document-stories.js +48 -4
- package/dist/docx/hyperlinkParser.d.ts +9 -1
- package/dist/docx/hyperlinkParser.js +19 -13
- package/dist/docx/packageParts.d.ts +35 -0
- package/dist/docx/packageParts.js +120 -0
- package/dist/docx/paraIdRangeNormalization.d.ts +40 -0
- package/dist/docx/paraIdRangeNormalization.js +64 -0
- package/dist/docx/paragraphParser.js +101 -26
- package/dist/docx/paragraphTraversal.js +1 -1
- package/dist/docx/renderedPageBreakNormalization.js +3 -1
- package/dist/docx/revisionIdNormalization.d.ts +27 -0
- package/dist/docx/revisionIdNormalization.js +121 -0
- package/dist/docx/rezip.d.ts +6 -0
- package/dist/docx/rezip.js +51 -19
- package/dist/docx/selectiveSave.js +2 -2
- package/dist/docx/serializer/commentSerializer.js +6 -1
- package/dist/docx/serializer/paragraphSerializer.js +73 -20
- package/dist/docx/serializer/runSerializer.js +11 -10
- package/dist/docx/serializer/tableSerializer.js +26 -10
- package/dist/docx/server/applyDocxXmlPatchProposal.js +1 -1
- package/dist/docx/server/createBilingualDocx.js +3 -2
- package/dist/docx/streamingXmlParser.d.ts +13 -1
- package/dist/docx/streamingXmlParser.js +49 -11
- package/dist/docx/unzip.d.ts +1 -2
- package/dist/docx/unzip.js +25 -6
- package/dist/docx/xmlParser.d.ts +3 -1
- package/dist/docx/xmlParser.js +33 -27
- package/dist/export-pdf.d.ts +43 -0
- package/dist/export-pdf.js +98 -0
- package/dist/fonts/headlessMeasure.d.ts +76 -0
- package/dist/fonts/headlessMeasure.js +0 -0
- package/dist/fonts/sfnt/parse.d.ts +51 -0
- package/dist/fonts/sfnt/parse.js +525 -0
- package/dist/fonts/sfnt/subset.d.ts +20 -0
- package/dist/fonts/sfnt/subset.js +350 -0
- package/dist/fonts/sfnt/tables.d.ts +88 -0
- package/dist/fonts/sfnt/tables.js +131 -0
- package/dist/fonts/sfnt/woff.d.ts +14 -0
- package/dist/fonts/sfnt/woff.js +163 -0
- package/dist/generated/text_shaper.js +324 -0
- package/dist/generated/text_shaper_bg.wasm +0 -0
- package/dist/headless-layout.d.ts +75 -0
- package/dist/headless-layout.js +350 -0
- package/dist/index.d.ts +9 -6
- package/dist/index.js +6 -3
- package/dist/layout-bridge/convert/footnoteLayout.d.ts +1 -0
- package/dist/layout-bridge/convert/footnoteLayout.js +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.d.ts +1 -0
- package/dist/layout-bridge/convert/headerFooterLayout.js +2 -0
- package/dist/layout-bridge/convert/toFlowBlocks.d.ts +3 -1
- package/dist/layout-bridge/convert/toFlowBlocks.js +2 -0
- package/dist/layout-engine/measure/advanceComposition.d.ts +62 -0
- package/dist/layout-engine/measure/advanceComposition.js +148 -0
- package/dist/layout-engine/measure/cache.d.ts +2 -0
- package/dist/layout-engine/measure/measureContainer.js +13 -0
- package/dist/layout-engine/measure/measureParagraph.js +1 -2
- package/dist/layout-engine/measure/measureTypes.d.ts +12 -0
- package/dist/layout-engine/measure/tableInlinePlacement.d.ts +3 -2
- package/dist/layout-engine/measure/tableInlinePlacement.js +14 -1
- package/dist/layout-engine/tableIndentCompatibility.d.ts +5 -0
- package/dist/layout-engine/tableIndentCompatibility.js +15 -0
- package/dist/layout-engine/types.d.ts +8 -0
- package/dist/layout-painter/renderPage.d.ts +29 -1
- package/dist/layout-painter/renderPage.js +47 -19
- package/dist/layout-painter/renderParagraph.d.ts +21 -2
- package/dist/layout-painter/renderParagraph.js +1 -1
- package/dist/layout-painter/renderTable.js +37 -4
- package/dist/managers/editorShortcuts.d.ts +33 -1
- package/dist/managers/editorShortcuts.js +20 -1
- package/dist/markdown/renderRuns.js +7 -2
- package/dist/markdown/renderTable.js +5 -1
- package/dist/model.d.ts +3 -3
- package/dist/model.js +2 -2
- package/dist/pdf/contentStream.d.ts +73 -0
- package/dist/pdf/contentStream.js +143 -0
- package/dist/pdf/fonts.d.ts +96 -0
- package/dist/pdf/fonts.js +593 -0
- package/dist/pdf/images.d.ts +28 -0
- package/dist/pdf/images.js +439 -0
- package/dist/pdf/objects.d.ts +93 -0
- package/dist/pdf/objects.js +237 -0
- package/dist/pdf/pageSpace.d.ts +43 -0
- package/dist/pdf/pageSpace.js +57 -0
- package/dist/pdf/paint.d.ts +32 -0
- package/dist/pdf/paint.js +442 -0
- package/dist/pdf/writePdf.d.ts +56 -0
- package/dist/pdf/writePdf.js +345 -0
- package/dist/prosemirror/commands/comments.js +96 -6
- package/dist/prosemirror/conversion/fromProseDoc.js +41 -7
- package/dist/prosemirror/conversion/toProseDoc.js +41 -4
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.d.ts +15 -2
- package/dist/prosemirror/extensions/features/ParaIdAllocatorExtension.js +35 -6
- package/dist/prosemirror/extensions/features/ParagraphChangeTrackerExtension.js +2 -2
- package/dist/prosemirror/extensions/features/pasteCleanup.js +4 -3
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.d.ts +3 -1
- package/dist/prosemirror/extensions/nodes/BookmarkBoundaryExtension.js +5 -3
- package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.d.ts +7 -1
- package/dist/prosemirror/extensions/nodes/RenderedPageBreakExtension.js +9 -3
- package/dist/prosemirror/extensions/nodes/TableExtension.js +56 -39
- package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.d.ts +3 -1
- package/dist/prosemirror/extensions/nodes/TextBoxAnchorExtension.js +5 -3
- package/dist/prosemirror/plugins/suggestionMode.js +1 -0
- package/dist/prosemirror/revisionCarriers.js +19 -2
- package/dist/prosemirror/utils/extractTrackedChanges.d.ts +2 -2
- package/dist/prosemirror/utils/extractTrackedChanges.js +59 -23
- package/dist/prosemirror/validation.js +55 -27
- package/dist/prosemirror/zeroWidthAnchors.d.ts +10 -0
- package/dist/prosemirror/zeroWidthAnchors.js +23 -0
- package/dist/redline.js +12 -79
- package/dist/server.d.ts +3 -3
- package/dist/server.js +2 -2
- package/dist/shaping/placeRun.d.ts +51 -0
- package/dist/shaping/placeRun.js +84 -0
- package/dist/shaping/shaper.d.ts +92 -0
- package/dist/shaping/shaper.js +102 -0
- package/dist/types/block-id.d.ts +18 -2
- package/dist/types/block-id.js +49 -5
- package/dist/types/content.d.ts +2 -2
- package/dist/utils/clipboard.js +4 -3
- package/dist/utils/fontResolver.js +183 -0
- package/dist/utils/htmlComments.d.ts +17 -0
- package/dist/utils/htmlComments.js +22 -0
- package/dist/utils/scriptSegments.d.ts +12 -1
- package/dist/utils/scriptSegments.js +17 -1
- package/dist/version-comparison.d.ts +1 -1
- package/package.json +7 -3
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { FolioDocxReviewer } from "../ai-edits/headless.js";
|
|
2
|
+
import "../document-operations.js";
|
|
3
|
+
import { pairFolioDocumentStories } from "../document-stories.js";
|
|
4
|
+
import { planStoryCompare } from "./plan.js";
|
|
5
|
+
import { withFixedPackageDates } from "./reproducible-package.js";
|
|
6
|
+
import { CompareDocxApplyError, CompareDocxFinalParagraphMarkError, CompareDocxOperationLimitError, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, InvalidCompareDocxOptionsError } from "./types.js";
|
|
7
|
+
import { classifyProjectionMismatch, deletedFinalParagraphMarks, projectSupportedInlineFormatting } from "./verification.js";
|
|
8
|
+
import { Result, panic } from "better-result";
|
|
9
|
+
//#region src/compare/compare.ts
|
|
10
|
+
/**
|
|
11
|
+
* Deterministic `.docx` compare: two packages in, one redlined package plus a
|
|
12
|
+
* JSON change list out.
|
|
13
|
+
*
|
|
14
|
+
* ## Determinism contract
|
|
15
|
+
*
|
|
16
|
+
* `compareDocx(base, target, options)` is a pure function of its three
|
|
17
|
+
* arguments. It reads no clock and no randomness: revision dates come from
|
|
18
|
+
* `options.timestamp`, and revision ids from a seed derived from the base
|
|
19
|
+
* document's own highest existing revision id, so two runs over the same
|
|
20
|
+
* inputs produce byte-identical buffers and deeply equal change lists.
|
|
21
|
+
*
|
|
22
|
+
* ## Round-trip contract
|
|
23
|
+
*
|
|
24
|
+
* Accepting every tracked change in the result yields the target's content;
|
|
25
|
+
* rejecting every one yields the base's. Both directions are checked before
|
|
26
|
+
* the call returns, and the verdict travels with the result as
|
|
27
|
+
* `verification`. Anything the comparison does not cover is reported in
|
|
28
|
+
* `unsupported`, or fails the call, rather than being silently dropped.
|
|
29
|
+
*
|
|
30
|
+
* An unproven redline is refused by default. `onUnverified: "emit"` returns it
|
|
31
|
+
* anyway, with every invariant that did not hold named: a caller that would
|
|
32
|
+
* rather show its best attempt and say what is missing can, and one that wants
|
|
33
|
+
* a redline it can stand behind still gets nothing else.
|
|
34
|
+
*
|
|
35
|
+
* ## Stages
|
|
36
|
+
*
|
|
37
|
+
* The call is four named steps: {@link parseComparison},
|
|
38
|
+
* {@link planComparison}, {@link applyComparison}, {@link serializeComparison}.
|
|
39
|
+
* `compareDocx` is their composition and nothing else, so the benchmark can
|
|
40
|
+
* time the stages separately without keeping a second copy of the pipeline
|
|
41
|
+
* that would drift from this one.
|
|
42
|
+
*
|
|
43
|
+
* @packageDocumentation
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Cap on operations one comparison generates. Both inputs are untrusted
|
|
47
|
+
* documents, and each operation costs a document walk plus revision ids.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_COMPARE_OPERATIONS = 1e4;
|
|
50
|
+
const parseSide = async (buffer, side, author) => await Result.tryPromise({
|
|
51
|
+
try: async () => await FolioDocxReviewer.fromBuffer(buffer, { author }),
|
|
52
|
+
catch: (cause) => new CompareDocxParseError({
|
|
53
|
+
message: `The ${side} document could not be parsed.`,
|
|
54
|
+
side,
|
|
55
|
+
cause
|
|
56
|
+
})
|
|
57
|
+
});
|
|
58
|
+
/** Read before either side is resolved, so it describes the package as it arrived. */
|
|
59
|
+
const existingRevisionsOf = (reviewer) => {
|
|
60
|
+
let highest = 0;
|
|
61
|
+
let present = false;
|
|
62
|
+
for (const { handle } of reviewer.listStories()) {
|
|
63
|
+
const story = reviewer.readReviewedStory({
|
|
64
|
+
story: handle,
|
|
65
|
+
view: "current-markup"
|
|
66
|
+
});
|
|
67
|
+
for (const change of story?.changes ?? []) {
|
|
68
|
+
highest = Math.max(highest, change.id);
|
|
69
|
+
present = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
idSeed: highest + 1,
|
|
74
|
+
present
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* One story's text-and-structure projection: every block's text tagged with
|
|
79
|
+
* the table cell it sits in. The tag is what makes the self-check below see a
|
|
80
|
+
* paragraph that landed beside a table instead of inside it.
|
|
81
|
+
*/
|
|
82
|
+
const projectBlocks = (blocks) => {
|
|
83
|
+
return blocks.map(({ text, table, styleId, listLevel }) => {
|
|
84
|
+
return `${table ? `t${String(table.tableIndex)}r${String(table.rowIndex)}c${String(table.cellIndex)}g${String(table.gridColumnIndex)}x${String(table.columnSpan)}y${String(table.rowSpan)}p${String(table.paragraphIndex)}` : "body"}|${styleId ?? ""}|${listLevel ?? ""}|${text}`;
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
/** Verify formatting only where the plan claims a text-equal formatting change. */
|
|
88
|
+
const formattingRoundTripFailure = ({ invariant, story, changes, actualBlocks, expectedBlocks, expectedBlockId }) => {
|
|
89
|
+
const expectedIndexById = new Map(expectedBlocks.map(({ id }, index) => [id, index]));
|
|
90
|
+
const checkedExpectedIds = /* @__PURE__ */ new Set();
|
|
91
|
+
for (const change of changes) {
|
|
92
|
+
if (change.kind !== "format") continue;
|
|
93
|
+
const expectedId = expectedBlockId(change);
|
|
94
|
+
if (checkedExpectedIds.has(expectedId)) continue;
|
|
95
|
+
checkedExpectedIds.add(expectedId);
|
|
96
|
+
const expectedIndex = expectedIndexById.get(expectedId) ?? -1;
|
|
97
|
+
const expected = expectedBlocks.at(expectedIndex);
|
|
98
|
+
const actual = actualBlocks.at(expectedIndex);
|
|
99
|
+
if (expectedIndex === -1 || !actual || !expected) return {
|
|
100
|
+
invariant,
|
|
101
|
+
cause: "inline-formatting",
|
|
102
|
+
story,
|
|
103
|
+
detail: "a text-equal aligned block could not be projected for formatting verification"
|
|
104
|
+
};
|
|
105
|
+
if (projectSupportedInlineFormatting(actual) !== projectSupportedInlineFormatting(expected)) return {
|
|
106
|
+
invariant,
|
|
107
|
+
cause: "inline-formatting",
|
|
108
|
+
story,
|
|
109
|
+
detail: "supported inline formatting differs in a text-equal aligned block"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
const numberingKey = ({ numId, level }) => `${String(numId)}:${String(level)}`;
|
|
115
|
+
const sameNumbering = (left, right) => left.format === right.format && left.levelText === right.levelText && left.start === right.start;
|
|
116
|
+
const compareNumbering = (base, target) => {
|
|
117
|
+
const baseLevels = new Map(base.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
|
|
118
|
+
const targetLevels = new Map(target.readNumberingDefinitions().map((level) => [numberingKey(level), level]));
|
|
119
|
+
const changes = [];
|
|
120
|
+
for (const [key, before] of baseLevels) {
|
|
121
|
+
const after = targetLevels.get(key) ?? null;
|
|
122
|
+
if (after === null || !sameNumbering(before, after)) changes.push({
|
|
123
|
+
kind: "numbering",
|
|
124
|
+
numId: before.numId,
|
|
125
|
+
level: before.level,
|
|
126
|
+
before,
|
|
127
|
+
after
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
for (const [key, after] of targetLevels) if (!baseLevels.has(key)) changes.push({
|
|
131
|
+
kind: "numbering",
|
|
132
|
+
numId: after.numId,
|
|
133
|
+
level: after.level,
|
|
134
|
+
before: null,
|
|
135
|
+
after
|
|
136
|
+
});
|
|
137
|
+
return changes.toSorted((left, right) => left.numId - right.numId || left.level - right.level);
|
|
138
|
+
};
|
|
139
|
+
/** Stage 1: both packages to editor models, paired story by story. */
|
|
140
|
+
const parseComparison = async (base, target, options) => {
|
|
141
|
+
const packageDate = new Date(options.timestamp);
|
|
142
|
+
if (Number.isNaN(packageDate.getTime())) return Result.err(new InvalidCompareDocxOptionsError({
|
|
143
|
+
message: "timestamp must be a date the package can be stamped with.",
|
|
144
|
+
option: "timestamp",
|
|
145
|
+
receivedValue: options.timestamp
|
|
146
|
+
}));
|
|
147
|
+
const baseParse = await parseSide(base, "base", options.author);
|
|
148
|
+
if (baseParse.isErr()) return Result.err(baseParse.error);
|
|
149
|
+
const targetParse = await parseSide(target, "target", options.author);
|
|
150
|
+
if (targetParse.isErr()) return Result.err(targetParse.error);
|
|
151
|
+
const reviewer = baseParse.value;
|
|
152
|
+
const targetReviewer = targetParse.value;
|
|
153
|
+
const existing = existingRevisionsOf(reviewer);
|
|
154
|
+
const pairs = [];
|
|
155
|
+
const unsupported = [];
|
|
156
|
+
for (const { baseStory, revisedStory: targetStory } of pairFolioDocumentStories(reviewer.listStories().map(({ handle }) => handle), targetReviewer.listStories().map(({ handle }) => handle))) {
|
|
157
|
+
if (!baseStory) {
|
|
158
|
+
unsupported.push({
|
|
159
|
+
reason: "story-missing-in-base",
|
|
160
|
+
baseStory: null,
|
|
161
|
+
targetStory
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!targetStory) {
|
|
166
|
+
unsupported.push({
|
|
167
|
+
reason: "story-missing-in-target",
|
|
168
|
+
baseStory,
|
|
169
|
+
targetStory: null
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
reviewer.resolveReviewedStory({
|
|
174
|
+
story: baseStory,
|
|
175
|
+
view: "final"
|
|
176
|
+
});
|
|
177
|
+
targetReviewer.resolveReviewedStory({
|
|
178
|
+
story: targetStory,
|
|
179
|
+
view: "final"
|
|
180
|
+
});
|
|
181
|
+
const baseSnapshot = reviewer.snapshotStory(baseStory);
|
|
182
|
+
const targetSnapshot = targetReviewer.snapshotStory(targetStory);
|
|
183
|
+
if (!baseSnapshot || !targetSnapshot) {
|
|
184
|
+
unsupported.push({
|
|
185
|
+
reason: "story-not-editable",
|
|
186
|
+
baseStory,
|
|
187
|
+
targetStory
|
|
188
|
+
});
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
pairs.push({
|
|
192
|
+
baseStory,
|
|
193
|
+
targetStory,
|
|
194
|
+
baseSnapshot,
|
|
195
|
+
targetSnapshot
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return Result.ok({
|
|
199
|
+
granularity: options.granularity ?? "word",
|
|
200
|
+
baseBuffer: base,
|
|
201
|
+
baseCarriedRevisions: existing.present,
|
|
202
|
+
reviewer,
|
|
203
|
+
targetReviewer,
|
|
204
|
+
revisionStamp: {
|
|
205
|
+
date: options.timestamp,
|
|
206
|
+
idSeed: existing.idSeed
|
|
207
|
+
},
|
|
208
|
+
packageDate,
|
|
209
|
+
pairs,
|
|
210
|
+
numberingChanges: compareNumbering(reviewer, targetReviewer),
|
|
211
|
+
unsupported
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* Stage 2: align every paired story and derive its operations. Pure — no
|
|
216
|
+
* parsing, no serialization, no clock.
|
|
217
|
+
*/
|
|
218
|
+
const planComparison = ({ pairs }) => {
|
|
219
|
+
const planned = [];
|
|
220
|
+
for (const pair of pairs) {
|
|
221
|
+
const plan = planStoryCompare({
|
|
222
|
+
story: pair.baseStory,
|
|
223
|
+
baseSnapshot: pair.baseSnapshot,
|
|
224
|
+
targetSnapshot: pair.targetSnapshot,
|
|
225
|
+
maxOperations: MAX_COMPARE_OPERATIONS
|
|
226
|
+
});
|
|
227
|
+
if (plan === null) return Result.err(new CompareDocxOperationLimitError({
|
|
228
|
+
message: "The comparison needs more operations than the engine generates.",
|
|
229
|
+
limit: MAX_COMPARE_OPERATIONS
|
|
230
|
+
}));
|
|
231
|
+
planned.push({
|
|
232
|
+
pair,
|
|
233
|
+
plan
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return Result.ok(planned);
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Stage 3: write the planned operations into the base document as tracked
|
|
240
|
+
* changes, then check the work rather than trust it. Both directions of the
|
|
241
|
+
* round trip are checked, structure included: accepting the story's generated
|
|
242
|
+
* revisions must reproduce the target, and rejecting them must reproduce the
|
|
243
|
+
* base it was compared from. A difference the operation vocabulary cannot
|
|
244
|
+
* express would otherwise leave a redline that reads plausibly and is wrong.
|
|
245
|
+
*
|
|
246
|
+
* The check reports rather than throws. {@link compareDocx} decides what to do
|
|
247
|
+
* with an unverified result, because "give me your best attempt and tell me
|
|
248
|
+
* what you could not represent" and "give me nothing unless you can prove it"
|
|
249
|
+
* are both legitimate asks and only the caller knows which one it is making.
|
|
250
|
+
*/
|
|
251
|
+
const applyComparison = ({ reviewer, revisionStamp, granularity, numberingChanges }, planned) => {
|
|
252
|
+
const changes = [...numberingChanges];
|
|
253
|
+
const failures = [];
|
|
254
|
+
let idSeed = revisionStamp.idSeed;
|
|
255
|
+
for (const { pair, plan } of planned) {
|
|
256
|
+
changes.push(...plan.changes);
|
|
257
|
+
if (plan.operations.length === 0) continue;
|
|
258
|
+
const baseBeforeBlocks = reviewer.readReviewedStory({
|
|
259
|
+
story: pair.baseStory,
|
|
260
|
+
view: "final"
|
|
261
|
+
})?.snapshot.blocks ?? [];
|
|
262
|
+
const baseBefore = projectBlocks(baseBeforeBlocks);
|
|
263
|
+
const { skipped, nextRevisionId } = reviewer.applyDocumentOperationsToStory({
|
|
264
|
+
story: pair.baseStory,
|
|
265
|
+
snapshot: pair.baseSnapshot,
|
|
266
|
+
revisionStamp: {
|
|
267
|
+
date: revisionStamp.date,
|
|
268
|
+
idSeed
|
|
269
|
+
},
|
|
270
|
+
wordDiff: { granularity },
|
|
271
|
+
batch: {
|
|
272
|
+
version: 1,
|
|
273
|
+
mode: "tracked-changes",
|
|
274
|
+
operations: plan.operations
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (nextRevisionId === void 0) panic("The applier did not report where it left the revision-id counter", { story: pair.baseStory });
|
|
278
|
+
idSeed = nextRevisionId;
|
|
279
|
+
if (skipped.length > 0) return Result.err(new CompareDocxApplyError({
|
|
280
|
+
message: "Some derived operations were refused, so the result would not match the target.",
|
|
281
|
+
skipped
|
|
282
|
+
}));
|
|
283
|
+
const acceptedStory = reviewer.readReviewedStory({
|
|
284
|
+
story: pair.baseStory,
|
|
285
|
+
view: "final"
|
|
286
|
+
});
|
|
287
|
+
const acceptFailure = classifyProjectionMismatch({
|
|
288
|
+
invariant: "accept-reproduces-target",
|
|
289
|
+
story: pair.baseStory,
|
|
290
|
+
actual: projectBlocks(acceptedStory?.snapshot.blocks ?? []),
|
|
291
|
+
expected: projectBlocks(pair.targetSnapshot.blocks)
|
|
292
|
+
});
|
|
293
|
+
if (acceptFailure) failures.push(acceptFailure);
|
|
294
|
+
else {
|
|
295
|
+
const formattingFailure = formattingRoundTripFailure({
|
|
296
|
+
invariant: "accept-reproduces-target",
|
|
297
|
+
story: pair.baseStory,
|
|
298
|
+
changes: plan.changes,
|
|
299
|
+
actualBlocks: acceptedStory?.snapshot.blocks ?? [],
|
|
300
|
+
expectedBlocks: pair.targetSnapshot.blocks,
|
|
301
|
+
expectedBlockId: ({ targetBlockId }) => targetBlockId
|
|
302
|
+
});
|
|
303
|
+
if (formattingFailure) failures.push(formattingFailure);
|
|
304
|
+
}
|
|
305
|
+
const rejectedStory = reviewer.readReviewedStory({
|
|
306
|
+
story: pair.baseStory,
|
|
307
|
+
view: "original"
|
|
308
|
+
});
|
|
309
|
+
const rejectFailure = classifyProjectionMismatch({
|
|
310
|
+
invariant: "reject-reproduces-base",
|
|
311
|
+
story: pair.baseStory,
|
|
312
|
+
actual: projectBlocks(rejectedStory?.snapshot.blocks ?? []),
|
|
313
|
+
expected: baseBefore
|
|
314
|
+
});
|
|
315
|
+
if (rejectFailure) failures.push(rejectFailure);
|
|
316
|
+
else {
|
|
317
|
+
const formattingFailure = formattingRoundTripFailure({
|
|
318
|
+
invariant: "reject-reproduces-base",
|
|
319
|
+
story: pair.baseStory,
|
|
320
|
+
changes: plan.changes,
|
|
321
|
+
actualBlocks: rejectedStory?.snapshot.blocks ?? [],
|
|
322
|
+
expectedBlocks: pair.baseSnapshot.blocks,
|
|
323
|
+
expectedBlockId: ({ baseBlockId }) => baseBlockId
|
|
324
|
+
});
|
|
325
|
+
if (formattingFailure) failures.push(formattingFailure);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return Result.ok({
|
|
329
|
+
changes,
|
|
330
|
+
verification: failures.length === 0 ? { status: "verified" } : {
|
|
331
|
+
status: "unverified",
|
|
332
|
+
failures
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* Stage 4: the result package, with every ZIP entry date pinned.
|
|
338
|
+
*
|
|
339
|
+
* A comparison that found nothing returns the base bytes as they arrived. A
|
|
340
|
+
* change is only ever reported alongside the operations that realize it, so no
|
|
341
|
+
* operations means no changes, and re-serializing then rewrites a document
|
|
342
|
+
* nobody edited: on a 2,200-block pair that was a second of work to reproduce
|
|
343
|
+
* the input.
|
|
344
|
+
*
|
|
345
|
+
* Unless the base carried revisions of its own. Then the compared base was its
|
|
346
|
+
* accepted view, the arriving bytes are a different document, and handing them
|
|
347
|
+
* back would make rejecting the result land before the previous reviewer's
|
|
348
|
+
* edits rather than after them.
|
|
349
|
+
*
|
|
350
|
+
* The short-circuit lives here rather than in {@link compareDocx} so that
|
|
351
|
+
* every caller of the stages sees the same decision. Putting it in the
|
|
352
|
+
* composition let the benchmark's own composition disagree with the shipped
|
|
353
|
+
* one within a single run.
|
|
354
|
+
*/
|
|
355
|
+
const serializeComparison = async ({ baseBuffer, baseCarriedRevisions, reviewer, packageDate }, planned) => {
|
|
356
|
+
if (!baseCarriedRevisions && planned.every(({ plan }) => plan.operations.length === 0)) return Result.ok(baseBuffer);
|
|
357
|
+
const deletions = deletedFinalParagraphMarks(reviewer.toDocument());
|
|
358
|
+
const [firstDeletion] = deletions;
|
|
359
|
+
if (firstDeletion !== void 0) return Result.err(new CompareDocxFinalParagraphMarkError({
|
|
360
|
+
message: `A container's final paragraph mark carries a ${firstDeletion.kind}, which no consumer can resolve: ${firstDeletion.container} paragraph ${String(firstDeletion.paragraphIndex)}.`,
|
|
361
|
+
deletions
|
|
362
|
+
}));
|
|
363
|
+
return await Result.tryPromise({
|
|
364
|
+
try: async () => await withFixedPackageDates(await reviewer.toBuffer(), packageDate),
|
|
365
|
+
catch: (cause) => new CompareDocxSerializeError({
|
|
366
|
+
message: "The compared document could not be serialized.",
|
|
367
|
+
cause
|
|
368
|
+
})
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
/**
|
|
372
|
+
* Compare `base` against `target` and return `base` carrying the tracked
|
|
373
|
+
* changes that turn it into `target`, alongside the change list describing
|
|
374
|
+
* them.
|
|
375
|
+
*
|
|
376
|
+
* The result is verified by default: a redline whose round trip cannot be
|
|
377
|
+
* proven is refused rather than returned, because one that reads plausibly and
|
|
378
|
+
* is wrong is worse than none. `onUnverified: "emit"` asks for the opposite
|
|
379
|
+
* trade — the best redline available, plus the typed list of what could not be
|
|
380
|
+
* represented — for a caller that would rather show something and say what is
|
|
381
|
+
* missing.
|
|
382
|
+
*/
|
|
383
|
+
const compareDocx = async (base, target, options) => {
|
|
384
|
+
const parsed = await parseComparison(base, target, options);
|
|
385
|
+
if (parsed.isErr()) return Result.err(parsed.error);
|
|
386
|
+
const planned = planComparison(parsed.value);
|
|
387
|
+
if (planned.isErr()) return Result.err(planned.error);
|
|
388
|
+
const applied = applyComparison(parsed.value, planned.value);
|
|
389
|
+
if (applied.isErr()) return Result.err(applied.error);
|
|
390
|
+
const { changes, verification } = applied.value;
|
|
391
|
+
if (verification.status === "unverified" && (options.onUnverified ?? "refuse") === "refuse") {
|
|
392
|
+
const [firstFailure] = verification.failures;
|
|
393
|
+
if (firstFailure === void 0) panic("An unverified comparison reported no failing invariant");
|
|
394
|
+
return Result.err(new CompareDocxRoundTripError({
|
|
395
|
+
message: `The generated tracked changes do not satisfy ${firstFailure.invariant}: ${firstFailure.detail}`,
|
|
396
|
+
story: firstFailure.story,
|
|
397
|
+
invariant: firstFailure.invariant,
|
|
398
|
+
cause: firstFailure.cause,
|
|
399
|
+
failures: verification.failures
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
const serialized = await serializeComparison(parsed.value, planned.value);
|
|
403
|
+
if (serialized.isErr()) return Result.err(serialized.error);
|
|
404
|
+
return Result.ok({
|
|
405
|
+
buffer: serialized.value,
|
|
406
|
+
changes,
|
|
407
|
+
verification,
|
|
408
|
+
unsupported: parsed.value.unsupported
|
|
409
|
+
});
|
|
410
|
+
};
|
|
411
|
+
//#endregion
|
|
412
|
+
export { MAX_COMPARE_OPERATIONS, applyComparison, compareDocx, parseComparison, planComparison, serializeComparison };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FolioAIBlock, FolioAIInlineFormatting } from "../ai-edits/types.js";
|
|
2
|
+
//#region src/compare/formatting.d.ts
|
|
3
|
+
/** One run of characters whose supported inline formatting differs. */
|
|
4
|
+
type InlineFormattingSegment = {
|
|
5
|
+
/** Zero-based UTF-16 offset into the block's visible text. */
|
|
6
|
+
startOffset: number;
|
|
7
|
+
endOffset: number;
|
|
8
|
+
/** Only the properties that differ, set to the target document's value. */
|
|
9
|
+
formatting: FolioAIInlineFormatting;
|
|
10
|
+
};
|
|
11
|
+
type InlineFormattingSegmentsOptions = {
|
|
12
|
+
baseBlock: FolioAIBlock;
|
|
13
|
+
targetBlock: FolioAIBlock;
|
|
14
|
+
/** Refuse (return `null`) rather than build more segments than this. */
|
|
15
|
+
maxSegments: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Segments where `targetBlock`'s bold / italic / underline / strike differs from
|
|
19
|
+
* `baseBlock`'s, for two blocks that carry the same text. Returns `null` only
|
|
20
|
+
* when the diff would exceed `maxSegments`.
|
|
21
|
+
*
|
|
22
|
+
* A block whose runs cannot be aligned to its text reports no segments rather
|
|
23
|
+
* than guessing: attributing formatting to the wrong characters is worse than
|
|
24
|
+
* missing a formatting-only change, and the caller has no offset it could
|
|
25
|
+
* trust instead.
|
|
26
|
+
*/
|
|
27
|
+
declare const inlineFormattingSegments: ({ baseBlock, targetBlock, maxSegments }: InlineFormattingSegmentsOptions) => InlineFormattingSegment[] | null;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { InlineFormattingSegment, inlineFormattingSegments };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
//#region src/compare/formatting.ts
|
|
2
|
+
const changedSupportedFormatting = (base, target) => ({
|
|
3
|
+
...Boolean(base.bold) !== Boolean(target.bold) && { bold: Boolean(target.bold) },
|
|
4
|
+
...Boolean(base.italic) !== Boolean(target.italic) && { italic: Boolean(target.italic) },
|
|
5
|
+
...Boolean(base.underline) !== Boolean(target.underline) && { underline: Boolean(target.underline) },
|
|
6
|
+
...Boolean(base.strike) !== Boolean(target.strike) && { strike: Boolean(target.strike) }
|
|
7
|
+
});
|
|
8
|
+
const sameInlineFormatting = (left, right) => left.bold === right.bold && left.italic === right.italic && left.underline === right.underline && left.strike === right.strike;
|
|
9
|
+
const hasInlineFormatting = (formatting) => formatting.bold !== void 0 || formatting.italic !== void 0 || formatting.underline !== void 0 || formatting.strike !== void 0;
|
|
10
|
+
/**
|
|
11
|
+
* A block's runs, or `null` when they cannot describe the block's text.
|
|
12
|
+
* Non-text inline content (a field, an image) leaves the concatenated run text
|
|
13
|
+
* shorter than the block text; attributing formatting by offset would then
|
|
14
|
+
* point at the wrong characters, so the caller must back off instead.
|
|
15
|
+
*/
|
|
16
|
+
const previewRunsForBlock = (block) => {
|
|
17
|
+
const runs = block.previewRuns ?? [{ text: block.text }];
|
|
18
|
+
return runs.map(({ text }) => text).join("") === block.text ? runs : null;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Segments where `targetBlock`'s bold / italic / underline / strike differs from
|
|
22
|
+
* `baseBlock`'s, for two blocks that carry the same text. Returns `null` only
|
|
23
|
+
* when the diff would exceed `maxSegments`.
|
|
24
|
+
*
|
|
25
|
+
* A block whose runs cannot be aligned to its text reports no segments rather
|
|
26
|
+
* than guessing: attributing formatting to the wrong characters is worse than
|
|
27
|
+
* missing a formatting-only change, and the caller has no offset it could
|
|
28
|
+
* trust instead.
|
|
29
|
+
*/
|
|
30
|
+
const inlineFormattingSegments = ({ baseBlock, targetBlock, maxSegments }) => {
|
|
31
|
+
const baseRuns = previewRunsForBlock(baseBlock);
|
|
32
|
+
const targetRuns = previewRunsForBlock(targetBlock);
|
|
33
|
+
if (!baseRuns || !targetRuns || baseBlock.text.length === 0) return [];
|
|
34
|
+
const segments = [];
|
|
35
|
+
let baseRunIndex = 0;
|
|
36
|
+
let targetRunIndex = 0;
|
|
37
|
+
let baseRunOffset = 0;
|
|
38
|
+
let targetRunOffset = 0;
|
|
39
|
+
let textOffset = 0;
|
|
40
|
+
while (baseRunIndex < baseRuns.length && targetRunIndex < targetRuns.length) {
|
|
41
|
+
const baseRun = baseRuns[baseRunIndex];
|
|
42
|
+
const targetRun = targetRuns[targetRunIndex];
|
|
43
|
+
if (!baseRun || !targetRun) break;
|
|
44
|
+
const length = Math.min(baseRun.text.length - baseRunOffset, targetRun.text.length - targetRunOffset);
|
|
45
|
+
if (length <= 0) {
|
|
46
|
+
if (baseRunOffset >= baseRun.text.length) {
|
|
47
|
+
baseRunIndex++;
|
|
48
|
+
baseRunOffset = 0;
|
|
49
|
+
}
|
|
50
|
+
if (targetRunOffset >= targetRun.text.length) {
|
|
51
|
+
targetRunIndex++;
|
|
52
|
+
targetRunOffset = 0;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const formatting = changedSupportedFormatting(baseRun, targetRun);
|
|
57
|
+
if (hasInlineFormatting(formatting)) {
|
|
58
|
+
const previous = segments.at(-1);
|
|
59
|
+
if (previous && previous.endOffset === textOffset && sameInlineFormatting(previous.formatting, formatting)) previous.endOffset += length;
|
|
60
|
+
else {
|
|
61
|
+
if (segments.length >= maxSegments) return null;
|
|
62
|
+
segments.push({
|
|
63
|
+
startOffset: textOffset,
|
|
64
|
+
endOffset: textOffset + length,
|
|
65
|
+
formatting
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
textOffset += length;
|
|
70
|
+
baseRunOffset += length;
|
|
71
|
+
targetRunOffset += length;
|
|
72
|
+
if (baseRunOffset >= baseRun.text.length) {
|
|
73
|
+
baseRunIndex++;
|
|
74
|
+
baseRunOffset = 0;
|
|
75
|
+
}
|
|
76
|
+
if (targetRunOffset >= targetRun.text.length) {
|
|
77
|
+
targetRunIndex++;
|
|
78
|
+
targetRunOffset = 0;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return segments;
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { inlineFormattingSegments };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { FolioAIEditOperation, FolioAIEditSnapshot } from "../ai-edits/types.js";
|
|
2
|
+
import { FolioDocumentStoryHandle } from "../ai-edits/headless.js";
|
|
3
|
+
import { CompareChange } from "./types.js";
|
|
4
|
+
//#region src/compare/plan.d.ts
|
|
5
|
+
type CompareStoryPlan = {
|
|
6
|
+
changes: CompareChange[];
|
|
7
|
+
operations: FolioAIEditOperation[];
|
|
8
|
+
};
|
|
9
|
+
type PlanStoryCompareOptions = {
|
|
10
|
+
story: FolioDocumentStoryHandle;
|
|
11
|
+
baseSnapshot: FolioAIEditSnapshot;
|
|
12
|
+
targetSnapshot: FolioAIEditSnapshot;
|
|
13
|
+
/** Cap on generated operations; the caller turns `null` into its own error. */
|
|
14
|
+
maxOperations: number;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Plan one story's comparison, or `null` when it needs more operations than
|
|
18
|
+
* `maxOperations`.
|
|
19
|
+
*/
|
|
20
|
+
declare const planStoryCompare: ({ story, baseSnapshot, targetSnapshot, maxOperations }: PlanStoryCompareOptions) => CompareStoryPlan | null;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { CompareStoryPlan, PlanStoryCompareOptions, planStoryCompare };
|