@stll/folio-core 0.44.0 → 0.45.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 (90) hide show
  1. package/dist/ai-edits/__fixtures__/paragraphs.js +2 -2
  2. package/dist/ai-edits/headless.js +1 -0
  3. package/dist/compare/content-alignment.js +78 -53
  4. package/dist/compare/inline-atoms.js +34 -20
  5. package/dist/content-controls/mutateContentControls.js +4 -2
  6. package/dist/display-list/dom/renderDisplayListToDom.js +8 -8
  7. package/dist/document-operations.js +14 -3
  8. package/dist/docx/borderParser.js +5 -5
  9. package/dist/docx/commentParser.js +47 -36
  10. package/dist/docx/commentThreadKey.d.ts +18 -0
  11. package/dist/docx/commentThreadKey.js +22 -0
  12. package/dist/docx/diagramPreview.js +87 -27
  13. package/dist/docx/groupDrawingParser.js +3 -3
  14. package/dist/docx/hyperlinkParser.js +2 -2
  15. package/dist/docx/imageParser.d.ts +9 -1
  16. package/dist/docx/imageParser.js +58 -12
  17. package/dist/docx/imageRawXml.d.ts +14 -1
  18. package/dist/docx/imageRawXml.js +30 -6
  19. package/dist/docx/mathToMathml.js +12 -14
  20. package/dist/docx/nonVisualDrawingProps.d.ts +34 -0
  21. package/dist/docx/nonVisualDrawingProps.js +46 -0
  22. package/dist/docx/paragraphTextBoxEnrichment.js +3 -0
  23. package/dist/docx/parser.js +2 -2
  24. package/dist/docx/previewBudget.d.ts +64 -0
  25. package/dist/docx/previewBudget.js +88 -0
  26. package/dist/docx/revisionIdNormalization.js +17 -5
  27. package/dist/docx/rezip.js +17 -18
  28. package/dist/docx/sdtPropertiesPatch.js +24 -18
  29. package/dist/docx/sectionReferenceHistory.js +2 -2
  30. package/dist/docx/selectiveSave.js +6 -6
  31. package/dist/docx/serializer/blockSdtSerializer.js +38 -26
  32. package/dist/docx/serializer/borderSerializer.d.ts +1 -1
  33. package/dist/docx/serializer/borderSerializer.js +13 -12
  34. package/dist/docx/serializer/commentSerializer.d.ts +41 -16
  35. package/dist/docx/serializer/commentSerializer.js +82 -85
  36. package/dist/docx/serializer/fontTableSerializer.js +6 -6
  37. package/dist/docx/serializer/headerFooterSerializer.js +5 -5
  38. package/dist/docx/serializer/markupRangeAttributes.js +2 -2
  39. package/dist/docx/serializer/numberingSerializer.js +7 -6
  40. package/dist/docx/serializer/paragraphSerializer.js +19 -18
  41. package/dist/docx/serializer/partNamespaces.js +2 -2
  42. package/dist/docx/serializer/runSerializer.js +48 -28
  43. package/dist/docx/serializer/sectionPropertiesSerializer.js +11 -10
  44. package/dist/docx/serializer/settingsSerializer.js +4 -3
  45. package/dist/docx/serializer/stylesSerializer.js +6 -6
  46. package/dist/docx/serializer/tableSerializer.js +10 -9
  47. package/dist/docx/serializer/textFormattingSerializer.js +29 -28
  48. package/dist/docx/serializer/themeSerializer.js +6 -6
  49. package/dist/docx/serializer/trackedChangeAttributes.js +2 -2
  50. package/dist/docx/serializer/xmlUtils.d.ts +1 -2
  51. package/dist/docx/serializer/xmlUtils.js +1 -13
  52. package/dist/docx/server/boundedArchive.d.ts +12 -0
  53. package/dist/docx/server/boundedArchive.js +20 -1
  54. package/dist/docx/server/validateDocxConformance.js +22 -1
  55. package/dist/docx/shapeParser.js +7 -5
  56. package/dist/docx/textBoxParser.js +7 -2
  57. package/dist/docx/unzip.d.ts +23 -0
  58. package/dist/docx/unzip.js +32 -22
  59. package/dist/docx/verbatimCapture.js +1 -1
  60. package/dist/docx/vmlImageParser.js +3 -2
  61. package/dist/docx/vmlPreview.d.ts +1 -3
  62. package/dist/docx/vmlPreview.js +2 -30
  63. package/dist/docx/xmlParser.d.ts +16 -1
  64. package/dist/docx/xmlParser.js +56 -26
  65. package/dist/docx/xmlResourceLimits.d.ts +89 -9
  66. package/dist/docx/xmlResourceLimits.js +105 -24
  67. package/dist/internal/paragraphFormattingSerialization.js +3 -2
  68. package/dist/layout-painter/renderImage.js +4 -3
  69. package/dist/layout-painter/renderParagraph.js +4 -3
  70. package/dist/managers/autoSaveCodec.js +2 -8
  71. package/dist/markdown/images.js +1 -4
  72. package/dist/prosemirror/attrs/index.js +69 -0
  73. package/dist/prosemirror/commands/image.js +1 -0
  74. package/dist/prosemirror/conversion/fromProseDoc.js +69 -29
  75. package/dist/prosemirror/conversion/toProseDoc.js +83 -19
  76. package/dist/prosemirror/extensions/marks/HyperlinkExtension.js +2 -3
  77. package/dist/prosemirror/extensions/nodes/ImageExtension.js +4 -0
  78. package/dist/prosemirror/extensions/nodes/ShapeExtension.js +7 -2
  79. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +8 -4
  80. package/dist/prosemirror/paragraphFormattingProvenance.d.ts +6 -3
  81. package/dist/prosemirror/paragraphFormattingProvenance.js +12 -3
  82. package/dist/prosemirror/schema/nodes.d.ts +50 -1
  83. package/dist/utils/base64.d.ts +36 -0
  84. package/dist/utils/base64.js +40 -0
  85. package/dist/utils/clipboard.js +2 -1
  86. package/dist/utils/units.d.ts +10 -1
  87. package/dist/utils/units.js +12 -1
  88. package/dist/utils/urlSecurity.d.ts +8 -2
  89. package/dist/utils/urlSecurity.js +21 -3
  90. package/package.json +2 -2
