@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
@@ -2,14 +2,16 @@ import { isValidHexColor } from "../utils/colorResolver.js";
2
2
  import { isValidHexId } from "../utils/hexId.js";
3
3
  import { parseBookmarkEnd as parseBookmarkEnd$1, parseBookmarkStart as parseBookmarkStart$1 } from "./bookmarkParser.js";
4
4
  import { parseFieldType } from "./fieldParser.js";
5
- import { parseHyperlink as parseHyperlink$1 } from "./hyperlinkParser.js";
5
+ import { parseHyperlink as parseHyperlink$1, parseHyperlinkChild } from "./hyperlinkParser.js";
6
6
  import { markerFormattingFromLevel } from "./numberingParser.js";
7
+ import { paraIdInRange } from "./paraIdRangeNormalization.js";
7
8
  import { BorderStyleSchema, FrameWrapSchema, FrameXAlignSchema, FrameYAlignSchema, LineSpacingRuleSchema, ParagraphAlignmentSchema, ShadingPatternSchema, TabLeaderSchema, TabStopAlignmentSchema, ThemeColorSlotSchema, narrowEnum } from "./parserEnums.js";
8
9
  import { consolidateParagraphContent } from "./runConsolidator.js";
9
10
  import { parseRun, parseRunProperties } from "./runParser.js";
10
11
  import { parseSdtProperties } from "./sdtProperties.js";
11
12
  import { parseSectionProperties } from "./sectionParser.js";
12
- import { WORDPROCESSINGML_NAMESPACE_URIS, elementToXml, findChild, findChildByNamespaceUri, findChildren, getAttribute, getChildElements, getLocalName, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseNumberingLevelAttribute, parseNumericAttribute } from "./xmlParser.js";
13
+ import { captureVerbatimXml } from "./verbatimCapture.js";
14
+ import { WORDPROCESSINGML_NAMESPACE_URIS, findChild, findChildByNamespaceUri, findChildren, getAttribute, getChildElements, getLocalName, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseNumberingLevelAttribute, parseNumericAttribute } from "./xmlParser.js";
13
15
  import { panic } from "better-result";
14
16
  import { PARAGRAPH_MARK_CHANGE_KINDS, normalizeRevisionId } from "@stll/docx-core/model";
15
17
  //#region src/docx/paragraphParser.ts
