@stll/folio-core 0.33.0 → 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 (98) hide show
  1. package/dist/ai-edits/apply.d.ts +15 -0
  2. package/dist/ai-edits/apply.js +135 -24
  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 +86 -4
  16. package/dist/compare/__fixtures__/body-sequence.js +169 -12
  17. package/dist/compare/compare.d.ts +15 -8
  18. package/dist/compare/compare.js +138 -34
  19. package/dist/compare/plan.d.ts +31 -1
  20. package/dist/compare/plan.js +277 -28
  21. package/dist/compare/reproducible-package.d.ts +7 -3
  22. package/dist/compare/reproducible-package.js +20 -3
  23. package/dist/compare/types.d.ts +20 -3
  24. package/dist/compare/types.js +13 -1
  25. package/dist/compare/verification.d.ts +49 -2
  26. package/dist/compare/verification.js +115 -1
  27. package/dist/compat/eigenpal.d.ts +5 -5
  28. package/dist/compat/eigenpal.js +3 -3
  29. package/dist/document-operations.d.ts +10 -1
  30. package/dist/document-operations.js +3 -2
  31. package/dist/docx/appVersionNormalization.d.ts +49 -0
  32. package/dist/docx/appVersionNormalization.js +74 -0
  33. package/dist/docx/blockContentParser.js +3 -2
  34. package/dist/docx/drawingUtils.js +4 -3
  35. package/dist/docx/hyperlinkParser.d.ts +9 -1
  36. package/dist/docx/hyperlinkParser.js +19 -13
  37. package/dist/docx/paraIdRangeNormalization.d.ts +40 -0
  38. package/dist/docx/paraIdRangeNormalization.js +64 -0
  39. package/dist/docx/paragraphParser.js +82 -6
  40. package/dist/docx/revisionIdNormalization.d.ts +15 -1
  41. package/dist/docx/revisionIdNormalization.js +22 -4
  42. package/dist/docx/rezip.d.ts +13 -1
  43. package/dist/docx/rezip.js +80 -21
  44. package/dist/docx/runParser.js +9 -8
  45. package/dist/docx/sdtProperties.js +4 -3
  46. package/dist/docx/selectiveSave.js +7 -6
  47. package/dist/docx/serializer/commentSerializer.d.ts +1 -1
  48. package/dist/docx/serializer/commentSerializer.js +43 -19
  49. package/dist/docx/serializer/documentSerializer.d.ts +3 -1
  50. package/dist/docx/serializer/documentSerializer.js +42 -78
  51. package/dist/docx/serializer/fontTableSerializer.js +8 -5
  52. package/dist/docx/serializer/headerFooterSerializer.d.ts +4 -1
  53. package/dist/docx/serializer/headerFooterSerializer.js +34 -29
  54. package/dist/docx/serializer/noteSerializer.js +34 -30
  55. package/dist/docx/serializer/numberingSerializer.js +8 -4
  56. package/dist/docx/serializer/paragraphSerializer.js +45 -21
  57. package/dist/docx/serializer/partNamespaces.d.ts +78 -0
  58. package/dist/docx/serializer/partNamespaces.js +324 -0
  59. package/dist/docx/serializer/runSerializer.js +1 -1
  60. package/dist/docx/serializer/settingsSerializer.js +8 -2
  61. package/dist/docx/serializer/stylesSerializer.js +8 -5
  62. package/dist/docx/serializer/tableSerializer.js +107 -27
  63. package/dist/docx/serializer/themeSerializer.js +10 -2
  64. package/dist/docx/server/build.d.ts +1 -1
  65. package/dist/docx/strictValueEncodings.gen.d.ts +15 -0
  66. package/dist/docx/strictValueEncodings.gen.js +275 -0
  67. package/dist/docx/tableParser.d.ts +0 -6
  68. package/dist/docx/tableParser.js +52 -7
  69. package/dist/docx/transitionalSpelling.d.ts +23 -0
  70. package/dist/docx/transitionalSpelling.js +36 -0
  71. package/dist/docx/universalMeasure.d.ts +20 -0
  72. package/dist/docx/universalMeasure.js +32 -0
  73. package/dist/docx/verbatimCapture.d.ts +20 -0
  74. package/dist/docx/verbatimCapture.js +131 -0
  75. package/dist/docx/vmlImageParser.js +4 -3
  76. package/dist/docx/watermarkParser.js +4 -3
  77. package/dist/docx/xmlParser.d.ts +23 -2
  78. package/dist/docx/xmlParser.js +43 -11
  79. package/dist/index.d.ts +5 -5
  80. package/dist/index.js +3 -3
  81. package/dist/model.d.ts +3 -3
  82. package/dist/model.js +2 -2
  83. package/dist/prosemirror/commands/comments.js +8 -2
  84. package/dist/prosemirror/containerFinalParagraph.d.ts +47 -0
  85. package/dist/prosemirror/containerFinalParagraph.js +92 -0
  86. package/dist/prosemirror/conversion/fromProseDoc.d.ts +8 -1
  87. package/dist/prosemirror/conversion/fromProseDoc.js +15 -4
  88. package/dist/prosemirror/conversion/toProseDoc.js +12 -3
  89. package/dist/prosemirror/extensions/nodes/TableExtension.js +5 -0
  90. package/dist/prosemirror/schema/marks.d.ts +1 -1
  91. package/dist/prosemirror/schema/nodes.d.ts +22 -0
  92. package/dist/server.d.ts +1 -1
  93. package/dist/types/block-id.d.ts +12 -1
  94. package/dist/types/block-id.js +17 -1
  95. package/dist/utils/canonicalJson.d.ts +14 -0
  96. package/dist/utils/canonicalJson.js +19 -0
  97. package/dist/utils/formatToStyle.d.ts +1 -1
  98. package/package.json +2 -2
