@stll/folio-core 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/ai-edits/apply.d.ts +2 -1
  2. package/dist/ai-edits/apply.js +118 -40
  3. package/dist/ai-edits/blockRange.js +1 -1
  4. package/dist/ai-edits/clean-text.js +2 -1
  5. package/dist/ai-edits/headless.js +3 -3
  6. package/dist/ai-edits/snapshot.js +21 -3
  7. package/dist/ai-edits/table-cell-mutations.js +2 -1
  8. package/dist/ai-edits/table-row-column-mutations.d.ts +9 -1
  9. package/dist/ai-edits/table-row-column-mutations.js +1 -1
  10. package/dist/ai-edits/types.d.ts +28 -1
  11. package/dist/ai-edits/word-diff.js +21 -0
  12. package/dist/compat/eigenpal.d.ts +3 -3
  13. package/dist/compat/eigenpal.js +3 -3
  14. package/dist/controller/headerFooterEditorManager.js +1 -1
  15. package/dist/controller/hiddenEditorManager.js +12 -1
  16. package/dist/controller/layoutPipeline.js +1 -1
  17. package/dist/controller/noteEditorManager.js +1 -1
  18. package/dist/document-operations.d.ts +13 -13
  19. package/dist/document-operations.js +31 -21
  20. package/dist/docx/blockContentParser.js +19 -5
  21. package/dist/docx/commentRangeIntegrity.d.ts +6 -0
  22. package/dist/docx/commentRangeIntegrity.js +186 -0
  23. package/dist/docx/conformance.js +57 -3
  24. package/dist/docx/encryption/agileDecryption.js +12 -2
  25. package/dist/docx/encryption/compoundFile.js +6 -1
  26. package/dist/docx/encryption/encryptionInfo.d.ts +9 -1
  27. package/dist/docx/encryption/encryptionInfo.js +18 -2
  28. package/dist/docx/encryption/openEncryptedDocx.js +1 -1
  29. package/dist/docx/ensureParaIds.js +7 -6
  30. package/dist/docx/groupDrawingParser.js +10 -5
  31. package/dist/docx/hyperlinkParser.js +1 -1
  32. package/dist/docx/imageParser.js +10 -6
  33. package/dist/docx/metadataPrivacy.js +17 -3
  34. package/dist/docx/paragraphParser.js +7 -3
  35. package/dist/docx/rezip.js +19 -8
  36. package/dist/docx/runParser.js +13 -4
  37. package/dist/docx/sectionParser.js +17 -4
  38. package/dist/docx/selectiveSave.js +4 -0
  39. package/dist/docx/selectiveXmlPatch.d.ts +22 -1
  40. package/dist/docx/selectiveXmlPatch.js +61 -1
  41. package/dist/docx/serializer/blockSdtSerializer.js +3 -2
  42. package/dist/docx/serializer/commentSerializer.js +4 -4
  43. package/dist/docx/serializer/paragraphSerializer.js +16 -14
  44. package/dist/docx/serializer/runSerializer.js +21 -19
  45. package/dist/docx/serializer/sectionPropertiesSerializer.js +2 -1
  46. package/dist/docx/serializer/tableSerializer.js +9 -7
  47. package/dist/docx/serializer/xmlUtils.d.ts +13 -1
  48. package/dist/docx/serializer/xmlUtils.js +27 -1
  49. package/dist/docx/server/docxToMarkdown.d.ts +15 -0
  50. package/dist/docx/server/docxToMarkdown.js +17 -0
  51. package/dist/docx/server/extractDocxText.js +55 -8
  52. package/dist/docx/settingsParser.js +15 -2
  53. package/dist/docx/tableParser.d.ts +8 -1
  54. package/dist/docx/tableParser.js +29 -8
  55. package/dist/docx/unzip.js +1 -0
  56. package/dist/docx/vmlImageParser.js +11 -3
  57. package/dist/docx/xmlParser.js +11 -1
  58. package/dist/fonts/embeddedFonts.d.ts +33 -5
  59. package/dist/fonts/embeddedFonts.js +37 -8
  60. package/dist/index.d.ts +3 -3
  61. package/dist/index.js +3 -3
  62. package/dist/layout-bridge/convert/toFlowBlocks.js +15 -0
  63. package/dist/layout-engine/index.js +19 -12
  64. package/dist/layout-engine/measure/cache.js +2 -0
  65. package/dist/layout-engine/measure/effectiveLineBreakPolicy.js +8 -2
  66. package/dist/layout-engine/measure/floatingTablePosition.d.ts +9 -0
  67. package/dist/layout-engine/measure/floatingTablePosition.js +17 -0
  68. package/dist/layout-engine/measure/index.d.ts +2 -1
  69. package/dist/layout-engine/measure/index.js +2 -1
  70. package/dist/layout-engine/measure/lineBreakProvider.d.ts +2 -2
  71. package/dist/layout-engine/measure/lineBreakProvider.js +3 -3
  72. package/dist/layout-engine/measure/measureBlocks.js +11 -8
  73. package/dist/layout-engine/measure/measureHelpers.d.ts +10 -1
  74. package/dist/layout-engine/measure/measureHelpers.js +12 -1
  75. package/dist/layout-engine/measure/measureParagraph.js +16 -0
  76. package/dist/layout-engine/types.d.ts +22 -2
  77. package/dist/layout-painter/renderImage.d.ts +12 -1
  78. package/dist/layout-painter/renderImage.js +21 -4
  79. package/dist/layout-painter/renderPage.js +5 -8
  80. package/dist/layout-painter/renderParagraph.js +33 -9
  81. package/dist/layout-painter/renderTable.js +2 -1
  82. package/dist/layout-painter/renderWatermark.d.ts +1 -1
  83. package/dist/layout-painter/renderWatermark.js +2 -1
  84. package/dist/markdown/fromMarkdown.js +1 -1
  85. package/dist/prosemirror/attrs/index.d.ts +1 -1
  86. package/dist/prosemirror/attrs/index.js +21 -0
  87. package/dist/prosemirror/commands/comments.d.ts +76 -1
  88. package/dist/prosemirror/commands/comments.js +401 -28
  89. package/dist/prosemirror/commands/hyperlink.js +15 -1
  90. package/dist/prosemirror/commands/image.js +7 -1
  91. package/dist/prosemirror/commands/index.d.ts +2 -2
  92. package/dist/prosemirror/commands/index.js +2 -2
  93. package/dist/prosemirror/commands/tableCellMergeResolution.js +1 -1
  94. package/dist/prosemirror/commentIdAllocator.js +13 -3
  95. package/dist/prosemirror/conversion/fromProseDoc.js +122 -5
  96. package/dist/prosemirror/conversion/index.js +1 -1
  97. package/dist/prosemirror/conversion/toProseDoc.js +20 -8
  98. package/dist/prosemirror/extensions/core/ParagraphExtension.js +2 -1
  99. package/dist/prosemirror/extensions/features/ImagePasteExtension.js +10 -2
  100. package/dist/prosemirror/extensions/features/pasteCleanup.js +15 -2
  101. package/dist/prosemirror/extensions/marks/HyperlinkExtension.js +30 -4
  102. package/dist/prosemirror/extensions/marks/TrackedChangeExtensions.js +49 -13
  103. package/dist/prosemirror/extensions/nodes/TableExtension.d.ts +1 -1
  104. package/dist/prosemirror/extensions/nodes/TableExtension.js +5 -3
  105. package/dist/prosemirror/index.d.ts +1 -1
  106. package/dist/prosemirror/index.js +1 -1
  107. package/dist/prosemirror/plugins/contentControlWidgets.d.ts +2 -2
  108. package/dist/prosemirror/plugins/contentControlWidgets.js +4 -4
  109. package/dist/prosemirror/plugins/revisionIds.d.ts +23 -0
  110. package/dist/prosemirror/plugins/revisionIds.js +57 -0
  111. package/dist/prosemirror/plugins/suggestionMode.js +4 -3
  112. package/dist/prosemirror/runFormattingMarkNames.d.ts +14 -0
  113. package/dist/prosemirror/runFormattingMarkNames.js +40 -0
  114. package/dist/prosemirror/schema/index.d.ts +1 -1
  115. package/dist/prosemirror/schema/marks.d.ts +27 -4
  116. package/dist/prosemirror/schema/marks.js +15 -0
  117. package/dist/prosemirror/schema/nodes.d.ts +50 -2
  118. package/dist/prosemirror/utils/tabCalculator.js +3 -3
  119. package/dist/render-dom/RemoteSelectionOverlay.js +2 -2
  120. package/dist/server.d.ts +3 -2
  121. package/dist/server.js +4 -3
  122. package/dist/utils/clipboard.d.ts +10 -1
  123. package/dist/utils/clipboard.js +33 -6
  124. package/dist/utils/colorResolver.d.ts +8 -1
  125. package/dist/utils/colorResolver.js +35 -5
  126. package/dist/utils/fontResolver.d.ts +2 -1
  127. package/dist/utils/fontResolver.js +9 -3
  128. package/dist/utils/hexId.d.ts +11 -1
  129. package/dist/utils/hexId.js +11 -1
  130. package/dist/utils/sanitizeImageSrc.d.ts +21 -0
  131. package/dist/utils/sanitizeImageSrc.js +30 -0
  132. package/dist/utils/stripXmlDeclarations.d.ts +11 -0
  133. package/dist/utils/stripXmlDeclarations.js +35 -0
  134. package/dist/version-comparison.d.ts +22 -2
  135. package/dist/version-comparison.js +41 -12
  136. package/dist/watermark/index.d.ts +11 -1
  137. package/dist/watermark/index.js +22 -1
  138. package/package.json +3 -2
