@stll/folio-core 0.33.1 → 0.33.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/dist/ai-edits/apply.d.ts +15 -0
  2. package/dist/ai-edits/apply.js +134 -25
  3. package/dist/ai-edits/headless.d.ts +47 -3
  4. package/dist/ai-edits/headless.js +53 -3
  5. package/dist/ai-edits/index.d.ts +1 -1
  6. package/dist/ai-edits/snapshot.d.ts +19 -1
  7. package/dist/ai-edits/snapshot.js +25 -3
  8. package/dist/ai-edits/table-geometry.d.ts +56 -0
  9. package/dist/ai-edits/table-geometry.js +217 -0
  10. package/dist/ai-edits/table-row-column-mutations.d.ts +34 -16
  11. package/dist/ai-edits/table-row-column-mutations.js +12 -4
  12. package/dist/ai-edits/table-template.d.ts +60 -0
  13. package/dist/ai-edits/table-template.js +176 -0
  14. package/dist/ai-edits/types.d.ts +7 -0
  15. package/dist/compare/__fixtures__/body-sequence.d.ts +61 -2
  16. package/dist/compare/__fixtures__/body-sequence.js +94 -6
  17. package/dist/compare/compare.d.ts +14 -7
  18. package/dist/compare/compare.js +136 -38
  19. package/dist/compare/plan.d.ts +31 -1
  20. package/dist/compare/plan.js +233 -66
  21. package/dist/compare/types.d.ts +9 -7
  22. package/dist/compare/types.js +7 -5
  23. package/dist/compare/verification.d.ts +31 -12
  24. package/dist/compare/verification.js +88 -17
  25. package/dist/compat/eigenpal.d.ts +4 -4
  26. package/dist/compat/eigenpal.js +2 -2
  27. package/dist/display-list/primitives.d.ts +1 -1
  28. package/dist/document-operations.d.ts +10 -1
  29. package/dist/document-operations.js +3 -2
  30. package/dist/docx/appVersionNormalization.d.ts +49 -0
  31. package/dist/docx/appVersionNormalization.js +74 -0
  32. package/dist/docx/blockContentParser.js +3 -2
  33. package/dist/docx/drawingUtils.js +4 -3
  34. package/dist/docx/paragraphParser.js +3 -2
  35. package/dist/docx/rezip.d.ts +10 -4
  36. package/dist/docx/rezip.js +54 -18
  37. package/dist/docx/runParser.js +9 -8
  38. package/dist/docx/sdtProperties.js +4 -3
  39. package/dist/docx/selectiveSave.js +7 -6
  40. package/dist/docx/serializer/commentSerializer.d.ts +1 -1
  41. package/dist/docx/serializer/commentSerializer.js +43 -19
  42. package/dist/docx/serializer/documentSerializer.d.ts +3 -1
  43. package/dist/docx/serializer/documentSerializer.js +42 -78
  44. package/dist/docx/serializer/fontTableSerializer.js +8 -5
  45. package/dist/docx/serializer/headerFooterSerializer.d.ts +4 -1
  46. package/dist/docx/serializer/headerFooterSerializer.js +34 -29
  47. package/dist/docx/serializer/noteSerializer.js +34 -30
  48. package/dist/docx/serializer/numberingSerializer.js +8 -4
  49. package/dist/docx/serializer/paragraphSerializer.js +3 -3
  50. package/dist/docx/serializer/partNamespaces.d.ts +78 -0
  51. package/dist/docx/serializer/partNamespaces.js +324 -0
  52. package/dist/docx/serializer/runSerializer.js +1 -1
  53. package/dist/docx/serializer/settingsSerializer.js +8 -2
  54. package/dist/docx/serializer/stylesSerializer.js +8 -5
  55. package/dist/docx/serializer/tableSerializer.js +83 -18
  56. package/dist/docx/serializer/themeSerializer.js +10 -2
  57. package/dist/docx/server/build.d.ts +1 -1
  58. package/dist/docx/strictValueEncodings.gen.d.ts +15 -0
  59. package/dist/docx/strictValueEncodings.gen.js +275 -0
  60. package/dist/docx/tableParser.d.ts +0 -6
  61. package/dist/docx/tableParser.js +52 -7
  62. package/dist/docx/transitionalSpelling.d.ts +23 -0
  63. package/dist/docx/transitionalSpelling.js +36 -0
  64. package/dist/docx/universalMeasure.d.ts +20 -0
  65. package/dist/docx/universalMeasure.js +32 -0
  66. package/dist/docx/verbatimCapture.d.ts +20 -0
  67. package/dist/docx/verbatimCapture.js +131 -0
  68. package/dist/docx/vmlImageParser.js +4 -3
  69. package/dist/docx/watermarkParser.js +4 -3
  70. package/dist/docx/xmlParser.d.ts +23 -2
  71. package/dist/docx/xmlParser.js +43 -11
  72. package/dist/index.d.ts +4 -4
  73. package/dist/index.js +2 -2
  74. package/dist/model.d.ts +3 -3
  75. package/dist/model.js +2 -2
  76. package/dist/prosemirror/commands/comments.js +2 -1
  77. package/dist/prosemirror/containerFinalParagraph.d.ts +47 -0
  78. package/dist/prosemirror/containerFinalParagraph.js +92 -0
  79. package/dist/prosemirror/conversion/fromProseDoc.d.ts +8 -1
  80. package/dist/prosemirror/conversion/fromProseDoc.js +15 -4
  81. package/dist/prosemirror/conversion/toProseDoc.js +12 -3
  82. package/dist/prosemirror/extensions/nodes/TableExtension.js +5 -0
  83. package/dist/prosemirror/schema/marks.d.ts +1 -1
  84. package/dist/prosemirror/schema/nodes.d.ts +22 -0
  85. package/dist/server.d.ts +1 -1
  86. package/dist/types/block-id.d.ts +12 -1
  87. package/dist/types/block-id.js +17 -1
  88. package/dist/utils/canonicalJson.d.ts +14 -0
  89. package/dist/utils/canonicalJson.js +19 -0
  90. package/dist/utils/formatToStyle.d.ts +1 -1
  91. package/package.json +2 -2
