@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
@@ -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 };
@@ -457,16 +457,8 @@ const isEmptyParagraphNode = (block, snapshot) => {
457
457
  const anchor = snapshot.anchors[block.id] ?? panic("A comparison snapshot block has no matching anchor", { blockId: block.id });
458
458
  return block.text === "" && block.table === void 0 && anchor.to - anchor.from === 2;
459
459
  };
460
- const buildSteps = ({ story, baseSnapshot, targetSnapshot }) => {
461
- const baseBlocks = baseSnapshot.blocks;
462
- const targetBlocks = targetSnapshot.blocks;
463
- const baseLast = baseBlocks.at(-1);
464
- const targetLast = targetBlocks.at(-1);
465
- const terminalCarrierPair = story.type === "main" && baseLast !== void 0 && isEmptyParagraphNode(baseLast, baseSnapshot) && targetLast !== void 0 && isEmptyParagraphNode(targetLast, targetSnapshot) ? {
466
- type: "pair",
467
- baseBlock: baseLast,
468
- targetBlock: targetLast
469
- } : null;
460
+ /** Align both stories, optionally holding the two last paragraphs out as a pair. */
461
+ const alignedSteps = (baseBlocks, targetBlocks, terminalCarrierPair) => {
470
462
  const alignedBaseBlocks = terminalCarrierPair ? baseBlocks.slice(0, -1) : baseBlocks;
471
463
  const alignedTargetBlocks = terminalCarrierPair ? targetBlocks.slice(0, -1) : targetBlocks;
472
464
  const steps = [];
@@ -485,6 +477,65 @@ const buildSteps = ({ story, baseSnapshot, targetSnapshot }) => {
485
477
  return steps;
486
478
  };
487
479
  /**
480
+ * Whether an alignment left the story's last paragraph removed with no
481
+ * paragraph of its own container surviving in front of it.
482
+ *
483
+ * That paragraph's mark is the one a story cannot lose, and a deleted mark
484
+ * joins two paragraphs of ONE container, so with nothing of that container in
485
+ * front of it there is no chain a removal could run down. The paragraph then
486
+ * stays whatever the plan says, and the story ends on a blank line the target
487
+ * does not have — unless the words the target ends with are written into it,
488
+ * which is what reserving it for the target's last paragraph does.
489
+ */
490
+ const terminalCarrierIsStranded = (steps, baseBlocks) => {
491
+ const carrier = baseBlocks.at(-1);
492
+ if (carrier === void 0) return false;
493
+ const removed = new Set(steps.flatMap((step) => step.type === "baseOnly" ? [step.block.id] : []));
494
+ if (!removed.has(carrier.id)) return false;
495
+ const container = containerKeyOf(carrier);
496
+ for (let index = baseBlocks.length - 2; index >= 0; index--) {
497
+ const block = baseBlocks[index];
498
+ if (block === void 0 || containerKeyOf(block) !== container) return true;
499
+ if (!removed.has(block.id)) return false;
500
+ }
501
+ return true;
502
+ };
503
+ /**
504
+ * Whether an alignment ADDED the story's last paragraph with something other
505
+ * than paragraphs between it and the base's.
506
+ *
507
+ * An inserted mark at a container's end has the same problem its deletion
508
+ * does: nothing follows it, so rejecting the addition cannot close the break
509
+ * back over the next paragraph. The applier rotates instead — the paragraph
510
+ * the run was appended after takes the inserted mark, and the last one takes
511
+ * the free mark — and that rotation reaches only across paragraphs. A table
512
+ * among them stops it, and the words the target ends with have to be written
513
+ * into the base's own last paragraph instead.
514
+ */
515
+ const addedTerminalCarrierIsStranded = (steps, baseBlocks, targetBlocks) => {
516
+ const baseLast = baseBlocks.at(-1);
517
+ const targetLast = targetBlocks.at(-1);
518
+ if (baseLast === void 0 || targetLast === void 0) return false;
519
+ if (!steps.some((step) => step.type === "targetOnly" && step.block.id === targetLast.id)) return false;
520
+ const baseLastStep = steps.findIndex((step) => step.type === "pair" && step.baseBlock.id === baseLast.id || step.type === "baseOnly" && step.block.id === baseLast.id);
521
+ return baseLastStep === -1 || steps.slice(baseLastStep + 1).some((step) => step.type !== "targetOnly");
522
+ };
523
+ const buildSteps = ({ baseSnapshot, targetSnapshot }) => {
524
+ const baseBlocks = baseSnapshot.blocks;
525
+ const targetBlocks = targetSnapshot.blocks;
526
+ const baseLast = baseBlocks.at(-1);
527
+ const targetLast = targetBlocks.at(-1);
528
+ const carrierPair = baseLast !== void 0 && targetLast !== void 0 ? {
529
+ type: "pair",
530
+ baseBlock: baseLast,
531
+ targetBlock: targetLast
532
+ } : null;
533
+ const bothStoriesEndBlank = carrierPair !== null && isEmptyParagraphNode(carrierPair.baseBlock, baseSnapshot) && isEmptyParagraphNode(carrierPair.targetBlock, targetSnapshot);
534
+ const steps = alignedSteps(baseBlocks, targetBlocks, bothStoriesEndBlank ? carrierPair : null);
535
+ if (carrierPair === null || bothStoriesEndBlank || !shareAContainer(carrierPair.baseBlock, carrierPair.targetBlock) || !(terminalCarrierIsStranded(steps, baseBlocks) || addedTerminalCarrierIsStranded(steps, baseBlocks, targetBlocks))) return steps;
536
+ return alignedSteps(baseBlocks, targetBlocks, carrierPair);
537
+ };
538
+ /**
488
539
  * Two blocks are in the same container when a paragraph mark between them
489
540
  * exists at all: two body paragraphs, or two paragraphs of one table cell. A
490
541
  * mark cannot span a cell boundary, so a split or a merge across one is not a
@@ -701,12 +752,198 @@ const locationOf = (story, block) => block.table ? {
701
752
  cell: block.table
702
753
  } : { story };
703
754
  /**
755
+ * The container a paragraph mark ends: the story's body, or one table cell. A
756
+ * mark joins the paragraph it ends with the next paragraph of the SAME
757
+ * container, so two blocks on either side of a boundary are not neighbours for
758
+ * this purpose however adjacent they read.
759
+ */
760
+ const containerKeyOf = ({ table }) => table ? `t${String(table.tableIndex)}r${String(table.rowIndex)}c${String(table.cellIndex)}` : "body";
761
+ /** Each container's last block, in the order the story holds them. */
762
+ const lastBlockByContainer = (blocks) => {
763
+ const last = /* @__PURE__ */ new Map();
764
+ for (const block of blocks) last.set(containerKeyOf(block), block);
765
+ return last;
766
+ };
767
+ /**
768
+ * Rewrite the plan so a container's final paragraph mark survives the removal
769
+ * of the paragraphs that end it.
770
+ *
771
+ * A deleted paragraph mark means "merge this paragraph into the following
772
+ * one". A container's final paragraph has no following one, so the mark cannot
773
+ * say it: the applier leaves that mark alone, and the removal has to be
774
+ * expressed one paragraph earlier. The carrier is that final paragraph, and
775
+ * every trailing removal resolves onto it.
776
+ *
777
+ * Nothing is inserted where the run was: the chain runs from the last
778
+ * SURVIVING paragraph forward. Its mark goes, each removed paragraph's mark
779
+ * goes with it, and the carrier loses its words and keeps its mark as the
780
+ * paragraph the merged text lands in. A carrier that has no words to lose is
781
+ * not deleted at all: its removal is entirely the marks in front of it, and an
782
+ * operation that would write no revision belongs nowhere in the plan.
783
+ *
784
+ * Something IS inserted there: the inserted paragraphs stand where the removed
785
+ * ones did, and the last of them lands INSIDE the carrier as inserted runs
786
+ * before its kept mark, so the carrier is the target's last paragraph rather
787
+ * than a blank line after it. The other inserted paragraphs carry inserted
788
+ * marks of their own and sit before the carrier. The mark count then works out
789
+ * on its own — one mark deleted for every removed paragraph but the carrier,
790
+ * one inserted for every added paragraph but the last — so the chain is not
791
+ * rotated as well.
792
+ *
793
+ * The carrier keeps its own mark either way, and a paragraph's properties live
794
+ * on its mark, so the merged paragraph ends up with the carrier's. Those
795
+ * properties therefore become the target's, written as `w:pPrChange` so
796
+ * rejecting restores what the carrier had. That is bookkeeping for the merge
797
+ * rather than an edit of its own, so it adds no entry to the change list: what
798
+ * the reader is told is that paragraphs were removed and paragraphs added.
799
+ */
800
+ const withTrailingDeletionRules = ({ baseSnapshot, targetSnapshot, operations, nextOperationId }) => {
801
+ const plan = [...operations];
802
+ const deletionIndexByBlockId = /* @__PURE__ */ new Map();
803
+ const insertIndexesByAnchor = /* @__PURE__ */ new Map();
804
+ const tableAnchorIds = /* @__PURE__ */ new Set();
805
+ const markedBlockIds = /* @__PURE__ */ new Set();
806
+ for (const [index, operation] of plan.entries()) switch (operation.type) {
807
+ case "deleteBlock":
808
+ deletionIndexByBlockId.set(operation.blockId, index);
809
+ break;
810
+ case "insertBeforeBlock":
811
+ case "insertAfterBlock": {
812
+ const placed = insertIndexesByAnchor.get(operation.blockId) ?? [];
813
+ placed.push(index);
814
+ insertIndexesByAnchor.set(operation.blockId, placed);
815
+ break;
816
+ }
817
+ case "insertTable":
818
+ tableAnchorIds.add(operation.blockId);
819
+ break;
820
+ case "splitBlock":
821
+ case "mergeBlockWithNext":
822
+ markedBlockIds.add(operation.blockId);
823
+ break;
824
+ default: break;
825
+ }
826
+ if (deletionIndexByBlockId.size === 0) return plan;
827
+ const blocks = baseSnapshot.blocks;
828
+ const indexById = new Map(blocks.map((block, index) => [block.id, index]));
829
+ /**
830
+ * Walk back from a container's last block over the run of deleted
831
+ * paragraphs it ends with. A block of another container in between — a
832
+ * table between two body paragraphs, a table nested in a cell — ends the
833
+ * walk: no mark joins across it.
834
+ */
835
+ const trailingRunOf = (container, carrier) => {
836
+ const blockIds = [];
837
+ const insertIndexes = [];
838
+ let chainStart = null;
839
+ let holdsAnInsertedTable = false;
840
+ let index = indexById.get(carrier.id) ?? panic("A container's last block is not in the snapshot it came from", { blockId: carrier.id });
841
+ for (; index >= 0; index--) {
842
+ const block = blocks[index];
843
+ if (block === void 0 || containerKeyOf(block) !== container) break;
844
+ if (!deletionIndexByBlockId.has(block.id)) {
845
+ chainStart = markedBlockIds.has(block.id) ? null : block;
846
+ break;
847
+ }
848
+ blockIds.push(block.id);
849
+ insertIndexes.push(...insertIndexesByAnchor.get(block.id) ?? []);
850
+ holdsAnInsertedTable ||= tableAnchorIds.has(block.id);
851
+ }
852
+ return {
853
+ blockIds,
854
+ chainStart,
855
+ insertIndexes: insertIndexes.toSorted((left, right) => left - right),
856
+ holdsAnInsertedTable
857
+ };
858
+ };
859
+ const targetLastByContainer = lastBlockByContainer(targetSnapshot.blocks);
860
+ const dropped = /* @__PURE__ */ new Set();
861
+ const appended = [];
862
+ for (const [container, carrier] of lastBlockByContainer(blocks)) {
863
+ const carrierDeletionIndex = deletionIndexByBlockId.get(carrier.id);
864
+ if (carrierDeletionIndex === void 0) continue;
865
+ const run = trailingRunOf(container, carrier);
866
+ if (run.holdsAnInsertedTable) continue;
867
+ const lastInsertIndex = run.insertIndexes.at(-1);
868
+ if (lastInsertIndex === void 0) {
869
+ if (run.chainStart) appended.push({
870
+ id: nextOperationId(),
871
+ type: "mergeBlockWithNext",
872
+ blockId: run.chainStart.id
873
+ });
874
+ if (isEmptyParagraphNode(carrier, baseSnapshot)) dropped.add(carrierDeletionIndex);
875
+ } else {
876
+ const lastInsert = plan[lastInsertIndex];
877
+ if (lastInsert === void 0 || lastInsert.type !== "insertBeforeBlock" && lastInsert.type !== "insertAfterBlock" || lastInsert.moveId !== void 0) continue;
878
+ const carrierDeletion = plan[carrierDeletionIndex];
879
+ if (carrierDeletion?.type !== "deleteBlock" || carrierDeletion.moveId !== void 0) continue;
880
+ dropped.add(lastInsertIndex);
881
+ if (carrier.text === lastInsert.text) dropped.add(carrierDeletionIndex);
882
+ else plan[carrierDeletionIndex] = {
883
+ id: carrierDeletion.id,
884
+ type: "replaceBlock",
885
+ blockId: carrier.id,
886
+ text: lastInsert.text
887
+ };
888
+ for (const index of run.insertIndexes.slice(0, -1)) {
889
+ const insert = plan[index];
890
+ if (insert?.type !== "insertBeforeBlock" && insert?.type !== "insertAfterBlock") continue;
891
+ plan[index] = {
892
+ id: insert.id,
893
+ type: "insertBeforeBlock",
894
+ blockId: carrier.id,
895
+ text: insert.text,
896
+ ...insert.moveId !== void 0 && { moveId: insert.moveId },
897
+ styleId: insert.styleId ?? null,
898
+ listLevel: insert.listLevel ?? null
899
+ };
900
+ }
901
+ }
902
+ const targetCarrier = targetLastByContainer.get(container);
903
+ const properties = targetCarrier && changedParagraphProperties(carrier, targetCarrier);
904
+ if (properties) appended.push({
905
+ id: nextOperationId(),
906
+ type: "setBlockParagraphProperties",
907
+ blockId: carrier.id,
908
+ properties
909
+ });
910
+ }
911
+ return [...plan.filter((_, index) => !dropped.has(index)), ...appended];
912
+ };
913
+ const cellCoordinate = ({ tableIndex, rowIndex, cellIndex }) => ({
914
+ tableIndex,
915
+ rowIndex,
916
+ cellIndex
917
+ });
918
+ /**
919
+ * The cells the alignment paired, one entry each. A cell holds several
920
+ * paragraphs and each pairs on its own, so the first pairing of a cell is the
921
+ * one kept: later ones would name the same two cells.
922
+ */
923
+ const tableGeometryPairingsOf = (steps) => {
924
+ const pairings = [];
925
+ const seen = /* @__PURE__ */ new Set();
926
+ for (const step of steps) {
927
+ if (step.type !== "pair") continue;
928
+ const base = step.baseBlock.table;
929
+ const target = step.targetBlock.table;
930
+ if (!base || !target) continue;
931
+ const key = `${String(base.tableIndex)}:${String(base.rowIndex)}:${String(base.cellIndex)}`;
932
+ if (seen.has(key)) continue;
933
+ seen.add(key);
934
+ pairings.push({
935
+ base: cellCoordinate(base),
936
+ target: cellCoordinate(target)
937
+ });
938
+ }
939
+ return pairings;
940
+ };
941
+ /**
704
942
  * Plan one story's comparison, or `null` when it needs more operations than
705
943
  * `maxOperations`.
706
944
  */
707
945
  const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }) => {
708
946
  const steps = buildSteps({
709
- story,
710
947
  baseSnapshot,
711
948
  targetSnapshot
712
949
  });
@@ -718,6 +955,7 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
718
955
  const anchorIds = nextBaseBlockIdByStep(steps);
719
956
  const changes = [];
720
957
  const operations = [];
958
+ const tableTemplates = [];
721
959
  /**
722
960
  * The anchor everything past the base document's content hangs from: its
723
961
  * last BODY-LEVEL paragraph, which the format guarantees exists because a
@@ -980,23 +1218,20 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
980
1218
  targetBlockIds: step.blocks.map(({ id }) => id)
981
1219
  });
982
1220
  const before = anchorIds[stepIndex] ?? null;
983
- if (before !== null) {
984
- operations.push({
985
- id: nextOperationId(),
986
- type: "insertTable",
987
- blockId: before,
988
- position: "before",
989
- rows
990
- });
991
- break;
992
- }
993
- if (tailAnchorId !== null) operations.push({
994
- id: nextOperationId(),
1221
+ const anchorBlockId = before ?? tailAnchorId;
1222
+ if (anchorBlockId === null) break;
1223
+ const operationId = nextOperationId();
1224
+ operations.push({
1225
+ id: operationId,
995
1226
  type: "insertTable",
996
- blockId: tailAnchorId,
997
- position: "after",
1227
+ blockId: anchorBlockId,
1228
+ position: before === null ? "after" : "before",
998
1229
  rows
999
1230
  });
1231
+ tableTemplates.push({
1232
+ operationId,
1233
+ targetTableIndex: step.location.tableIndex
1234
+ });
1000
1235
  break;
1001
1236
  }
1002
1237
  case "targetRow": {
@@ -1017,13 +1252,19 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
1017
1252
  for (const block of step.blocks) pushInsertOperation(block, anchorIds[stepIndex] ?? null);
1018
1253
  break;
1019
1254
  }
1255
+ const rowOperationId = nextOperationId();
1020
1256
  operations.push({
1021
- id: nextOperationId(),
1257
+ id: rowOperationId,
1022
1258
  type: "insertTableRow",
1023
1259
  blockId: anchor.blockId,
1024
1260
  position: anchor.position,
1025
1261
  cellTexts: cells
1026
1262
  });
1263
+ tableTemplates.push({
1264
+ operationId: rowOperationId,
1265
+ targetTableIndex: step.location.tableIndex,
1266
+ targetRowIndex: step.location.rowIndex
1267
+ });
1027
1268
  break;
1028
1269
  }
1029
1270
  case "targetColumn":
@@ -1050,9 +1291,17 @@ const planStoryCompare = ({ story, baseSnapshot, targetSnapshot, maxOperations }
1050
1291
  }
1051
1292
  if (operations.length > maxOperations) return null;
1052
1293
  }
1053
- return operations.length > maxOperations ? null : {
1294
+ const planned = withTrailingDeletionRules({
1295
+ baseSnapshot,
1296
+ targetSnapshot,
1297
+ operations,
1298
+ nextOperationId
1299
+ });
1300
+ return planned.length > maxOperations ? null : {
1054
1301
  changes,
1055
- operations
1302
+ operations: planned,
1303
+ tableTemplates,
1304
+ tableGeometryPairings: tableGeometryPairingsOf(steps)
1056
1305
  };
1057
1306
  };
1058
1307
  //#endregion
@@ -1,6 +1,6 @@
1
1
  //#region src/compare/reproducible-package.d.ts
2
2
  /**
3
- * The last clock in the compare path is the ZIP container itself.
3
+ * The last two clocks in the compare path are outside the document body.
4
4
  *
5
5
  * Every part the serializer rewrites is stored with JSZip's default entry
6
6
  * date, which is `new Date()`. The XML is identical between two runs, but the
@@ -8,8 +8,12 @@
8
8
  * both runs land in the same two-second bucket — so the packages match most of
9
9
  * the time and differ occasionally, which is worse than differing always.
10
10
  *
11
- * Restamping every entry from the comparison's own timestamp removes it. It
12
- * also states the truth about the package: a generated redline is dated by the
11
+ * The save also stamps `dcterms:modified` in `docProps/core.xml` from the wall
12
+ * clock, which is the same failure one part deeper: two runs over identical
13
+ * inputs differ in that part alone.
14
+ *
15
+ * Restamping both from the comparison's own timestamp removes them. It also
16
+ * states the truth about the package: a generated redline is dated by the
13
17
  * comparison that produced it, not by the second it happened to be written.
14
18
  */
15
19
  declare const withFixedPackageDates: (buffer: ArrayBuffer, date: Date) => Promise<ArrayBuffer>;
@@ -1,7 +1,7 @@
1
1
  import JSZip from "jszip";
2
2
  //#region src/compare/reproducible-package.ts
3
3
  /**
4
- * The last clock in the compare path is the ZIP container itself.
4
+ * The last two clocks in the compare path are outside the document body.
5
5
  *
6
6
  * Every part the serializer rewrites is stored with JSZip's default entry
7
7
  * date, which is `new Date()`. The XML is identical between two runs, but the
@@ -9,14 +9,31 @@ import JSZip from "jszip";
9
9
  * both runs land in the same two-second bucket — so the packages match most of
10
10
  * the time and differ occasionally, which is worse than differing always.
11
11
  *
12
- * Restamping every entry from the comparison's own timestamp removes it. It
13
- * also states the truth about the package: a generated redline is dated by the
12
+ * The save also stamps `dcterms:modified` in `docProps/core.xml` from the wall
13
+ * clock, which is the same failure one part deeper: two runs over identical
14
+ * inputs differ in that part alone.
15
+ *
16
+ * Restamping both from the comparison's own timestamp removes them. It also
17
+ * states the truth about the package: a generated redline is dated by the
14
18
  * comparison that produced it, not by the second it happened to be written.
15
19
  */
16
20
  /** JSZip deflate level `repackDocx` writes DOCX parts at. */
17
21
  const DOCX_COMPRESSION_LEVEL = 6;
22
+ const CORE_PROPERTIES_PATH = "docProps/core.xml";
23
+ const MODIFIED_ELEMENT = /<dcterms:modified[^<>]*>[^<]*<\/dcterms:modified>/u;
24
+ /**
25
+ * Rewrite `dcterms:modified` where the save already wrote one. An absent
26
+ * element stays absent: the comparison edits the document it was handed, and
27
+ * synthesizing metadata the input never carried is a different decision.
28
+ */
29
+ const withFixedModifiedDate = (corePropsXml, date) => corePropsXml.replace(MODIFIED_ELEMENT, `<dcterms:modified xsi:type="dcterms:W3CDTF">${date.toISOString()}</dcterms:modified>`);
18
30
  const withFixedPackageDates = async (buffer, date) => {
19
31
  const zip = await JSZip.loadAsync(buffer);
32
+ const coreProps = zip.file(CORE_PROPERTIES_PATH);
33
+ if (coreProps) zip.file(CORE_PROPERTIES_PATH, withFixedModifiedDate(await coreProps.async("text"), date), {
34
+ compression: "DEFLATE",
35
+ compressionOptions: { level: DOCX_COMPRESSION_LEVEL }
36
+ });
20
37
  zip.forEach((_path, file) => {
21
38
  file.date = date;
22
39
  });
@@ -1,7 +1,7 @@
1
1
  import { FolioAIBlockParagraphProperties, FolioAIBlockTableLocation, FolioAIEditSkippedOperation, FolioAIInlineFormatting } from "../ai-edits/types.js";
2
2
  import { WordDiffGranularity } from "../ai-edits/word-diff.js";
3
3
  import { FolioDocumentStoryHandle, FolioNumberingLevel } from "../ai-edits/headless.js";
4
- import { CompareVerification, CompareVerificationCause, CompareVerificationFailure, CompareVerificationInvariant } from "./verification.js";
4
+ import { CompareVerification, CompareVerificationCause, CompareVerificationFailure, CompareVerificationInvariant, FinalParagraphMarkRevision } from "./verification.js";
5
5
  //#region src/compare/types.d.ts
6
6
  /** Everything {@link compareDocx} needs; nothing it reads from the ambient clock. */
7
7
  type CompareDocxOptions = {
@@ -277,6 +277,23 @@ declare class CompareDocxSerializeError extends CompareDocxSerializeError_base<{
277
277
  message: string;
278
278
  cause: unknown;
279
279
  }> {}
280
- type CompareDocxError = CompareDocxApplyError | CompareDocxOperationLimitError | CompareDocxParseError | CompareDocxRoundTripError | CompareDocxSerializeError | InvalidCompareDocxOptionsError;
280
+ declare const CompareDocxFinalParagraphMarkError_base: import("better-result").TaggedErrorClass<"CompareDocxFinalParagraphMarkError">;
281
+ /**
282
+ * A container's final paragraph mark carries a revision, so the package would
283
+ * not open, or would open carrying one no reader can resolve.
284
+ *
285
+ * A deleted paragraph mark means "merge this paragraph into the following
286
+ * one", an inserted one means the break was added and rejecting it closes the
287
+ * paragraph back over the next one, and a container's last paragraph has no
288
+ * following one either way. Checked before the package is written, and fatal
289
+ * under either `onUnverified` setting: there is no redline to emit when a
290
+ * consumer refuses the file.
291
+ */
292
+ declare class CompareDocxFinalParagraphMarkError extends CompareDocxFinalParagraphMarkError_base<{
293
+ message: string;
294
+ /** Every container that carries one, each named structurally. */
295
+ revisions: readonly FinalParagraphMarkRevision[];
296
+ }> {}
297
+ type CompareDocxError = CompareDocxApplyError | CompareDocxFinalParagraphMarkError | CompareDocxOperationLimitError | CompareDocxParseError | CompareDocxRoundTripError | CompareDocxSerializeError | InvalidCompareDocxOptionsError;
281
298
  //#endregion
282
- export { COMPARE_UNSUPPORTED_REASONS, CompareChange, CompareChangeLocation, CompareDocxApplyError, CompareDocxError, CompareDocxOperationLimitError, CompareDocxOptions, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, CompareFormatRange, CompareResult, CompareUnsupportedPart, CompareUnsupportedReason, InvalidCompareDocxOptionsError };
299
+ export { COMPARE_UNSUPPORTED_REASONS, CompareChange, CompareChangeLocation, CompareDocxApplyError, CompareDocxError, CompareDocxFinalParagraphMarkError, CompareDocxOperationLimitError, CompareDocxOptions, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, CompareFormatRange, CompareResult, CompareUnsupportedPart, CompareUnsupportedReason, InvalidCompareDocxOptionsError };
@@ -28,5 +28,17 @@ var CompareDocxRoundTripError = class extends TaggedError("CompareDocxRoundTripE
28
28
  /** The difference needs more operations than the engine will generate. */
29
29
  var CompareDocxOperationLimitError = class extends TaggedError("CompareDocxOperationLimitError") {};
30
30
  var CompareDocxSerializeError = class extends TaggedError("CompareDocxSerializeError") {};
31
+ /**
32
+ * A container's final paragraph mark carries a revision, so the package would
33
+ * not open, or would open carrying one no reader can resolve.
34
+ *
35
+ * A deleted paragraph mark means "merge this paragraph into the following
36
+ * one", an inserted one means the break was added and rejecting it closes the
37
+ * paragraph back over the next one, and a container's last paragraph has no
38
+ * following one either way. Checked before the package is written, and fatal
39
+ * under either `onUnverified` setting: there is no redline to emit when a
40
+ * consumer refuses the file.
41
+ */
42
+ var CompareDocxFinalParagraphMarkError = class extends TaggedError("CompareDocxFinalParagraphMarkError") {};
31
43
  //#endregion
32
- export { COMPARE_UNSUPPORTED_REASONS, CompareDocxApplyError, CompareDocxOperationLimitError, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, InvalidCompareDocxOptionsError };
44
+ export { COMPARE_UNSUPPORTED_REASONS, CompareDocxApplyError, CompareDocxFinalParagraphMarkError, CompareDocxOperationLimitError, CompareDocxParseError, CompareDocxRoundTripError, CompareDocxSerializeError, InvalidCompareDocxOptionsError };
@@ -1,5 +1,6 @@
1
1
  import { FolioAIBlock } from "../ai-edits/types.js";
2
2
  import { FolioDocumentStoryHandle } from "../ai-edits/headless.js";
3
+ import { ParagraphMarkChangeKind } from "@stll/docx-core/model";
3
4
  //#region src/compare/verification.d.ts
4
5
  /** The two directions of the round trip, each an invariant of its own. */
5
6
  declare const COMPARE_VERIFICATION_INVARIANTS: readonly ["accept-reproduces-target", "reject-reproduces-base"];
@@ -9,7 +10,7 @@ type CompareVerificationInvariant = (typeof COMPARE_VERIFICATION_INVARIANTS)[num
9
10
  * that fits is the one reported, because a block in the wrong container is a
10
11
  * different finding from a block whose words are wrong even when both are true.
11
12
  */
12
- declare const COMPARE_VERIFICATION_CAUSES: readonly ["invisible-structure", "block-count", "container", "style", "list-level", "inline-formatting", "whitespace", "text"];
13
+ declare const COMPARE_VERIFICATION_CAUSES: readonly ["invisible-structure", "block-count", "container", "table-geometry", "style", "list-level", "inline-formatting", "whitespace", "text"];
13
14
  type CompareVerificationCause = (typeof COMPARE_VERIFICATION_CAUSES)[number];
14
15
  /** One invariant that did not hold, in one story. */
15
16
  type CompareVerificationFailure = {
@@ -44,6 +45,13 @@ type ClassifyOptions = {
44
45
  /** What the invariant says it should leave. */
45
46
  expected: readonly string[];
46
47
  };
48
+ /**
49
+ * The failure two table-geometry projections describe, or `null` when they
50
+ * agree. One line per table, so the detail names which table diverged and
51
+ * whether the count itself did — never a property value, which could carry a
52
+ * style name either document chose.
53
+ */
54
+ declare const classifyGeometryMismatch: ({ invariant, story, actual, expected }: ClassifyOptions) => CompareVerificationFailure | null;
47
55
  /**
48
56
  * The failure two projections describe, or `null` when they agree.
49
57
  *
@@ -51,5 +59,44 @@ type ClassifyOptions = {
51
59
  * a divergence always produces a failure rather than being dropped.
52
60
  */
53
61
  declare const classifyProjectionMismatch: ({ invariant, story, actual, expected }: ClassifyOptions) => CompareVerificationFailure | null;
62
+ /**
63
+ * A container whose last paragraph carries a revision on its mark.
64
+ *
65
+ * Structural facts only — a path through the package model and an index — so
66
+ * the finding is safe to log, report or quote.
67
+ */
68
+ type FinalParagraphMarkRevision = {
69
+ /** Where the container sits in the package model, e.g. `package.document.content`. */
70
+ container: string;
71
+ /** The paragraph's index among its container's children. */
72
+ paragraphIndex: number;
73
+ /** The mark kind found there, `moveFrom` / `moveTo` for a relocation's ends. */
74
+ kind: ParagraphMarkChangeKind;
75
+ };
76
+ /**
77
+ * Every container in a package whose final paragraph mark carries a revision.
78
+ *
79
+ * A deleted paragraph mark says "join this paragraph with the one after it",
80
+ * and an inserted one says that break was ADDED, so rejecting it closes the
81
+ * paragraph back over the next one. The last paragraph of a body, a table
82
+ * cell, a header or footer, a note or a text box has no paragraph after it, so
83
+ * neither direction states an edit that can be carried out: a consumer refuses
84
+ * the package, or opens it and leaves a revision standing that neither
85
+ * accepting nor rejecting everything can clear. The exception is a cell of a
86
+ * row the package is DELETING: there the mark leaves with its row.
87
+ *
88
+ * The walk is over the package model rather than over a list of the containers
89
+ * known today: a container is any sequence that ends in a paragraph, so a part
90
+ * the model grows later is covered the day it arrives instead of the day
91
+ * someone remembers this function.
92
+ *
93
+ * `since` scopes it to the revisions a comparison MINTED: a base may arrive
94
+ * carrying one of these on a paragraph in a part no story mounts, which folio
95
+ * preserves the way it preserves everything else it parses. What this proves
96
+ * is that the comparison writes none of its own.
97
+ */
98
+ declare const revisedFinalParagraphMarks: (packageModel: unknown, { since }?: {
99
+ since?: number;
100
+ }) => FinalParagraphMarkRevision[];
54
101
  //#endregion
55
- export { COMPARE_VERIFICATION_CAUSES, COMPARE_VERIFICATION_INVARIANTS, CompareVerification, CompareVerificationCause, CompareVerificationFailure, CompareVerificationInvariant, classifyProjectionMismatch, projectSupportedInlineFormatting, sameProjection };
102
+ export { COMPARE_VERIFICATION_CAUSES, COMPARE_VERIFICATION_INVARIANTS, CompareVerification, CompareVerificationCause, CompareVerificationFailure, CompareVerificationInvariant, FinalParagraphMarkRevision, classifyGeometryMismatch, classifyProjectionMismatch, projectSupportedInlineFormatting, revisedFinalParagraphMarks, sameProjection };