@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,34 @@
1
1
  //#region src/utils/colorResolver.ts
2
2
  /**
3
+ * Strict hex-color validator shared by every OOXML → CSS color path in this
4
+ * module. OOXML readers are lenient about `w:val`/`w:fill` attribute content
5
+ * and theme swatch text, so a crafted `<w:color>`, table shading, or
6
+ * `themeN.xml` color-scheme entry can carry arbitrary text — including a CSS
7
+ * `url(...)` payload — instead of a real color. Accepts a 3, 6, or 8 digit
8
+ * hex string with an optional leading `#`; anything else (including the
9
+ * literal `"auto"`, which callers handle separately) is rejected.
10
+ */
11
+ const HEX_COLOR_PATTERN = /^#?[0-9A-Fa-f]{3}(?:[0-9A-Fa-f]{3})?(?:[0-9A-Fa-f]{2})?$/u;
12
+ /**
13
+ * Normalize a raw color string to a bare uppercase hex value (no `#`), or
14
+ * `undefined` when it is not a valid 3/6/8-digit hex color. This is the
15
+ * single choke point every resolved color passes through before becoming a
16
+ * CSS string — see {@link resolveColor} and {@link resolveColorToHex}.
17
+ */
18
+ function normalizeHexColor(value) {
19
+ if (!value) return;
20
+ return HEX_COLOR_PATTERN.test(value) ? value.replace(/^#/u, "").toUpperCase() : void 0;
21
+ }
22
+ /**
23
+ * Public form of the same hex-color check, for callers that read a color
24
+ * value from an untrusted surface (e.g. a pasted DOM node's `dataset`,
25
+ * which bypasses the CSSOM's own value validation) and must reject it
26
+ * before storing it as editor state.
27
+ */
28
+ function isValidHexColor(value) {
29
+ return typeof value === "string" && normalizeHexColor(value) !== void 0;
30
+ }
31
+ /**
3
32
  * Default theme colors (Office 2016 default theme)
4
33
  */
5
34
  const DEFAULT_THEME_COLORS = {
@@ -194,8 +223,9 @@ function resolveThemeColorSlot(colorName) {
194
223
  * @returns CSS color string (e.g., "#FF0000" or "inherit")
195
224
  */
196
225
  function resolveColor(color, theme, defaultColor = "000000") {
197
- if (!color) return `#${defaultColor}`;
198
- if (color.auto) return `#${defaultColor}`;
226
+ const safeDefault = normalizeHexColor(defaultColor) ?? "000000";
227
+ if (!color) return `#${safeDefault}`;
228
+ if (color.auto) return `#${safeDefault}`;
199
229
  let hexColor;
200
230
  if (color.themeColor) {
201
231
  const slot = resolveThemeColorSlot(color.themeColor);
@@ -210,7 +240,7 @@ function resolveColor(color, theme, defaultColor = "000000") {
210
240
  }
211
241
  } else if (color.rgb) hexColor = color.rgb === "auto" ? defaultColor : color.rgb;
212
242
  else hexColor = defaultColor;
213
- return `#${hexColor.toUpperCase().replace(/^#/u, "")}`;
243
+ return `#${normalizeHexColor(hexColor) ?? safeDefault}`;
214
244
  }
215
245
  /**
216
246
  * Resolve a highlight color name to CSS
@@ -227,7 +257,7 @@ function resolveColor(color, theme, defaultColor = "000000") {
227
257
  function resolveColorToHex(color, theme) {
228
258
  if (!color || color.auto) return void 0;
229
259
  if (color.themeColor && theme) return resolveColor(color, theme).slice(1);
230
- if (color.rgb && color.rgb !== "auto") return color.rgb.toUpperCase().replace(/^#/, "");
260
+ if (color.rgb && color.rgb !== "auto") return normalizeHexColor(color.rgb);
231
261
  }
232
262
  function resolveHighlightColor(highlight) {
233
263
  if (!highlight || highlight === "none") return "";
@@ -565,4 +595,4 @@ function colorsEqual(color1, color2, theme) {
565
595
  return resolveColor(color1, theme).toUpperCase() === resolveColor(color2, theme).toUpperCase();
566
596
  }
567
597
  //#endregion
568
- export { STANDARD_TEXT_COLORS, blendColors, colorsEqual, createRgbColor, createThemeColor, darkenColor, ensureHexPrefix, generateThemeTintShadeMatrix, getContrastingColor, getThemeTintShadeHex, isBlack, isWhite, lightenColor, parseColorString, resolveColor, resolveColorToHex, resolveHighlightColor, resolveHighlightToCss, resolveShadingColor };
598
+ export { STANDARD_TEXT_COLORS, blendColors, colorsEqual, createRgbColor, createThemeColor, darkenColor, ensureHexPrefix, generateThemeTintShadeMatrix, getContrastingColor, getThemeTintShadeHex, isBlack, isValidHexColor, isWhite, lightenColor, parseColorString, resolveColor, resolveColorToHex, resolveHighlightColor, resolveHighlightToCss, resolveShadingColor };
@@ -40,6 +40,7 @@ declare function isCjkFont(family: string): boolean;
40
40
  declare const CJK_FALLBACK_FONT_FAMILY = "MS Mincho";
41
41
  declare const setGoogleFontsEnabled: (enabled: boolean) => void;
42
42
  declare const getGoogleFontsEnabled: () => boolean;
43
+ declare const setEmbeddedFontFamilyMap: (map: ReadonlyMap<string, string> | null) => void;
43
44
  declare function resolveFontFamily(docxFontName: string): ResolvedFont;
44
45
  /**
45
46
  * Resolve a theme font reference to actual font names
@@ -79,4 +80,4 @@ declare function getGoogleFontEquivalent(docxFontName: string): string | null;
79
80
  */
80
81
  declare function hasGoogleFontEquivalent(docxFontName: string): boolean;
81
82
  //#endregion
82
- export { CJK_FALLBACK_FONT_FAMILY, DEFAULT_SINGLE_LINE_RATIO, ResolvedFont, buildFontFamilyString, getGoogleFontEquivalent, getGoogleFontsEnabled, getGoogleFontsToLoad, hasGoogleFontEquivalent, isCjkFont, resolveFontFamily, resolveThemeFont, setGoogleFontsEnabled };
83
+ export { CJK_FALLBACK_FONT_FAMILY, DEFAULT_SINGLE_LINE_RATIO, ResolvedFont, buildFontFamilyString, getGoogleFontEquivalent, getGoogleFontsEnabled, getGoogleFontsToLoad, hasGoogleFontEquivalent, isCjkFont, resolveFontFamily, resolveThemeFont, setEmbeddedFontFamilyMap, setGoogleFontsEnabled };
@@ -809,15 +809,21 @@ const setGoogleFontsEnabled = (enabled) => {
809
809
  googleFontsEnabled = enabled;
810
810
  };
811
811
  const getGoogleFontsEnabled = () => googleFontsEnabled;
812
+ let embeddedFontFamilyMap = null;
813
+ const setEmbeddedFontFamilyMap = (map) => {
814
+ embeddedFontFamilyMap = map;
815
+ };
812
816
  function resolveFontFamily(docxFontName) {
813
817
  const normalizedName = docxFontName.trim().toLowerCase();
818
+ const scopedFamily = embeddedFontFamilyMap?.get(docxFontName) ?? null;
814
819
  const aliasTarget = CJK_FONT_ALIASES[normalizedName];
815
820
  const mapping = FONT_MAPPINGS[aliasTarget ?? normalizedName];
816
821
  if (mapping) {
817
822
  const fallbackStack = aliasTarget && !mapping.fallbackStack.some((f) => f.toLowerCase() === normalizedName) ? [docxFontName, ...mapping.fallbackStack] : mapping.fallbackStack;
823
+ const primaryStack = scopedFamily ? [scopedFamily, ...fallbackStack.filter((f) => f.toLowerCase() !== normalizedName)] : fallbackStack;
818
824
  return {
819
825
  googleFont: googleFontsEnabled ? mapping.googleFont : null,
820
- cssFallback: withArabicFallback(fallbackStack.map(quoteFontName).join(", ")),
826
+ cssFallback: withArabicFallback(primaryStack.map(quoteFontName).join(", ")),
821
827
  originalFont: docxFontName,
822
828
  hasGoogleEquivalent: googleFontsEnabled,
823
829
  singleLineRatio: mapping.singleLineRatio
@@ -827,7 +833,7 @@ function resolveFontFamily(docxFontName) {
827
833
  const defaultFallback = DEFAULT_FALLBACKS[category];
828
834
  return {
829
835
  googleFont: null,
830
- cssFallback: withArabicFallback(`${quoteFontName(docxFontName)}, ${defaultFallback}`),
836
+ cssFallback: withArabicFallback(`${quoteFontName(scopedFamily ?? docxFontName)}, ${defaultFallback}`),
831
837
  originalFont: docxFontName,
832
838
  hasGoogleEquivalent: false,
833
839
  singleLineRatio: DEFAULT_SINGLE_LINE_RATIO
@@ -922,4 +928,4 @@ function hasGoogleFontEquivalent(docxFontName) {
922
928
  return (CJK_FONT_ALIASES[normalizedName] ?? normalizedName) in FONT_MAPPINGS;
923
929
  }
924
930
  //#endregion
925
- export { CJK_FALLBACK_FONT_FAMILY, DEFAULT_SINGLE_LINE_RATIO, buildFontFamilyString, getGoogleFontEquivalent, getGoogleFontsEnabled, getGoogleFontsToLoad, hasGoogleFontEquivalent, isCjkFont, resolveFontFamily, resolveThemeFont, setGoogleFontsEnabled };
931
+ export { CJK_FALLBACK_FONT_FAMILY, DEFAULT_SINGLE_LINE_RATIO, buildFontFamilyString, getGoogleFontEquivalent, getGoogleFontsEnabled, getGoogleFontsToLoad, hasGoogleFontEquivalent, isCjkFont, resolveFontFamily, resolveThemeFont, setEmbeddedFontFamilyMap, setGoogleFontsEnabled };
@@ -15,6 +15,16 @@
15
15
  * strict OOXML validators.
16
16
  */
17
17
  declare const MAX_HEX_ID_EXCLUSIVE = 2147483647;
18
+ /**
19
+ * OOXML `ST_LongHexNumber` shape: exactly 8 hex digits (`xsd:hexBinary`,
20
+ * length 4 bytes). `w14:paraId` / `w14:textId` / comment `paraId` /
21
+ * `w15:paraIdParent` are all typed this way. A value that doesn't match this
22
+ * is not a real Word id — it must not be trusted as one (e.g. echoed
23
+ * unescaped into serialized XML attributes).
24
+ */
25
+ declare const HEX_ID_PATTERN: RegExp;
26
+ /** Whether `value` is a well-formed 8-hex-digit OOXML long-hex id. */
27
+ declare const isValidHexId: (value: string | undefined | null) => boolean;
18
28
  /**
19
29
  * Random 8-char uppercase hex id, matching Microsoft's `w14:paraId`
20
30
  * extension format (also reused for comment `paraId` / `durableId`).
@@ -37,4 +47,4 @@ declare const generateHexId: () => string;
37
47
  */
38
48
  declare const deterministicHexId: (seed: string) => string;
39
49
  //#endregion
40
- export { MAX_HEX_ID_EXCLUSIVE, deterministicHexId, generateHexId };
50
+ export { HEX_ID_PATTERN, MAX_HEX_ID_EXCLUSIVE, deterministicHexId, generateHexId, isValidHexId };
@@ -16,6 +16,16 @@
16
16
  */
17
17
  const MAX_HEX_ID_EXCLUSIVE = 2147483647;
18
18
  /**
19
+ * OOXML `ST_LongHexNumber` shape: exactly 8 hex digits (`xsd:hexBinary`,
20
+ * length 4 bytes). `w14:paraId` / `w14:textId` / comment `paraId` /
21
+ * `w15:paraIdParent` are all typed this way. A value that doesn't match this
22
+ * is not a real Word id — it must not be trusted as one (e.g. echoed
23
+ * unescaped into serialized XML attributes).
24
+ */
25
+ const HEX_ID_PATTERN = /^[0-9A-Fa-f]{8}$/u;
26
+ /** Whether `value` is a well-formed 8-hex-digit OOXML long-hex id. */
27
+ const isValidHexId = (value) => typeof value === "string" && HEX_ID_PATTERN.test(value);
28
+ /**
19
29
  * Random 8-char uppercase hex id, matching Microsoft's `w14:paraId`
20
30
  * extension format (also reused for comment `paraId` / `durableId`).
21
31
  *
@@ -42,4 +52,4 @@ const deterministicHexId = (seed) => {
42
52
  return (value === 0 ? 1 : value).toString(16).toUpperCase().padStart(8, "0");
43
53
  };
44
54
  //#endregion
45
- export { MAX_HEX_ID_EXCLUSIVE, deterministicHexId, generateHexId };
55
+ export { HEX_ID_PATTERN, MAX_HEX_ID_EXCLUSIVE, deterministicHexId, generateHexId, isValidHexId };
@@ -0,0 +1,21 @@
1
+ //#region src/utils/sanitizeImageSrc.d.ts
2
+ /**
3
+ * Keep painted image sources local to bytes the host already owns.
4
+ *
5
+ * Document media and pasted files enter the editor as `data:image/*` or
6
+ * `blob:` URLs. Network and executable schemes are rejected so opening or
7
+ * pasting an untrusted document cannot trigger an external request or run a
8
+ * script scheme through `<img src>`.
9
+ */
10
+ declare function sanitizeImageSrc(src: string | null | undefined): string | undefined;
11
+ /**
12
+ * Assign a sanitized source to an `<img>` element. When the source is rejected
13
+ * the `src` attribute is left unset: assigning `""` makes some browsers resolve
14
+ * it against the page URL and fire a spurious request for the current page.
15
+ *
16
+ * Structural parameter type so painter unit tests (which fake the DOM) can
17
+ * exercise it without an `HTMLImageElement`.
18
+ */
19
+ declare function applySanitizedImageSrc(imgEl: Pick<HTMLImageElement, "src">, src: string | null | undefined): void;
20
+ //#endregion
21
+ export { applySanitizedImageSrc, sanitizeImageSrc };
@@ -0,0 +1,30 @@
1
+ //#region src/utils/sanitizeImageSrc.ts
2
+ /**
3
+ * Keep painted image sources local to bytes the host already owns.
4
+ *
5
+ * Document media and pasted files enter the editor as `data:image/*` or
6
+ * `blob:` URLs. Network and executable schemes are rejected so opening or
7
+ * pasting an untrusted document cannot trigger an external request or run a
8
+ * script scheme through `<img src>`.
9
+ */
10
+ function sanitizeImageSrc(src) {
11
+ if (typeof src !== "string") return;
12
+ const value = src.trim();
13
+ if (!value) return;
14
+ if (/^blob:/iu.test(value)) return value;
15
+ if (/^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/iu.test(value)) return value;
16
+ }
17
+ /**
18
+ * Assign a sanitized source to an `<img>` element. When the source is rejected
19
+ * the `src` attribute is left unset: assigning `""` makes some browsers resolve
20
+ * it against the page URL and fire a spurious request for the current page.
21
+ *
22
+ * Structural parameter type so painter unit tests (which fake the DOM) can
23
+ * exercise it without an `HTMLImageElement`.
24
+ */
25
+ function applySanitizedImageSrc(imgEl, src) {
26
+ const safeSrc = sanitizeImageSrc(src);
27
+ if (safeSrc !== void 0) imgEl.src = safeSrc;
28
+ }
29
+ //#endregion
30
+ export { applySanitizedImageSrc, sanitizeImageSrc };
@@ -0,0 +1,11 @@
1
+ //#region src/utils/stripXmlDeclarations.d.ts
2
+ /**
3
+ * Remove complete `<?xml ...?>` declarations from attacker-controlled clipboard HTML.
4
+ *
5
+ * Linear scan instead of `/<\?xml[^>]*>/gi`: that pattern backtracks polynomially
6
+ * when many `<?xml` openers appear with no closing `>`. An unterminated opener
7
+ * is preserved verbatim (same as the regex, which never matched it).
8
+ */
9
+ declare function stripXmlDeclarations(html: string): string;
10
+ //#endregion
11
+ export { stripXmlDeclarations };
@@ -0,0 +1,35 @@
1
+ //#region src/utils/stripXmlDeclarations.ts
2
+ /**
3
+ * Remove complete `<?xml ...?>` declarations from attacker-controlled clipboard HTML.
4
+ *
5
+ * Linear scan instead of `/<\?xml[^>]*>/gi`: that pattern backtracks polynomially
6
+ * when many `<?xml` openers appear with no closing `>`. An unterminated opener
7
+ * is preserved verbatim (same as the regex, which never matched it).
8
+ */
9
+ function stripXmlDeclarations(html) {
10
+ const chunks = [];
11
+ let cursor = 0;
12
+ let searchFrom = 0;
13
+ while (true) {
14
+ const start = html.indexOf("<", searchFrom);
15
+ if (start === -1) {
16
+ chunks.push(html.slice(cursor));
17
+ break;
18
+ }
19
+ if (!(html.charCodeAt(start + 1) === 63 && (html.charCodeAt(start + 2) === 88 || html.charCodeAt(start + 2) === 120) && (html.charCodeAt(start + 3) === 77 || html.charCodeAt(start + 3) === 109) && (html.charCodeAt(start + 4) === 76 || html.charCodeAt(start + 4) === 108))) {
20
+ searchFrom = start + 1;
21
+ continue;
22
+ }
23
+ const end = html.indexOf(">", start + 5);
24
+ if (end === -1) {
25
+ chunks.push(html.slice(cursor));
26
+ break;
27
+ }
28
+ chunks.push(html.slice(cursor, start));
29
+ cursor = end + 1;
30
+ searchFrom = cursor;
31
+ }
32
+ return chunks.join("");
33
+ }
34
+ //#endregion
35
+ export { stripXmlDeclarations };
@@ -109,6 +109,20 @@ type FolioVersionDiff = {
109
109
  };
110
110
  /** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
111
111
  declare const exceedsLcsBudget: (unpairedBaseCount: number, unpairedRevisedCount: number) => boolean;
112
+ /**
113
+ * Mutable cell budget SHARED across every story pair one {@link compareDocxVersions}
114
+ * call compares. {@link exceedsLcsBudget} alone only bounds a single pass 2
115
+ * call's own table; without an aggregate budget, a document with many
116
+ * attacker-controlled stories (footnotes/endnotes) could still force a fresh
117
+ * near-{@link MAX_LCS_CELLS}-sized allocation for EVERY story pair. Each
118
+ * `pairByExactText` call that actually runs pass 2 decrements
119
+ * `remainingCells` by its own `m * n`; once exhausted, every subsequent
120
+ * story's pass 2 is refused regardless of that story's own size, falling
121
+ * through to pass 3's linear positional zip.
122
+ */
123
+ type FolioVersionComparisonLcsBudget = {
124
+ remainingCells: number;
125
+ };
112
126
  /**
113
127
  * One step of a completed alignment, in revised-side document order with
114
128
  * base-only blocks slotted where they sat. `pair` events cover pass 1/2
@@ -131,8 +145,14 @@ type FolioAlignedBlockEvent = {
131
145
  * snapshots and flatten it into an ordered event stream. Shared by
132
146
  * {@link compareDocxVersions} and the redline generator so both interpret
133
147
  * one document walk instead of re-deriving it.
148
+ *
149
+ * `lcsBudget` defaults to a fresh, single-call budget so a caller comparing
150
+ * one block pair in isolation (the redline generator) behaves exactly as
151
+ * before. {@link compareDocxVersions} passes one budget object shared across
152
+ * every story pair instead, so pass 2's cell allowance is aggregate across
153
+ * the whole comparison rather than reset per story.
134
154
  */
135
- declare const alignFolioBlocks: (baseBlocks: readonly FolioAIBlock[], revisedBlocks: readonly FolioAIBlock[]) => FolioAlignedBlockEvent[];
155
+ declare const alignFolioBlocks: (baseBlocks: readonly FolioAIBlock[], revisedBlocks: readonly FolioAIBlock[], lcsBudget?: FolioVersionComparisonLcsBudget) => FolioAlignedBlockEvent[];
136
156
  /** Apply auditable, output-only privacy transforms to a structured version diff. */
137
157
  declare const applyFolioVersionDiffPrivacy: (diff: FolioVersionDiff, options: FolioVersionDiffPrivacyOptions) => FolioVersionDiff;
138
158
  /**
@@ -143,4 +163,4 @@ declare const applyFolioVersionDiffPrivacy: (diff: FolioVersionDiff, options: Fo
143
163
  */
144
164
  declare const compareDocxVersions: (base: ArrayBuffer, revised: ArrayBuffer, options?: FolioCompareDocxVersionsOptions) => Promise<FolioVersionDiff>;
145
165
  //#endregion
146
- export { FOLIO_DOCUMENT_METADATA_PROPERTIES, FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS, FOLIO_VERSION_COMPARISON_SCOPES, FolioAlignedBlockEvent, FolioBlockDiff, FolioCompareDocxVersionsOptions, type FolioDocumentMetadataProperty, FolioDocumentMetadataValue, FolioFormatProperty, FolioMetadataDiff, FolioStoryDiff, FolioVersionBlockHandle, FolioVersionComparisonPrivacyTransform, FolioVersionComparisonScope, FolioVersionDiff, FolioVersionDiffPrivacyOptions, FolioVersionDiffPrivacyReport, FolioVersionDiffSegment, FolioVersionDiffSummaryCounts, InvalidFolioVersionComparisonOptionsError, alignFolioBlocks, applyFolioVersionDiffPrivacy, compareDocxVersions, exceedsLcsBudget, isFolioVersionComparisonPrivacyTransform, isFolioVersionComparisonScope };
166
+ export { FOLIO_DOCUMENT_METADATA_PROPERTIES, FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS, FOLIO_VERSION_COMPARISON_SCOPES, FolioAlignedBlockEvent, FolioBlockDiff, FolioCompareDocxVersionsOptions, type FolioDocumentMetadataProperty, FolioDocumentMetadataValue, FolioFormatProperty, FolioMetadataDiff, FolioStoryDiff, FolioVersionBlockHandle, FolioVersionComparisonLcsBudget, FolioVersionComparisonPrivacyTransform, FolioVersionComparisonScope, FolioVersionDiff, FolioVersionDiffPrivacyOptions, FolioVersionDiffPrivacyReport, FolioVersionDiffSegment, FolioVersionDiffSummaryCounts, InvalidFolioVersionComparisonOptionsError, alignFolioBlocks, applyFolioVersionDiffPrivacy, compareDocxVersions, exceedsLcsBudget, isFolioVersionComparisonPrivacyTransform, isFolioVersionComparisonScope };
@@ -160,12 +160,14 @@ const pairByStableId = (base, revised) => {
160
160
  const MAX_LCS_CELLS = 4e6;
161
161
  /** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
162
162
  const exceedsLcsBudget = (unpairedBaseCount, unpairedRevisedCount) => unpairedBaseCount * unpairedRevisedCount > MAX_LCS_CELLS;
163
+ const createLcsBudget = () => ({ remainingCells: MAX_LCS_CELLS });
163
164
  /** Pass 2: order-preserving LCS by exact text equality over the blocks pass 1 left unpaired. */
164
- const pairByExactText = (base, revised) => {
165
+ const pairByExactText = (base, revised, lcsBudget) => {
165
166
  const m = base.length;
166
167
  const n = revised.length;
167
168
  if (m === 0 || n === 0) return [];
168
- if (exceedsLcsBudget(m, n)) return [];
169
+ if (exceedsLcsBudget(m, n) || lcsBudget.remainingCells <= 0) return [];
170
+ lcsBudget.remainingCells -= m * n;
169
171
  const baseTexts = base.map(({ block }) => block.text);
170
172
  const revisedTexts = revised.map(({ block }) => block.text);
171
173
  const stride = n + 1;
@@ -205,8 +207,14 @@ const pairByExactText = (base, revised) => {
205
207
  * snapshots and flatten it into an ordered event stream. Shared by
206
208
  * {@link compareDocxVersions} and the redline generator so both interpret
207
209
  * one document walk instead of re-deriving it.
210
+ *
211
+ * `lcsBudget` defaults to a fresh, single-call budget so a caller comparing
212
+ * one block pair in isolation (the redline generator) behaves exactly as
213
+ * before. {@link compareDocxVersions} passes one budget object shared across
214
+ * every story pair instead, so pass 2's cell allowance is aggregate across
215
+ * the whole comparison rather than reset per story.
208
216
  */
209
- const alignFolioBlocks = (baseBlocks, revisedBlocks) => {
217
+ const alignFolioBlocks = (baseBlocks, revisedBlocks, lcsBudget = createLcsBudget()) => {
210
218
  const stableIdAnchors = pairByStableId(baseBlocks, revisedBlocks);
211
219
  const usedBaseIndexes = new Set(stableIdAnchors.map((anchor) => anchor.baseIndex));
212
220
  const usedRevisedIndexes = new Set(stableIdAnchors.map((anchor) => anchor.revisedIndex));
@@ -224,7 +232,7 @@ const alignFolioBlocks = (baseBlocks, revisedBlocks) => {
224
232
  index: blockIndex
225
233
  });
226
234
  });
227
- const exactTextAnchors = pairByExactText(baseRemaining, revisedRemaining);
235
+ const exactTextAnchors = pairByExactText(baseRemaining, revisedRemaining, lcsBudget);
228
236
  const anchors = longestIncreasingByRevisedIndex([...stableIdAnchors, ...exactTextAnchors].toSorted((a, b) => a.baseIndex - b.baseIndex));
229
237
  const events = [];
230
238
  /** Pass 3: positionally zip the leftover blocks in one gap between anchors. */
@@ -331,6 +339,20 @@ const meetsMoveWordCount = (text) => {
331
339
  return false;
332
340
  };
333
341
  /**
342
+ * Cap on how many same-text `deleted` candidates {@link detectMoves} queues
343
+ * per distinct text. Duplicated boilerplate at or above the word floor
344
+ * (e.g. a repeated long clause) could otherwise grow one text's candidate
345
+ * list without bound; past this cap, further same-text deletions are simply
346
+ * left as `deleted` (never matched to a move) instead of queued.
347
+ */
348
+ const MAX_MOVE_CANDIDATES_PER_TEXT = 1e4;
349
+ const dequeueDeletedIndex = (queue) => {
350
+ if (!queue || queue.head >= queue.items.length) return;
351
+ const index = queue.items[queue.head];
352
+ queue.head += 1;
353
+ return index;
354
+ };
355
+ /**
334
356
  * Re-classify `deleted` + `added` pairs with identical text as
335
357
  * `movedFrom` / `movedTo` entries sharing a `moveGroupId`, in place, so each
336
358
  * side keeps its slot in the revised-side document order. Matching is FIFO
@@ -340,17 +362,22 @@ const meetsMoveWordCount = (text) => {
340
362
  const detectMoves = (changes, counts, firstMoveGroupId) => {
341
363
  const deletedIndexesByText = /* @__PURE__ */ new Map();
342
364
  changes.forEach((change, index) => {
343
- if (change.type === "deleted" && meetsMoveWordCount(change.text)) {
344
- const queue = deletedIndexesByText.get(change.text) ?? [];
345
- queue.push(index);
346
- deletedIndexesByText.set(change.text, queue);
365
+ if (change.type !== "deleted" || !meetsMoveWordCount(change.text)) return;
366
+ const queue = deletedIndexesByText.get(change.text);
367
+ if (!queue) {
368
+ deletedIndexesByText.set(change.text, {
369
+ items: [index],
370
+ head: 0
371
+ });
372
+ return;
347
373
  }
374
+ if (queue.items.length < MAX_MOVE_CANDIDATES_PER_TEXT) queue.items.push(index);
348
375
  });
349
376
  if (deletedIndexesByText.size === 0) return;
350
377
  let moveGroupId = firstMoveGroupId - 1;
351
378
  changes.forEach((change, index) => {
352
379
  if (change.type !== "added") return;
353
- const deletedIndex = deletedIndexesByText.get(change.text)?.shift();
380
+ const deletedIndex = dequeueDeletedIndex(deletedIndexesByText.get(change.text));
354
381
  if (deletedIndex === void 0) return;
355
382
  const deleted = changes[deletedIndex];
356
383
  if (!deleted || deleted.type !== "deleted") return;
@@ -394,10 +421,10 @@ const addSummaryCounts = (target, source) => {
394
421
  target.metadataChanged += source.metadataChanged;
395
422
  target.unchanged += source.unchanged;
396
423
  };
397
- const compareStoryBlocks = ({ baseStory, revisedStory, baseBlocks, revisedBlocks, firstMoveGroupId, includeText, includeFormatting }) => {
424
+ const compareStoryBlocks = ({ baseStory, revisedStory, baseBlocks, revisedBlocks, firstMoveGroupId, includeText, includeFormatting, lcsBudget }) => {
398
425
  const changes = [];
399
426
  const counts = createSummaryCounts();
400
- for (const event of alignFolioBlocks(baseBlocks, revisedBlocks)) {
427
+ for (const event of alignFolioBlocks(baseBlocks, revisedBlocks, lcsBudget)) {
401
428
  if (event.type === "pair") {
402
429
  if (!baseStory || !revisedStory) panic("A paired comparison event requires both story handles");
403
430
  const { baseBlock, revisedBlock } = event;
@@ -554,6 +581,7 @@ const compareDocxVersions = async (base, revised, options = {}) => {
554
581
  const baseStories = baseReviewer.listStories().map(({ handle }) => handle);
555
582
  const revisedStories = revisedReviewer.listStories().map(({ handle }) => handle);
556
583
  let nextMoveGroupId = 1;
584
+ const lcsBudget = createLcsBudget();
557
585
  for (const pair of pairFolioDocumentStories(baseStories, revisedStories)) {
558
586
  const baseBlocks = pair.baseStory ? baseReviewer.readReviewedStory({
559
587
  story: pair.baseStory,
@@ -569,7 +597,8 @@ const compareDocxVersions = async (base, revised, options = {}) => {
569
597
  revisedBlocks,
570
598
  firstMoveGroupId: nextMoveGroupId,
571
599
  includeText: scopes.has("text"),
572
- includeFormatting: scopes.has("formatting")
600
+ includeFormatting: scopes.has("formatting"),
601
+ lcsBudget
573
602
  });
574
603
  stories.push(storyDiff);
575
604
  for (const change of storyDiff.changes) changes.push(change);
@@ -1,6 +1,16 @@
1
1
  import { document_d_exports } from "../types/document.js";
2
2
 
3
3
  //#region src/watermark/index.d.ts
4
+ /**
5
+ * Whether `target` is safe to save as a watermark picture's external image
6
+ * relationship. A watermark dialog lets an author type an arbitrary string
7
+ * for this field; without a scheme check it becomes a `TargetMode="External"`
8
+ * relationship verbatim (see `docx/rezip.ts`), which would let a `file:` URL
9
+ * or UNC path (`\\host\share\...`) into the exported `.docx` — a resource
10
+ * whatever later opens the file (e.g. Word) would try to resolve. Only
11
+ * `http:`/`https:` targets are allowed.
12
+ */
13
+ declare function isAllowedExternalWatermarkImageUrl(target: string): boolean;
4
14
  /**
5
15
  * Read the document's watermark. Walks every header part and returns
6
16
  * the first watermark encountered (header insertion order). Returns
@@ -36,4 +46,4 @@ declare function ensureWatermarkHeaderCoverage(doc: document_d_exports.Document,
36
46
  type PictureWatermark = document_d_exports.PictureWatermark;
37
47
  type TextWatermark = document_d_exports.TextWatermark;
38
48
  type Watermark = document_d_exports.Watermark;
39
- export { type PictureWatermark, type TextWatermark, type Watermark, ensureWatermarkHeaderCoverage, getDocumentWatermark, setDocumentWatermark };
49
+ export { type PictureWatermark, type TextWatermark, type Watermark, ensureWatermarkHeaderCoverage, getDocumentWatermark, isAllowedExternalWatermarkImageUrl, setDocumentWatermark };
@@ -1,5 +1,26 @@
1
1
  //#region src/watermark/index.ts
2
2
  /**
3
+ * Schemes allowed for a picture watermark's external image target
4
+ * (`TargetMode="External"` in the saved package's relationships).
5
+ */
6
+ const ALLOWED_EXTERNAL_IMAGE_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:"]);
7
+ /**
8
+ * Whether `target` is safe to save as a watermark picture's external image
9
+ * relationship. A watermark dialog lets an author type an arbitrary string
10
+ * for this field; without a scheme check it becomes a `TargetMode="External"`
11
+ * relationship verbatim (see `docx/rezip.ts`), which would let a `file:` URL
12
+ * or UNC path (`\\host\share\...`) into the exported `.docx` — a resource
13
+ * whatever later opens the file (e.g. Word) would try to resolve. Only
14
+ * `http:`/`https:` targets are allowed.
15
+ */
16
+ function isAllowedExternalWatermarkImageUrl(target) {
17
+ try {
18
+ return ALLOWED_EXTERNAL_IMAGE_SCHEMES.has(new URL(target).protocol);
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+ /**
3
24
  * Read the document's watermark. Walks every header part and returns
4
25
  * the first watermark encountered (header insertion order). Returns
5
26
  * `undefined` when no header carries one.
@@ -146,4 +167,4 @@ function ensureWatermarkHeaderCoverage(doc, watermark) {
146
167
  };
147
168
  }
148
169
  //#endregion
149
- export { ensureWatermarkHeaderCoverage, getDocumentWatermark, setDocumentWatermark };
170
+ export { ensureWatermarkHeaderCoverage, getDocumentWatermark, isAllowedExternalWatermarkImageUrl, setDocumentWatermark };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -107,11 +107,12 @@
107
107
  "perf": "bun scripts/profile-editor.ts"
108
108
  },
109
109
  "dependencies": {
110
- "@stll/docx-core": "^0.1.0",
110
+ "@stll/docx-core": "^0.4.0",
111
111
  "@stll/docx-utils": "^0.1.0",
112
112
  "@stll/template-conditions": "^0.1.0",
113
113
  "better-result": "2.9.2",
114
114
  "csstype": "^3.1.3",
115
+ "dompurify": "^3.4.12",
115
116
  "fast-xml-parser": "^5.9.3",
116
117
  "hyphen": "1.14.1",
117
118
  "jszip": "3.10.1",