@@ -13,14 +13,88 @@
13
13
  * gets written.
14
14
  */
15
15
  type CellContent = string | readonly BodyItem[];
16
+ /** `w:tcPr` children a fixture can author, each named after the element. */
17
+ type CellProperties = {
18
+ /** `w:gridSpan`: grid columns this cell occupies. */
19
+ gridSpan?: number;
20
+ /** `w:vMerge`: the cell starts a vertical merge, or continues one. */
21
+ verticalMerge?: "restart" | "continue";
22
+ /** `w:tcW` in twips. */
23
+ width?: number;
24
+ /** `w:shd` fill colour, as six hex digits. */
25
+ shadingFill?: string;
26
+ /** `w:vAlign`. */
27
+ verticalAlign?: "top" | "center" | "bottom";
28
+ /** `w:tcBorders`, single style on all four sides, in eighths of a point. */
29
+ borderSize?: number;
30
+ /** `w:tcMar`, the same margin on all four sides, in twips. */
31
+ margin?: number;
32
+ };
33
+ /** A cell: its content alone, or its content and its own `w:tcPr`. */
34
+ type Cell = CellContent | ({
35
+ content: CellContent;
36
+ } & CellProperties);
37
+ /** A row: its cells alone, or its cells and its own `w:trPr`. */
38
+ type TableRow = readonly Cell[] | {
39
+ cells: readonly Cell[];
40
+ /** `w:trHeight` in twips. */
41
+ height?: number;
42
+ /** `w:tblHeader`: the row repeats at the top of every page. */
43
+ header?: boolean;
44
+ /** `w:jc` on the row. */
45
+ justification?: "left" | "center" | "right";
46
+ };
47
+ /** `w:tblPr` children a fixture can author, each named after the element. */
48
+ type TableProperties = {
49
+ /** `w:tblStyle`. */
50
+ styleId?: string;
51
+ /** `w:tblW`. */
52
+ width?: {
53
+ value: number;
54
+ type: "auto" | "dxa" | "pct";
55
+ };
56
+ /** `w:jc` on the table. */
57
+ justification?: "left" | "center" | "right";
58
+ /** `w:tblInd` in twips. */
59
+ indent?: number;
60
+ /** `w:tblBorders`, single style on every side, in eighths of a point. */
61
+ borderSize?: number;
62
+ /** `w:shd` fill colour on the table, as six hex digits. */
63
+ shadingFill?: string;
64
+ /** `w:tblLayout`. */
65
+ layout?: "fixed" | "autofit";
66
+ /** `w:tblCellMar`, the same margin on all four sides, in twips. */
67
+ cellMargin?: number;
68
+ /** `w:tblLook` value, as four hex digits. */
69
+ look?: string;
70
+ };
71
+ /**
72
+ * One inline of a paragraph: plain text, or text carrying an external
73
+ * hyperlink. A link is the case where a revision wrapper and the linked runs
74
+ * have to nest one inside the other, so the fixture has to be able to author
75
+ * one.
76
+ */
77
+ type ParagraphInline = string | {
78
+ text: string;
79
+ href: string;
80
+ };
16
81
  /** One body-level item: a paragraph, or a table given row by row. */
