@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 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 helpers for server-side use (block-id derivation, document creation, re-zip) |
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. Runs unconditionally: the underlying command also
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. Runs unconditionally: the underlying command also
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.countTrackedChanges();
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.countTrackedChanges();
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];
@@ -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
  /**
@@ -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;
@@ -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 countDocumentSections = (xml) => Array.from(xml.matchAll(/<w:sectPr\b/gu)).length;
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 >= 100) return { status: "unsupported" };
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
@@ -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: 128 * MEBIBYTE,
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 inline revision marks
42
- * carrying any of the given AI-edit `revisionIds`. Returns null when
43
- * none of those marks are present (already accepted/rejected, or
44
- * never existed). A replace operation typically passes two ids (one
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 inline revision marks
458
- * carrying any of the given AI-edit `revisionIds`. Returns null when
459
- * none of those marks are present (already accepted/rejected, or
460
- * never existed). A replace operation typically passes two ids (one
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
- const start = pos;
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
- const start = pos;
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
- const start = pos;
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
- const start = pos;
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.17.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.13.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",