@@ -646,6 +648,80 @@ function pushInlineSdtSegments({ contents, properties, parsedContent }) {
646
648
  function parseHyperlink(node, rels, styles, theme, media, rootXmlns = {}) {
647
649
  return parseHyperlink$1(node, rels, styles, theme, media, rootXmlns);
648
650
  }
651
+ /** The revision wrapper a `w:hyperlink` child is, when it is one. */
652
+ const hyperlinkRevisionWrapperType = (node) => {
653
+ switch (getLocalName(node.name)) {
654
+ case "ins": return "insertion";
655
+ case "del": return "deletion";
656
+ case "moveFrom": return "moveFrom";
657
+ case "moveTo": return "moveTo";
658
+ default: return;
659
+ }
660
+ };
661
+ const isHyperlinkChildContent = (content) => content.type === "run" || content.type === "bookmarkStart" || content.type === "bookmarkEnd";
662
+ /**
663
+ * A `w:hyperlink` as paragraph content, with any revision wrapper it holds
664
+ * hoisted around it.
665
+ *
666
+ * OOXML nests `w:ins`/`w:del` INSIDE `w:hyperlink`; the model nests the
667
+ * hyperlink inside the revision, because a revision is the unit a redline
668
+ * reads and a link that is half deleted is two links to it. This is the exact
669
+ * inverse of what the serializer writes, so a package survives the round trip.
670
+ */
671
+ function parseHyperlinkParagraphContents(node, rels, styles, theme, media, rootXmlns) {
672
+ const children = getChildElements(node);
673
+ if (!children.some((child) => hyperlinkRevisionWrapperType(child) !== void 0)) return [parseHyperlink(node, rels, styles, theme, media, rootXmlns)];
674
+ const inScopeXmlns = mergeXmlnsDeclarations(rootXmlns, node);
675
+ const shell = parseHyperlink(node, rels, styles, theme, media, rootXmlns);
676
+ const linkOver = (linkChildren) => ({
677
+ ...shell,
678
+ children: [...linkChildren]
679
+ });
680
+ const contents = [];
681
+ let plain = [];
682
+ const flushPlain = () => {
683
+ if (plain.length > 0) {
684
+ contents.push(linkOver(plain));
685
+ plain = [];
686
+ }
687
+ };
688
+ for (const child of children) {
689
+ const wrapperType = hyperlinkRevisionWrapperType(child);
690
+ if (wrapperType === void 0) {
691
+ const parsed = parseHyperlinkChild(child, styles, theme, rels, media, inScopeXmlns);
692
+ if (parsed) plain.push(parsed);
693
+ continue;
694
+ }
695
+ flushPlain();
696
+ const wrapped = parseParagraphContents(child, styles, theme, null, rels, media, wrapperType === "deletion" || wrapperType === "moveFrom" ? "deletion" : "default", inScopeXmlns);
697
+ const content = [];
698
+ let linked = [];
699
+ const flushLinked = () => {
700
+ if (linked.length > 0) {
701
+ content.push(linkOver(linked));
702
+ linked = [];
703
+ }
704
+ };
705
+ for (const item of wrapped) {
706
+ if (isHyperlinkChildContent(item)) {
707
+ linked.push(item);
708
+ continue;
709
+ }
710
+ flushLinked();
711
+ if (isTrackedChangeWrapperChild(item)) content.push(item);
712
+ }
713
+ flushLinked();
714
+ pushTrackedChangeWrapper({
715
+ contents,
716
+ type: wrapperType,
717
+ info: parseTrackedChangeInfo(child),
718
+ content,
719
+ preserveEmpty: true
720
+ });
721
+ }
722
+ flushPlain();
723
+ return contents;
724
+ }
649
725
  /**
650
726
  * Parse bookmark start (w:bookmarkStart)
651
727
  * Delegates to bookmarkParser module.
@@ -798,7 +874,7 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
798
874
  break;
799
875
  }
800
876
  case "hyperlink":
801
- contents.push(parseHyperlink(child, rels, styles, theme, media, inScopeXmlns));
877
+ contents.push(...parseHyperlinkParagraphContents(child, rels, styles, theme, media, inScopeXmlns));
802
878
  break;
803
879
  case "bookmarkStart":
804
880
  contents.push(parseBookmarkStart(child));
@@ -920,7 +996,7 @@ function parseParagraphContents(paraElement, styles, theme, _numbering, rels, me
920
996
  case "oMath":
921
997
  case "oMathPara": {
922
998
  const isBlock = localName === "oMathPara";
923
- const ommlXml = elementToXml(child);
999
+ const ommlXml = captureVerbatimXml(child);
924
1000
  const plainText = extractMathText(child);
925
1001
  const mathEq = {
926
1002
  type: "mathEquation",
@@ -961,9 +1037,9 @@ function parseParagraph(node, styles, theme, numbering, rels = null, media = nul
961
1037
  content: []
962
1038
  };
963
1039
  const paraId = getAttribute(node, "w14", "paraId") ?? getAttribute(node, "w", "paraId");
964
- if (paraId && isValidHexId(paraId)) paragraph.paraId = paraId;
1040
+ if (paraId && isValidHexId(paraId)) paragraph.paraId = paraIdInRange(paraId);
965
1041
  const textId = getAttribute(node, "w14", "textId") ?? getAttribute(node, "w", "textId");
966
- if (textId && isValidHexId(textId)) paragraph.textId = textId;
1042
+ if (textId && isValidHexId(textId)) paragraph.textId = paraIdInRange(textId);
967
1043
  if (!options?.inHeaderFooter && paragraphStartsWithRenderedPageBreak(node)) paragraph.renderedPageBreakBefore = true;
968
1044
  const pPr = findChild(node, "w", "pPr");
969
1045
  if (pPr) {
@@ -1,4 +1,18 @@
1
1
  //#region src/docx/revisionIdNormalization.d.ts
2
+ declare const RevisionIdCollisionError_base: import("better-result").TaggedErrorClass<"RevisionIdCollisionError">;
3
+ /**
4
+ * Two revision elements in one package claimed the same `w:id`.
5
+ *
6
+ * `w:id` on a revision element is unique across the package, so a collision is
7
+ * a package a consumer may reject rather than a cosmetic detail. The
8
+ * normalization below hands every id it emits to one choke point, which throws
9
+ * this rather than letting the duplicate reach the ZIP.
10
+ */
11
+ declare class RevisionIdCollisionError extends RevisionIdCollisionError_base<{
12
+ message: string;
13
+ revisionId: number;
14
+ part: string;
15
+ }> {}
2
16
  declare const REVISION_ELEMENT_NAMES: Set<string>;
3
17
  /**
4
18
  * Keep physical tracked-change element ids unique across a package.
@@ -10,4 +24,4 @@ declare const REVISION_ELEMENT_NAMES: Set<string>;
10
24
  */
11
25
  declare const normalizeRevisionIdsInXmlParts: (parts: ReadonlyMap<string, string>) => Map<string, string>;
12
26
  //#endregion
13
- export { REVISION_ELEMENT_NAMES, normalizeRevisionIdsInXmlParts };
27
+ export { REVISION_ELEMENT_NAMES, RevisionIdCollisionError, normalizeRevisionIdsInXmlParts };
@@ -1,7 +1,17 @@
1
1
  import { rewriteStreamingXmlDecimalAttributes } from "./streamingXmlParser.js";
2
2
  import { WORDPROCESSINGML_NAMESPACE_URIS, findAttributeByNamespaceUri, getLocalName, getNamespaceUri } from "./xmlParser.js";
3
3
  import { XmlResourceLimitError, assertXmlResourceLimits } from "./xmlResourceLimits.js";
4
+ import { TaggedError } from "better-result";
4
5
  //#region src/docx/revisionIdNormalization.ts
6
+ /**
7
+ * Two revision elements in one package claimed the same `w:id`.
8
+ *
9
+ * `w:id` on a revision element is unique across the package, so a collision is
10
+ * a package a consumer may reject rather than a cosmetic detail. The
11
+ * normalization below hands every id it emits to one choke point, which throws
12
+ * this rather than letting the duplicate reach the ZIP.
13
+ */
14
+ var RevisionIdCollisionError = class extends TaggedError("RevisionIdCollisionError") {};
5
15
  const REVISION_ELEMENT_NAMES = /* @__PURE__ */ new Set([
6
16
  "cellDel",
7
17
  "cellIns",
@@ -76,19 +86,27 @@ const normalizeRevisionIdsInXmlParts = (parts) => {
76
86
  for (const [path, xml] of candidates) {
77
87
  const ids = occurrencesByPath.get(path);
78
88
  if (!ids) continue;
89
+ const claim = (id) => {
90
+ if (seen.has(id)) throw new RevisionIdCollisionError({
91
+ message: `Revision id ${String(id)} is claimed twice in ${path}`,
92
+ revisionId: id,
93
+ part: path
94
+ });
95
+ seen.add(id);
96
+ };
79
97
  if (!repeatedPaths.has(path) && ids.every((id) => !seen.has(id))) {
80
- for (const id of ids) seen.add(id);
98
+ for (const id of ids) claim(id);
81
99
  continue;
82
100
  }
83
101
  const rewritten = rewriteStreamingXmlDecimalAttributes(xml, (element) => {
84
102
  const attribute = revisionAttribute(element);
85
103
  if (!attribute) return null;
86
104
  if (!seen.has(attribute.id)) {
87
- seen.add(attribute.id);
105
+ claim(attribute.id);
88
106
  return null;
89
107
  }
90
108
  const replacement = allocate();
91
- seen.add(replacement);
109
+ claim(replacement);
92
110
  return /* @__PURE__ */ new Map([[attribute.name, String(replacement)]]);
93
111
  });
94
112
  if (rewritten.status === "unsupported") throw new XmlResourceLimitError({
@@ -100,4 +118,4 @@ const normalizeRevisionIdsInXmlParts = (parts) => {
100
118
  return normalized;
101
119
  };
102
120
  //#endregion
103
- export { REVISION_ELEMENT_NAMES, normalizeRevisionIdsInXmlParts };
121
+ export { REVISION_ELEMENT_NAMES, RevisionIdCollisionError, normalizeRevisionIdsInXmlParts };
@@ -105,6 +105,12 @@ declare function updateMultipleFiles(originalBuffer: ArrayBuffer, updates: Map<s
105
105
  /**
106
106
  * Apply file updates to an already-loaded JSZip instance and generate the output.
107
107
  * Use this when the zip is already loaded to avoid a redundant decompression pass.
108
+ *
109
+ * This is the selective save's exit, so it owes the package the same passes
110
+ * {@link generateDocxZip} runs: a save that rewrites only the changed
111
+ * paragraphs still has to see the parts it left alone, both to know which
112
+ * revision ids are free and because an out-of-range paragraph id, or a
113
+ * malformed application version, can sit in a part it never touched.
108
114
  */
109
115
  declare function applyUpdatesToZip(zip: JSZip, updates: Map<string, string | ArrayBuffer>, options?: RepackOptions): Promise<ArrayBuffer>;
110
116
  /**
@@ -153,7 +159,13 @@ declare function hasUnmaterializedHeaderFooter(doc: document_d_exports.Document)
153
159
  * have been set without yet resolving in that header's rels).
154
160
  */
155
161
  declare function hasModelDrivenPictureWatermark(doc: document_d_exports.Document): boolean;
156
- declare function collectHeaderFooterUpdates(doc: document_d_exports.Document): Map<string, string>;
162
+ /**
163
+ * Re-serialize every header/footer the model still owns, keyed by part path.
164
+ *
165
+ * `sourceZip` supplies each part as it stands before the save so the rebuilt
166
+ * root can keep any prefix binding only the source document declared.
167
+ */
168
+ declare function collectHeaderFooterUpdates(doc: document_d_exports.Document, sourceZip: JSZip): Promise<Map<string, string>>;
157
169
  /** `word/Footnotes.xml` -> `word/_rels/Footnotes.xml.rels` (casing preserved). */
158
170
  declare function notePartRelsPath(partPath: string): string;
159
171
  /** Update existing core-property values without synthesizing absent metadata. */
@@ -1,4 +1,5 @@
1
1
  import { isAllowedExternalWatermarkImageUrl } from "../watermark/index.js";
2
+ import { normalizeAppVersionInExtendedProperties } from "./appVersionNormalization.js";
2
3
  import { withoutOrphanCommentRanges } from "./commentRangeIntegrity.js";
3
4
  import { applyReplyThreadMarkers } from "./commentReplyMarkers.js";
4
5
  import { parseEndnotes, parseFootnotes } from "./footnoteParser.js";
@@ -6,6 +7,7 @@ import { assertValidFolioDocumentModel } from "./modelValidation.js";
6
7
  import { isNewDataUrlDrawing } from "./newImage.js";
7
8
  import { parseNumbering } from "./numberingParser.js";
8
9
  import { isUnsafePackagePath, reconcilePackageReferences, removeUnsafeEntries } from "./packageParts.js";
10
+ import { normalizeParaIdRangeInXmlParts } from "./paraIdRangeNormalization.js";
9
11
  import { RELATIONSHIP_TYPES, parseRelationships, resolveRelativePath } from "./relsParser.js";
10
12
  import { normalizeRevisionIdsInXmlParts } from "./revisionIdNormalization.js";
11
13
  import { appendNumberingDefs, buildPatchedNotePartXml, buildPatchedNumberingXml, collectAddedNumberingDefs, collectChangedNoteParaIds, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
@@ -15,6 +17,7 @@ import { serializeFontTableXml } from "./serializer/fontTableSerializer.js";
15
17
  import { serializeHeaderFooter } from "./serializer/headerFooterSerializer.js";
16
18
  import { serializeEndnotes, serializeFootnotes, serializeNewEndnotesPart, serializeNewFootnotesPart } from "./serializer/noteSerializer.js";
17
19
  import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
20
+ import { readRootNamespaceBindings } from "./serializer/partNamespaces.js";
18
21
  import { serializeSettingsXml } from "./serializer/settingsSerializer.js";
19
22
  import { serializeStyle, serializeStylesXml } from "./serializer/stylesSerializer.js";
20
23
  import { serializeThemeXml } from "./serializer/themeSerializer.js";
@@ -121,13 +124,12 @@ function assertDocumentPackageFidelity(originalDocumentXml, serializedDocumentXm
121
124
  async function serializeCommentsToZip(doc, zip, compressionLevel) {
122
125
  const comments = doc.package.document.comments ?? [];
123
126
  const sourceCommentsFile = findZipEntryCaseInsensitive(zip, "word/comments.xml");
127
+ const sourceCommentsXml = sourceCommentsFile ? await sourceCommentsFile.async("text") : void 0;
124
128
  if (comments.length === 0) {
125
- if (!sourceCommentsFile) return;
126
- const sourceCommentsXml = await sourceCommentsFile.async("text");
127
- if (!hasCommentEntries(sourceCommentsXml)) return;
129
+ if (sourceCommentsXml === void 0 || !hasCommentEntries(sourceCommentsXml)) return;
128
130
  }
129
131
  ensureThreadedCommentParaIds(comments);
130
- const commentsXml = serializeComments(comments);
132
+ const commentsXml = serializeComments(comments, sourceCommentsXml === void 0 ? void 0 : readRootNamespaceBindings(sourceCommentsXml));
131
133
  zip.file(sourceCommentsFile?.name ?? "word/comments.xml", commentsXml, {
132
134
  compression: "DEFLATE",
133
135
  compressionOptions: { level: compressionLevel }
@@ -407,19 +409,58 @@ async function processNewHyperlinks(parts, zip, compressionLevel) {
407
409
  }
408
410
  }
409
411
  /**
410
- * The single exit for a repacked package. Reconciliation runs here rather than
411
- * at each caller so no save path can emit a package whose relationships or
412
- * content types name a part it does not hold.
412
+ * Bring the ids a package addresses itself by inside the bounds the format
413
+ * gives them.
414
+ *
415
+ * Both bounds belong to the package rather than to a part, and neither is
416
+ * something one serializer can see on its own. A revision `w:id` is unique
417
+ * across the package, and one logical revision serializes as several physical
418
+ * wrappers — a word diff cut around unchanged words, a revision split around a
419
+ * hyperlink. A paragraph id is 31-bit, and a paragraph is referenced by id
420
+ * from parts other than the one it lives in. So the passes run at the exits,
421
+ * over every `word/*.xml` part including the ones a save left untouched.
413
422
  */
414
- const generateDocxZip = async (zip, compressionLevel) => {
415
- await reconcilePackageReferences(zip, compressionLevel);
423
+ const normalizePackageIdsInZip = async (zip, compressionLevel) => {
416
424
  const xmlParts = /* @__PURE__ */ new Map();
417
425
  for (const [path, file] of Object.entries(zip.files)) if (!file.dir && path.startsWith("word/") && path.endsWith(".xml")) xmlParts.set(path, await file.async("text"));
418
- const normalizedParts = normalizeRevisionIdsInXmlParts(xmlParts);
426
+ const normalizedParts = normalizeParaIdRangeInXmlParts(normalizeRevisionIdsInXmlParts(xmlParts));
419
427
  for (const [path, xml] of normalizedParts) if (xml !== xmlParts.get(path)) zip.file(path, xml, {
420
428
  compression: "DEFLATE",
421
429
  compressionOptions: { level: compressionLevel }
422
430
  });
431
+ };
432
+ const EXTENDED_PROPERTIES_PATH = "docProps/app.xml";
433
+ /**
434
+ * Bring the application version the package states about itself into the form
435
+ * the schema gives it. A package that holds no extended properties keeps
436
+ * holding none.
437
+ */
438
+ const normalizeAppVersionInZip = async (zip, compressionLevel) => {
439
+ const extendedProperties = zip.file(EXTENDED_PROPERTIES_PATH);
440
+ if (!extendedProperties) return;
441
+ const xml = await extendedProperties.async("text");
442
+ const normalized = normalizeAppVersionInExtendedProperties(xml);
443
+ if (normalized !== xml) zip.file(EXTENDED_PROPERTIES_PATH, normalized, {
444
+ compression: "DEFLATE",
445
+ compressionOptions: { level: compressionLevel }
446
+ });
447
+ };
448
+ /**
449
+ * Every pass a save owes the package as a whole, in one function so that the
450
+ * full repack and the selective save cannot drift into running different ones.
451
+ */
452
+ const normalizePackageOnSave = async (zip, compressionLevel) => {
453
+ await normalizePackageIdsInZip(zip, compressionLevel);
454
+ await normalizeAppVersionInZip(zip, compressionLevel);
455
+ };
456
+ /**
457
+ * The single exit for a repacked package. Reconciliation runs here rather than
458
+ * at each caller so no save path can emit a package whose relationships or
459
+ * content types name a part it does not hold.
460
+ */
461
+ const generateDocxZip = async (zip, compressionLevel) => {
462
+ await reconcilePackageReferences(zip, compressionLevel);
463
+ await normalizePackageOnSave(zip, compressionLevel);
423
464
  return zip.generateAsync({
424
465
  type: "arraybuffer",
425
466
  compression: "DEFLATE",
@@ -454,14 +495,14 @@ const finishRepack = async ({ document, originalZip, outputZip, originalDocument
454
495
  await processNewHyperlinks(parts, outputZip, compressionLevel);
455
496
  assertValidFolioDocumentModel(document, "Cannot repack invalid DOCX document model");
456
497
  applyReplyThreadMarkers(document);
457
- const documentXml = serializeDocument(document);
498
+ const documentXml = serializeDocument(document, originalDocumentXml === void 0 ? void 0 : readRootNamespaceBindings(originalDocumentXml));
458
499
  if (originalDocumentXml) assertDocumentPackageFidelity(originalDocumentXml, documentXml, document);
459
500
  outputZip.file("word/document.xml", documentXml, {
460
501
  compression: "DEFLATE",
461
502
  compressionOptions: { level: compressionLevel }
462
503
  });
463
504
  await rebindWatermarkRelIds(document, outputZip, compressionLevel);
464
- serializeHeadersFootersToZip(document, outputZip, compressionLevel);
505
+ await serializeHeadersFootersToZip(document, outputZip, compressionLevel);
465
506
  await serializeNotesToZip({
466
507
  doc: document,
467
508
  originalZip,
@@ -543,14 +584,14 @@ async function repackDocxFromRaw(doc, rawContent, options = {}) {
543
584
  await processNewHyperlinks(parts, newZip, compressionLevel);
544
585
  assertValidFolioDocumentModel(exportDocument, "Cannot repack invalid DOCX document model");
545
586
  applyReplyThreadMarkers(exportDocument);
546
- const documentXml = serializeDocument(exportDocument);
587
+ const documentXml = serializeDocument(exportDocument, rawContent.documentXml ? readRootNamespaceBindings(rawContent.documentXml) : void 0);
547
588
  if (rawContent.documentXml) assertDocumentPackageFidelity(rawContent.documentXml, documentXml, exportDocument);
548
589
  newZip.file("word/document.xml", documentXml, {
549
590
  compression: "DEFLATE",
550
591
  compressionOptions: { level: compressionLevel }
551
592
  });
552
593
  await rebindWatermarkRelIds(exportDocument, newZip, compressionLevel);
553
- serializeHeadersFootersToZip(exportDocument, newZip, compressionLevel);
594
+ await serializeHeadersFootersToZip(exportDocument, newZip, compressionLevel);
554
595
  await serializeNotesToZip({
555
596
  doc: exportDocument,
556
597
  originalZip: rawContent.originalZip,
@@ -758,14 +799,21 @@ async function updateMultipleFiles(originalBuffer, updates, options = {}) {
758
799
  /**
759
800
  * Apply file updates to an already-loaded JSZip instance and generate the output.
760
801
  * Use this when the zip is already loaded to avoid a redundant decompression pass.
802
+ *
803
+ * This is the selective save's exit, so it owes the package the same passes
804
+ * {@link generateDocxZip} runs: a save that rewrites only the changed
805
+ * paragraphs still has to see the parts it left alone, both to know which
806
+ * revision ids are free and because an out-of-range paragraph id, or a
807
+ * malformed application version, can sit in a part it never touched.
761
808
  */
762
- function applyUpdatesToZip(zip, updates, options = {}) {
809
+ async function applyUpdatesToZip(zip, updates, options = {}) {
763
810
  const { compressionLevel = 6 } = options;
764
811
  for (const [path, content] of updates) zip.file(path, content, {
765
812
  compression: "DEFLATE",
766
813
  compressionOptions: { level: compressionLevel }
767
814
  });
768
- return zip.generateAsync({
815
+ await normalizePackageOnSave(zip, compressionLevel);
816
+ return await zip.generateAsync({
769
817
  type: "arraybuffer",
770
818
  compression: "DEFLATE",
771
819
  compressionOptions: { level: compressionLevel }
@@ -1067,7 +1115,13 @@ async function rebindWatermarkRelIds(doc, zip, compressionLevel) {
1067
1115
  });
1068
1116
  }
1069
1117
  }
1070
- function collectHeaderFooterUpdates(doc) {
1118
+ /**
1119
+ * Re-serialize every header/footer the model still owns, keyed by part path.
1120
+ *
1121
+ * `sourceZip` supplies each part as it stands before the save so the rebuilt
1122
+ * root can keep any prefix binding only the source document declared.
1123
+ */
1124
+ async function collectHeaderFooterUpdates(doc, sourceZip) {
1071
1125
  const updates = /* @__PURE__ */ new Map();
1072
1126
  const rels = doc.package.relationships;
1073
1127
  if (!rels) return updates;
@@ -1084,8 +1138,13 @@ function collectHeaderFooterUpdates(doc) {
1084
1138
  for (const [rId, headerFooter] of map.entries()) {
1085
1139
  const rel = rels.get(rId);
1086
1140
  if (rel && rel.type === type && rel.target) {
1087
- const filename = resolveRelativePath(documentRelsPath, rel.target);
1088
- updates.set(filename, serializeHeaderFooter(headerFooter));
1141
+ const path = resolveRelativePath(documentRelsPath, rel.target);
1142
+ const sourceFile = findZipEntryCaseInsensitive(sourceZip, path.toLowerCase());
1143
+ const bindings = sourceFile ? readRootNamespaceBindings(await sourceFile.async("text")) : /* @__PURE__ */ new Map();
1144
+ updates.set(path, serializeHeaderFooter(headerFooter, {
1145
+ path,
1146
+ bindings
1147
+ }));
1089
1148
  }
1090
1149
  }
1091
1150
  }
@@ -1094,9 +1153,9 @@ function collectHeaderFooterUpdates(doc) {
1094
1153
  /**
1095
1154
  * Serialize modified headers and footers into the ZIP
1096
1155
  */
1097
- function serializeHeadersFootersToZip(doc, zip, compressionLevel) {
1156
+ async function serializeHeadersFootersToZip(doc, zip, compressionLevel) {
1098
1157
  const compressionOptions = { level: compressionLevel };
1099
- for (const [filename, xml] of collectHeaderFooterUpdates(doc)) zip.file(filename, xml, {
1158
+ for (const [filename, xml] of await collectHeaderFooterUpdates(doc, zip)) zip.file(filename, xml, {
1100
1159
  compression: "DEFLATE",
1101
1160
  compressionOptions
1102
1161
  });
@@ -7,8 +7,9 @@ import { parseShapeFromDrawing, shouldPreserveRawShapeDrawing } from "./shapePar
7
7
  import { isTextBoxDrawing } from "./textBoxParser.js";
8
8
  import { requiresXmlSpacePreserve } from "./textWhitespace.js";
9
9
  import { resolveThemeFontRef } from "./themeParser.js";
10
+ import { captureVerbatimXml } from "./verbatimCapture.js";
10
11
  import { parseVmlImageContent } from "./vmlImageParser.js";
11
- import { cloneWithXmlnsDeclarations, elementToXml, findAllDeep, findChild, findChildren, getAttribute, getChildElements, getLocalName, getTextContent, mergeXmlnsDeclarations, parseBooleanElement, parseNumericAttribute } from "./xmlParser.js";
12
+ import { cloneWithXmlnsDeclarations, findAllDeep, findChild, findChildren, getAttribute, getChildElements, getLocalName, getTextContent, mergeXmlnsDeclarations, parseBooleanElement, parseNumericAttribute } from "./xmlParser.js";
12
13
  import { DRAWING_RAW_XML_MODES, normalizeRevisionId } from "@stll/docx-core/model";
13
14
  //#region src/docx/runParser.ts
14
15
  /**
@@ -489,7 +490,7 @@ function parseDrawingContent(element, rels, media) {
489
490
  if (groupImage) return {
490
491
  type: "drawing",
491
492
  image: groupImage,
492
- rawXml: elementToXml(element)
493
+ rawXml: captureVerbatimXml(element)
493
494
  };
494
495
  if (shouldPreserveRawShapeDrawing(element)) return {
495
496
  type: "drawing",
@@ -502,7 +503,7 @@ function parseDrawingContent(element, rels, media) {
502
503
  },
503
504
  wrap: { type: "inline" }
504
505
  },
505
- rawXml: elementToXml(element),
506
+ rawXml: captureVerbatimXml(element),
506
507
  rawXmlMode: DRAWING_RAW_XML_MODES.PRESERVE_ONLY
507
508
  };
508
509
  const shape = parseShapeFromDrawing(element);
@@ -516,7 +517,7 @@ function parseDrawingContent(element, rels, media) {
516
517
  type: "drawing",
517
518
  image
518
519
  };
519
- if (!image.src) drawing.rawXml = elementToXml(element);
520
+ if (!image.src) drawing.rawXml = captureVerbatimXml(element);
520
521
  return drawing;
521
522
  }
522
523
  /**
@@ -593,7 +594,7 @@ function parseRunContents(runElement, rels, media, rootXmlns = {}) {
593
594
  if (groupedChoiceDrawing) {
594
595
  const groupedDrawing = parseDrawingContent(groupedChoiceDrawing, rels, media);
595
596
  if (groupedDrawing?.type === "drawing" && groupedDrawing.image.src) {
596
- groupedDrawing.rawXml = elementToXml(child);
597
+ groupedDrawing.rawXml = captureVerbatimXml(child);
597
598
  contents.push(groupedDrawing);
598
599
  break;
599
600
  }
@@ -601,7 +602,7 @@ function parseRunContents(runElement, rels, media, rootXmlns = {}) {
601
602
  const fallbackPict = fallbackEl ? getChildElements(fallbackEl).find((el) => getLocalName(el.name) === "pict") : void 0;
602
603
  const fallbackVml = fallbackPict && !choiceTextBoxDrawing ? parseVmlImageContent(fallbackPict, rels, media, rootXmlns) : null;
603
604
  if (fallbackVml?.image.src) {
604
- fallbackVml.rawXml = elementToXml(cloneWithXmlnsDeclarations(child, rootXmlns));
605
+ fallbackVml.rawXml = captureVerbatimXml(cloneWithXmlnsDeclarations(child, rootXmlns));
605
606
  contents.push(fallbackVml);
606
607
  break;
607
608
  }
@@ -611,13 +612,13 @@ function parseRunContents(runElement, rels, media, rootXmlns = {}) {
611
612
  if (innerName === "drawing") {
612
613
  const innerDrawing = parseDrawingContent(innerChild, rels, media);
613
614
  if (innerDrawing) {
614
- if (innerDrawing.type === "drawing" && (innerDrawing.rawXml !== void 0 || !innerDrawing.image.src)) innerDrawing.rawXml = elementToXml(child);
615
+ if (innerDrawing.type === "drawing" && (innerDrawing.rawXml !== void 0 || !innerDrawing.image.src)) innerDrawing.rawXml = captureVerbatimXml(child);
615
616
  contents.push(innerDrawing);
616
617
  }
617
618
  } else if (innerName === "pict") {
618
619
  const innerVml = parseVmlImageContent(innerChild, rels, media, rootXmlns);
619
620
  if (innerVml) {
620
- innerVml.rawXml = elementToXml(cloneWithXmlnsDeclarations(child, rootXmlns));
621
+ innerVml.rawXml = captureVerbatimXml(cloneWithXmlnsDeclarations(child, rootXmlns));
621
622
  contents.push(innerVml);
622
623
  }
623
624
  }
@@ -1,5 +1,6 @@
1
1
  import { SdtLockSchema, narrowEnum } from "./parserEnums.js";
2
- import { elementToXml, findChild, getAttributeAnyPrefix, getLocalName, parseBooleanElement } from "./xmlParser.js";
2
+ import { captureVerbatimXml } from "./verbatimCapture.js";
3
+ import { findChild, getAttributeAnyPrefix, getLocalName, parseBooleanElement } from "./xmlParser.js";
3
4
  //#region src/docx/sdtProperties.ts
4
5
  function parseListItems(el) {
5
6
  const items = [];
@@ -201,7 +202,7 @@ function rewriteOpenTagAttrPrefixes(raw, canonical, local) {
201
202
  function parseSdtProperties(sdtPr, sdtEndPr) {
202
203
  const props = { sdtType: "richText" };
203
204
  if (sdtPr) {
204
- props.rawPropertiesXml = normalizeWordPrefix(elementToXml(sdtPr), sdtPr);
205
+ props.rawPropertiesXml = normalizeWordPrefix(captureVerbatimXml(sdtPr), sdtPr);
205
206
  for (const el of sdtPr.elements ?? []) {
206
207
  if (el.type !== "element") continue;
207
208
  const name = el.name ? getLocalName(el.name) : "";
@@ -283,7 +284,7 @@ function parseSdtProperties(sdtPr, sdtEndPr) {
283
284
  }
284
285
  }
285
286
  }
286
- if (sdtEndPr) props.rawEndPropertiesXml = normalizeWordPrefix(elementToXml(sdtEndPr), sdtEndPr);
287
+ if (sdtEndPr) props.rawEndPropertiesXml = normalizeWordPrefix(captureVerbatimXml(sdtEndPr), sdtEndPr);
287
288
  return props;
288
289
  }
289
290
  //#endregion
@@ -13,6 +13,7 @@ import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExten
13
13
  import { serializeDocument } from "./serializer/documentSerializer.js";
14
14
  import { serializeEndnotes, serializeFootnotes } from "./serializer/noteSerializer.js";
15
15
  import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
16
+ import { readRootNamespaceBindings } from "./serializer/partNamespaces.js";
16
17
  //#region src/docx/selectiveSave.ts
17
18
  /**
18
19
  * Check if document content has new images (data: URL without rId) or
@@ -209,7 +210,6 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
209
210
  if (!validateFolioDocumentModel(doc).valid) return null;
210
211
  const comments = doc.package.document.comments ?? [];
211
212
  const hasComments = comments.length > 0;
212
- const headerFooterUpdates = collectHeaderFooterUpdates(doc);
213
213
  try {
214
214
  const zip = await (await import("jszip")).default.loadAsync(originalBuffer);
215
215
  for (const [path, file] of Object.entries(zip.files)) if (!file.dir && isUnsafePackagePath(path)) return null;
@@ -228,15 +228,16 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
228
228
  if (unrouted === null || unrouted.size > 0) return null;
229
229
  }
230
230
  if (bodyChangedIds.size > 0) {
231
- const patchedDocXml = buildPatchedDocumentXml(originalDocXml, serializeDocument(doc), bodyChangedIds);
231
+ const patchedDocXml = buildPatchedDocumentXml(originalDocXml, serializeDocument(doc, readRootNamespaceBindings(originalDocXml)), bodyChangedIds);
232
232
  if (!patchedDocXml) return null;
233
233
  updates.set("word/document.xml", patchedDocXml);
234
234
  }
235
235
  }
236
- const hadCommentsFile = zip.file("word/comments.xml") !== null;
237
- if (hasComments || hadCommentsFile) {
236
+ const sourceCommentsFile = zip.file("word/comments.xml");
237
+ if (hasComments || sourceCommentsFile) {
238
238
  ensureThreadedCommentParaIds(comments);
239
- updates.set("word/comments.xml", serializeComments(comments));
239
+ const sourceBindings = sourceCommentsFile ? readRootNamespaceBindings(await sourceCommentsFile.async("text")) : void 0;
240
+ updates.set("word/comments.xml", serializeComments(comments, sourceBindings));
240
241
  }
241
242
  if (hasComments) {
242
243
  const ctFile = zip.file("[Content_Types].xml");
@@ -256,7 +257,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
256
257
  }
257
258
  if (!await patchCommentsExtended(zip, comments, updates)) return null;
258
259
  await patchNumberingPart(zip, doc, updates);
259
- for (const [path, xml] of headerFooterUpdates) updates.set(path, xml);
260
+ for (const [path, xml] of await collectHeaderFooterUpdates(doc, zip)) updates.set(path, xml);
260
261
  await queueSettingsUpdates(zip, updates);
261
262
  const corePropsFile = zip.file("docProps/core.xml");
262
263
  if (corePropsFile) {
@@ -7,7 +7,7 @@ import { Comment } from "../../types/content.js";
7
7
  * comment — leaving the previous file in place would otherwise re-emit
8
8
  * the orphaned comment threads on every save.
9
9
  */
10
- declare function serializeComments(comments: Comment[]): string;
10
+ declare function serializeComments(comments: Comment[], sourceBindings?: ReadonlyMap<string, string>): string;
11
11
  /**
12
12
  * Assign a deterministic `w14:paraId` to the LAST paragraph of every comment
13
13
  * that needs a commentsExtended entry (a reply, a reply's parent, or a resolved