17
82
  type BodyItem = {
18
83
  kind: "paragraph";
19
- text: string;
84
+ text: string | readonly ParagraphInline[];
20
85
  styleId?: string;
86
+ /**
87
+ * An authored `w14:paraId`, for a package whose ids a producer wrote
88
+ * without respecting the 31-bit bound the schema puts on them.
89
+ */
90
+ paraId?: string;
21
91
  } | {
22
92
  kind: "table";
23
- rows: readonly (readonly CellContent[])[];
93
+ rows: readonly TableRow[];
94
+ /** `w:tblPr` children, written in the order the schema requires. */
95
+ properties?: TableProperties;
96
+ /** `w:tblGrid` column widths, in twips. One per grid column. */
97
+ columnWidths?: readonly number[];
24
98
  /**
25
99
  * Rows a package hides with `w:hidden`. The snapshot skips their whole
26
100
  * subtree, so a document that has one is the case where the snapshot
@@ -28,6 +102,14 @@ type BodyItem = {
28
102
  */
29
103
  hiddenRows?: readonly number[];
30
104
  };
31
- declare const buildBodySequenceDocx: (items: readonly BodyItem[]) => Promise<ArrayBuffer>;
105
+ type BodySequenceOptions = {
106
+ /**
107
+ * A default header part, written as its own sequence. A header is a story of
108
+ * its own: it ends with its own paragraph, and a comparison writes it with
109
+ * its own revision ids.
110
+ */
111
+ header?: readonly BodyItem[];
112
+ };
113
+ declare const buildBodySequenceDocx: (items: readonly BodyItem[], { header }?: BodySequenceOptions) => Promise<ArrayBuffer>;
32
114
  //#endregion
33
- export { BodyItem, CellContent, buildBodySequenceDocx };
115
+ export { BodyItem, BodySequenceOptions, Cell, CellContent, CellProperties, ParagraphInline, TableProperties, TableRow, buildBodySequenceDocx };
@@ -12,6 +12,12 @@ const NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
12
12
  const RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006/relationships";
13
13
  const OFFICE_RELATIONSHIPS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
14
14
  const WORDPROCESSING = "application/vnd.openxmlformats-officedocument.wordprocessingml";
15
+ const MARKUP_COMPATIBILITY = "http://schemas.openxmlformats.org/markup-compatibility/2006";
16
+ const PACKAGE_RELATIONSHIPS = "http://schemas.openxmlformats.org/package/2006";
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";
20
+ const WORDML_2010 = "http://schemas.microsoft.com/office/word/2010/wordml";
15
21
  /**
16
22
  * `createFolders: false` because JSZip stamps the folder entries it
17
23
  * synthesizes with `new Date()`, which the fixed date above does not reach.
@@ -20,14 +26,62 @@ const ZIP_ENTRY_OPTIONS = {
20
26
  date: new Date(Date.UTC(2e3, 0, 1)),
21
27
  createFolders: false
22
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 : {};
35
+ /**
36
+ * A paragraph's id is derived from its own content, not from its position.
37
+ *
38
+ * Two packages authored from two descriptions are a base and a target, and a
39
+ * paragraph that appears in both is the same paragraph. Numbering the ids in
40
+ * document order would instead give the same id to the paragraph that happens
41
+ * to sit at the same index, which is how the fixture would tell a comparison
42
+ * that a removed paragraph was a rewrite of the one after it.
43
+ */
44
+ const createParaIdAllocator = () => {
45
+ const taken = /* @__PURE__ */ new Set();
46
+ return (content) => {
47
+ let hash = 2166136261;
48
+ for (let index = 0; index < content.length; index += 1) hash = Math.imul(hash ^ content.charCodeAt(index), 16777619) >>> 0;
49
+ let candidate = hash % 2147483646;
50
+ while (taken.has((candidate + 1).toString(16).toUpperCase().padStart(8, "0"))) candidate = (candidate + 1) % 2147483646;
51
+ const paraId = (candidate + 1).toString(16).toUpperCase().padStart(8, "0");
52
+ taken.add(paraId);
53
+ return paraId;
54
+ };
55
+ };
56
+ const run = (text) => `<w:r><w:t xml:space="preserve">${text}</w:t></w:r>`;
57
+ const inlineXml = (inline, { links }) => typeof inline === "string" ? run(inline) : `<w:hyperlink r:id="${links.get(inline.href) ?? ""}">${run(inline.text)}</w:hyperlink>`;
23
58
  /**
24
59
  * An empty paragraph is a `w:p` with no run at all, which is what a package
25
60
  * holds for a blank line or an empty cell. It is not the same thing as a
26
61
  * paragraph whose run carries an empty string, and both shapes occur.
27
62
  */