@@ -15,6 +15,8 @@ const WORDPROCESSING = "application/vnd.openxmlformats-officedocument.wordproces
15
15
  const MARKUP_COMPATIBILITY = "http://schemas.openxmlformats.org/markup-compatibility/2006";
16
16
  const PACKAGE_RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006";
17
17
  const CORE_PROPERTIES_TYPE = "application/vnd.openxmlformats-package.core-properties+xml";
18
+ const EXTENDED_PROPERTIES = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties";
19
+ const EXTENDED_PROPERTIES_TYPE = "application/vnd.openxmlformats-officedocument.extended-properties+xml";
18
20
  const WORDML_2010 = "http://schemas.microsoft.com/office/word/2010/wordml";
19
21
  /**
20
22
  * `createFolders: false` because JSZip stamps the folder entries it
@@ -24,6 +26,12 @@ const ZIP_ENTRY_OPTIONS = {
24
26
  date: new Date(Date.UTC(2e3, 0, 1)),
25
27
  createFolders: false
26
28
  };
29
+ const isRowOptions = (row) => "cells" in row;
30
+ const isCellSpec = (cell) => typeof cell === "object" && "content" in cell;
31
+ const rowCells = (row) => isRowOptions(row) ? row.cells : row;
32
+ const rowOptions = (row) => isRowOptions(row) ? row : {};
33
+ const cellContent = (cell) => isCellSpec(cell) ? cell.content : cell;
34
+ const cellProperties = (cell) => isCellSpec(cell) ? cell : {};
27
35
  /**
28
36
  * A paragraph's id is derived from its own content, not from its position.
29
37
  *
@@ -69,7 +77,10 @@ const collectHrefs = (items, hrefs) => {
69
77
  for (const inline of item.text) if (typeof inline !== "string" && !hrefs.includes(inline.href)) hrefs.push(inline.href);
70
78
  continue;
71
79
  }
72
- for (const row of item.rows) for (const cell of row) if (typeof cell !== "string") collectHrefs(cell, hrefs);
80
+ for (const row of item.rows) for (const cell of rowCells(row)) {
81
+ const content = cellContent(cell);
82
+ if (typeof content !== "string") collectHrefs(content, hrefs);
83
+ }
73
84
  }
74
85
  };
75
86
  const EMPTY_PARAGRAPH = {
@@ -89,14 +100,90 @@ const closedSequence = (items) => {
89
100
  /** A cell must also contain a paragraph, which the empty sequence supplies. */
90
101
  const cellXml = (content, context) => typeof content === "string" ? paragraph(content, context) : itemsXml(closedSequence(content), context);
91
102
  /** `w:tbl` is `w:tblPr, w:tblGrid, rows`: a fixture without the grid is not one. */
