@stll/folio-core 0.17.0 → 0.18.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/README.md +24 -1
- package/dist/ai-edits/headless.d.ts +1 -13
- package/dist/ai-edits/headless.js +3 -32
- package/dist/ai-edits/read.d.ts +2 -1
- package/dist/ai-edits/read.js +19 -0
- package/dist/docx/rezip.js +24 -2
- package/dist/docx/streamingXmlParser.js +2 -1
- package/dist/docx/unzip.js +2 -1
- package/dist/docx/xmlResourceLimits.d.ts +27 -0
- package/dist/docx/xmlResourceLimits.js +108 -0
- package/dist/layout-painter/renderParagraph.js +2 -1
- package/dist/prosemirror/commands/comments.d.ts +4 -6
- package/dist/prosemirror/commands/comments.js +14 -22
- package/dist/prosemirror/revisionCarriers.d.ts +19 -0
- package/dist/prosemirror/revisionCarriers.js +105 -0
- package/dist/redline.d.ts +5 -0
- package/dist/redline.js +90 -1
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -29,7 +29,8 @@ bun add @stll/folio-core
|
|
|
29
29
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
30
30
|
| `@stll/folio-core` | the headless public API — `createEmptyDocument`, `createDocx`, the document model, AI-suggestion primitives, ProseMirror plugins |
|
|
31
31
|
| `@stll/folio-core/markdown` | DOCX ↔ Markdown conversion |
|
|
32
|
-
| `@stll/folio-core/server` | DOM-free
|
|
32
|
+
| `@stll/folio-core/server` | DOM-free document review, explicit tracked edits, comparison, creation, and package helpers |
|
|
33
|
+
| `@stll/folio-core/redline` | Compare two `.docx` buffers and generate a native Word redline |
|
|
33
34
|
| `@stll/folio-core/*` | the source-mirrored module tree (e.g. `@stll/folio-core/types/document`, `@stll/folio-core/prosemirror/schema`) for adapters that need lower-level building blocks |
|
|
34
35
|
|
|
35
36
|
## New documents and reusable style sets
|
|
@@ -71,6 +72,28 @@ Extraction excludes document content, metadata, relationships, media, comments,
|
|
|
71
72
|
and revision data. It keeps only the selected styles and the numbering, theme,
|
|
72
73
|
font-table, and settings data required to reproduce their formatting.
|
|
73
74
|
|
|
75
|
+
## Native Word redlines
|
|
76
|
+
|
|
77
|
+
Generate a reviewable `.docx` whose text and supported inline-formatting
|
|
78
|
+
differences are native tracked changes:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { generateRedlineDocx } from "@stll/folio-core/redline";
|
|
82
|
+
|
|
83
|
+
const result = await generateRedlineDocx(originalDocx, revisedDocx, {
|
|
84
|
+
author: "Reviewer",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await store(result.buffer);
|
|
88
|
+
console.log(result.applied, result.skipped, result.unprocessedStories);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
For deterministic operations against one document, use `FolioDocxReviewer`
|
|
92
|
+
from `@stll/folio-core/server`. Its operations default to tracked changes and
|
|
93
|
+
can be enumerated, accepted, or rejected before saving. `getChanges()` includes
|
|
94
|
+
inline edits, formatting, paragraph marks, and paragraph, section, table, row,
|
|
95
|
+
and cell property changes.
|
|
96
|
+
|
|
74
97
|
## License
|
|
75
98
|
|
|
76
99
|
[Apache-2.0](./LICENSE)
|
|
@@ -275,10 +275,7 @@ declare class FolioDocxReviewer {
|
|
|
275
275
|
rejectChange(target: FolioReviewChange | number): boolean;
|
|
276
276
|
/**
|
|
277
277
|
* Accept every tracked change in the body. Returns the number of changes
|
|
278
|
-
* present before the sweep.
|
|
279
|
-
* resolves paragraph-level changes (`w:pPrChange`, paragraph-boundary
|
|
280
|
-
* ins/del) that {@link getChanges} does not enumerate. Note-body changes are
|
|
281
|
-
* out of scope.
|
|
278
|
+
* present before the sweep. Note-body changes are out of scope.
|
|
282
279
|
*/
|
|
283
280
|
acceptAll(): number;
|
|
284
281
|
/** Reject every tracked change in the body. See {@link acceptAll}. */
|
|
@@ -307,15 +304,6 @@ declare class FolioDocxReviewer {
|
|
|
307
304
|
private getHeaderFooterStoryText;
|
|
308
305
|
private getNoteStoryText;
|
|
309
306
|
private mergeEditedSecondaryStories;
|
|
310
|
-
/**
|
|
311
|
-
* Count every tracked change an accept-all / reject-all sweep resolves: the
|
|
312
|
-
* inline insertion / deletion groups {@link getChanges} enumerates, plus the
|
|
313
|
-
* property-change records living on node attrs rather than inline marks —
|
|
314
|
-
* paragraph-level (`pPrMark`, `_propertyChanges`, the inline sectPr's
|
|
315
|
-
* `propertyChanges`) and table-level (`tblPrChange` / `trPrChange` /
|
|
316
|
-
* `tcPrChange`).
|
|
317
|
-
*/
|
|
318
|
-
private countTrackedChanges;
|
|
319
307
|
/** Apply any {@link resolveComment} overrides recorded for these comments. */
|
|
320
308
|
private withResolvedOverrides;
|
|
321
309
|
/** Map each anchored comment id to its anchored text and containing block id. */
|
|
@@ -647,19 +647,16 @@ var FolioDocxReviewer = class FolioDocxReviewer {
|
|
|
647
647
|
}
|
|
648
648
|
/**
|
|
649
649
|
* Accept every tracked change in the body. Returns the number of changes
|
|
650
|
-
* present before the sweep.
|
|
651
|
-
* resolves paragraph-level changes (`w:pPrChange`, paragraph-boundary
|
|
652
|
-
* ins/del) that {@link getChanges} does not enumerate. Note-body changes are
|
|
653
|
-
* out of scope.
|
|
650
|
+
* present before the sweep. Note-body changes are out of scope.
|
|
654
651
|
*/
|
|
655
652
|
acceptAll() {
|
|
656
|
-
const count = this.
|
|
653
|
+
const count = this.getChanges().length;
|
|
657
654
|
this.runCommand(acceptAllChanges());
|
|
658
655
|
return count;
|
|
659
656
|
}
|
|
660
657
|
/** Reject every tracked change in the body. See {@link acceptAll}. */
|
|
661
658
|
rejectAll() {
|
|
662
|
-
const count = this.
|
|
659
|
+
const count = this.getChanges().length;
|
|
663
660
|
this.runCommand(rejectAllChanges());
|
|
664
661
|
return count;
|
|
665
662
|
}
|
|
@@ -823,32 +820,6 @@ var FolioDocxReviewer = class FolioDocxReviewer {
|
|
|
823
820
|
if (footnotes) document.package.footnotes = footnotes;
|
|
824
821
|
if (endnotes) document.package.endnotes = endnotes;
|
|
825
822
|
}
|
|
826
|
-
/**
|
|
827
|
-
* Count every tracked change an accept-all / reject-all sweep resolves: the
|
|
828
|
-
* inline insertion / deletion groups {@link getChanges} enumerates, plus the
|
|
829
|
-
* property-change records living on node attrs rather than inline marks —
|
|
830
|
-
* paragraph-level (`pPrMark`, `_propertyChanges`, the inline sectPr's
|
|
831
|
-
* `propertyChanges`) and table-level (`tblPrChange` / `trPrChange` /
|
|
832
|
-
* `tcPrChange`).
|
|
833
|
-
*/
|
|
834
|
-
countTrackedChanges() {
|
|
835
|
-
const hasEntries = (value) => Array.isArray(value) && value.length > 0;
|
|
836
|
-
let count = this.getChanges().length;
|
|
837
|
-
this.state.doc.descendants((node) => {
|
|
838
|
-
const typeName = node.type.name;
|
|
839
|
-
if (typeName === "table" && hasEntries(node.attrs["tblPrChange"])) count += 1;
|
|
840
|
-
if (typeName === "tableRow" && hasEntries(node.attrs["trPrChange"])) count += 1;
|
|
841
|
-
if ((typeName === "tableCell" || typeName === "tableHeader") && hasEntries(node.attrs["tcPrChange"])) count += 1;
|
|
842
|
-
if (typeName !== "paragraph") return;
|
|
843
|
-
const pPrMark = node.attrs["pPrMark"];
|
|
844
|
-
if (pPrMark !== null && pPrMark !== void 0) count += 1;
|
|
845
|
-
if (hasEntries(node.attrs["_propertyChanges"])) count += 1;
|
|
846
|
-
const sectionProperties = node.attrs["_sectionProperties"];
|
|
847
|
-
if (hasEntries(sectionProperties?.propertyChanges)) count += 1;
|
|
848
|
-
return false;
|
|
849
|
-
});
|
|
850
|
-
return count;
|
|
851
|
-
}
|
|
852
823
|
/** Apply any {@link resolveComment} overrides recorded for these comments. */
|
|
853
824
|
withResolvedOverrides(comments) {
|
|
854
825
|
if (this.resolvedOverrides.size === 0) return [...comments];
|
package/dist/ai-edits/read.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { FolioNodeRevisionKind } from "../prosemirror/revisionCarriers.js";
|
|
1
2
|
import { Node } from "prosemirror-model";
|
|
2
3
|
//#region src/ai-edits/read.d.ts
|
|
3
|
-
type FolioReviewChangeKind = "insertion" | "deletion" | "formatting" | "rowInserted" | "rowDeleted" | "cellInserted" | "cellDeleted" | "cellMerged";
|
|
4
|
+
type FolioReviewChangeKind = "insertion" | "deletion" | "formatting" | "rowInserted" | "rowDeleted" | "cellInserted" | "cellDeleted" | "cellMerged" | FolioNodeRevisionKind;
|
|
4
5
|
/** A tracked change discovered in the document body. */
|
|
5
6
|
type FolioReviewChange = {
|
|
6
7
|
/**
|
package/dist/ai-edits/read.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expectRunPropertyChangeMarkAttrs } from "../prosemirror/attrs/index.js";
|
|
2
|
+
import { getFolioNodeRevisionCarriers } from "../prosemirror/revisionCarriers.js";
|
|
2
3
|
import { getTableCellMergeChange } from "../prosemirror/tableCellMergeRevision.js";
|
|
3
4
|
import { createFolioAIEditSnapshot } from "./snapshot.js";
|
|
4
5
|
//#region src/ai-edits/read.ts
|
|
@@ -30,6 +31,24 @@ const getTrackedChangesFromDoc = (doc) => {
|
|
|
30
31
|
const structuralChangeCellPositions = /* @__PURE__ */ new Map();
|
|
31
32
|
let currentBlockId = null;
|
|
32
33
|
doc.descendants((node, pos) => {
|
|
34
|
+
const nodeRevisionCarriers = getFolioNodeRevisionCarriers(node, pos);
|
|
35
|
+
if (nodeRevisionCarriers.length > 0) {
|
|
36
|
+
const blockId = node.isTextblock ? blockStarts.get(pos) ?? null : firstBlockIdWithin({
|
|
37
|
+
node,
|
|
38
|
+
nodePos: pos,
|
|
39
|
+
blockStarts
|
|
40
|
+
});
|
|
41
|
+
nodeRevisionCarriers.forEach((carrier, carrierIndex) => {
|
|
42
|
+
grouped.set(`node:${String(pos)}:${carrier.type}:${String(carrier.id)}:${String(carrierIndex)}`, {
|
|
43
|
+
id: carrier.id,
|
|
44
|
+
type: carrier.type,
|
|
45
|
+
author: carrier.author,
|
|
46
|
+
date: carrier.date,
|
|
47
|
+
text: carrier.text,
|
|
48
|
+
blockId
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
33
52
|
if (node.type.name === "tableRow") for (const [attrName, kind] of [["trIns", "rowInserted"], ["trDel", "rowDeleted"]]) {
|
|
34
53
|
const marker = node.attrs[attrName];
|
|
35
54
|
if (typeof marker !== "object" || marker === null || !("revisionId" in marker) || typeof marker.revisionId !== "number") continue;
|
package/dist/docx/rezip.js
CHANGED
|
@@ -18,7 +18,8 @@ import { serializeStylesXml } from "./serializer/stylesSerializer.js";
|
|
|
18
18
|
import { serializeThemeXml } from "./serializer/themeSerializer.js";
|
|
19
19
|
import { escapeXml } from "./serializer/xmlUtils.js";
|
|
20
20
|
import { isPreservableDocxEntry } from "./unzip.js";
|
|
21
|
-
import { findChild, getChildElements, matchesName, parseXml } from "./xmlParser.js";
|
|
21
|
+
import { findChild, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml } from "./xmlParser.js";
|
|
22
|
+
import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
|
|
22
23
|
import { panic } from "better-result";
|
|
23
24
|
import JSZip from "jszip";
|
|
24
25
|
//#region src/docx/rezip.ts
|
|
@@ -69,7 +70,28 @@ function findMaxRId(relsXml) {
|
|
|
69
70
|
}
|
|
70
71
|
return maxId;
|
|
71
72
|
}
|
|
72
|
-
const
|
|
73
|
+
const WORDPROCESSINGML_NAMESPACES = /* @__PURE__ */ new Set(["http://schemas.openxmlformats.org/wordprocessingml/2006/main", "http://purl.oclc.org/ooxml/wordprocessingml/main"]);
|
|
74
|
+
const isWordprocessingElement = (element, localName) => getLocalName(element.name) === localName && WORDPROCESSINGML_NAMESPACES.has(getNamespaceUri(element) ?? "");
|
|
75
|
+
const countDocumentSections = (xml) => {
|
|
76
|
+
assertXmlResourceLimits(xml);
|
|
77
|
+
let count = 0;
|
|
78
|
+
const pending = [{
|
|
79
|
+
element: parseXml(xml),
|
|
80
|
+
insidePropertyChange: false
|
|
81
|
+
}];
|
|
82
|
+
while (pending.length > 0) {
|
|
83
|
+
const current = pending.pop();
|
|
84
|
+
if (!current) break;
|
|
85
|
+
const { element, insidePropertyChange } = current;
|
|
86
|
+
const isPropertyChange = isWordprocessingElement(element, "sectPrChange");
|
|
87
|
+
if (!insidePropertyChange && isWordprocessingElement(element, "sectPr")) count += 1;
|
|
88
|
+
for (const child of getChildElements(element)) pending.push({
|
|
89
|
+
element: child,
|
|
90
|
+
insidePropertyChange: insidePropertyChange || isPropertyChange
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return count;
|
|
94
|
+
};
|
|
73
95
|
const extractHeaderFooterReferences = (xml) => {
|
|
74
96
|
const references = [];
|
|
75
97
|
for (const match of xml.matchAll(/<w:(?<element>headerReference|footerReference)\b[^>]*>/gu)) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FOLIO_XML_RESOURCE_LIMITS } from "./xmlResourceLimits.js";
|
|
1
2
|
//#region src/docx/streamingXmlParser.ts
|
|
2
3
|
const BUILT_IN_ENTITIES = {
|
|
3
4
|
amp: "&",
|
|
@@ -59,7 +60,7 @@ const parseStreamingXml = (xml) => {
|
|
|
59
60
|
const parent = stack.at(-1)?.element ?? root;
|
|
60
61
|
appendElement(parent, parsedTag.element);
|
|
61
62
|
if (!parsedTag.selfClosing) {
|
|
62
|
-
if (stack.length >=
|
|
63
|
+
if (stack.length >= FOLIO_XML_RESOURCE_LIMITS.maxDepth) return { status: "unsupported" };
|
|
63
64
|
stack.push({
|
|
64
65
|
element: parsedTag.element,
|
|
65
66
|
name: parsedTag.name
|
package/dist/docx/unzip.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DOCX_CONTAINER_TYPES, detectDocxContainerType } from "./encryption/containerFormat.js";
|
|
2
2
|
import { openDocxBuffer } from "./encryption/openEncryptedDocx.js";
|
|
3
|
+
import { FOLIO_XML_RESOURCE_LIMITS } from "./xmlResourceLimits.js";
|
|
3
4
|
import JSZip from "jszip";
|
|
4
5
|
//#region src/docx/unzip.ts
|
|
5
6
|
/**
|
|
@@ -53,7 +54,7 @@ const MEBIBYTE = 1024 * 1024;
|
|
|
53
54
|
const DEFAULT_UNZIP_LIMITS = {
|
|
54
55
|
maxInputBytes: 50 * MEBIBYTE,
|
|
55
56
|
maxFiles: 5e3,
|
|
56
|
-
maxXmlBytes:
|
|
57
|
+
maxXmlBytes: FOLIO_XML_RESOURCE_LIMITS.maxBytes,
|
|
57
58
|
maxMediaBytes: 25 * MEBIBYTE,
|
|
58
59
|
maxFontBytes: 10 * MEBIBYTE,
|
|
59
60
|
maxTotalUncompressedBytes: 250 * MEBIBYTE,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/docx/xmlResourceLimits.d.ts
|
|
2
|
+
/** Shared bounds for XML parts parsed by Folio. */
|
|
3
|
+
declare const FOLIO_XML_RESOURCE_LIMITS: {
|
|
4
|
+
readonly maxBytes: number;
|
|
5
|
+
readonly maxDepth: 100;
|
|
6
|
+
readonly maxNodes: 1000000;
|
|
7
|
+
};
|
|
8
|
+
type XmlResourceLimits = {
|
|
9
|
+
maxBytes: number;
|
|
10
|
+
maxDepth: number;
|
|
11
|
+
maxNodes: number;
|
|
12
|
+
};
|
|
13
|
+
type XmlResourceLimitKind = "bytes" | "depth" | "nodes" | "syntax";
|
|
14
|
+
declare const XmlResourceLimitError_base: import("better-result").TaggedErrorClass<"XmlResourceLimitError", {
|
|
15
|
+
message: string;
|
|
16
|
+
limit: XmlResourceLimitKind;
|
|
17
|
+
}>;
|
|
18
|
+
/** XML input exceeded a parser resource bound or could not be scanned safely. */
|
|
19
|
+
declare class XmlResourceLimitError extends XmlResourceLimitError_base {}
|
|
20
|
+
/**
|
|
21
|
+
* Bound XML bytes, element count, and nesting before building an object tree.
|
|
22
|
+
* The lexical scan is iterative, so deeply nested input cannot consume the JS
|
|
23
|
+
* call stack before the depth limit is enforced.
|
|
24
|
+
*/
|
|
25
|
+
declare const assertXmlResourceLimits: (xml: string, limits?: XmlResourceLimits) => void;
|
|
26
|
+
//#endregion
|
|
27
|
+
export { FOLIO_XML_RESOURCE_LIMITS, XmlResourceLimitError, assertXmlResourceLimits };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { TaggedError } from "better-result";
|
|
2
|
+
/** Shared bounds for XML parts parsed by Folio. */
|
|
3
|
+
const FOLIO_XML_RESOURCE_LIMITS = {
|
|
4
|
+
maxBytes: 128 * (1024 * 1024),
|
|
5
|
+
maxDepth: 100,
|
|
6
|
+
maxNodes: 1e6
|
|
7
|
+
};
|
|
8
|
+
/** XML input exceeded a parser resource bound or could not be scanned safely. */
|
|
9
|
+
var XmlResourceLimitError = class extends TaggedError("XmlResourceLimitError")() {};
|
|
10
|
+
const exceedsUtf8ByteLimit = (value, maxBytes) => {
|
|
11
|
+
let bytes = 0;
|
|
12
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
13
|
+
const codeUnit = value.charCodeAt(index);
|
|
14
|
+
if (codeUnit <= 127) bytes += 1;
|
|
15
|
+
else if (codeUnit <= 2047) bytes += 2;
|
|
16
|
+
else if (codeUnit >= 55296 && codeUnit <= 56319 && index + 1 < value.length && value.charCodeAt(index + 1) >= 56320 && value.charCodeAt(index + 1) <= 57343) {
|
|
17
|
+
bytes += 4;
|
|
18
|
+
index += 1;
|
|
19
|
+
} else bytes += 3;
|
|
20
|
+
if (bytes > maxBytes) return true;
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
const isXmlWhitespace = (code) => code === 9 || code === 10 || code === 13 || code === 32;
|
|
25
|
+
const findTagClose = (xml, start) => {
|
|
26
|
+
let quote = 0;
|
|
27
|
+
for (let cursor = start; cursor < xml.length; cursor += 1) {
|
|
28
|
+
const code = xml.charCodeAt(cursor);
|
|
29
|
+
if (quote !== 0) {
|
|
30
|
+
if (code === quote) quote = 0;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (code === 34 || code === 39) {
|
|
34
|
+
quote = code;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (code === 62) return cursor;
|
|
38
|
+
}
|
|
39
|
+
return -1;
|
|
40
|
+
};
|
|
41
|
+
const throwSyntaxLimit = () => {
|
|
42
|
+
throw new XmlResourceLimitError({
|
|
43
|
+
message: "XML resource preflight could not safely scan malformed markup",
|
|
44
|
+
limit: "syntax"
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Bound XML bytes, element count, and nesting before building an object tree.
|
|
49
|
+
* The lexical scan is iterative, so deeply nested input cannot consume the JS
|
|
50
|
+
* call stack before the depth limit is enforced.
|
|
51
|
+
*/
|
|
52
|
+
const assertXmlResourceLimits = (xml, limits = FOLIO_XML_RESOURCE_LIMITS) => {
|
|
53
|
+
if (exceedsUtf8ByteLimit(xml, limits.maxBytes)) throw new XmlResourceLimitError({
|
|
54
|
+
message: `XML part exceeds ${String(limits.maxBytes)} bytes`,
|
|
55
|
+
limit: "bytes"
|
|
56
|
+
});
|
|
57
|
+
let cursor = 0;
|
|
58
|
+
let depth = 0;
|
|
59
|
+
let nodes = 0;
|
|
60
|
+
while (cursor < xml.length) {
|
|
61
|
+
const open = xml.indexOf("<", cursor);
|
|
62
|
+
if (open === -1) break;
|
|
63
|
+
if (xml.startsWith("<!--", open)) {
|
|
64
|
+
const close = xml.indexOf("-->", open + 4);
|
|
65
|
+
if (close === -1) throwSyntaxLimit();
|
|
66
|
+
cursor = close + 3;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (xml.startsWith("<![CDATA[", open)) {
|
|
70
|
+
const close = xml.indexOf("]]>", open + 9);
|
|
71
|
+
if (close === -1) throwSyntaxLimit();
|
|
72
|
+
cursor = close + 3;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (xml.startsWith("<?", open)) {
|
|
76
|
+
const close = xml.indexOf("?>", open + 2);
|
|
77
|
+
if (close === -1) throwSyntaxLimit();
|
|
78
|
+
cursor = close + 2;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (xml.startsWith("<!", open)) throwSyntaxLimit();
|
|
82
|
+
const close = findTagClose(xml, open + 1);
|
|
83
|
+
if (close === -1) throwSyntaxLimit();
|
|
84
|
+
if (xml.charCodeAt(open + 1) === 47) {
|
|
85
|
+
if (depth === 0) throwSyntaxLimit();
|
|
86
|
+
depth -= 1;
|
|
87
|
+
cursor = close + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
nodes += 1;
|
|
91
|
+
if (nodes > limits.maxNodes) throw new XmlResourceLimitError({
|
|
92
|
+
message: `XML part contains more than ${String(limits.maxNodes)} elements`,
|
|
93
|
+
limit: "nodes"
|
|
94
|
+
});
|
|
95
|
+
let lastContent = close - 1;
|
|
96
|
+
while (lastContent > open && isXmlWhitespace(xml.charCodeAt(lastContent))) lastContent -= 1;
|
|
97
|
+
const elementDepth = depth + 1;
|
|
98
|
+
if (elementDepth > limits.maxDepth) throw new XmlResourceLimitError({
|
|
99
|
+
message: `XML part is nested deeper than ${String(limits.maxDepth)} elements`,
|
|
100
|
+
limit: "depth"
|
|
101
|
+
});
|
|
102
|
+
if (xml.charCodeAt(lastContent) !== 47) depth = elementDepth;
|
|
103
|
+
cursor = close + 1;
|
|
104
|
+
}
|
|
105
|
+
if (depth !== 0) throwSyntaxLimit();
|
|
106
|
+
};
|
|
107
|
+
//#endregion
|
|
108
|
+
export { FOLIO_XML_RESOURCE_LIMITS, XmlResourceLimitError, assertXmlResourceLimits };
|
|
@@ -1074,10 +1074,11 @@ function renderLine(block, line, alignment, doc, options) {
|
|
|
1074
1074
|
if (alignment === "justify" && options) {
|
|
1075
1075
|
if (!options.isLastLine || options.paragraphEndsWithLineBreak) {
|
|
1076
1076
|
const firstLineIndentPx = options.isFirstLine ? options.firstLineIndentPx ?? 0 : 0;
|
|
1077
|
+
const firstLinePositiveIndentPx = Math.max(0, firstLineIndentPx);
|
|
1077
1078
|
const firstLineHangingPx = Math.max(0, -firstLineIndentPx);
|
|
1078
1079
|
const hasVisibleListMarker = options.isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden;
|
|
1079
1080
|
const firstLineHangingExpansionPx = hasVisibleListMarker ? Math.min(firstLineHangingPx, Math.max(0, options.leftIndentPx ?? 0)) : firstLineHangingPx;
|
|
1080
|
-
const justifyCapacityPx = options.availableWidth + firstLineHangingExpansionPx;
|
|
1081
|
+
const justifyCapacityPx = options.availableWidth - firstLinePositiveIndentPx + firstLineHangingExpansionPx;
|
|
1081
1082
|
const overfullPx = line.width - justifyCapacityPx;
|
|
1082
1083
|
const shrinkableSpaces = countShrinkableSpaces(runsForLine.filter((run) => !isTextRun(run) || !isCollapsedLineEdgeSpaceRun(run)), options.context);
|
|
1083
1084
|
if (overfullPx > RIGHT_EDGE_EPSILON_PX && shrinkableSpaces > 0) {
|
|
@@ -38,12 +38,10 @@ declare function acceptAllChanges(): Command;
|
|
|
38
38
|
*/
|
|
39
39
|
declare function rejectAllChanges(): Command;
|
|
40
40
|
/**
|
|
41
|
-
* Find the document range covered by all
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* for its deletion side, one for its insertion side); inserts and
|
|
46
|
-
* standalone deletions and formatting changes pass a single id.
|
|
41
|
+
* Find the document range covered by all revision carriers with any of the
|
|
42
|
+
* given ids. Returns null when none are present (already accepted/rejected, or
|
|
43
|
+
* never existed). A replace operation typically passes two ids; standalone
|
|
44
|
+
* text, formatting, paragraph, section, and table revisions pass a single id.
|
|
47
45
|
*/
|
|
48
46
|
declare function findAIEditRevisionRange(state: EditorState, revisionIds: number | readonly number[]): {
|
|
49
47
|
from: number;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expectRunPropertyChangeMarkAttrs } from "../attrs/index.js";
|
|
2
2
|
import { textFormattingToMarks } from "../conversion/toProseDoc.js";
|
|
3
3
|
import { markStructuralChange } from "../extensions/features/ParagraphChangeTrackerExtension.js";
|
|
4
|
+
import { getFolioNodeRevisionCarriers } from "../revisionCarriers.js";
|
|
4
5
|
import { RUN_FORMATTING_MARK_NAMES } from "../runFormattingMarkNames.js";
|
|
5
6
|
import { getTableCellMergeChange } from "../tableCellMergeRevision.js";
|
|
6
7
|
import { paragraphRejectAttrPatch, paragraphRejectOriginalFormatting, sectionRejectProperties, tableCellRejectAttrPatch, tableRejectAttrPatch, tableRowRejectAttrPatch } from "./propertyChangeScope.js";
|
|
@@ -454,12 +455,10 @@ function rejectAllChanges() {
|
|
|
454
455
|
return (state, dispatch) => rejectChange(0, state.doc.content.size)(state, dispatch);
|
|
455
456
|
}
|
|
456
457
|
/**
|
|
457
|
-
* Find the document range covered by all
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* for its deletion side, one for its insertion side); inserts and
|
|
462
|
-
* standalone deletions and formatting changes pass a single id.
|
|
458
|
+
* Find the document range covered by all revision carriers with any of the
|
|
459
|
+
* given ids. Returns null when none are present (already accepted/rejected, or
|
|
460
|
+
* never existed). A replace operation typically passes two ids; standalone
|
|
461
|
+
* text, formatting, paragraph, section, and table revisions pass a single id.
|
|
463
462
|
*/
|
|
464
463
|
function findAIEditRevisionRange(state, revisionIds) {
|
|
465
464
|
const insertionType = state.schema.marks["insertion"];
|
|
@@ -470,14 +469,16 @@ function findAIEditRevisionRange(state, revisionIds) {
|
|
|
470
469
|
from: null,
|
|
471
470
|
to: null
|
|
472
471
|
};
|
|
472
|
+
const includeRange = (from, to) => {
|
|
473
|
+
if (range.from === null || from < range.from) range.from = from;
|
|
474
|
+
if (range.to === null || to > range.to) range.to = to;
|
|
475
|
+
};
|
|
473
476
|
state.doc.descendants((node, pos) => {
|
|
477
|
+
for (const carrier of getFolioNodeRevisionCarriers(node, pos)) if (idSet.has(carrier.id)) includeRange(carrier.from, carrier.to);
|
|
474
478
|
if (node.type.name === "tableRow") for (const attrName of ["trIns", "trDel"]) {
|
|
475
479
|
const marker = node.attrs[attrName];
|
|
476
480
|
if (isTableRowRevisionAttr(marker) && idSet.has(marker.revisionId)) {
|
|
477
|
-
|
|
478
|
-
const end = pos + node.nodeSize;
|
|
479
|
-
if (range.from === null || start < range.from) range.from = start;
|
|
480
|
-
if (range.to === null || end > range.to) range.to = end;
|
|
481
|
+
includeRange(pos, pos + node.nodeSize);
|
|
481
482
|
return false;
|
|
482
483
|
}
|
|
483
484
|
}
|
|
@@ -490,27 +491,18 @@ function findAIEditRevisionRange(state, revisionIds) {
|
|
|
490
491
|
return change !== null && idSet.has(change.info.id);
|
|
491
492
|
});
|
|
492
493
|
if (hasDirectRevision || hasCollapsedRevision) {
|
|
493
|
-
|
|
494
|
-
const end = pos + node.nodeSize;
|
|
495
|
-
if (range.from === null || start < range.from) range.from = start;
|
|
496
|
-
if (range.to === null || end > range.to) range.to = end;
|
|
494
|
+
includeRange(pos, pos + node.nodeSize);
|
|
497
495
|
return false;
|
|
498
496
|
}
|
|
499
497
|
}
|
|
500
498
|
if (!node.isInline) return;
|
|
501
499
|
for (const mark of node.marks) {
|
|
502
500
|
if (mark.type === runPropertyChangeType && expectRunPropertyChangeMarkAttrs(mark).changes.some((change) => idSet.has(change.info.id))) {
|
|
503
|
-
|
|
504
|
-
const end = pos + node.nodeSize;
|
|
505
|
-
if (range.from === null || start < range.from) range.from = start;
|
|
506
|
-
if (range.to === null || end > range.to) range.to = end;
|
|
501
|
+
includeRange(pos, pos + node.nodeSize);
|
|
507
502
|
break;
|
|
508
503
|
}
|
|
509
504
|
if ((mark.type === insertionType || mark.type === deletionType) && typeof mark.attrs["revisionId"] === "number" && idSet.has(mark.attrs["revisionId"])) {
|
|
510
|
-
|
|
511
|
-
const end = pos + node.nodeSize;
|
|
512
|
-
if (range.from === null || start < range.from) range.from = start;
|
|
513
|
-
if (range.to === null || end > range.to) range.to = end;
|
|
505
|
+
includeRange(pos, pos + node.nodeSize);
|
|
514
506
|
break;
|
|
515
507
|
}
|
|
516
508
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Node } from "prosemirror-model";
|
|
2
|
+
//#region src/prosemirror/revisionCarriers.d.ts
|
|
3
|
+
type FolioNodeRevisionKind = "paragraphMarkInserted" | "paragraphMarkDeleted" | "paragraphPropertiesChanged" | "sectionPropertiesChanged" | "tablePropertiesChanged" | "rowPropertiesChanged" | "cellPropertiesChanged";
|
|
4
|
+
type FolioNodeRevisionCarrier = {
|
|
5
|
+
id: number;
|
|
6
|
+
type: FolioNodeRevisionKind;
|
|
7
|
+
author: string;
|
|
8
|
+
date: string | null;
|
|
9
|
+
text: string;
|
|
10
|
+
from: number;
|
|
11
|
+
to: number;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Read revision records stored on ProseMirror node attributes and return the
|
|
15
|
+
* exact range the shared accept/reject command requires for each carrier.
|
|
16
|
+
*/
|
|
17
|
+
declare const getFolioNodeRevisionCarriers: (node: Node, nodePos: number) => FolioNodeRevisionCarrier[];
|
|
18
|
+
//#endregion
|
|
19
|
+
export { FolioNodeRevisionCarrier, FolioNodeRevisionKind, getFolioNodeRevisionCarriers };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//#region src/prosemirror/revisionCarriers.ts
|
|
2
|
+
const isObjectRecord = (value) => typeof value === "object" && value !== null;
|
|
3
|
+
const revisionMetadata = (value) => {
|
|
4
|
+
if (!isObjectRecord(value) || typeof value["id"] !== "number") return null;
|
|
5
|
+
return {
|
|
6
|
+
id: value["id"],
|
|
7
|
+
author: typeof value["author"] === "string" ? value["author"] : "",
|
|
8
|
+
date: typeof value["date"] === "string" ? value["date"] : null
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
const appendPropertyCarriers = ({ carriers, changes, type, node, from, to }) => {
|
|
12
|
+
if (!Array.isArray(changes)) return;
|
|
13
|
+
let text = null;
|
|
14
|
+
for (const change of changes) {
|
|
15
|
+
if (!isObjectRecord(change)) continue;
|
|
16
|
+
const metadata = revisionMetadata(change["info"]);
|
|
17
|
+
if (!metadata) continue;
|
|
18
|
+
text ??= node.textContent;
|
|
19
|
+
carriers.push({
|
|
20
|
+
...metadata,
|
|
21
|
+
type,
|
|
22
|
+
text,
|
|
23
|
+
from,
|
|
24
|
+
to
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Read revision records stored on ProseMirror node attributes and return the
|
|
30
|
+
* exact range the shared accept/reject command requires for each carrier.
|
|
31
|
+
*/
|
|
32
|
+
const getFolioNodeRevisionCarriers = (node, nodePos) => {
|
|
33
|
+
const carriers = [];
|
|
34
|
+
if (node.type.name === "paragraph") {
|
|
35
|
+
const from = nodePos + node.nodeSize - 1;
|
|
36
|
+
const to = nodePos + node.nodeSize;
|
|
37
|
+
const paragraphMark = node.attrs["pPrMark"];
|
|
38
|
+
if (isObjectRecord(paragraphMark) && (paragraphMark["kind"] === "ins" || paragraphMark["kind"] === "del")) {
|
|
39
|
+
const metadata = revisionMetadata(paragraphMark["info"]);
|
|
40
|
+
if (metadata) carriers.push({
|
|
41
|
+
...metadata,
|
|
42
|
+
type: paragraphMark["kind"] === "ins" ? "paragraphMarkInserted" : "paragraphMarkDeleted",
|
|
43
|
+
text: node.textContent,
|
|
44
|
+
from,
|
|
45
|
+
to
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
appendPropertyCarriers({
|
|
49
|
+
carriers,
|
|
50
|
+
changes: node.attrs["_propertyChanges"],
|
|
51
|
+
type: "paragraphPropertiesChanged",
|
|
52
|
+
node,
|
|
53
|
+
from,
|
|
54
|
+
to
|
|
55
|
+
});
|
|
56
|
+
const sectionProperties = node.attrs["_sectionProperties"];
|
|
57
|
+
appendPropertyCarriers({
|
|
58
|
+
carriers,
|
|
59
|
+
changes: isObjectRecord(sectionProperties) ? sectionProperties["propertyChanges"] : null,
|
|
60
|
+
type: "sectionPropertiesChanged",
|
|
61
|
+
node,
|
|
62
|
+
from,
|
|
63
|
+
to
|
|
64
|
+
});
|
|
65
|
+
return carriers;
|
|
66
|
+
}
|
|
67
|
+
const range = {
|
|
68
|
+
from: nodePos,
|
|
69
|
+
to: nodePos + node.nodeSize
|
|
70
|
+
};
|
|
71
|
+
switch (node.type.name) {
|
|
72
|
+
case "table":
|
|
73
|
+
appendPropertyCarriers({
|
|
74
|
+
carriers,
|
|
75
|
+
changes: node.attrs["tblPrChange"],
|
|
76
|
+
type: "tablePropertiesChanged",
|
|
77
|
+
node,
|
|
78
|
+
...range
|
|
79
|
+
});
|
|
80
|
+
break;
|
|
81
|
+
case "tableRow":
|
|
82
|
+
appendPropertyCarriers({
|
|
83
|
+
carriers,
|
|
84
|
+
changes: node.attrs["trPrChange"],
|
|
85
|
+
type: "rowPropertiesChanged",
|
|
86
|
+
node,
|
|
87
|
+
...range
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
case "tableCell":
|
|
91
|
+
case "tableHeader":
|
|
92
|
+
appendPropertyCarriers({
|
|
93
|
+
carriers,
|
|
94
|
+
changes: node.attrs["tcPrChange"],
|
|
95
|
+
type: "cellPropertiesChanged",
|
|
96
|
+
node,
|
|
97
|
+
...range
|
|
98
|
+
});
|
|
99
|
+
break;
|
|
100
|
+
default: break;
|
|
101
|
+
}
|
|
102
|
+
return carriers;
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
export { getFolioNodeRevisionCarriers };
|
package/dist/redline.d.ts
CHANGED
|
@@ -18,10 +18,15 @@ declare const InvalidGenerateRedlineDocxOptionsError_base: import("better-result
|
|
|
18
18
|
option: "baseView" | "revisedView";
|
|
19
19
|
receivedValue: unknown;
|
|
20
20
|
}>;
|
|
21
|
+
/** Raised when a resolved input view is not `"original"` or `"final"`. */
|
|
21
22
|
declare class InvalidGenerateRedlineDocxOptionsError extends InvalidGenerateRedlineDocxOptionsError_base {}
|
|
23
|
+
/** A document story that could not be paired across the two input packages. */
|
|
22
24
|
type GenerateRedlineUnprocessedStory = {
|
|
25
|
+
/** Story in the base package, or `null` when it exists only in the revision. */
|
|
23
26
|
baseStory: FolioDocumentStoryHandle | null;
|
|
27
|
+
/** Story in the revised package, or `null` when it exists only in the base. */
|
|
24
28
|
revisedStory: FolioDocumentStoryHandle | null;
|
|
29
|
+
/** Why the story could not be represented in the generated redline. */
|
|
25
30
|
reason: "missing-base-story" | "missing-revised-story";
|
|
26
31
|
};
|
|
27
32
|
/** Result of {@link generateRedlineDocx}. */
|
package/dist/redline.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FolioDocxReviewer, isFolioResolvedReviewedView } from "./ai-edits/headless.js";
|
|
2
|
+
import { createFolioAITextRangeHandle } from "./ai-edits/snapshot.js";
|
|
2
3
|
import "./document-operations.js";
|
|
3
4
|
import { pairFolioDocumentStories } from "./document-stories.js";
|
|
4
5
|
import { resolveFolioDocumentPrivacyTransforms, rewriteDocxMetadataPrivacy } from "./docx/metadataPrivacy.js";
|
|
@@ -7,13 +8,16 @@ import { TaggedError, panic } from "better-result";
|
|
|
7
8
|
//#region src/redline.ts
|
|
8
9
|
/**
|
|
9
10
|
* Compare two `.docx` buffers and produce a third buffer whose text
|
|
10
|
-
* differences are represented as tracked changes.
|
|
11
|
+
* and supported inline-formatting differences are represented as tracked changes.
|
|
11
12
|
*
|
|
12
13
|
* Each matched editable story is processed independently through the shared
|
|
13
14
|
* block alignment and document-operation paths. Package parts that exist on
|
|
14
15
|
* only one side are reported because creating or removing those parts is a
|
|
15
16
|
* distinct package-level operation.
|
|
17
|
+
*
|
|
18
|
+
* @packageDocumentation
|
|
16
19
|
*/
|
|
20
|
+
/** Raised when a resolved input view is not `"original"` or `"final"`. */
|
|
17
21
|
var InvalidGenerateRedlineDocxOptionsError = class extends TaggedError("InvalidGenerateRedlineDocxOptionsError")() {};
|
|
18
22
|
const nextBaseBlockIdByIndex = (events) => {
|
|
19
23
|
const nextIds = Array.from({ length: events.length });
|
|
@@ -26,6 +30,86 @@ const nextBaseBlockIdByIndex = (events) => {
|
|
|
26
30
|
}
|
|
27
31
|
return nextIds;
|
|
28
32
|
};
|
|
33
|
+
const changedSupportedFormatting = (base, revised) => ({
|
|
34
|
+
...Boolean(base.bold) !== Boolean(revised.bold) && { bold: Boolean(revised.bold) },
|
|
35
|
+
...Boolean(base.italic) !== Boolean(revised.italic) && { italic: Boolean(revised.italic) },
|
|
36
|
+
...Boolean(base.underline) !== Boolean(revised.underline) && { underline: Boolean(revised.underline) }
|
|
37
|
+
});
|
|
38
|
+
const sameInlineFormatting = (left, right) => left.bold === right.bold && left.italic === right.italic && left.underline === right.underline;
|
|
39
|
+
const hasInlineFormatting = (formatting) => formatting.bold !== void 0 || formatting.italic !== void 0 || formatting.underline !== void 0;
|
|
40
|
+
const previewRunsForBlock = (block) => {
|
|
41
|
+
const runs = block.previewRuns ?? [{ text: block.text }];
|
|
42
|
+
return runs.map(({ text }) => text).join("") === block.text ? runs : null;
|
|
43
|
+
};
|
|
44
|
+
const formattingSegments = (baseBlock, revisedBlock) => {
|
|
45
|
+
const baseRuns = previewRunsForBlock(baseBlock);
|
|
46
|
+
const revisedRuns = previewRunsForBlock(revisedBlock);
|
|
47
|
+
if (!baseRuns || !revisedRuns || baseBlock.text.length === 0) return [];
|
|
48
|
+
const segments = [];
|
|
49
|
+
let baseRunIndex = 0;
|
|
50
|
+
let revisedRunIndex = 0;
|
|
51
|
+
let baseRunOffset = 0;
|
|
52
|
+
let revisedRunOffset = 0;
|
|
53
|
+
let textOffset = 0;
|
|
54
|
+
while (baseRunIndex < baseRuns.length && revisedRunIndex < revisedRuns.length) {
|
|
55
|
+
const baseRun = baseRuns[baseRunIndex];
|
|
56
|
+
const revisedRun = revisedRuns[revisedRunIndex];
|
|
57
|
+
if (!baseRun || !revisedRun) break;
|
|
58
|
+
const length = Math.min(baseRun.text.length - baseRunOffset, revisedRun.text.length - revisedRunOffset);
|
|
59
|
+
if (length <= 0) {
|
|
60
|
+
if (baseRunOffset >= baseRun.text.length) {
|
|
61
|
+
baseRunIndex++;
|
|
62
|
+
baseRunOffset = 0;
|
|
63
|
+
}
|
|
64
|
+
if (revisedRunOffset >= revisedRun.text.length) {
|
|
65
|
+
revisedRunIndex++;
|
|
66
|
+
revisedRunOffset = 0;
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const formatting = changedSupportedFormatting(baseRun, revisedRun);
|
|
71
|
+
if (hasInlineFormatting(formatting)) {
|
|
72
|
+
const previous = segments.at(-1);
|
|
73
|
+
if (previous && previous.endOffset === textOffset && sameInlineFormatting(previous.formatting, formatting)) previous.endOffset += length;
|
|
74
|
+
else segments.push({
|
|
75
|
+
startOffset: textOffset,
|
|
76
|
+
endOffset: textOffset + length,
|
|
77
|
+
formatting
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
textOffset += length;
|
|
81
|
+
baseRunOffset += length;
|
|
82
|
+
revisedRunOffset += length;
|
|
83
|
+
if (baseRunOffset >= baseRun.text.length) {
|
|
84
|
+
baseRunIndex++;
|
|
85
|
+
baseRunOffset = 0;
|
|
86
|
+
}
|
|
87
|
+
if (revisedRunOffset >= revisedRun.text.length) {
|
|
88
|
+
revisedRunIndex++;
|
|
89
|
+
revisedRunOffset = 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return segments;
|
|
93
|
+
};
|
|
94
|
+
const buildFormattingRedlineOperations = ({ baseBlock, revisedBlock, nextOperationId }) => {
|
|
95
|
+
const operations = [];
|
|
96
|
+
for (const { startOffset, endOffset, formatting } of formattingSegments(baseBlock, revisedBlock)) {
|
|
97
|
+
const range = createFolioAITextRangeHandle({
|
|
98
|
+
blockId: baseBlock.id,
|
|
99
|
+
text: baseBlock.text,
|
|
100
|
+
startOffset,
|
|
101
|
+
endOffset
|
|
102
|
+
});
|
|
103
|
+
if (!range) panic("An aligned formatting range could not be represented");
|
|
104
|
+
operations.push({
|
|
105
|
+
id: nextOperationId(),
|
|
106
|
+
type: "formatRange",
|
|
107
|
+
range,
|
|
108
|
+
formatting
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return operations;
|
|
112
|
+
};
|
|
29
113
|
const buildRedlineOperations = ({ baseSnapshot, revisedBlocks, nextOperationId }) => {
|
|
30
114
|
const events = alignFolioBlocks(baseSnapshot.blocks, revisedBlocks);
|
|
31
115
|
const anchorIds = nextBaseBlockIdByIndex(events);
|
|
@@ -40,6 +124,11 @@ const buildRedlineOperations = ({ baseSnapshot, revisedBlocks, nextOperationId }
|
|
|
40
124
|
blockId: event.baseBlock.id,
|
|
41
125
|
text: event.revisedBlock.text
|
|
42
126
|
});
|
|
127
|
+
else operations.push(...buildFormattingRedlineOperations({
|
|
128
|
+
baseBlock: event.baseBlock,
|
|
129
|
+
revisedBlock: event.revisedBlock,
|
|
130
|
+
nextOperationId
|
|
131
|
+
}));
|
|
43
132
|
return;
|
|
44
133
|
}
|
|
45
134
|
if (event.type === "baseOnly") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stll/folio-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"document-model",
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"ooxml",
|
|
10
10
|
"pagination",
|
|
11
11
|
"prosemirror",
|
|
12
|
+
"redline",
|
|
13
|
+
"tracked-changes",
|
|
12
14
|
"word"
|
|
13
15
|
],
|
|
14
16
|
"homepage": "https://github.com/stella/folio",
|
|
@@ -37,6 +39,10 @@
|
|
|
37
39
|
"types": "./dist/server.d.ts",
|
|
38
40
|
"import": "./dist/server.js"
|
|
39
41
|
},
|
|
42
|
+
"./redline": {
|
|
43
|
+
"types": "./dist/redline.d.ts",
|
|
44
|
+
"import": "./dist/redline.js"
|
|
45
|
+
},
|
|
40
46
|
"./ai-edits": {
|
|
41
47
|
"types": "./dist/ai-edits/index.d.ts",
|
|
42
48
|
"import": "./dist/ai-edits/index.js"
|
|
@@ -107,7 +113,7 @@
|
|
|
107
113
|
"perf": "bun scripts/profile-editor.ts"
|
|
108
114
|
},
|
|
109
115
|
"dependencies": {
|
|
110
|
-
"@stll/docx-core": "^0.
|
|
116
|
+
"@stll/docx-core": "^0.14.0",
|
|
111
117
|
"@stll/docx-utils": "^0.1.0",
|
|
112
118
|
"@stll/template-conditions": "^0.1.0",
|
|
113
119
|
"better-result": "2.10.0",
|