28
- const paragraph = (text, styleId) => {
63
+ /** A blank line carries no run at all, so an empty string is no inline. */
64
+ const nonEmptyInlines = (text) => text.length === 0 ? [] : [text];
65
+ const paragraph = (text, context, { styleId, paraId } = {}) => {
29
66
  const properties = styleId === void 0 ? "" : `<w:pPr><w:pStyle w:val="${styleId}"/></w:pPr>`;
30
- return text.length === 0 ? `<w:p>${properties}</w:p>` : `<w:p>${properties}<w:r><w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
67
+ const inlines = typeof text === "string" ? nonEmptyInlines(text) : text;
68
+ const content = inlines.map((inline) => typeof inline === "string" ? inline : inline.text).join("");
69
+ const id = paraId ?? context.paraId(`${styleId ?? ""}|${content}`);
70
+ return `<w:p w14:paraId="${id}" w14:textId="${id}">${properties}${inlines.map((inline) => inlineXml(inline, context)).join("")}</w:p>`;
71
+ };
72
+ /** Every href the body carries, in document order, so the ids are stable. */
73
+ const collectHrefs = (items, hrefs) => {
74
+ for (const item of items) {
75
+ if (item.kind === "paragraph") {
76
+ if (typeof item.text === "string") continue;
77
+ for (const inline of item.text) if (typeof inline !== "string" && !hrefs.includes(inline.href)) hrefs.push(inline.href);
78
+ continue;
79
+ }
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
+ }
84
+ }
31
85
  };
32
86
  const EMPTY_PARAGRAPH = {
33
87
  kind: "paragraph",
@@ -44,20 +98,123 @@ const closedSequence = (items) => {
44
98
  return last === void 0 || last.kind === "table" ? [...items, EMPTY_PARAGRAPH] : items;
45
99
  };
46
100
  /** A cell must also contain a paragraph, which the empty sequence supplies. */
47
- const cellXml = (content) => typeof content === "string" ? paragraph(content) : itemsXml(closedSequence(content));
48
- const table = (item) => {
101
+ const cellXml = (content, context) => typeof content === "string" ? paragraph(content, context) : itemsXml(closedSequence(content), context);
102
+ /** `w:tbl` is `w:tblPr, w:tblGrid, rows`: a fixture without the grid is not one. */
103
+ const tableGrid = (item) => {
104
+ if (item.columnWidths) return `<w:tblGrid>${item.columnWidths.map((width) => `<w:gridCol w:w="${String(width)}"/>`).join("")}</w:tblGrid>`;
105
+ let columns = 0;
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
+ }
111
+ return `<w:tblGrid>${`<w:gridCol w:w="2000"/>`.repeat(columns)}</w:tblGrid>`;
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
+ };
184
+ const table = (item, context) => {
49
185
  const hidden = new Set(item.hiddenRows ?? []);
50
- return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>` + 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)}</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>`;
51
187
  };
52
- const itemsXml = (items) => items.map((item) => item.kind === "paragraph" ? paragraph(item.text, item.styleId) : table(item)).join("");
53
- const bodyXml = (items) => itemsXml(closedSequence(items));
54
- const buildBodySequenceDocx = async (items) => {
188
+ const itemsXml = (items, context) => items.map((item) => item.kind === "paragraph" ? paragraph(item.text, context, {
189
+ ...item.styleId === void 0 ? {} : { styleId: item.styleId },
190
+ ...item.paraId === void 0 ? {} : { paraId: item.paraId }
191
+ }) : table(item, context)).join("");
192
+ const bodyXml = (items, context) => itemsXml(closedSequence(items), context);
193
+ /** The relationship id the default header takes when a fixture asks for one. */
194
+ const HEADER_RELATIONSHIP_ID = "rId2";
195
+ const buildBodySequenceDocx = async (items, { header } = {}) => {
196
+ const hrefs = [];
197
+ collectHrefs(closedSequence(items), hrefs);
198
+ collectHrefs(closedSequence(header ?? []), hrefs);
199
+ const firstLinkRelationship = header ? 3 : 2;
200
+ const links = new Map(hrefs.map((href, index) => [href, `rId${index + firstLinkRelationship}`]));
201
+ const linkRelationships = hrefs.map((href, index) => `<Relationship Id="rId${index + firstLinkRelationship}" Type="${OFFICE_RELATIONSHIPS}/hyperlink" Target="${href}" TargetMode="External"/>`).join("");
202
+ const paraId = createParaIdAllocator();
55
203
  const parts = {
56
- "[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"/></Types>`,
57
- "_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"/></Relationships>`,
58
- "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"/></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>`,
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>`,
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>`,
59
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>`,
60
- "word/document.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="${NAMESPACE}"><w:body>` + bodyXml(items) + "<w:sectPr><w:pgSz w:w=\"12240\" w:h=\"15840\"/><w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\"/></w:sectPr></w:body></w:document>"
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, {
211
+ links,
212
+ paraId
213
+ }) + `<w:sectPr>` + (header ? `<w:headerReference w:type="default" r:id="${HEADER_RELATIONSHIP_ID}"/>` : "") + "<w:pgSz w:w=\"12240\" w:h=\"15840\"/><w:pgMar w:top=\"1440\" w:right=\"1440\" w:bottom=\"1440\" w:left=\"1440\"/></w:sectPr></w:body></w:document>",
214
+ ...header ? { "word/header1.xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:hdr xmlns:w="${NAMESPACE}" xmlns:r="${OFFICE_RELATIONSHIPS}" xmlns:mc="${MARKUP_COMPATIBILITY}" xmlns:w14="${WORDML_2010}" mc:Ignorable="w14">${itemsXml(closedSequence(header), {
215
+ links,
216
+ paraId
217
+ })}</w:hdr>` } : {}
61
218
  };
62
219
  const zip = new JSZip();
63
220
  for (const name of Object.keys(parts).toSorted()) zip.file(name, parts[name] ?? "", ZIP_ENTRY_OPTIONS);
@@ -3,7 +3,7 @@ import { WordDiffGranularity } from "../ai-edits/word-diff.js";
3
3
  import { FolioRevisionStamp } from "../ai-edits/apply.js";
4
4
  import { FolioDocumentStoryHandle, FolioDocxReviewer } from "../ai-edits/headless.js";
5
5
  import { CompareVerification } from "./verification.js";
6
- import { CompareChange, CompareDocxApplyError, CompareDocxError, CompareDocxOperationLimitError, CompareDocxOptions, CompareDocxParseError, CompareDocxSerializeError, CompareResult, CompareUnsupportedPart, InvalidCompareDocxOptionsError } from "./types.js";
6
+ import { CompareChange, CompareDocxApplyError, CompareDocxError, CompareDocxFinalParagraphMarkError, CompareDocxOperationLimitError, CompareDocxOptions, CompareDocxParseError, CompareDocxSerializeError, CompareResult, CompareUnsupportedPart, InvalidCompareDocxOptionsError } from "./types.js";
7
7
  import { CompareStoryPlan } from "./plan.js";
8
8
  import { Result } from "better-result";
9
9
  //#region src/compare/compare.d.ts
@@ -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>>;
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
- import { CompareDocxApplyError, CompareDocxOperationLimitError, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, InvalidCompareDocxOptionsError } from "./types.js";
7
- import { classifyProjectionMismatch, projectSupportedInlineFormatting } from "./verification.js";
7
+ import { CompareDocxApplyError, CompareDocxFinalParagraphMarkError, CompareDocxOperationLimitError, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, InvalidCompareDocxOptionsError } from "./types.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,8 +450,14 @@ 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);
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
460
+ }));
357
461
  return await Result.tryPromise({
358
462
  try: async () => await withFixedPackageDates(await reviewer.toBuffer(), packageDate),
359
463
  catch: (cause) => new CompareDocxSerializeError({
@@ -393,7 +497,7 @@ const compareDocx = async (base, target, options) => {
393
497
  failures: verification.failures
394
498
  }));
395
499
  }
396
- const serialized = await serializeComparison(parsed.value, planned.value);
500
+ const serialized = await serializeComparison(parsed.value, applied.value);
397
501
  if (serialized.isErr()) return Result.err(serialized.error);
398
502
  return Result.ok({
399
503
  buffer: serialized.value,