@@ -1,4 +1,5 @@
1
1
  import { findChildByLocalName, getLocalName } from "./xmlParser.js";
2
+ import { escapeXmlText } from "@stll/docx-core";
2
3
  //#region src/docx/mathToMathml.ts
3
4
  /**
4
5
  * OMML → MathML conversion.
@@ -95,13 +96,13 @@ const MATH_OPERATOR_HINT = /* @__PURE__ */ new Set([
95
96
  "…"
96
97
  ]);
97
98
  function renderElement(el) {
98
- if (el.type === "text") return escapeXml(textValue(el));
99
+ if (el.type === "text") return escapeXmlText(textValue(el));
99
100
  if (el.type !== "element") return "";
100
101
  switch (el.name ? getLocalName(el.name) : "") {
101
102
  case "oMath":
102
103
  case "oMathPara": return renderChildren(el);
103
104
  case "r": return renderRun(el);
104
- case "t": return escapeXml(extractText(el));
105
+ case "t": return escapeXmlText(extractText(el));
105
106
  case "f": return renderFraction(el);
106
107
  case "num":
107
108
  case "den": return wrapMrow(renderChildren(el));
@@ -179,8 +180,8 @@ function tokenizeMathText(text) {
179
180
  let bufKind = null;
180
181
  const flush = () => {
181
182
  if (!buf) return;
182
- if (bufKind === "digit") out += `<mn>${escapeXml(buf)}</mn>`;
183
- else out += `<mi>${escapeXml(buf)}</mi>`;
183
+ if (bufKind === "digit") out += `<mn>${escapeXmlText(buf)}</mn>`;
184
+ else out += `<mi>${escapeXmlText(buf)}</mi>`;
184
185
  buf = "";
185
186
  bufKind = null;
186
187
  };
@@ -193,7 +194,7 @@ function tokenizeMathText(text) {
193
194
  const kind = classifyChar(ch);
194
195
  if (kind === "operator") {
195
196
  flush();
196
- out += `<mo>${escapeXml(ch)}</mo>`;
197
+ out += `<mo>${escapeXmlText(ch)}</mo>`;
197
198
  continue;
198
199
  }
199
200
  if (kind === "digit") {
@@ -202,7 +203,7 @@ function tokenizeMathText(text) {
202
203
  continue;
203
204
  }
204
205
  flush();
205
- out += `<mi>${escapeXml(ch)}</mi>`;
206
+ out += `<mi>${escapeXmlText(ch)}</mi>`;
206
207
  }
207
208
  flush();
208
209
  return out;
@@ -258,7 +259,7 @@ function renderNary(el) {
258
259
  const chr = (chrEl ? getAttrValue(chrEl, "val") : null) || "∫";
259
260
  const limLocEl = naryPr ? findChildByLocalName(naryPr, "limLoc") : null;
260
261
  const limLoc = (limLocEl ? getAttrValue(limLocEl, "val") : null) || "subSup";
261
- const opMml = `<mo>${escapeXml(chr)}</mo>`;
262
+ const opMml = `<mo>${escapeXmlText(chr)}</mo>`;
262
263
  const subMml = sub ? wrapMrow(renderChildren(sub)) : "";
263
264
  const supMml = sup ? wrapMrow(renderChildren(sup)) : "";
264
265
  const bodyMml = body ? wrapMrow(renderChildren(body)) : "<mrow/>";
@@ -277,8 +278,8 @@ function renderDelimiter(el) {
277
278
  const begChr = begChrEl ? getAttrValue(begChrEl, "val") ?? "(" : "(";
278
279
  const endChr = endChrEl ? getAttrValue(endChrEl, "val") ?? ")" : ")";
279
280
  const sepChr = sepChrEl ? getAttrValue(sepChrEl, "val") ?? "|" : "|";
280
- const inner = (el.elements ?? []).filter((c) => c.type === "element" && getLocalName(c.name ?? "") === "e").map((c) => wrapMrow(renderChildren(c))).join(`<mo>${escapeXml(sepChr)}</mo>`);
281
- return `<mrow><mo>${escapeXml(begChr)}</mo>${inner}<mo>${escapeXml(endChr)}</mo></mrow>`;
281
+ const inner = (el.elements ?? []).filter((c) => c.type === "element" && getLocalName(c.name ?? "") === "e").map((c) => wrapMrow(renderChildren(c))).join(`<mo>${escapeXmlText(sepChr)}</mo>`);
282
+ return `<mrow><mo>${escapeXmlText(begChr)}</mo>${inner}<mo>${escapeXmlText(endChr)}</mo></mrow>`;
282
283
  }
283
284
  function renderMatrix(el) {
284
285
  return `<mtable>${(el.elements ?? []).filter((c) => c.type === "element" && getLocalName(c.name ?? "") === "mr").map(renderMatrixRow).join("")}</mtable>`;
@@ -291,7 +292,7 @@ function renderAccent(el) {
291
292
  const chrEl = accPr ? findChildByLocalName(accPr, "chr") : null;
292
293
  const chr = chrEl ? getAttrValue(chrEl, "val") ?? "̂" : "̂";
293
294
  const base = findChildByLocalName(el, "e");
294
- return `<mover accent="true">${base ? wrapMrow(renderChildren(base)) : "<mrow/>"}<mo>${escapeXml(chr)}</mo></mover>`;
295
+ return `<mover accent="true">${base ? wrapMrow(renderChildren(base)) : "<mrow/>"}<mo>${escapeXmlText(chr)}</mo></mover>`;
295
296
  }
296
297
  function renderBar(el) {
297
298
  const barPr = findChildByLocalName(el, "barPr");
@@ -311,7 +312,7 @@ function renderGroupChr(el) {
311
312
  const chr = chrEl ? getAttrValue(chrEl, "val") ?? defaultChr : defaultChr;
312
313
  const base = findChildByLocalName(el, "e");
313
314
  const baseMml = base ? wrapMrow(renderChildren(base)) : "<mrow/>";
314
- const grouper = `<mo stretchy="true">${escapeXml(chr)}</mo>`;
315
+ const grouper = `<mo stretchy="true">${escapeXmlText(chr)}</mo>`;
315
316
  return pos === "top" ? `<mover>${baseMml}${grouper}</mover>` : `<munder>${baseMml}${grouper}</munder>`;
316
317
  }
317
318
  function renderLimLow(el) {
@@ -350,8 +351,5 @@ function getAttrValue(el, localAttr) {
350
351
  return String(v);
351
352
  }
352
353
  }
353
- function escapeXml(s) {
354
- return s.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll("'", "&apos;");
355
- }
356
354
  //#endregion
357
355
  export { ommlToMathml };
@@ -0,0 +1,34 @@
1
+ import { XmlElement } from "./xmlParser.js";
2
+ //#region src/docx/nonVisualDrawingProps.d.ts
3
+ /** The authored name, alt text and title of a drawing object. */
4
+ type NonVisualDrawingNames = {
5
+ /** `@name`, schema-required, so `""` means the object was never named. */
6
+ name?: string;
7
+ /** `@descr`: alt text, accessibility content. */
8
+ alt?: string;
9
+ /** `@title`. */
10
+ title?: string;
11
+ };
12
+ /**
13
+ * Read `@name`, `@descr` and `@title` off a `CT_NonVisualDrawingProps` element.
14
+ *
15
+ * An attribute that is not there stays absent. `@name` is schema-required, so
16
+ * an unnamed object still writes one, and `name=""` is the marker the writer
17
+ * below mints for exactly that: reading it back as an authored name would make
18
+ * `absent → save → parse` land on `""` instead of absent, and no reader can
19
+ * tell the two apart. `@descr` and `@title` are optional and written only when
20
+ * authored, so `""` in either is a string someone wrote.
21
+ */
22
+ declare const parseNonVisualDrawingNames: (element: XmlElement | null | undefined) => NonVisualDrawingNames;
23
+ /**
24
+ * The `name`, `descr` and `title` attributes of a `CT_NonVisualDrawingProps`
25
+ * element, leading space included.
26
+ *
27
+ * `@name` is schema-required, so an object carrying none writes the empty
28
+ * string. folio does not name an object it did not author: a generated
29
+ * "Shape 3" would overwrite the author's own name for every drawing whose
30
+ * name did not survive the model, and read back as authored content.
31
+ */
32
+ declare const serializeNonVisualDrawingNames: (names: NonVisualDrawingNames) => string;
33
+ //#endregion
34
+ export { NonVisualDrawingNames, parseNonVisualDrawingNames, serializeNonVisualDrawingNames };
@@ -0,0 +1,46 @@
1
+ import { getAttribute } from "./xmlParser.js";
2
+ import { escapeXmlAttribute } from "@stll/docx-core";
3
+ //#region src/docx/nonVisualDrawingProps.ts
4
+ /**
5
+ * `CT_NonVisualDrawingProps` (`wp:docPr`, `wps:cNvPr`, `pic:cNvPr`) carries the
6
+ * three authored strings every drawing object has: its name, its alt text
7
+ * (`descr`) and its title. They are authored content, not folio's to mint, so
8
+ * one reader and one writer own them for every drawing kind.
9
+ */
10
+ /**
11
+ * Read `@name`, `@descr` and `@title` off a `CT_NonVisualDrawingProps` element.
12
+ *
13
+ * An attribute that is not there stays absent. `@name` is schema-required, so
14
+ * an unnamed object still writes one, and `name=""` is the marker the writer
15
+ * below mints for exactly that: reading it back as an authored name would make
16
+ * `absent → save → parse` land on `""` instead of absent, and no reader can
17
+ * tell the two apart. `@descr` and `@title` are optional and written only when
18
+ * authored, so `""` in either is a string someone wrote.
19
+ */
20
+ const parseNonVisualDrawingNames = (element) => {
21
+ if (!element) return {};
22
+ const name = getAttribute(element, null, "name");
23
+ const descr = getAttribute(element, null, "descr");
24
+ const title = getAttribute(element, null, "title");
25
+ return {
26
+ ...name !== null && name !== "" ? { name } : {},
27
+ ...descr !== null ? { alt: descr } : {},
28
+ ...title !== null ? { title } : {}
29
+ };
30
+ };
31
+ /**
32
+ * The `name`, `descr` and `title` attributes of a `CT_NonVisualDrawingProps`
33
+ * element, leading space included.
34
+ *
35
+ * `@name` is schema-required, so an object carrying none writes the empty
36
+ * string. folio does not name an object it did not author: a generated
37
+ * "Shape 3" would overwrite the author's own name for every drawing whose
38
+ * name did not survive the model, and read back as authored content.
39
+ */
40
+ const serializeNonVisualDrawingNames = (names) => {
41
+ const descr = names.alt === void 0 ? "" : ` descr="${escapeXmlAttribute(names.alt)}"`;
42
+ const title = names.title === void 0 ? "" : ` title="${escapeXmlAttribute(names.title)}"`;
43
+ return ` name="${escapeXmlAttribute(names.name ?? "")}"${descr}${title}`;
44
+ };
45
+ //#endregion
46
+ export { parseNonVisualDrawingNames, serializeNonVisualDrawingNames };
@@ -198,6 +198,9 @@ const enrichTextBoxRuns = ({ content, xmlChildren, styles, theme, numbering, rel
198
198
  type: "shape",
199
199
  shapeType: "textBox",
200
200
  size: textBox.size,
201
+ ...textBox.name !== void 0 ? { name: textBox.name } : {},
202
+ ...textBox.alt !== void 0 ? { alt: textBox.alt } : {},
203
+ ...textBox.title !== void 0 ? { title: textBox.title } : {},
201
204
  ...textBox.position !== void 0 ? { position: textBox.position } : {},
202
205
  ...textBox.wrap !== void 0 ? { wrap: textBox.wrap } : {},
203
206
  ...textBox.fill !== void 0 ? { fill: textBox.fill } : {},
@@ -23,6 +23,7 @@ import { UNNUMBERED_PARAGRAPH_WARNING, UNNUMBERED_STYLE_WARNING, normalizeNumber
23
23
  import { assignDocumentParagraphPropertySourceContract } from "./paragraphPropertySource.js";
24
24
  import { createParseWarningCollector } from "./parseContext.js";
25
25
  import { formatParseWarnings } from "./parseWarningMessage.js";
26
+ import { enforcePackagePreviewBudget } from "./previewBudget.js";
26
27
  import { RELATIONSHIP_TYPES, parseRelationships, resolveRelativePath } from "./relsParser.js";
27
28
  import { normalizeRenderedPageBreakHints } from "./renderedPageBreakNormalization.js";
28
29
  import { parseSettings } from "./settingsParser.js";
@@ -30,7 +31,6 @@ import { parseStylesPackage } from "./styleParser.js";
30
31
  import { applyThemeFontLang, parseTheme } from "./themeParser.js";
31
32
  import { UNBALANCED_MOVE_RANGE_WARNING, normalizeTrackedMoveRanges } from "./trackedMoveRangeNormalization.js";
32
33
  import { getMediaMimeType, mediaToDataUrl, unzipDocx } from "./unzip.js";
33
- import { enforcePackageVmlPreviewBudget } from "./vmlPreview.js";
34
34
  import { FOLIO_XML_RESOURCE_LIMITS } from "./xmlResourceLimits.js";
35
35
  import { TaggedError } from "better-result";
36
36
  import { PARSE_WARNING_CODES } from "@stll/docx-core/model";
@@ -281,7 +281,7 @@ async function parseDocx(input, options = {}) {
281
281
  ...requiredFonts.length > 0 ? { requiredFonts } : {}
282
282
  };
283
283
  assignDocumentParagraphPropertySourceContract(document, await paragraphPropertySourceDigest);
284
- enforcePackageVmlPreviewBudget(document.package);
284
+ enforcePackagePreviewBudget(document.package);
285
285
  const validation = validateFolioDocumentModel(document);
286
286
  const parsedCompleteModel = parseHeadersFooters && parseNotes;
287
287
  if (!validation.valid && parsedCompleteModel) throw new DocxModelValidationError("Parsed DOCX produced an invalid document model", validation.issues);
@@ -0,0 +1,64 @@
1
+ //#region src/docx/previewBudget.d.ts
2
+ /**
3
+ * Every synthetic preview a parse attaches to the model, and the budget each
4
+ * one answers to.
5
+ *
6
+ * A preview is not document content. It is a drawing folio makes up so a shape
7
+ * it cannot project still occupies the page, and the package round-trips from
8
+ * its preserved XML whether the preview exists or not. So a preview is the one
9
+ * thing in the model that may be dropped, and a package that would retain more
10
+ * of it than it is worth has it dropped rather than being refused.
11
+ *
12
+ * Producer and budget have to agree on what a preview looks like. They used to
13
+ * agree by coincidence: the VML producer wrote its filename as a literal and
14
+ * the budget recognized it as a constant in another file, so renaming one
15
+ * would have stopped the other charging for it without anything failing. The
16
+ * table below is that agreement written once, and a producer builds its image
17
+ * out of the entry the budget matches against.
18
+ */
19
+ declare const VML_PREVIEW_DATA_URL_PREFIX = "data:image/svg+xml;charset=utf-8,";
20
+ declare const PREVIEW_KINDS: {
21
+ /** A VML shape folio renders rather than projects (`v:shape`, `v:rect`, ...). */
22
+ readonly vmlShape: {
23
+ readonly mimeType: "image/svg+xml";
24
+ readonly filename: "vml-shape-preview.svg";
25
+ readonly srcPrefix: "data:image/svg+xml;charset=utf-8,";
26
+ readonly maxPackageCharacters: number;
27
+ };
28
+ /**
29
+ * A SmartArt diagram: its extent filled with one flat rectangle per shape.
30
+ *
31
+ * A raster rather than a vector because the display list decodes only base64
32
+ * PNG and JPEG, so a vector preview would be missing from every display-list
33
+ * backend (PDF among them) while still showing in the DOM. That is what
34
+ * makes this preview expensive: one of them is 7.3 MB of data URL whatever
35
+ * the package weighs, because its cost follows the extent the author chose
36
+ * rather than anything the drawing contains.
37
+ *
38
+ * Across the public corpus, the fifty packages that produce one retain a
39
+ * median of 7.3 MB and a maximum of 51.3 MB (ten previews, from a package
40
+ * under a megabyte). The cap is set above that maximum: it refuses no
41
+ * legitimate file in the corpus while bounding what had no bound at all, and
42
+ * it is a ceiling rather than a fix. The fix is for the preview to be a
43
+ * descriptor the renderer rasterizes, which needs the display-list contract
44
+ * to carry one.
45
+ */
46
+ readonly smartArt: {
47
+ readonly mimeType: "image/png";
48
+ readonly filename: "smartart-preview.png";
49
+ readonly srcPrefix: "data:image/png;base64,";
50
+ readonly maxPackageCharacters: number;
51
+ };
52
+ };
53
+ type PreviewKindName = keyof typeof PREVIEW_KINDS;
54
+ /** Per-kind allowances for one package, defaulting to the table's caps. */
55
+ type PreviewBudgetOverrides = Partial<Record<PreviewKindName, number>>;
56
+ /**
57
+ * Charge every generated preview in the model against its kind's allowance and
58
+ * drop the `src` of those past it. Dropping leaves the image in place with its
59
+ * size and wrap, so the page still reserves the space the drawing occupies,
60
+ * and never touches the preserved XML the package saves from.
61
+ */
62
+ declare const enforcePackagePreviewBudget: (root: unknown, overrides?: PreviewBudgetOverrides) => void;
63
+ //#endregion
64
+ export { PREVIEW_KINDS, PreviewBudgetOverrides, VML_PREVIEW_DATA_URL_PREFIX, enforcePackagePreviewBudget };
@@ -0,0 +1,88 @@
1
+ //#region src/docx/previewBudget.ts
2
+ const VML_PREVIEW_DATA_URL_PREFIX = "data:image/svg+xml;charset=utf-8,";
3
+ const MEBIBYTE = 1024 * 1024;
4
+ const PREVIEW_KINDS = {
5
+ /** A VML shape folio renders rather than projects (`v:shape`, `v:rect`, ...). */
6
+ vmlShape: {
7
+ mimeType: "image/svg+xml",
8
+ filename: "vml-shape-preview.svg",
9
+ srcPrefix: VML_PREVIEW_DATA_URL_PREFIX,
10
+ maxPackageCharacters: 8 * MEBIBYTE
11
+ },
12
+ /**
13
+ * A SmartArt diagram: its extent filled with one flat rectangle per shape.
14
+ *
15
+ * A raster rather than a vector because the display list decodes only base64
16
+ * PNG and JPEG, so a vector preview would be missing from every display-list
17
+ * backend (PDF among them) while still showing in the DOM. That is what
18
+ * makes this preview expensive: one of them is 7.3 MB of data URL whatever
19
+ * the package weighs, because its cost follows the extent the author chose
20
+ * rather than anything the drawing contains.
21
+ *
22
+ * Across the public corpus, the fifty packages that produce one retain a
23
+ * median of 7.3 MB and a maximum of 51.3 MB (ten previews, from a package
24
+ * under a megabyte). The cap is set above that maximum: it refuses no
25
+ * legitimate file in the corpus while bounding what had no bound at all, and
26
+ * it is a ceiling rather than a fix. The fix is for the preview to be a
27
+ * descriptor the renderer rasterizes, which needs the display-list contract
28
+ * to carry one.
29
+ */
30
+ smartArt: {
31
+ mimeType: "image/png",
32
+ filename: "smartart-preview.png",
33
+ srcPrefix: "data:image/png;base64,",
34
+ maxPackageCharacters: 64 * MEBIBYTE
35
+ }
36
+ };
37
+ const KIND_NAMES = Object.keys(PREVIEW_KINDS);
38
+ /**
39
+ * The kind a model image was generated as, or `undefined` for one the package
40
+ * actually carries. A generated preview has no relationship behind it, so
41
+ * `rId` is empty; the filename and data-URL prefix name which producer made it.
42
+ */
43
+ const previewKindOf = (value) => {
44
+ if (!("type" in value) || value.type !== "image" || !("rId" in value) || value.rId !== "" || !("src" in value) || typeof value.src !== "string" || !("mimeType" in value) || !("filename" in value)) return;
45
+ const { src, mimeType, filename } = value;
46
+ return KIND_NAMES.find((name) => {
47
+ const kind = PREVIEW_KINDS[name];
48
+ return mimeType === kind.mimeType && filename === kind.filename && src.startsWith(kind.srcPrefix);
49
+ });
50
+ };
51
+ /**
52
+ * Charge every generated preview in the model against its kind's allowance and
53
+ * drop the `src` of those past it. Dropping leaves the image in place with its
54
+ * size and wrap, so the page still reserves the space the drawing occupies,
55
+ * and never touches the preserved XML the package saves from.
56
+ */
57
+ const enforcePackagePreviewBudget = (root, overrides = {}) => {
58
+ const remaining = new Map(KIND_NAMES.map((name) => [name, Math.max(0, overrides[name] ?? PREVIEW_KINDS[name].maxPackageCharacters)]));
59
+ const visited = /* @__PURE__ */ new WeakSet();
60
+ const visit = (value) => {
61
+ if (value === null || typeof value !== "object" || visited.has(value)) return;
62
+ visited.add(value);
63
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return;
64
+ if (value instanceof Map) {
65
+ for (const child of value.values()) visit(child);
66
+ return;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ for (const child of value) visit(child);
70
+ return;
71
+ }
72
+ const kind = previewKindOf(value);
73
+ if (kind !== void 0) {
74
+ const image = value;
75
+ const length = image.src.length;
76
+ const left = remaining.get(kind);
77
+ if (length <= left) remaining.set(kind, left - length);
78
+ else {
79
+ remaining.set(kind, 0);
80
+ delete image.src;
81
+ }
82
+ }
83
+ for (const child of Object.values(value)) visit(child);
84
+ };
85
+ visit(root);
86
+ };
87
+ //#endregion
88
+ export { PREVIEW_KINDS, VML_PREVIEW_DATA_URL_PREFIX, enforcePackagePreviewBudget };
@@ -117,7 +117,10 @@ const normalizeRevisionIdsInXmlParts = (parts) => {
117
117
  const occurrencesByPath = /* @__PURE__ */ new Map();
118
118
  const reserved = /* @__PURE__ */ new Set();
119
119
  for (const [path, xml] of candidates) {
120
- assertXmlResourceLimits(xml);
120
+ assertXmlResourceLimits({
121
+ xml,
122
+ partPath: path
123
+ });
121
124
  const ids = [];
122
125
  if (rewriteStreamingXmlDecimalAttributes(xml, (element) => {
123
126
  const identified = identifiedElement(element);
@@ -127,7 +130,9 @@ const normalizeRevisionIdsInXmlParts = (parts) => {
127
130
  return null;
128
131
  }).status === "unsupported") throw new XmlResourceLimitError({
129
132
  message: `Revision-id normalization could not safely scan ${path}`,
130
- limit: "syntax"
133
+ limit: "syntax",
134
+ observed: 0,
135
+ allowed: 0
131
136
  });
132
137
  occurrencesByPath.set(path, ids);
133
138
  }
@@ -137,14 +142,19 @@ const normalizeRevisionIdsInXmlParts = (parts) => {
137
142
  else firstSeen.add(id);
138
143
  if (repeatedPaths.size > 0) for (const [path, xml] of parts) {
139
144
  if (occurrencesByPath.has(path) || !ANNOTATION_ELEMENT_CANDIDATE.test(xml)) continue;
140
- assertXmlResourceLimits(xml);
145
+ assertXmlResourceLimits({
146
+ xml,
147
+ partPath: path
148
+ });
141
149
  if (rewriteStreamingXmlDecimalAttributes(xml, (element) => {
142
150
  const identified = identifiedElement(element);
143
151
  if (identified !== null) reserved.add(identified.id);
144
152
  return null;
145
153
  }).status === "unsupported") throw new XmlResourceLimitError({
146
154
  message: `Revision-id normalization could not safely scan ${path}`,
147
- limit: "syntax"
155
+ limit: "syntax",
156
+ observed: 0,
157
+ allowed: 0
148
158
  });
149
159
  }
150
160
  let nextId = 0;
@@ -185,7 +195,9 @@ const normalizeRevisionIdsInXmlParts = (parts) => {
185
195
  });
186
196
  if (rewritten.status === "unsupported") throw new XmlResourceLimitError({
187
197
  message: `Revision-id normalization could not safely rewrite ${path}`,
188
- limit: "syntax"
198
+ limit: "syntax",
199
+ observed: 0,
200
+ allowed: 0
189
201
  });
190
202
  normalized.set(path, rewritten.value);
191
203
  }
@@ -19,7 +19,7 @@ import { RELATIONSHIP_TYPES, parseRelationships, resolveRelativePath } from "./r
19
19
  import { removeResolvedHeaderFooterParts } from "./removeHeaderFooterParts.js";
20
20
  import { normalizeRevisionIdsInXmlParts } from "./revisionIdNormalization.js";
21
21
  import { buildPatchedNotePartXml, collectChangedNoteParaIds, collectParaIds, patchNumberingDefinitions } from "./selectiveXmlPatch.js";
22
- import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
22
+ import { planCommentParts, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
23
23
  import { serializeDocument } from "./serializer/documentSerializer.js";
24
24
  import { serializeFontTableXml } from "./serializer/fontTableSerializer.js";
25
25
  import { serializeHeaderFooter } from "./serializer/headerFooterSerializer.js";
@@ -29,11 +29,10 @@ import { readRootNamespaceBindings } from "./serializer/partNamespaces.js";
29
29
  import { serializeSettingsXml } from "./serializer/settingsSerializer.js";
30
30
  import { serializeStyle, serializeStylesXml } from "./serializer/stylesSerializer.js";
31
31
  import { serializeThemeXml } from "./serializer/themeSerializer.js";
32
- import { escapeXml } from "./serializer/xmlUtils.js";
33
32
  import { OFFICE_RELATIONSHIP_NAMESPACE_URIS, WORDPROCESSINGML_NAMESPACE_URIS, findChild, getAttribute, getAttributeByNamespaceUri, getChildElements, getLocalName, getNamespaceUri, matchesName, parseXml, parseXmlDocument } from "./xmlParser.js";
34
33
  import { assertXmlResourceLimits } from "./xmlResourceLimits.js";
35
34
  import { panic } from "better-result";
36
- import { validateDocxPackage } from "@stll/docx-core";
35
+ import { escapeXmlAttribute, escapeXmlText, validateDocxPackage } from "@stll/docx-core";
37
36
  import JSZip from "jszip";
38
37
  //#region src/docx/rezip.ts
39
38
  /**
@@ -85,7 +84,7 @@ function findMaxRId(relsXml) {
85
84
  }
86
85
  const isWordprocessingElement = (element, localName) => getLocalName(element.name) === localName && WORDPROCESSINGML_NAMESPACE_URIS.has(getNamespaceUri(element) ?? "");
87
86
  const countDocumentSections = (xml) => {
88
- assertXmlResourceLimits(xml);
87
+ assertXmlResourceLimits({ xml });
89
88
  let count = 0;
90
89
  const pending = [{
91
90
  element: parseXml(xml),
@@ -106,7 +105,7 @@ const countDocumentSections = (xml) => {
106
105
  };
107
106
  const extractHeaderFooterReferences = (xml) => {
108
107
  const references = [];
109
- assertXmlResourceLimits(xml);
108
+ assertXmlResourceLimits({ xml });
110
109
  const pending = [parseXml(xml)];
111
110
  while (pending.length > 0) {
112
111
  const node = pending.pop();
@@ -166,15 +165,15 @@ async function serializeCommentsToZip(doc, zip, compressionLevel) {
166
165
  if (comments.length === 0) {
167
166
  if (sourceCommentsXml === void 0 || !hasCommentEntries(sourceCommentsXml)) return;
168
167
  }
169
- ensureThreadedCommentParaIds(comments);
170
- const commentsXml = serializeComments(comments, sourceCommentsXml === void 0 ? void 0 : readRootNamespaceBindings(sourceCommentsXml));
168
+ const plan = planCommentParts(comments);
169
+ const commentsXml = serializeComments(plan, sourceCommentsXml === void 0 ? void 0 : readRootNamespaceBindings(sourceCommentsXml));
171
170
  zip.file(sourceCommentsFile?.name ?? "word/comments.xml", commentsXml, {
172
171
  compression: "DEFLATE",
173
172
  compressionOptions: { level: compressionLevel }
174
173
  });
175
174
  await ensureCommentsContentType(zip, compressionLevel);
176
175
  await ensureCommentsRelationship(zip, compressionLevel);
177
- await syncCommentsExtendedPart(comments, zip, compressionLevel);
176
+ await syncCommentsExtendedPart(plan, zip, compressionLevel);
178
177
  }
179
178
  const hasCommentEntries = (xml) => {
180
179
  const root = parseXml(xml);
@@ -189,8 +188,8 @@ const hasCommentEntries = (xml) => {
189
188
  * removed part triggers Word's repair prompt). The reply-thread markers in
190
189
  * `document.xml` are synthesized separately (see {@link applyReplyThreadMarkers}).
191
190
  */
192
- async function syncCommentsExtendedPart(comments, zip, compressionLevel) {
193
- const xml = serializeCommentsExtended(comments);
191
+ async function syncCommentsExtendedPart(plan, zip, compressionLevel) {
192
+ const xml = serializeCommentsExtended(plan);
194
193
  const existing = findZipEntryCaseInsensitive(zip, COMMENTS_EXTENDED_PART_LOWER);
195
194
  if (!xml) {
196
195
  if (!existing) return;
@@ -396,7 +395,7 @@ async function processNewImages(parts, zip, compressionLevel) {
396
395
  compression: "DEFLATE",
397
396
  compressionOptions: { level: compressionLevel }
398
397
  });
399
- relEntries.push(`<Relationship Id="${newRId}" Type="${RELATIONSHIP_TYPES.image}" Target="${escapeXml(relativeTargetForPart(partPath, mediaPath))}"/>`);
398
+ relEntries.push(`<Relationship Id="${newRId}" Type="${RELATIONSHIP_TYPES.image}" Target="${escapeXmlAttribute(relativeTargetForPart(partPath, mediaPath))}"/>`);
400
399
  extensionsAdded.add(extension);
401
400
  if (drawing.rawXml) drawing.rawXml = rebindDrawingImageRelationship({
402
401
  xml: drawing.rawXml,
@@ -476,7 +475,7 @@ async function processNewHyperlinks(parts, zip, compressionLevel) {
476
475
  if (!hyperlink.href) continue;
477
476
  maxId++;
478
477
  const newRId = `rId${maxId}`;
479
- relEntries.push(`<Relationship Id="${newRId}" Type="${RELATIONSHIP_TYPES.hyperlink}" Target="${escapeXml(hyperlink.href)}" TargetMode="External"/>`);
478
+ relEntries.push(`<Relationship Id="${newRId}" Type="${RELATIONSHIP_TYPES.hyperlink}" Target="${escapeXmlAttribute(hyperlink.href)}" TargetMode="External"/>`);
480
479
  hyperlink.rId = newRId;
481
480
  }
482
481
  zip.file(relsPath, relsXml.replace("</Relationships>", `${relEntries.join("")}</Relationships>`), {
@@ -952,7 +951,7 @@ async function addRelationship(originalBuffer, relationship) {
952
951
  const relsXml = await relsFile.async("text");
953
952
  const newRId = `rId${findMaxRId(relsXml) + 1}`;
954
953
  const targetModeAttr = relationship.targetMode === "External" ? " TargetMode=\"External\"" : "";
955
- const newRelElement = `<Relationship Id="${newRId}" Type="${relationship.type}" Target="${escapeXml(relationship.target)}"${targetModeAttr}/>`;
954
+ const newRelElement = `<Relationship Id="${newRId}" Type="${relationship.type}" Target="${escapeXmlAttribute(relationship.target)}"${targetModeAttr}/>`;
956
955
  const updatedRelsXml = relsXml.replace("</Relationships>", `${newRelElement}</Relationships>`);
957
956
  zip.file(relsPath, updatedRelsXml);
958
957
  return {
@@ -1101,7 +1100,7 @@ async function materializeNewHeaderFooterParts(doc, zip, compressionLevel) {
1101
1100
  type: relType,
1102
1101
  target: filename
1103
1102
  });
1104
- relEntries.push(`<Relationship Id="${escapeXml(effectiveRId)}" Type="${relType}" Target="${filename}"/>`);
1103
+ relEntries.push(`<Relationship Id="${escapeXmlAttribute(effectiveRId)}" Type="${relType}" Target="${filename}"/>`);
1105
1104
  overrides.push(`<Override PartName="/word/${filename}" ContentType="${contentType}"/>`);
1106
1105
  }
1107
1106
  };
@@ -1218,7 +1217,7 @@ async function rebindWatermarkRelIds(doc, zip, compressionLevel) {
1218
1217
  }
1219
1218
  if (!resolvedRId) {
1220
1219
  resolvedRId = `rId${findMaxRId(relsXml) + 1}`;
1221
- const relXml = canonical.mode === "external" ? `<Relationship Id="${resolvedRId}" Type="${RELATIONSHIP_TYPES.image}" Target="${escapeXml(canonical.url)}" TargetMode="External"/>` : `<Relationship Id="${resolvedRId}" Type="${RELATIONSHIP_TYPES.image}" Target="${escapeXml(relativeTargetForPart(partPath, canonical.absolute))}"/>`;
1220
+ const relXml = canonical.mode === "external" ? `<Relationship Id="${resolvedRId}" Type="${RELATIONSHIP_TYPES.image}" Target="${escapeXmlAttribute(canonical.url)}" TargetMode="External"/>` : `<Relationship Id="${resolvedRId}" Type="${RELATIONSHIP_TYPES.image}" Target="${escapeXmlAttribute(relativeTargetForPart(partPath, canonical.absolute))}"/>`;
1222
1221
  relsXmlByPath.set(relsPath, relsXml.replace("</Relationships>", `${relXml}</Relationships>`));
1223
1222
  changedPaths.add(relsPath);
1224
1223
  }
@@ -1531,7 +1530,7 @@ function updateCoreProperties(corePropsXml, { updateModifiedDate, modifiedBy })
1531
1530
  if (result.includes("<dcterms:modified")) result = result.replace(/<dcterms:modified[^<>]*>[^<]*<\/dcterms:modified>/u, `<dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified>`);
1532
1531
  }
1533
1532
  if (modifiedBy) {
1534
- if (result.includes("<cp:lastModifiedBy")) result = result.replace(/<cp:lastModifiedBy>[^<]*<\/cp:lastModifiedBy>/u, `<cp:lastModifiedBy>${escapeXml(modifiedBy)}</cp:lastModifiedBy>`);
1533
+ if (result.includes("<cp:lastModifiedBy")) result = result.replace(/<cp:lastModifiedBy>[^<]*<\/cp:lastModifiedBy>/u, `<cp:lastModifiedBy>${escapeXmlText(modifiedBy)}</cp:lastModifiedBy>`);
1535
1534
  }
1536
1535
  return result;
1537
1536
  }
@@ -1638,13 +1637,13 @@ const createEmptyDocxZip = ({ creator, application }) => {
1638
1637
  </w:document>`);
1639
1638
  zip.file(STYLES_PART_PATH, SEED_STYLES_XML);
1640
1639
  const now = (/* @__PURE__ */ new Date()).toISOString();
1641
- const creatorElement = creator === void 0 ? "" : `\n <dc:creator>${escapeXml(creator)}</dc:creator>`;
1640
+ const creatorElement = creator === void 0 ? "" : `\n <dc:creator>${escapeXmlText(creator)}</dc:creator>`;
1642
1641
  zip.file("docProps/core.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1643
1642
  <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">${creatorElement}
1644
1643
  <dcterms:created xsi:type="dcterms:W3CDTF">${now}</dcterms:created>
1645
1644
  <dcterms:modified xsi:type="dcterms:W3CDTF">${now}</dcterms:modified>
1646
1645
  </cp:coreProperties>`);
1647
- const applicationElements = application === void 0 ? "" : `\n <Application>${escapeXml(application)}</Application>\n <AppVersion>${CREATED_APP_VERSION}</AppVersion>`;
1646
+ const applicationElements = application === void 0 ? "" : `\n <Application>${escapeXmlText(application)}</Application>\n <AppVersion>${CREATED_APP_VERSION}</AppVersion>`;
1648
1647
  zip.file("docProps/app.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
1649
1648
  <Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">${applicationElements}
1650
1649
  </Properties>`);
@@ -1,21 +1,27 @@
1
+ import { escapeXmlAttribute } from "@stll/docx-core";
1
2
  //#region src/docx/sdtPropertiesPatch.ts
2
- const XML_ATTR_ESCAPES = {
3
- "&": "&amp;",
4
- "<": "&lt;",
5
- ">": "&gt;",
6
- "\"": "&quot;",
7
- "'": "&apos;"
8
- };
9
3
  /**
10
- * Escape a value for embedding in an XML attribute. Single-pass replace
11
- * over the five XML metacharacters (incl. apostrophe so the function
12
- * stays correct for `'`-delimited attributes even though this file always
13
- * emits double-quoted ones). Output is XML written to a DOCX zip — no
14
- * HTML / browser interpretation downstream.
4
+ * Surgical patches for the captured `<w:sdtPr>` XML string.
5
+ *
6
+ * Background. The block-SDT serializer (commit 3) replays
7
+ * `properties.rawPropertiesXml` verbatim so unmodeled OOXML markers
8
+ * (`w:dataBinding`, `w15:repeatingSection`, custom XML mappings) survive
9
+ * a round trip. That replay is correct for unchanged controls but goes
10
+ * stale the moment the editor mutates a modeled property: a user
11
+ * toggling a checkbox, picking a date, or choosing a dropdown value
12
+ * updates `properties.checked` / `dateFormat` / etc., but the raw
13
+ * `w14:checked w14:val="0"` already encoded by the source DOCX stays
14
+ * in `rawPropertiesXml` and gets written back on save — Word reopens
15
+ * the document with the user's interactive change discarded.
16
+ *
17
+ * `reconcileRawSdtPr` walks every modeled field that has an OOXML
18
+ * representation inside `<w:sdtPr>` and, if the field is set on the
19
+ * model, rewrites the matching element in the raw string. Unmodeled
20
+ * markers are untouched, so dataBinding / repeatingSection round-trip
21
+ * stays lossless even after the user has mutated the control.
22
+ *
23
+ * Picked up from upstream eigenpal/docx-editor#661.
15
24
  */
16
- function escapeXmlAttr(value) {
17
- return value.replace(/[&<>"']/gu, (ch) => XML_ATTR_ESCAPES[ch] ?? ch);
18
- }
19
25
  /**
20
26
  * Drop any `*:lastValue="…"` attribute (any namespace prefix, or
21
27
  * unprefixed) from an attribute-list string. We avoid a single greedy
@@ -116,8 +122,8 @@ function reconcileRawSdtPr(raw, props, options = {}) {
116
122
  if (fullDate !== void 0 || dateFormat !== void 0) {
117
123
  const wDate = /<(?<prefix>\w+):date\b(?<attrs>[^>]*)>(?<inner>[\s\S]*?)<\/\w+:date>/iu;
118
124
  const wDateSelf = /<(?<prefix>\w+):date\b(?<attrs>[^/>]*)\/>/iu;
119
- const fullDateAttr = fullDate !== void 0 ? ` w:fullDate="${escapeXmlAttr(fullDate)}"` : "";
120
- const formatChild = dateFormat !== void 0 ? `<w:dateFormat w:val="${escapeXmlAttr(dateFormat)}"/>` : "";
125
+ const fullDateAttr = fullDate !== void 0 ? ` w:fullDate="${escapeXmlAttribute(fullDate)}"` : "";
126
+ const formatChild = dateFormat !== void 0 ? `<w:dateFormat w:val="${escapeXmlAttribute(dateFormat)}"/>` : "";
121
127
  if (wDate.test(next)) next = next.replace(wDate, (_match, prefix, matchedAttrs, inner) => {
122
128
  let body = inner.replaceAll(/<\w+:dateFormat\b[^>]*(?:\/>|>[\s\S]*?<\/\w+:dateFormat>)/giu, "");
123
129
  if (formatChild) body = `${formatChild}${body}`;
@@ -128,7 +134,7 @@ function reconcileRawSdtPr(raw, props, options = {}) {
128
134
  }
129
135
  }
130
136
  if ((props.sdtType === "dropdown" || props.sdtType === "comboBox") && options.dropdownLastValue !== void 0) {
131
- const escapedValue = escapeXmlAttr(options.dropdownLastValue);
137
+ const escapedValue = escapeXmlAttribute(options.dropdownLastValue);
132
138
  const opened = /<(?<prefix>\w+):(?<name>dropDownList|comboBox)\b(?<attrs>[^>]*)>(?<inner>[\s\S]*?)<\/\w+:(?:dropDownList|comboBox)>/iu;
133
139
  const selfClosing = /<(?<prefix>\w+):(?<name>dropDownList|comboBox)\b(?<attrs>[^/>]*)\/>/iu;
134
140
  if (opened.test(next)) next = next.replace(opened, (_match, prefix, name, attrs, inner) => {
@@ -1,7 +1,7 @@
1
1
  import { canonicalJson } from "../utils/canonicalJson.js";
2
- import { escapeXml } from "./serializer/xmlUtils.js";
3
2
  import { OFFICE_RELATIONSHIP_NAMESPACE_URIS, WORDPROCESSINGML_NAMESPACE_URIS, findChildrenByNamespaceUri, getAttributeByNamespaceUri, getChildElements, getLocalName, getNamespaceUri } from "./xmlParser.js";
4
3
  import { TaggedError } from "better-result";
4
+ import { escapeXmlAttribute } from "@stll/docx-core";
5
5
  //#region src/docx/sectionReferenceHistory.ts
6
6
  const SECTION_REFERENCE_HISTORY_NAMESPACE = "urn:stella:folio:section-reference-history:1";
7
7
  const HISTORY_NAMESPACES = /* @__PURE__ */ new Set([SECTION_REFERENCE_HISTORY_NAMESPACE]);
@@ -41,7 +41,7 @@ const serializeSectionReferenceHistory = (references) => {
41
41
  if (new Set(values.map(({ type }) => type)).size !== values.length) return invalidHistory();
42
42
  return values.map(({ type, rId }) => {
43
43
  if (!rId || rId.trim() !== rId) return invalidHistory();
44
- return `<w:${kind}Reference w:type="${type}" r:id="${escapeXml(rId)}"/>`;
44
+ return `<w:${kind}Reference w:type="${type}" r:id="${escapeXmlAttribute(rId)}"/>`;
45
45
  }).join("");
46
46
  };
47
47
  const content = serializeReferences("header", references.headerReferences ?? []) + serializeReferences("footer", references.footerReferences ?? []);