@@ -1,5 +1,6 @@
1
1
  import { deterministicHexId } from "../utils/hexId.js";
2
2
  import { isXmlNameBoundary } from "./selectiveXmlPatch.js";
3
+ import { loadDocxArchive } from "./server/boundedArchive.js";
3
4
  import { TaggedError } from "better-result";
4
5
  import JSZip from "jszip";
5
6
  //#region src/docx/ensureParaIds.ts
@@ -350,18 +351,17 @@ const hasDigitalSignatureParts = (zip) => Object.values(zip.files).some(({ dir,
350
351
  * the buffer is not a WordprocessingML package or a part is malformed.
351
352
  */
352
353
  const ensureParaIdsInternal = async (docx, options) => {
353
- const zip = await JSZip.loadAsync(docx);
354
- const xmlPartNames = [];
355
- zip.forEach((relativePath, entry) => {
354
+ const archive = await loadDocxArchive(docx);
355
+ const xmlPartNames = archive.entries.filter((relativePath) => {
356
356
  const lower = relativePath.toLowerCase();
357
- if (!entry.dir && lower.startsWith("word/") && lower.endsWith(".xml")) xmlPartNames.push(relativePath);
357
+ return lower.startsWith("word/") && lower.endsWith(".xml");
358
358
  });
359
359
  const documentPartName = xmlPartNames.find((name) => name.toLowerCase() === DOCUMENT_PART);
360
360
  if (documentPartName === void 0) throw createEnsureParaIdsError("word/document.xml not found: not a WordprocessingML package");
361
361
  const partTexts = /* @__PURE__ */ new Map();
362
362
  for (const name of xmlPartNames) {
363
- const entry = zip.file(name);
364
- if (entry) partTexts.set(name, await entry.async("text"));
363
+ const text = await archive.readEntryString(name);
364
+ if (text !== null) partTexts.set(name, text);
365
365
  }
366
366
  const taken = /* @__PURE__ */ new Set();
367
367
  for (const text of partTexts.values()) collectExistingParaIds(text, taken);
@@ -390,6 +390,7 @@ const ensureParaIdsInternal = async (docx, options) => {
390
390
  deduplicated: 0,
391
391
  alreadyComplete: true
392
392
  };
393
+ const zip = await JSZip.loadAsync(docx);
393
394
  if (hasDigitalSignatureParts(zip) && options.allowSignedPackageMutation !== true) throw createEnsureParaIdsError("Refusing to normalize a digitally signed package because rewriting OOXML invalidates its signatures. Warn the user and pass allowSignedPackageMutation only if invalidation is acceptable.");
394
395
  for (const [partPath, content] of updates) zip.file(partPath, content, {
395
396
  compression: "DEFLATE",
@@ -150,12 +150,17 @@ const groupViewBox = (group, width, height) => {
150
150
  };
151
151
  };
152
152
  const createSvg = (group, width, height, rels, media) => {
153
- const content = getChildElements(group).slice(0, MAX_GROUP_SHAPES).map((child, index) => {
153
+ const children = getChildElements(group).slice(0, MAX_GROUP_SHAPES);
154
+ let content = "";
155
+ for (const [index, child] of children.entries()) {
154
156
  const localName = getLocalName(child.name ?? "");
155
- if (localName === "pic") return renderPicture(child, index, rels, media);
156
- if (localName !== "wsp") return "";
157
- return findChildByLocalName(child, "txbx") ? renderTextBox(child) : renderGeometry(child);
158
- }).join("");
157
+ let piece;
158
+ if (localName === "pic") piece = renderPicture(child, index, rels, media);
159
+ else if (localName === "wsp") piece = findChildByLocalName(child, "txbx") ? renderTextBox(child) : renderGeometry(child);
160
+ else continue;
161
+ content += piece;
162
+ if (content.length > MAX_SVG_CHARACTERS) return null;
163
+ }
159
164
  if (!content) return null;
160
165
  const viewBox = groupViewBox(group, width, height);
161
166
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}" width="${emuToPixels(width)}" height="${emuToPixels(height)}">${content}</svg>`;
@@ -1,6 +1,6 @@
1
1
  import { getAttribute, getChildElements, mergeXmlnsDeclarations, parseNumericAttribute } from "./xmlParser.js";
2
- import { parseRun } from "./runParser.js";
3
2
  import { sanitizeExternalUrl, sanitizeLinkTarget } from "../utils/urlSecurity.js";
3
+ import { parseRun } from "./runParser.js";
4
4
  //#region src/docx/hyperlinkParser.ts
5
5
  /**
6
6
  * Get the local name of an element (without namespace prefix)
@@ -1,5 +1,7 @@
1
1
  import { findByFullName, findChild, getAttribute, getChildElements, parseNumericAttribute } from "./xmlParser.js";
2
2
  import { emuToPixels } from "../utils/units.js";
3
+ import { sanitizeExternalUrl } from "../utils/urlSecurity.js";
4
+ import { sanitizeImageSrc } from "../utils/sanitizeImageSrc.js";
3
5
  import { WRAP_ELEMENT_NAMES, parsePositionH, parsePositionV, parseWrapElement } from "./drawingUtils.js";
4
6
  import { resolveTarget } from "./relsParser.js";
5
7
  import { isTextBoxDrawing } from "./textBoxParser.js";
@@ -326,7 +328,8 @@ function parseInline(inlineEl, rels, media) {
326
328
  if (props.alt) image.alt = props.alt;
327
329
  if (props.title) image.title = props.title;
328
330
  if (props.decorative) image.decorative = true;
329
- if (imageData.src) image.src = imageData.src;
331
+ const safeSrc = sanitizeImageSrc(imageData.src);
332
+ if (safeSrc) image.src = safeSrc;
330
333
  if (imageData.mimeType) image.mimeType = imageData.mimeType;
331
334
  if (imageData.filename) image.filename = imageData.filename;
332
335
  if (padding) image.padding = padding;
@@ -334,8 +337,8 @@ function parseInline(inlineEl, rels, media) {
334
337
  if (crop) image.crop = crop;
335
338
  if (opacity !== void 0) image.opacity = opacity;
336
339
  if (props.hlinkRId && rels) {
337
- const href = resolveTarget(rels, props.hlinkRId);
338
- if (href) image.hlinkHref = href;
340
+ const safeHref = sanitizeExternalUrl(resolveTarget(rels, props.hlinkRId));
341
+ if (safeHref) image.hlinkHref = safeHref;
339
342
  }
340
343
  return image;
341
344
  }
@@ -391,7 +394,8 @@ function parseAnchor(anchorEl, rels, media) {
391
394
  if (props.alt) image.alt = props.alt;
392
395
  if (props.title) image.title = props.title;
393
396
  if (props.decorative) image.decorative = true;
394
- if (imageData.src) image.src = imageData.src;
397
+ const safeSrc = sanitizeImageSrc(imageData.src);
398
+ if (safeSrc) image.src = safeSrc;
395
399
  if (imageData.mimeType) image.mimeType = imageData.mimeType;
396
400
  if (imageData.filename) image.filename = imageData.filename;
397
401
  if (position) image.position = position;
@@ -402,8 +406,8 @@ function parseAnchor(anchorEl, rels, media) {
402
406
  if (layoutInCell !== void 0) image.layoutInCell = layoutInCell;
403
407
  if (allowOverlap !== void 0) image.allowOverlap = allowOverlap;
404
408
  if (props.hlinkRId && rels) {
405
- const href = resolveTarget(rels, props.hlinkRId);
406
- if (href) image.hlinkHref = href;
409
+ const safeHref = sanitizeExternalUrl(resolveTarget(rels, props.hlinkRId));
410
+ if (safeHref) image.hlinkHref = safeHref;
407
411
  }
408
412
  return image;
409
413
  }
@@ -44,6 +44,20 @@ const XML_DECLARATION_PATTERN = /^\s*<\?xml[^?]*\?>/u;
44
44
  const MAX_INPUT_BYTES = 50 * 1024 * 1024;
45
45
  const MAX_ARCHIVE_ENTRIES = 5e3;
46
46
  const MAX_CORE_PROPERTIES_BYTES = 1024 * 1024;
47
+ const CORE_PROPERTIES_PATH_LOWER = "docprops/core.xml";
48
+ /**
49
+ * Look up a package part by its conventional lowercase path, falling back to
50
+ * a case-insensitive scan. OPC part names are case-insensitive, so a
51
+ * producer that wrote `docProps/Core.xml` (or another casing) must still be
52
+ * found — otherwise the privacy rewrite silently no-ops and the unscrubbed
53
+ * buffer round-trips untouched.
54
+ */
55
+ const findZipEntryCaseInsensitive = (zip, lowerPath) => {
56
+ const direct = zip.file(lowerPath);
57
+ if (direct) return direct;
58
+ for (const [path, file] of Object.entries(zip.files)) if (!file.dir && path.toLowerCase() === lowerPath) return file;
59
+ return null;
60
+ };
47
61
  const loadPrivacyArchive = async (buffer) => {
48
62
  if (buffer.byteLength > MAX_INPUT_BYTES) throw new FolioDocumentPrivacyArchiveError({
49
63
  message: "Document privacy input exceeded the compressed-size limit",
@@ -63,7 +77,7 @@ const loadPrivacyArchive = async (buffer) => {
63
77
  message: "Document privacy input exceeded the package-entry limit",
64
78
  reason: "too-many-entries"
65
79
  });
66
- const coreProperties = zip.file("docProps/core.xml");
80
+ const coreProperties = findZipEntryCaseInsensitive(zip, CORE_PROPERTIES_PATH_LOWER);
67
81
  if (!coreProperties) return zip;
68
82
  const data = "_data" in coreProperties ? coreProperties._data : void 0;
69
83
  const declaredBytes = typeof data === "object" && data !== null && "uncompressedSize" in data ? data.uncompressedSize : void 0;
@@ -99,7 +113,7 @@ const rewriteCorePropertiesPrivacy = (xml, transforms) => {
99
113
  const rewriteDocxMetadataPrivacy = async (buffer, { transforms }) => {
100
114
  const appliedTransforms = resolveFolioDocumentPrivacyTransforms(transforms);
101
115
  const zip = await loadPrivacyArchive(buffer);
102
- const coreProperties = zip.file("docProps/core.xml");
116
+ const coreProperties = findZipEntryCaseInsensitive(zip, CORE_PROPERTIES_PATH_LOWER);
103
117
  if (!coreProperties) return {
104
118
  buffer,
105
119
  privacyReport: {
@@ -115,7 +129,7 @@ const rewriteDocxMetadataPrivacy = async (buffer, { transforms }) => {
115
129
  removedMetadataProperties: []
116
130
  }
117
131
  };
118
- zip.file("docProps/core.xml", rewritten.xml);
132
+ zip.file(coreProperties.name, rewritten.xml);
119
133
  return {
120
134
  buffer: await zip.generateAsync({
121
135
  type: "arraybuffer",
@@ -2,11 +2,13 @@ import { BorderStyleSchema, FrameWrapSchema, FrameXAlignSchema, FrameYAlignSchem
2
2
  import { elementToXml, findChild, findChildren, getAttribute, getChildElements, mergeXmlnsDeclarations, parseBooleanElement, parseNumericAttribute } from "./xmlParser.js";
3
3
  import { parseRun, parseRunProperties } from "./runParser.js";
4
4
  import { parseFieldType } from "./fieldParser.js";
5
+ import { isValidHexId } from "../utils/hexId.js";
5
6
  import { parseBookmarkEnd as parseBookmarkEnd$1, parseBookmarkStart as parseBookmarkStart$1 } from "./bookmarkParser.js";
6
7
  import { parseHyperlink as parseHyperlink$1 } from "./hyperlinkParser.js";
7
8
  import { consolidateParagraphContent } from "./runConsolidator.js";
8
9
  import { parseSdtProperties } from "./sdtProperties.js";
9
10
  import { parseSectionProperties } from "./sectionParser.js";
11
+ import { normalizeRevisionId } from "@stll/docx-core/model";
10
12
  //#region src/docx/paragraphParser.ts
11
13
  /**
12
14
  * Extract plain text from a math element (recursive text content extraction)
@@ -486,11 +488,13 @@ function parseTrackedChangeInfo(node) {
486
488
  const rawDate = getAttribute(node, "w", "date");
487
489
  const author = rawAuthor?.trim() ?? "";
488
490
  const date = rawDate?.trim() ?? "";
491
+ const initials = (getAttribute(node, "w", "initials") ?? "").trim();
489
492
  const info = {
490
- id: Number.isInteger(parsedId) && parsedId >= 0 ? parsedId : 0,
493
+ id: normalizeRevisionId(parsedId),
491
494
  author: author.length > 0 ? author : "Unknown"
492
495
  };
493
496
  if (date.length > 0) info.date = date;
497
+ if (initials.length > 0) info.initials = initials;
494
498
  return info;
495
499
  }
496
500
  function parsePropertyChangeInfo(node) {
@@ -939,9 +943,9 @@ function parseParagraph(node, styles, theme, numbering, rels = null, media = nul
939
943
  content: []
940
944
  };
941
945
  const paraId = getAttribute(node, "w14", "paraId") ?? getAttribute(node, "w", "paraId");
942
- if (paraId) paragraph.paraId = paraId;
946
+ if (paraId && isValidHexId(paraId)) paragraph.paraId = paraId;
943
947
  const textId = getAttribute(node, "w14", "textId") ?? getAttribute(node, "w", "textId");
944
- if (textId) paragraph.textId = textId;
948
+ if (textId && isValidHexId(textId)) paragraph.textId = textId;
945
949
  if (!options?.inHeaderFooter && paragraphStartsWithRenderedPageBreak(node)) paragraph.renderedPageBreakBefore = true;
946
950
  const pPr = findChild(node, "w", "pPr");
947
951
  if (pPr) {
@@ -4,7 +4,8 @@ import { parseEndnotes, parseFootnotes } from "./footnoteParser.js";
4
4
  import { assertValidFolioDocumentModel } from "./modelValidation.js";
5
5
  import { isPreservableDocxEntry } from "./unzip.js";
6
6
  import { applyReplyThreadMarkers } from "./commentReplyMarkers.js";
7
- import { buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds, extractParagraphXml } from "./selectiveXmlPatch.js";
7
+ import { withoutOrphanCommentRanges } from "./commentRangeIntegrity.js";
8
+ import { buildParagraphOffsetIndex, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
8
9
  import { escapeXml } from "./serializer/xmlUtils.js";
9
10
  import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
10
11
  import { serializeDocument } from "./serializer/documentSerializer.js";
@@ -15,6 +16,7 @@ import { serializeFontTableXml } from "./serializer/fontTableSerializer.js";
15
16
  import { serializeSettingsXml } from "./serializer/settingsSerializer.js";
16
17
  import { serializeStylesXml } from "./serializer/stylesSerializer.js";
17
18
  import { serializeThemeXml } from "./serializer/themeSerializer.js";
19
+ import { isAllowedExternalWatermarkImageUrl } from "../watermark/index.js";
18
20
  import { panic } from "better-result";
19
21
  import JSZip from "jszip";
20
22
  //#region src/docx/rezip.ts
@@ -369,7 +371,7 @@ async function processNewHyperlinks(newHyperlinks, zip, compressionLevel) {
369
371
  async function repackDocx(doc, options = {}) {
370
372
  if (!doc.originalBuffer) panic("Cannot repack document: no original buffer for round-trip. Use createDocx() for new documents.");
371
373
  const { compressionLevel = 6, updateModifiedDate = true, modifiedBy } = options;
372
- const exportDocument = doc;
374
+ const exportDocument = withoutOrphanCommentRanges(doc);
373
375
  const originalZip = await JSZip.loadAsync(doc.originalBuffer);
374
376
  const newZip = new JSZip();
375
377
  for (const [path, file] of Object.entries(originalZip.files)) {
@@ -431,7 +433,7 @@ async function repackDocx(doc, options = {}) {
431
433
  */
432
434
  async function repackDocxFromRaw(doc, rawContent, options = {}) {
433
435
  const { compressionLevel = 6, updateModifiedDate = true, modifiedBy } = options;
434
- const exportDocument = doc;
436
+ const exportDocument = withoutOrphanCommentRanges(doc);
435
437
  const newZip = new JSZip();
436
438
  for (const [path, file] of Object.entries(rawContent.originalZip.files)) {
437
439
  if (file.dir) {
@@ -854,10 +856,11 @@ async function rebindWatermarkRelIds(doc, zip, compressionLevel) {
854
856
  const changedPaths = /* @__PURE__ */ new Set();
855
857
  for (const { watermark, relsPath, partPath } of pending) {
856
858
  let canonical;
857
- if (watermark.imageTarget !== void 0) canonical = watermark.imageTargetExternal ? {
859
+ if (watermark.imageTarget !== void 0) if (watermark.imageTargetExternal) canonical = isAllowedExternalWatermarkImageUrl(watermark.imageTarget) ? {
858
860
  mode: "external",
859
861
  url: watermark.imageTarget
860
- } : {
862
+ } : resolveCanonical(watermark.imageRId);
863
+ else canonical = {
861
864
  mode: "internal",
862
865
  absolute: watermark.imageTarget
863
866
  };
@@ -1000,15 +1003,23 @@ async function patchNotePartIntoZip(conventionalLowerPath, currentXml, baselineF
1000
1003
  * (re-parsed original) serialization — i.e. the ones actually edited. A
1001
1004
  * paragraph is only considered when its `paraId` resolves uniquely in both,
1002
1005
  * so it can be spliced safely.
1006
+ *
1007
+ * Builds one {@link buildParagraphOffsetIndex} per side (a single linear scan
1008
+ * each) instead of calling `extractParagraphXml` per candidate id, which
1009
+ * re-scanned the whole XML per id — O(note count * XML size) for a document
1010
+ * with many footnotes/endnotes. The index turns each lookup below into O(1).
1003
1011
  */
1004
1012
  function collectChangedNoteParaIds(baselineXml, currentXml) {
1005
1013
  const changed = /* @__PURE__ */ new Set();
1006
1014
  const baselineIds = collectParaIds(baselineXml);
1015
+ const baselineOffsets = buildParagraphOffsetIndex(baselineXml);
1016
+ const currentOffsets = buildParagraphOffsetIndex(currentXml);
1007
1017
  for (const [id, count] of collectParaIds(currentXml)) {
1008
1018
  if (count !== 1 || baselineIds.get(id) !== 1) continue;
1009
- const before = extractParagraphXml(baselineXml, id);
1010
- const after = extractParagraphXml(currentXml, id);
1011
- if (before !== null && after !== null && before !== after) changed.add(id);
1019
+ const beforeRange = baselineOffsets.get(id);
1020
+ const afterRange = currentOffsets.get(id);
1021
+ if (!beforeRange || !afterRange) continue;
1022
+ if (baselineXml.slice(beforeRange.start, beforeRange.end) !== currentXml.slice(afterRange.start, afterRange.end)) changed.add(id);
1012
1023
  }
1013
1024
  return changed;
1014
1025
  }
@@ -5,8 +5,17 @@ import { parseGroupDrawing } from "./groupDrawingParser.js";
5
5
  import { parseShapeFromDrawing, shouldPreserveRawShapeDrawing } from "./shapeParser.js";
6
6
  import { parseVmlImageContent } from "./vmlImageParser.js";
7
7
  import { resolveThemeFontRef } from "./themeParser.js";
8
+ import { normalizeRevisionId } from "@stll/docx-core/model";
8
9
  //#region src/docx/runParser.ts
9
10
  /**
11
+ * Sanity cap on `w:lang` `@w:val`/`@w:eastAsia`/`@w:bidi` tag length. BCP-47
12
+ * tags top out well under this; a hostile/corrupt tag here would drive the
13
+ * hyphenation dictionary lookup and segmenter cache keying with an
14
+ * attacker-sized string per run.
15
+ */
16
+ const MAX_LANGUAGE_TAG_LENGTH = 35;
17
+ const truncateLanguageTag = (value) => value === void 0 ? void 0 : value.slice(0, MAX_LANGUAGE_TAG_LENGTH);
18
+ /**
10
19
  * Parse color value from attributes
11
20
  */
12
21
  function parseColorValue(rgb, themeColor, themeTint, themeShade) {
@@ -267,9 +276,9 @@ function parseRunProperties(rPr, theme, _styles) {
267
276
  }
268
277
  const lang = propertyChildren.lang;
269
278
  if (lang) {
270
- const val = getAttribute(lang, "w", "val") || void 0;
271
- const eastAsia = getAttribute(lang, "w", "eastAsia") || void 0;
272
- const bidi = getAttribute(lang, "w", "bidi") || void 0;
279
+ const val = truncateLanguageTag(getAttribute(lang, "w", "val") || void 0);
280
+ const eastAsia = truncateLanguageTag(getAttribute(lang, "w", "eastAsia") || void 0);
281
+ const bidi = truncateLanguageTag(getAttribute(lang, "w", "bidi") || void 0);
273
282
  if (val || eastAsia || bidi) formatting.language = {
274
283
  ...val ? { val } : {},
275
284
  ...eastAsia ? { eastAsia } : {},
@@ -332,7 +341,7 @@ function parsePropertyChangeInfo(changeElement) {
332
341
  const date = (getAttribute(changeElement, "w", "date") ?? "").trim();
333
342
  const rsid = (getAttribute(changeElement, "w", "rsid") ?? "").trim();
334
343
  const info = {
335
- id: Number.isInteger(parsedId) && parsedId >= 0 ? parsedId : 0,
344
+ id: normalizeRevisionId(parsedId),
336
345
  author: author.length > 0 ? author : "Unknown"
337
346
  };
338
347
  if (date.length > 0) info.date = date;
@@ -2,7 +2,20 @@ import { BorderStyleSchema, ThemeColorSlotSchema, narrowEnum } from "./parserEnu
2
2
  import { findChild, findChildren, getAttribute, getChildElements, getLocalName, parseBooleanElement, parseNumericAttribute } from "./xmlParser.js";
3
3
  import { parseFooterReference, parseHeaderReference } from "./headerFooterRefParser.js";
4
4
  import { parseEndnoteProperties, parseFootnoteProperties } from "./notePropertiesParser.js";
5
+ import { normalizeRevisionId } from "@stll/docx-core/model";
5
6
  //#region src/docx/sectionParser.ts
7
+ /**
8
+ * Sanity cap on `w:cols/@w:num`. Word's column picker tops out well below
9
+ * this; a hostile/corrupt value here would force the layout engine to
10
+ * generate a proportional band per column.
11
+ */
12
+ const MAX_SECTION_COLUMNS = 45;
13
+ /**
14
+ * Sanity cap on `w:pgSz` `@w:w`/`@w:h`, in twips (~22in). Matches the
15
+ * existing `w:defaultTabStop` cap (`settingsParser.ts`) and bounds the
16
+ * ruler tick generators, which are sized off the page dimensions.
17
+ */
18
+ const MAX_PAGE_DIMENSION_TWIPS = 31680;
6
19
  const serializedSectionPropertyChildNames = /* @__PURE__ */ new Set([
7
20
  "headerReference",
8
21
  "footerReference",
@@ -55,7 +68,7 @@ function parsePropertyChangeInfo(node) {
55
68
  const date = (getAttribute(node, "w", "date") ?? "").trim();
56
69
  const rsid = (getAttribute(node, "w", "rsid") ?? "").trim();
57
70
  const info = {
58
- id: Number.isInteger(parsedId) && parsedId >= 0 ? parsedId : 0,
71
+ id: normalizeRevisionId(parsedId),
59
72
  author: author.length > 0 ? author : "Unknown"
60
73
  };
61
74
  if (date.length > 0) info.date = date;
@@ -170,9 +183,9 @@ function parseSectionProperties(sectPr) {
170
183
  const pgSz = findChild(sectPr, "w", "pgSz");
171
184
  if (pgSz) {
172
185
  const w = parseNumericAttribute(pgSz, "w", "w");
173
- if (w !== void 0) props.pageWidth = w;
186
+ if (w !== void 0) props.pageWidth = Math.min(w, MAX_PAGE_DIMENSION_TWIPS);
174
187
  const h = parseNumericAttribute(pgSz, "w", "h");
175
- if (h !== void 0) props.pageHeight = h;
188
+ if (h !== void 0) props.pageHeight = Math.min(h, MAX_PAGE_DIMENSION_TWIPS);
176
189
  const orientation = parseOrientation(getAttribute(pgSz, "w", "orient"));
177
190
  if (orientation) props.orientation = orientation;
178
191
  }
@@ -196,7 +209,7 @@ function parseSectionProperties(sectPr) {
196
209
  const cols = findChild(sectPr, "w", "cols");
197
210
  if (cols) {
198
211
  const num = parseNumericAttribute(cols, "w", "num");
199
- if (num !== void 0) props.columnCount = num;
212
+ if (num !== void 0) props.columnCount = Math.min(num, MAX_SECTION_COLUMNS);
200
213
  const space = parseNumericAttribute(cols, "w", "space");
201
214
  if (space !== void 0) props.columnSpace = space;
202
215
  const equalWidth = getAttribute(cols, "w", "equalWidth");
@@ -2,7 +2,9 @@ import { parseNumbering } from "./numberingParser.js";
2
2
  import { RELATIONSHIP_TYPES } from "./relsParser.js";
3
3
  import { parseCommentsExtended } from "./commentParser.js";
4
4
  import { validateFolioDocumentModel } from "./modelValidation.js";
5
+ import { isPreservableDocxEntry } from "./unzip.js";
5
6
  import { hasUnsynthesizedReplyRanges } from "./commentReplyMarkers.js";
7
+ import { withoutOrphanCommentRanges } from "./commentRangeIntegrity.js";
6
8
  import { buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds } from "./selectiveXmlPatch.js";
7
9
  import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
8
10
  import { serializeDocument } from "./serializer/documentSerializer.js";
@@ -182,6 +184,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
182
184
  if (hasNewImagesOrHyperlinks(content)) return null;
183
185
  if (hasUnmaterializedHeaderFooter(doc)) return null;
184
186
  if (hasModelDrivenPictureWatermark(doc)) return null;
187
+ if (withoutOrphanCommentRanges(doc) !== doc) return null;
185
188
  if (hasUnsynthesizedReplyRanges(doc)) return null;
186
189
  if (!validateFolioDocumentModel(doc).valid) return null;
187
190
  const comments = doc.package.document.comments ?? [];
@@ -189,6 +192,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
189
192
  const headerFooterUpdates = collectHeaderFooterUpdates(doc);
190
193
  try {
191
194
  const zip = await (await import("jszip")).default.loadAsync(originalBuffer);
195
+ for (const [path, file] of Object.entries(zip.files)) if (!file.dir && !isPreservableDocxEntry(path)) return null;
192
196
  const updates = /* @__PURE__ */ new Map();
193
197
  if (changedParaIds.size > 0) {
194
198
  const docXmlFile = zip.file("word/document.xml");
@@ -33,6 +33,27 @@ declare function findParagraphOffsets(xml: string, paraId: string): {
33
33
  * from a fully serialized document.xml string.
34
34
  */
35
35
  declare function extractParagraphXml(serializedXml: string, paraId: string): string | null;
36
+ type ParagraphOffsets = {
37
+ start: number;
38
+ end: number;
39
+ };
40
+ /**
41
+ * Single linear-scan index of every `<w:p>` element's start/end offsets,
42
+ * keyed by `w14:paraId`. Generalizes {@link findParagraphOffsets} (one
43
+ * regex-scan-plus-depth-walk per lookup) to build every paragraph's offsets
44
+ * in a single pass, so a caller that needs many paragraphs from the same XML
45
+ * (e.g. `collectChangedNoteParaIds` in rezip.ts, walking every note paraId)
46
+ * does O(1) map lookups afterward instead of re-scanning the whole XML once
47
+ * per id — O(ids * XML length) collapses to O(XML length).
48
+ *
49
+ * A `<w:p>` nested inside another (e.g. inside `mc:AlternateContent`) is
50
+ * indexed too via a depth stack, matching every element `findParagraphOffsets`
51
+ * can resolve. A paraId that appears on more than one element is ambiguous —
52
+ * mirroring {@link findParagraphOffsets}'s single-match requirement — and is
53
+ * omitted from the index, so a lookup misses exactly where the per-id
54
+ * function would return null.
55
+ */
56
+ declare function buildParagraphOffsetIndex(xml: string): Map<string, ParagraphOffsets>;
36
57
  /**
37
58
  * Count <w:p> elements in an XML string (top-level paragraph count).
38
59
  * Counts opening <w:p tags that are NOT self-closing.
@@ -109,4 +130,4 @@ declare function collectChangedNumberingDefs(baselineXml: string, currentXml: st
109
130
  */
110
131
  declare function buildPatchedNumberingXml(originalXml: string, currentXml: string, changed: ChangedNumberingDefs): string | null;
111
132
  //#endregion
112
- export { ChangedNumberingDefs, PatchSafetyOptions, PatchValidationResult, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
133
+ export { ChangedNumberingDefs, ParagraphOffsets, PatchSafetyOptions, PatchValidationResult, buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
@@ -74,6 +74,66 @@ function extractParagraphXml(serializedXml, paraId) {
74
74
  return serializedXml.slice(offsets.start, offsets.end);
75
75
  }
76
76
  /**
77
+ * Single linear-scan index of every `<w:p>` element's start/end offsets,
78
+ * keyed by `w14:paraId`. Generalizes {@link findParagraphOffsets} (one
79
+ * regex-scan-plus-depth-walk per lookup) to build every paragraph's offsets
80
+ * in a single pass, so a caller that needs many paragraphs from the same XML
81
+ * (e.g. `collectChangedNoteParaIds` in rezip.ts, walking every note paraId)
82
+ * does O(1) map lookups afterward instead of re-scanning the whole XML once
83
+ * per id — O(ids * XML length) collapses to O(XML length).
84
+ *
85
+ * A `<w:p>` nested inside another (e.g. inside `mc:AlternateContent`) is
86
+ * indexed too via a depth stack, matching every element `findParagraphOffsets`
87
+ * can resolve. A paraId that appears on more than one element is ambiguous —
88
+ * mirroring {@link findParagraphOffsets}'s single-match requirement — and is
89
+ * omitted from the index, so a lookup misses exactly where the per-id
90
+ * function would return null.
91
+ */
92
+ function buildParagraphOffsetIndex(xml) {
93
+ const seenCount = /* @__PURE__ */ new Map();
94
+ const ranges = /* @__PURE__ */ new Map();
95
+ const stack = [];
96
+ let pos = 0;
97
+ const noteOpen = (paraId) => {
98
+ if (paraId) seenCount.set(paraId, (seenCount.get(paraId) ?? 0) + 1);
99
+ };
100
+ const noteRange = (paraId, start, end) => {
101
+ if (paraId) ranges.set(paraId, {
102
+ start,
103
+ end
104
+ });
105
+ };
106
+ while (pos < xml.length) {
107
+ const tagStart = xml.indexOf("<", pos);
108
+ if (tagStart === -1) break;
109
+ if (xml.startsWith("</w:p>", tagStart)) {
110
+ const frame = stack.pop();
111
+ const end = tagStart + 6;
112
+ if (frame) noteRange(frame.paraId, frame.start, end);
113
+ pos = end;
114
+ continue;
115
+ }
116
+ if (!xml.startsWith("<w:p", tagStart) || !isXmlNameBoundary(xml[tagStart + 4])) {
117
+ pos = tagStart + 1;
118
+ continue;
119
+ }
120
+ const tagEnd = xml.indexOf(">", tagStart);
121
+ if (tagEnd === -1) break;
122
+ const openTag = xml.slice(tagStart, tagEnd + 1);
123
+ const paraId = /\bw14:paraId="(?<id>[^"]*)"/u.exec(openTag)?.groups?.["id"];
124
+ noteOpen(paraId);
125
+ if (xml[tagEnd - 1] === "/") noteRange(paraId, tagStart, tagEnd + 1);
126
+ else stack.push({
127
+ start: tagStart,
128
+ paraId
129
+ });
130
+ pos = tagEnd + 1;
131
+ }
132
+ const index = /* @__PURE__ */ new Map();
133
+ for (const [id, range] of ranges) if (seenCount.get(id) === 1) index.set(id, range);
134
+ return index;
135
+ }
136
+ /**
77
137
  * Count <w:p> elements in an XML string (top-level paragraph count).
78
138
  * Counts opening <w:p tags that are NOT self-closing.
79
139
  */
@@ -484,4 +544,4 @@ function escapeRegExp(str) {
484
544
  return str.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
485
545
  }
486
546
  //#endregion
487
- export { buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
547
+ export { buildParagraphOffsetIndex, buildPatchedDocumentXml, buildPatchedNoteXml, buildPatchedNumberingXml, collectChangedNumberingDefs, collectParaIds, countParagraphElements, extractParagraphXml, findParagraphOffsets, isXmlNameBoundary, validatePatchSafety };
@@ -1,3 +1,4 @@
1
+ import { isSingleWellFormedElement } from "./xmlUtils.js";
1
2
  import { reconcileRawSdtPr } from "../sdtPropertiesPatch.js";
2
3
  //#region src/docx/serializer/blockSdtSerializer.ts
3
4
  function escapeXmlAttr(value) {
@@ -73,14 +74,14 @@ function extractDateFullDate(blockSdt) {
73
74
  }
74
75
  function serializeBlockSdt(blockSdt, serializeChild) {
75
76
  const props = blockSdt.properties;
76
- const baseSdtPr = props.rawPropertiesXml ?? serializeFallbackSdtPr(props);
77
+ const baseSdtPr = props.rawPropertiesXml && isSingleWellFormedElement(props.rawPropertiesXml, "sdtPr") ? props.rawPropertiesXml : serializeFallbackSdtPr(props);
77
78
  const dateFullDate = extractDateFullDate(blockSdt);
78
79
  const dropdownLastValue = extractDropdownLastValue(blockSdt);
79
80
  const sdtPrXml = reconcileRawSdtPr(baseSdtPr, props, {
80
81
  ...dateFullDate !== void 0 ? { dateFullDate } : {},
81
82
  ...dropdownLastValue !== void 0 ? { dropdownLastValue } : {}
82
83
  });
83
- const sdtEndPrXml = props.rawEndPropertiesXml ?? "";
84
+ const sdtEndPrXml = props.rawEndPropertiesXml && isSingleWellFormedElement(props.rawEndPropertiesXml, "sdtEndPr") ? props.rawEndPropertiesXml : "";
84
85
  const contentXml = blockSdt.content.map(serializeChild).join("");
85
86
  return `<w:sdt>${sdtPrXml}${sdtEndPrXml}${props.rawSdtChildrenBeforeContent ?? ""}<w:sdtContent>${contentXml}</w:sdtContent>${props.rawSdtChildrenAfterContent ?? ""}</w:sdt>`;
86
87
  }
@@ -27,8 +27,8 @@ function serializeRunContent(run) {
27
27
  */
28
28
  function commentParagraphOpenTag(p) {
29
29
  const attrs = [];
30
- if (p.paraId) attrs.push(`w14:paraId="${p.paraId}"`);
31
- if (p.textId) attrs.push(`w14:textId="${p.textId}"`);
30
+ if (p.paraId) attrs.push(`w14:paraId="${escapeXml(p.paraId)}"`);
31
+ if (p.textId) attrs.push(`w14:textId="${escapeXml(p.textId)}"`);
32
32
  return attrs.length > 0 ? `<w:p ${attrs.join(" ")}>` : "<w:p>";
33
33
  }
34
34
  function serializeParagraph(p) {
@@ -173,8 +173,8 @@ function serializeCommentsExtended(comments) {
173
173
  if (!entries) return null;
174
174
  let xml = COMMENTS_EXTENDED_HEADER;
175
175
  for (const entry of entries) {
176
- const parentAttr = entry.paraIdParent !== void 0 ? ` w15:paraIdParent="${entry.paraIdParent}"` : "";
177
- xml += `<w15:commentEx w15:paraId="${entry.paraId}"${parentAttr} w15:done="${entry.done ? "1" : "0"}"/>`;
176
+ const parentAttr = entry.paraIdParent !== void 0 ? ` w15:paraIdParent="${escapeXml(entry.paraIdParent)}"` : "";
177
+ xml += `<w15:commentEx w15:paraId="${escapeXml(entry.paraId)}"${parentAttr} w15:done="${entry.done ? "1" : "0"}"/>`;
178
178
  }
179
179
  xml += "</w15:commentsEx>";
180
180
  return xml;