92
- const tableGrid = (rows) => {
103
+ const tableGrid = (item) => {
104
+ if (item.columnWidths) return `<w:tblGrid>${item.columnWidths.map((width) => `<w:gridCol w:w="${String(width)}"/>`).join("")}</w:tblGrid>`;
93
105
  let columns = 0;
94
- for (const cells of rows) columns = Math.max(columns, cells.length);
106
+ for (const row of item.rows) {
107
+ let spanned = 0;
108
+ for (const cell of rowCells(row)) spanned += cellProperties(cell).gridSpan ?? 1;
109
+ columns = Math.max(columns, spanned);
110
+ }
95
111
  return `<w:tblGrid>${`<w:gridCol w:w="2000"/>`.repeat(columns)}</w:tblGrid>`;
96
112
  };
113
+ const BORDER_SIDES = [
114
+ "top",
115
+ "left",
116
+ "bottom",
117
+ "right",
118
+ "insideH",
119
+ "insideV"
120
+ ];
121
+ const CELL_BORDER_SIDES = [
122
+ "top",
123
+ "left",
124
+ "bottom",
125
+ "right"
126
+ ];
127
+ const MARGIN_SIDES = [
128
+ "top",
129
+ "left",
130
+ "bottom",
131
+ "right"
132
+ ];
133
+ const borders = (element, sides, size) => `<w:${element}>${sides.map((side) => `<w:${side} w:val="single" w:sz="${String(size)}" w:space="0" w:color="000000"/>`).join("")}</w:${element}>`;
134
+ const margins = (element, value) => `<w:${element}>${MARGIN_SIDES.map((side) => `<w:${side} w:w="${String(value)}" w:type="dxa"/>`).join("")}</w:${element}>`;
135
+ /**
136
+ * `w:tblPr` children in the order CT_TblPrBase declares: tblStyle, tblW, jc,
137
+ * tblInd, tblBorders, shd, tblLayout, tblCellMar, tblLook. `w:tblW` is always
138
+ * written.
139
+ */
140
+ const tableProperties = ({ properties }) => {
141
+ const width = properties?.width ?? {
142
+ value: 0,
143
+ type: "auto"
144
+ };
145
+ return `<w:tblPr>${[
146
+ ...properties?.styleId === void 0 ? [] : [`<w:tblStyle w:val="${properties.styleId}"/>`],
147
+ `<w:tblW w:w="${String(width.value)}" w:type="${width.type}"/>`,
148
+ ...properties?.justification === void 0 ? [] : [`<w:jc w:val="${properties.justification}"/>`],
149
+ ...properties?.indent === void 0 ? [] : [`<w:tblInd w:w="${String(properties.indent)}" w:type="dxa"/>`],
150
+ ...properties?.borderSize === void 0 ? [] : [borders("tblBorders", BORDER_SIDES, properties.borderSize)],
151
+ ...properties?.shadingFill === void 0 ? [] : [`<w:shd w:val="clear" w:color="auto" w:fill="${properties.shadingFill}"/>`],
152
+ ...properties?.layout === void 0 ? [] : [`<w:tblLayout w:type="${properties.layout}"/>`],
153
+ ...properties?.cellMargin === void 0 ? [] : [margins("tblCellMar", properties.cellMargin)],
154
+ ...properties?.look === void 0 ? [] : [`<w:tblLook w:val="${properties.look}"/>`]
155
+ ].join("")}</w:tblPr>`;
156
+ };
157
+ /**
158
+ * `w:trPr` children. `CT_TrPrBase` is a repeated choice rather than a
159
+ * sequence, so the order here is the readable one rather than a required one.
160
+ */
161
+ const rowProperties = (row, hidden) => {
162
+ const { header, height, justification } = rowOptions(row);
163
+ const parts = [
164
+ ...height === void 0 ? [] : [`<w:trHeight w:val="${String(height)}"/>`],
165
+ ...header === true ? ["<w:tblHeader/>"] : [],
166
+ ...justification === void 0 ? [] : [`<w:jc w:val="${justification}"/>`],
167
+ ...hidden ? ["<w:hidden/>"] : []
168
+ ];
169
+ return parts.length === 0 ? "" : `<w:trPr>${parts.join("")}</w:trPr>`;
170
+ };
171
+ /** `w:tcPr` children in schema order; `w:tcW` is always written. */
172
+ const cellPropertiesXml = (cell) => {
173
+ const properties = cellProperties(cell);
174
+ return `<w:tcPr>${[
175
+ `<w:tcW w:w="${String(properties.width ?? 2e3)}" w:type="dxa"/>`,
176
+ ...properties.gridSpan === void 0 ? [] : [`<w:gridSpan w:val="${String(properties.gridSpan)}"/>`],
177
+ ...properties.verticalMerge === void 0 ? [] : [properties.verticalMerge === "restart" ? `<w:vMerge w:val="restart"/>` : `<w:vMerge/>`],
178
+ ...properties.borderSize === void 0 ? [] : [borders("tcBorders", CELL_BORDER_SIDES, properties.borderSize)],
179
+ ...properties.shadingFill === void 0 ? [] : [`<w:shd w:val="clear" w:color="auto" w:fill="${properties.shadingFill}"/>`],
180
+ ...properties.margin === void 0 ? [] : [margins("tcMar", properties.margin)],
181
+ ...properties.verticalAlign === void 0 ? [] : [`<w:vAlign w:val="${properties.verticalAlign}"/>`]
182
+ ].join("")}</w:tcPr>`;
183
+ };
97
184
  const table = (item, context) => {
98
185
  const hidden = new Set(item.hiddenRows ?? []);
99
- return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>` + tableGrid(item.rows) + item.rows.map((cells, rowIndex) => `<w:tr>${hidden.has(rowIndex) ? `<w:trPr><w:hidden/></w:trPr>` : ""}${cells.map((content) => `<w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr>${cellXml(content, context)}</w:tc>`).join("")}</w:tr>`).join("") + `</w:tbl>`;
186
+ return `<w:tbl>${tableProperties(item)}` + tableGrid(item) + item.rows.map((row, rowIndex) => `<w:tr>${rowProperties(row, hidden.has(rowIndex))}${rowCells(row).map((cell) => `<w:tc>${cellPropertiesXml(cell)}${cellXml(cellContent(cell), context)}</w:tc>`).join("")}</w:tr>`).join("") + `</w:tbl>`;
100
187
  };
101
188
  const itemsXml = (items, context) => items.map((item) => item.kind === "paragraph" ? paragraph(item.text, context, {
102
189
  ...item.styleId === void 0 ? {} : { styleId: item.styleId },
@@ -114,9 +201,10 @@ const buildBodySequenceDocx = async (items, { header } = {}) => {
114
201
  const linkRelationships = hrefs.map((href, index) => `<Relationship Id="rId${index + firstLinkRelationship}" Type="${OFFICE_RELATIONSHIPS}/hyperlink" Target="${href}" TargetMode="External"/>`).join("");
115
202
  const paraId = createParaIdAllocator();
116
203
  const parts = {
117
- "[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="${WORDPROCESSING}.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="${WORDPROCESSING}.styles+xml"/>` + (header ? `<Override PartName="/word/header1.xml" ContentType="${WORDPROCESSING}.header+xml"/>` : "") + `<Override PartName="/docProps/core.xml" ContentType="${CORE_PROPERTIES_TYPE}"/></Types>`,
118
- "_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="${PACKAGE_RELATIONSHIPS}/metadata/core-properties" Target="docProps/core.xml"/></Relationships>`,
204
+ "[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="${WORDPROCESSING}.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="${WORDPROCESSING}.styles+xml"/>` + (header ? `<Override PartName="/word/header1.xml" ContentType="${WORDPROCESSING}.header+xml"/>` : "") + `<Override PartName="/docProps/core.xml" ContentType="${CORE_PROPERTIES_TYPE}"/><Override PartName="/docProps/app.xml" ContentType="${EXTENDED_PROPERTIES_TYPE}"/></Types>`,
205
+ "_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/officeDocument" Target="word/document.xml"/><Relationship Id="rId2" Type="${PACKAGE_RELATIONSHIPS}/metadata/core-properties" Target="docProps/core.xml"/><Relationship Id="rId3" Type="${OFFICE_RELATIONSHIPS}/extended-properties" Target="docProps/app.xml"/></Relationships>`,
119
206
  "docProps/core.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="${PACKAGE_RELATIONSHIPS}/metadata/core-properties" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dcterms:created xsi:type="dcterms:W3CDTF">2000-01-01T00:00:00Z</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">2000-01-01T00:00:00Z</dcterms:modified></cp:coreProperties>`,
207
+ "docProps/app.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="${EXTENDED_PROPERTIES}"><AppVersion>1.0.0</AppVersion></Properties>`,
120
208
  "word/_rels/document.xml.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${RELATIONSHIPS}"><Relationship Id="rId1" Type="${OFFICE_RELATIONSHIPS}/styles" Target="styles.xml"/>` + (header ? `<Relationship Id="${HEADER_RELATIONSHIP_ID}" Type="${OFFICE_RELATIONSHIPS}/header" Target="header1.xml"/>` : "") + linkRelationships + `</Relationships>`,
121
209
  "word/styles.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="${NAMESPACE}"><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/></w:style></w:styles>`,
122
210
  "word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIPS}" xmlns:mc="${MARKUP_COMPATIBILITY}" xmlns:w14="${WORDML_2010}" mc:Ignorable="w14"><w:body>` + bodyXml(items, {
@@ -51,25 +51,32 @@ type PlannedStoryComparison = {
51
51
  * parsing, no serialization, no clock.
52
52
  */
53
53
  declare const planComparison: ({ pairs }: ParsedComparison) => Result<readonly PlannedStoryComparison[], CompareDocxOperationLimitError>;
54
- /** What stage 3 produced: the change list, and whether it was proven. */
54
+ /** What stage 3 produced: the change list, whether it was proven, and whether it wrote anything. */
55
55
  type AppliedComparison = {
56
56
  changes: readonly CompareChange[];
57
57
  verification: CompareVerification;
58
+ /**
59
+ * Whether the stage wrote into the base document at all. A comparison that
60
+ * found nothing writes nothing, and the serialize stage then hands the
61
+ * arriving bytes back rather than reproducing them.
62
+ */
63
+ documentChanged: boolean;
58
64
  };
59
65
  /**
60
66
  * Stage 3: write the planned operations into the base document as tracked
61
67
  * changes, then check the work rather than trust it. Both directions of the
62
- * round trip are checked, structure included: accepting the story's generated
63
- * revisions must reproduce the target, and rejecting them must reproduce the
64
- * base it was compared from. A difference the operation vocabulary cannot
65
- * express would otherwise leave a redline that reads plausibly and is wrong.
68
+ * round trip are checked, structure and table geometry included: accepting the
69
+ * story's generated revisions must reproduce the target, and rejecting them
70
+ * must reproduce the base it was compared from. A difference the operation
71
+ * vocabulary cannot express would otherwise leave a redline that reads
72
+ * plausibly and is wrong.
66
73
  *
67
74
  * The check reports rather than throws. {@link compareDocx} decides what to do
68
75
  * with an unverified result, because "give me your best attempt and tell me
69
76
  * what you could not represent" and "give me nothing unless you can prove it"
70
77
  * are both legitimate asks and only the caller knows which one it is making.
71
78
  */
72
- declare const applyComparison: ({ reviewer, revisionStamp, granularity, numberingChanges }: ParsedComparison, planned: readonly PlannedStoryComparison[]) => Result<AppliedComparison, CompareDocxApplyError>;
79
+ declare const applyComparison: ({ reviewer, targetReviewer, revisionStamp, granularity, numberingChanges }: ParsedComparison, planned: readonly PlannedStoryComparison[]) => Result<AppliedComparison, CompareDocxApplyError>;
73
80
  /**
74
81
  * Stage 4: the result package, with every ZIP entry date pinned.
75
82
  *
@@ -89,7 +96,7 @@ declare const applyComparison: ({ reviewer, revisionStamp, granularity, numberin
89
96
  * composition let the benchmark's own composition disagree with the shipped
90
97
  * one within a single run.
91
98
  */
92
- declare const serializeComparison: ({ baseBuffer, baseCarriedRevisions, reviewer, packageDate }: ParsedComparison, planned: readonly PlannedStoryComparison[]) => Promise<Result<ArrayBuffer, CompareDocxSerializeError | CompareDocxFinalParagraphMarkError>>;
99
+ declare const serializeComparison: ({ baseBuffer, baseCarriedRevisions, reviewer, packageDate, revisionStamp }: ParsedComparison, { documentChanged }: AppliedComparison) => Promise<Result<ArrayBuffer, CompareDocxSerializeError | CompareDocxFinalParagraphMarkError>>;
93
100
  /**
94
101
  * Compare `base` against `target` and return `base` carrying the tracked
95
102
  * changes that turn it into `target`, alongside the change list describing
@@ -1,10 +1,11 @@
1
1
  import { FolioDocxReviewer } from "../ai-edits/headless.js";
2
+ import { projectTableGeometry } from "../ai-edits/table-geometry.js";
2
3
  import "../document-operations.js";
3
4
  import { pairFolioDocumentStories } from "../document-stories.js";
4
5
  import { planStoryCompare } from "./plan.js";
5
6
  import { withFixedPackageDates } from "./reproducible-package.js";
6
7
  import { CompareDocxApplyError, CompareDocxFinalParagraphMarkError, CompareDocxOperationLimitError, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, InvalidCompareDocxOptionsError } from "./types.js";
7
- import { classifyProjectionMismatch, deletedFinalParagraphMarks, projectSupportedInlineFormatting } from "./verification.js";
8
+ import { classifyGeometryMismatch, classifyProjectionMismatch, projectSupportedInlineFormatting, revisedFinalParagraphMarks } from "./verification.js";
8
9
  import { Result, panic } from "better-result";
9
10
  //#region src/compare/compare.ts
10
11
  /**
@@ -151,6 +152,14 @@ const parseComparison = async (base, target, options) => {
151
152
  const reviewer = baseParse.value;
152
153
  const targetReviewer = targetParse.value;
153
154
  const existing = existingRevisionsOf(reviewer);
155
+ for (const { handle } of reviewer.listStories()) reviewer.resolveReviewedStory({
156
+ story: handle,
157
+ view: "final"
158
+ });
159
+ for (const { handle } of targetReviewer.listStories()) targetReviewer.resolveReviewedStory({
160
+ story: handle,
161
+ view: "final"
162
+ });
154
163
  const pairs = [];
155
164
  const unsupported = [];
156
165
  for (const { baseStory, revisedStory: targetStory } of pairFolioDocumentStories(reviewer.listStories().map(({ handle }) => handle), targetReviewer.listStories().map(({ handle }) => handle))) {
@@ -170,14 +179,6 @@ const parseComparison = async (base, target, options) => {
170
179
  });
171
180
  continue;
172
181
  }
173
- reviewer.resolveReviewedStory({
174
- story: baseStory,
175
- view: "final"
176
- });
177
- targetReviewer.resolveReviewedStory({
178
- story: targetStory,
179
- view: "final"
180
- });
181
182
  const baseSnapshot = reviewer.snapshotStory(baseStory);
182
183
  const targetSnapshot = targetReviewer.snapshotStory(targetStory);
183
184
  if (!baseSnapshot || !targetSnapshot) {
@@ -236,50 +237,126 @@ const planComparison = ({ pairs }) => {
236
237
  return Result.ok(planned);
237
238
  };
238
239
  /**
240
+ * What a skipped operation says about the plan that derived it.
241
+ *
242
+ * Two different things go wrong at apply time, and only one of them leaves the
243
+ * result unusable. A reason that says the plan did not match the document it
244
+ * was planned against is an engine defect — the operations came from this very
245
+ * snapshot moments earlier, so nothing should have moved under them, and a
246
+ * redline built on the rest is built on a document the plan no longer
247
+ * describes. A reason that says the applier had nothing to write, or could not
248
+ * write that shape THERE, leaves the redline standing and turns the question
249
+ * into "was anything lost", which is what the round-trip check answers a few
250
+ * lines below. Refusing on those instead trades a partial answer for none, and
251
+ * refuses a whole document because one paragraph sits inside a structure the
252
+ * block snapshot does not model — a text box, a content control — where a
253
+ * paragraph mark has nowhere to go.
254
+ */
255
+ const COMPARE_SKIP_DISPOSITION = {
256
+ missingBlock: "fatal",
257
+ changedBlock: "fatal",
258
+ ambiguousFind: "fatal",
259
+ missingFind: "fatal",
260
+ unsupportedBlock: "unwritable",
261
+ unsupportedMode: "fatal",
262
+ atomicBatchRejected: "fatal",
263
+ preconditionFailed: "fatal",
264
+ staleRange: "fatal",
265
+ emptyOperation: "unwritable",
266
+ noopOperation: "unwritable",
267
+ documentVersionMismatch: "fatal",
268
+ documentNotEditable: "fatal"
269
+ };
270
+ /**
271
+ * The table each `insertTable` / `insertTableRow` in the plan should place,
272
+ * resolved against the target document the plan named it in.
273
+ *
274
+ * The plan is pure and names a table by index; the node lives in the other
275
+ * package, which only this stage holds. A request naming a table the target
276
+ * does not have resolves to nothing and the operation falls back to its cell
277
+ * texts, which is the same redline the comparison produced before.
278
+ */
279
+ const resolveTableTemplates = (targetTables, requests) => {
280
+ const templates = /* @__PURE__ */ new Map();
281
+ for (const { operationId, targetTableIndex, targetRowIndex } of requests) {
282
+ const table = targetTables.get(targetTableIndex);
283
+ if (!table) continue;
284
+ if (targetRowIndex === void 0) {
285
+ templates.set(operationId, table);
286
+ continue;
287
+ }
288
+ const row = table.maybeChild(targetRowIndex);
289
+ if (row) templates.set(operationId, row);
290
+ }
291
+ return templates;
292
+ };
293
+ /**
239
294
  * Stage 3: write the planned operations into the base document as tracked
240
295
  * 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.
296
+ * round trip are checked, structure and table geometry included: accepting the
297
+ * story's generated revisions must reproduce the target, and rejecting them
298
+ * must reproduce the base it was compared from. A difference the operation
299
+ * vocabulary cannot express would otherwise leave a redline that reads
300
+ * plausibly and is wrong.
245
301
  *
246
302
  * The check reports rather than throws. {@link compareDocx} decides what to do
247
303
  * with an unverified result, because "give me your best attempt and tell me
248
304
  * what you could not represent" and "give me nothing unless you can prove it"
249
305
  * are both legitimate asks and only the caller knows which one it is making.
250
306
  */
251
- const applyComparison = ({ reviewer, revisionStamp, granularity, numberingChanges }, planned) => {
307
+ const applyComparison = ({ reviewer, targetReviewer, revisionStamp, granularity, numberingChanges }, planned) => {
252
308
  const changes = [...numberingChanges];
253
309
  const failures = [];
254
310
  let idSeed = revisionStamp.idSeed;
311
+ let documentChanged = false;
255
312
  for (const { pair, plan } of planned) {
256
313
  changes.push(...plan.changes);
257
- if (plan.operations.length === 0) continue;
314
+ if (plan.operations.length === 0 && plan.tableGeometryPairings.length === 0) continue;
258
315
  const baseBeforeBlocks = reviewer.readReviewedStory({
259
316
  story: pair.baseStory,
260
317
  view: "final"
261
318
  })?.snapshot.blocks ?? [];
262
319
  const baseBefore = projectBlocks(baseBeforeBlocks);
263
- const { skipped, nextRevisionId } = reviewer.applyDocumentOperationsToStory({
320
+ const baseBeforeGeometry = projectTableGeometry(reviewer.storyTables({ story: pair.baseStory }));
321
+ const targetTables = new Map(targetReviewer.storyTables({ story: pair.targetStory }).map(({ index, node }) => [index, node]));
322
+ const afterGeometry = reviewer.matchStoryTableGeometry({
264
323
  story: pair.baseStory,
265
- snapshot: pair.baseSnapshot,
324
+ targetTables,
325
+ pairings: plan.tableGeometryPairings,
266
326
  revisionStamp: {
267
327
  date: revisionStamp.date,
268
328
  idSeed
269
- },
270
- wordDiff: { granularity },
271
- batch: {
272
- version: 1,
273
- mode: "tracked-changes",
274
- operations: plan.operations
275
329
  }
276
330
  });
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
- }));
331
+ const geometryChanged = afterGeometry > idSeed;
332
+ documentChanged ||= geometryChanged;
333
+ idSeed = afterGeometry;
334
+ if (plan.operations.length === 0 && !geometryChanged) continue;
335
+ if (plan.operations.length > 0) {
336
+ const { skipped, nextRevisionId } = reviewer.applyDocumentOperationsToStory({
337
+ story: pair.baseStory,
338
+ snapshot: pair.baseSnapshot,
339
+ revisionStamp: {
340
+ date: revisionStamp.date,
341
+ idSeed
342
+ },
343
+ wordDiff: { granularity },
344
+ batch: {
345
+ version: 1,
346
+ mode: "tracked-changes",
347
+ operations: plan.operations
348
+ },
349
+ tableTemplates: resolveTableTemplates(targetTables, plan.tableTemplates)
350
+ });
351
+ if (nextRevisionId === void 0) panic("The applier did not report where it left the revision-id counter", { story: pair.baseStory });
352
+ idSeed = nextRevisionId;
353
+ documentChanged = true;
354
+ const refused = skipped.filter(({ reason }) => COMPARE_SKIP_DISPOSITION[reason] === "fatal");
355
+ if (refused.length > 0) return Result.err(new CompareDocxApplyError({
356
+ message: "Some derived operations were refused, so the result would not match the target.",
357
+ skipped: refused
358
+ }));
359
+ }
283
360
  const acceptedStory = reviewer.readReviewedStory({
284
361
  story: pair.baseStory,
285
362
  view: "final"
@@ -324,13 +401,34 @@ const applyComparison = ({ reviewer, revisionStamp, granularity, numberingChange
324
401
  });
325
402
  if (formattingFailure) failures.push(formattingFailure);
326
403
  }
404
+ const geometryAcceptFailure = classifyGeometryMismatch({
405
+ invariant: "accept-reproduces-target",
406
+ story: pair.baseStory,
407
+ actual: projectTableGeometry(reviewer.storyTables({
408
+ story: pair.baseStory,
409
+ view: "final"
410
+ })),
411
+ expected: projectTableGeometry(targetReviewer.storyTables({ story: pair.targetStory }))
412
+ });
413
+ if (geometryAcceptFailure) failures.push(geometryAcceptFailure);
414
+ const geometryRejectFailure = classifyGeometryMismatch({
415
+ invariant: "reject-reproduces-base",
416
+ story: pair.baseStory,
417
+ actual: projectTableGeometry(reviewer.storyTables({
418
+ story: pair.baseStory,
419
+ view: "original"
420
+ })),
421
+ expected: baseBeforeGeometry
422
+ });
423
+ if (geometryRejectFailure) failures.push(geometryRejectFailure);
327
424
  }
328
425
  return Result.ok({
329
426
  changes,
330
427
  verification: failures.length === 0 ? { status: "verified" } : {
331
428
  status: "unverified",
332
429
  failures
333
- }
430
+ },
431
+ documentChanged
334
432
  });
335
433
  };
336
434
  /**
@@ -352,13 +450,13 @@ const applyComparison = ({ reviewer, revisionStamp, granularity, numberingChange
352
450
  * composition let the benchmark's own composition disagree with the shipped
353
451
  * one within a single run.
354
452
  */
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
453
+ const serializeComparison = async ({ baseBuffer, baseCarriedRevisions, reviewer, packageDate, revisionStamp }, { documentChanged }) => {
454
+ if (!baseCarriedRevisions && !documentChanged) return Result.ok(baseBuffer);
455
+ const revisions = revisedFinalParagraphMarks(reviewer.toDocument(), { since: revisionStamp.idSeed });
456
+ const [first] = revisions;
457
+ if (first !== void 0) return Result.err(new CompareDocxFinalParagraphMarkError({
458
+ message: `A container's final paragraph mark carries a ${first.kind}, which no consumer can resolve: ${first.container} paragraph ${String(first.paragraphIndex)}.`,
459
+ revisions
362
460
  }));
363
461
  return await Result.tryPromise({
364
462
  try: async () => await withFixedPackageDates(await reviewer.toBuffer(), packageDate),
@@ -399,7 +497,7 @@ const compareDocx = async (base, target, options) => {
399
497
  failures: verification.failures
400
498
  }));
401
499
  }
402
- const serialized = await serializeComparison(parsed.value, planned.value);
500
+ const serialized = await serializeComparison(parsed.value, applied.value);
403
501
  if (serialized.isErr()) return Result.err(serialized.error);
404
502
  return Result.ok({
405
503
  buffer: serialized.value,
@@ -1,10 +1,40 @@
1
1
  import { FolioAIEditOperation, FolioAIEditSnapshot } from "../ai-edits/types.js";
2
+ import { TableGeometryPairing } from "../ai-edits/table-geometry.js";
2
3
  import { FolioDocumentStoryHandle } from "../ai-edits/headless.js";
3
4
  import { CompareChange } from "./types.js";
4
5
  //#region src/compare/plan.d.ts
6
+ /**
7
+ * A table the target document already holds, which the operation naming it
8
+ * should place verbatim instead of rebuilding from its cell texts.
9
+ *
10
+ * The plan is pure and sees only block snapshots, so it names the table by the
11
+ * index the snapshot numbers tables with; the caller, which has both
12
+ * documents, resolves the index to the node.
13
+ */
14
+ type CompareTableTemplateRequest = {
15
+ /** The `insertTable` or `insertTableRow` operation this table belongs to. */
16
+ operationId: string;
17
+ /** Index of the table in the TARGET story. */
18
+ targetTableIndex: number;
19
+ /** Set for a row insertion: which of that table's rows to place. */
20
+ targetRowIndex?: number;
21
+ };
5
22
  type CompareStoryPlan = {
6
23
  changes: CompareChange[];
7
24
  operations: FolioAIEditOperation[];
25
+ /**
26
+ * Where an operation's table comes from. Kept beside the operations rather
27
+ * than inside them because a table node is not JSON, and the serialized
28
+ * operation contract describes a table by its cell texts.
29
+ */
30
+ tableTemplates: CompareTableTemplateRequest[];
31
+ /**
32
+ * Base cells the alignment put opposite a target cell. Their tables, rows
33
+ * and cells are the ones whose `w:tblPr` / `w:trPr` / `w:tcPr` the caller
34
+ * matches: a table that stayed in place while its widths, shading or header
35
+ * row changed moves no block, so no operation carries the difference.
36
+ */
37
+ tableGeometryPairings: TableGeometryPairing[];
8
38
  };
9
39
  type PlanStoryCompareOptions = {
10
40
  story: FolioDocumentStoryHandle;
@@ -19,4 +49,4 @@ type PlanStoryCompareOptions = {
19
49
  */
20
50
  declare const planStoryCompare: ({ story, baseSnapshot, targetSnapshot, maxOperations }: PlanStoryCompareOptions) => CompareStoryPlan | null;
21
51
  //#endregion
22
- export { CompareStoryPlan, PlanStoryCompareOptions, planStoryCompare };
52
+ export { CompareStoryPlan, CompareTableTemplateRequest, PlanStoryCompareOptions, planStoryCompare };