@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
@@ -8,7 +8,7 @@ import { RELATIONSHIP_TYPES } from "./relsParser.js";
8
8
  import { COMMENTS_CONTENT_TYPE, COMMENTS_EXTENDED_PART_LOWER, addCommentsExtendedOverride, addCommentsExtendedRelationship, applyUpdatesToZip, collectHeaderFooterUpdates, findMaxRId, hasModelDrivenPictureWatermark, hasUnmaterializedHeaderFooter, hasUnmaterializedInlineResources, updateCoreProperties, withoutAttachedTemplate } from "./rezip.js";
9
9
  import "./selectiveSaveFlags.js";
10
10
  import { buildPatchedDocumentXml, buildPatchedNoteXml, collectParaIds, patchNumberingDefinitions } from "./selectiveXmlPatch.js";
11
- import { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
11
+ import { planCommentParts, serializeComments, serializeCommentsExtended } from "./serializer/commentSerializer.js";
12
12
  import { serializeDocument } from "./serializer/documentSerializer.js";
13
13
  import { serializeEndnotes, serializeFootnotes } from "./serializer/noteSerializer.js";
14
14
  import { serializeNumberingXml } from "./serializer/numberingSerializer.js";
@@ -84,8 +84,8 @@ const commentsExtendedInfoEqual = (a, b) => {
84
84
  * - Threading changed: write the part and ensure its override + relationship,
85
85
  * bailing if the packaging files needed to wire it are absent.
86
86
  */
87
- async function patchCommentsExtended(zip, comments, updates) {
88
- const desiredXml = serializeCommentsExtended(comments);
87
+ async function patchCommentsExtended(zip, plan, updates) {
88
+ const desiredXml = serializeCommentsExtended(plan);
89
89
  const existing = findZipEntryCaseInsensitive(zip, COMMENTS_EXTENDED_PART_LOWER);
90
90
  if (!desiredXml) return existing === null;
91
91
  const baselineInfo = existing ? parseCommentsExtended(await existing.async("text")) : /* @__PURE__ */ new Map();
@@ -190,6 +190,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
190
190
  if (!validateFolioDocumentModel(doc).valid) return null;
191
191
  const comments = doc.package.document.comments ?? [];
192
192
  const hasComments = comments.length > 0;
193
+ const commentPlan = planCommentParts(comments);
193
194
  try {
194
195
  const zip = await (await import("jszip")).default.loadAsync(originalBuffer);
195
196
  for (const [path, file] of Object.entries(zip.files)) if (!file.dir && isUnsafePackagePath(path)) return null;
@@ -215,9 +216,8 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
215
216
  }
216
217
  const sourceCommentsFile = zip.file("word/comments.xml");
217
218
  if (hasComments || sourceCommentsFile) {
218
- ensureThreadedCommentParaIds(comments);
219
219
  const sourceBindings = sourceCommentsFile ? readRootNamespaceBindings(await sourceCommentsFile.async("text")) : void 0;
220
- updates.set("word/comments.xml", serializeComments(comments, sourceBindings));
220
+ updates.set("word/comments.xml", serializeComments(commentPlan, sourceBindings));
221
221
  }
222
222
  if (hasComments) {
223
223
  const ctFile = zip.file("[Content_Types].xml");
@@ -235,7 +235,7 @@ async function attemptSelectiveSave(doc, originalBuffer, options) {
235
235
  }
236
236
  }
237
237
  }
238
- if (!await patchCommentsExtended(zip, comments, updates)) return null;
238
+ if (!await patchCommentsExtended(zip, commentPlan, updates)) return null;
239
239
  await patchNumberingPart(zip, doc, updates);
240
240
  for (const [path, xml] of await collectHeaderFooterUpdates(doc, zip)) updates.set(path, xml);
241
241
  await queueSettingsUpdates(zip, updates);
@@ -1,24 +1,39 @@
1
1
  import { reconcileRawSdtPr } from "../sdtPropertiesPatch.js";
2
2
  import { isSingleWellFormedElement } from "./xmlUtils.js";
3
+ import { escapeXmlAttribute } from "@stll/docx-core";
3
4
  //#region src/docx/serializer/blockSdtSerializer.ts
4
- function escapeXmlAttr(value) {
5
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
6
- }
5
+ /**
6
+ * Block-level SDT serializer.
7
+ *
8
+ * Replays `<w:sdtPr>` (and `<w:sdtEndPr>` when present) verbatim from the
9
+ * `rawPropertiesXml` / `rawEndPropertiesXml` strings captured by the parser.
10
+ * That keeps OOXML element order intact (`CT_SdtPr` is an `xsd:sequence`,
11
+ * ECMA-376 §17.5.2) and round-trips unmodeled features — `w:dataBinding`,
12
+ * `w15:repeatingSection`, `@lastValue`, custom XML mappings — without us
13
+ * having to enumerate them.
14
+ *
15
+ * If a `BlockSdt` was constructed programmatically (no parsed snapshot to
16
+ * replay), fall back to a minimal projection from the modeled fields so the
17
+ * result is still a valid `<w:sdt>`.
18
+ *
19
+ * Sharing the helper between the document body and the header/footer
20
+ * serializers keeps body↔HF parity in one place.
21
+ */
7
22
  function serializeFallbackSdtPr(props) {
8
23
  const parts = [];
9
24
  if (props.id !== void 0) parts.push(`<w:id w:val="${props.id}"/>`);
10
- if (props.alias) parts.push(`<w:alias w:val="${escapeXmlAttr(props.alias)}"/>`);
11
- if (props.tag) parts.push(`<w:tag w:val="${escapeXmlAttr(props.tag)}"/>`);
25
+ if (props.alias) parts.push(`<w:alias w:val="${escapeXmlAttribute(props.alias)}"/>`);
26
+ if (props.tag) parts.push(`<w:tag w:val="${escapeXmlAttribute(props.tag)}"/>`);
12
27
  if (props.lock) parts.push(`<w:lock w:val="${props.lock}"/>`);
13
- if (props.placeholder) parts.push(`<w:placeholder><w:docPart w:val="${escapeXmlAttr(props.placeholder)}"/></w:placeholder>`);
28
+ if (props.placeholder) parts.push(`<w:placeholder><w:docPart w:val="${escapeXmlAttribute(props.placeholder)}"/></w:placeholder>`);
14
29
  if (props.showingPlaceholder) parts.push("<w:showingPlcHdr/>");
15
30
  switch (props.sdtType) {
16
31
  case "plainText":
17
32
  parts.push("<w:text/>");
18
33
  break;
19
34
  case "date": {
20
- const fullDateAttr = props.dateValueISO ? ` w:fullDate="${escapeXmlAttr(props.dateValueISO)}"` : "";
21
- const formatChild = props.dateFormat ? `<w:dateFormat w:val="${escapeXmlAttr(props.dateFormat)}"/>` : "";
35
+ const fullDateAttr = props.dateValueISO ? ` w:fullDate="${escapeXmlAttribute(props.dateValueISO)}"` : "";
36
+ const formatChild = props.dateFormat ? `<w:dateFormat w:val="${escapeXmlAttribute(props.dateFormat)}"/>` : "";
22
37
  if (fullDateAttr || formatChild) parts.push(`<w:date${fullDateAttr}>${formatChild}</w:date>`);
23
38
  else parts.push("<w:date/>");
24
39
  break;
@@ -26,7 +41,7 @@ function serializeFallbackSdtPr(props) {
26
41
  case "dropdown":
27
42
  case "comboBox": {
28
43
  const tag = props.sdtType === "dropdown" ? "w:dropDownList" : "w:comboBox";
29
- const items = (props.listItems ?? []).map((item) => `<w:listItem w:displayText="${escapeXmlAttr(item.displayText)}" w:value="${escapeXmlAttr(item.value)}"/>`).join("");
44
+ const items = (props.listItems ?? []).map((item) => `<w:listItem w:displayText="${escapeXmlAttribute(item.displayText)}" w:value="${escapeXmlAttribute(item.value)}"/>`).join("");
30
45
  parts.push(`<${tag}>${items}</${tag}>`);
31
46
  break;
32
47
  }
@@ -48,25 +63,22 @@ function serializeFallbackSdtPr(props) {
48
63
  }
49
64
  return `<w:sdtPr>${parts.join("")}</w:sdtPr>`;
50
65
  }
66
+ /**
67
+ * `properties.dropdownLastValue` is the only record of what was selected.
68
+ *
69
+ * The XSD default of `@w:lastValue` is the empty string, so "never selected",
70
+ * "cleared" and "selected" are three distinguishable states, and the body's
71
+ * display text is evidence for none of them: it is equally the placeholder of
72
+ * a dropdown nobody has touched, and a displayText shared by two list items
73
+ * picks the wrong sibling. The parser records the authored `@w:lastValue` and
74
+ * `setContentControlValue` records a pick; what neither wrote is no selection.
75
+ *
76
+ * `""` is a value a producer can author (`<w:listItem w:value=""/>`), so
77
+ * presence is the test, not truthiness.
78
+ */
51
79
  function extractDropdownLastValue(blockSdt) {
52
80
  if (blockSdt.properties.sdtType !== "dropdown" && blockSdt.properties.sdtType !== "comboBox") return;
53
- const modeled = blockSdt.properties.dropdownLastValue;
54
- if (modeled !== void 0) return modeled;
55
- if (blockSdt.properties.rawPropertiesXml !== void 0) return;
56
- const firstBlock = blockSdt.content[0];
57
- if (!firstBlock || firstBlock.type !== "paragraph") return;
58
- const parts = [];
59
- for (const child of firstBlock.content) if (child.type === "run") {
60
- for (const item of child.content) if (item.type === "text") parts.push(item.text);
61
- }
62
- const text = parts.join("");
63
- if (text.length === 0) return;
64
- const items = blockSdt.properties.listItems;
65
- if (items) {
66
- const match = items.find((item) => item.displayText === text);
67
- if (match) return match.value;
68
- }
69
- return text;
81
+ return blockSdt.properties.dropdownLastValue;
70
82
  }
71
83
  function extractDateFullDate(blockSdt) {
72
84
  if (blockSdt.properties.sdtType !== "date") return;
@@ -18,7 +18,7 @@ type ExhaustiveBorder = document_d_exports.ExhaustiveFields<document_d_exports.B
18
18
  *
19
19
  * `style` and the color values come straight from the parsed DOCX (the parser
20
20
  * casts `w:val`/`w:color` without validating the enum), so they are
21
- * untrusted and are `escapeXml`'d before re-entering XML attributes; for valid
21
+ * untrusted and are `escapeXmlAttribute`'d before re-entering XML attributes; for valid
22
22
  * documents these are enum/hex values, so escaping is a no-op.
23
23
  */
24
24
  declare function serializeBorder(input: ExhaustiveBorder | undefined, elementName: string): string;
@@ -1,4 +1,5 @@
1
- import { escapeXml, intAttr } from "./xmlUtils.js";
1
+ import { intAttr } from "./xmlUtils.js";
2
+ import { escapeXmlAttribute } from "@stll/docx-core";
2
3
  //#region src/docx/serializer/borderSerializer.ts
3
4
  /**
4
5
  * Serialize a single border element (`<w:top .../>`, `<w:left .../>`, ...).
@@ -16,30 +17,30 @@ import { escapeXml, intAttr } from "./xmlUtils.js";
16
17
  *
17
18
  * `style` and the color values come straight from the parsed DOCX (the parser
18
19
  * casts `w:val`/`w:color` without validating the enum), so they are
19
- * untrusted and are `escapeXml`'d before re-entering XML attributes; for valid
20
+ * untrusted and are `escapeXmlAttribute`'d before re-entering XML attributes; for valid
20
21
  * documents these are enum/hex values, so escaping is a no-op.
21
22
  */
22
23
  function serializeBorder(input, elementName) {
23
24
  if (!input) return "";
24
25
  const { style, color, size, space, shadow, frame, artRelationshipId, topLeftArtRelationshipId, topRightArtRelationshipId, bottomLeftArtRelationshipId, bottomRightArtRelationshipId } = input;
25
- const attrs = [`w:val="${escapeXml(style)}"`];
26
+ const attrs = [`w:val="${escapeXmlAttribute(style)}"`];
26
27
  if (size !== void 0) attrs.push(`w:sz="${intAttr(size)}"`);
27
28
  if (space !== void 0) attrs.push(`w:space="${intAttr(space)}"`);
28
29
  if (color) {
29
30
  const { auto, rgb, themeColor, themeTint, themeShade } = color;
30
31
  if (auto) attrs.push("w:color=\"auto\"");
31
- else if (rgb) attrs.push(`w:color="${escapeXml(rgb)}"`);
32
- if (themeColor) attrs.push(`w:themeColor="${escapeXml(themeColor)}"`);
33
- if (themeTint) attrs.push(`w:themeTint="${escapeXml(themeTint)}"`);
34
- if (themeShade) attrs.push(`w:themeShade="${escapeXml(themeShade)}"`);
32
+ else if (rgb) attrs.push(`w:color="${escapeXmlAttribute(rgb)}"`);
33
+ if (themeColor) attrs.push(`w:themeColor="${escapeXmlAttribute(themeColor)}"`);
34
+ if (themeTint) attrs.push(`w:themeTint="${escapeXmlAttribute(themeTint)}"`);
35
+ if (themeShade) attrs.push(`w:themeShade="${escapeXmlAttribute(themeShade)}"`);
35
36
  }
36
37
  if (shadow) attrs.push("w:shadow=\"true\"");
37
38
  if (frame) attrs.push("w:frame=\"true\"");
38
- if (artRelationshipId) attrs.push(`w:id="${escapeXml(artRelationshipId)}"`);
39
- if (topLeftArtRelationshipId) attrs.push(`w:topLeft="${escapeXml(topLeftArtRelationshipId)}"`);
40
- if (topRightArtRelationshipId) attrs.push(`w:topRight="${escapeXml(topRightArtRelationshipId)}"`);
41
- if (bottomLeftArtRelationshipId) attrs.push(`w:bottomLeft="${escapeXml(bottomLeftArtRelationshipId)}"`);
42
- if (bottomRightArtRelationshipId) attrs.push(`w:bottomRight="${escapeXml(bottomRightArtRelationshipId)}"`);
39
+ if (artRelationshipId) attrs.push(`r:id="${escapeXmlAttribute(artRelationshipId)}"`);
40
+ if (topLeftArtRelationshipId) attrs.push(`r:topLeft="${escapeXmlAttribute(topLeftArtRelationshipId)}"`);
41
+ if (topRightArtRelationshipId) attrs.push(`r:topRight="${escapeXmlAttribute(topRightArtRelationshipId)}"`);
42
+ if (bottomLeftArtRelationshipId) attrs.push(`r:bottomLeft="${escapeXmlAttribute(bottomLeftArtRelationshipId)}"`);
43
+ if (bottomRightArtRelationshipId) attrs.push(`r:bottomRight="${escapeXmlAttribute(bottomRightArtRelationshipId)}"`);
43
44
  return `<w:${elementName} ${attrs.join(" ")}/>`;
44
45
  }
45
46
  //#endregion
@@ -1,30 +1,55 @@
1
1
  import { Comment } from "../../types/content.js";
2
2
  //#region src/docx/serializer/commentSerializer.d.ts
3
3
  /**
4
- * Serialize comments array to comments.xml content. Returns a valid empty
5
- * `<w:comments/>` document for an empty array so callers can overwrite an
4
+ * What every comment part is written from: one ordered list of comment ids, and
5
+ * the facts each id carries.
6
+ *
7
+ * `word/comments.xml` and `word/commentsExtended.xml` describe the same
8
+ * comments and have to agree about which is which. They agreed by each walking
9
+ * the `Comment[]` they were handed and pairing entries up by position, so any
10
+ * disagreement about order — one part reordered, the other not — silently moved
11
+ * a comment's thread link, resolved state and paraId onto a different comment.
12
+ * Positions cannot disagree if neither part has one: both walk `order` and look
13
+ * every fact up by `w:id`, the only identity the package itself has.
14
+ */
15
+ type CommentPartPlan = {
16
+ /** The `w:id`s to write, in order, each exactly once. */
17
+ readonly order: readonly number[];
18
+ readonly byId: ReadonlyMap<number, Comment>;
19
+ /** The `w14:paraId` each comment is threaded by, minted where the model had none. */
20
+ readonly threadParaIdById: ReadonlyMap<number, string>;
21
+ /** The comments needing a `commentsExtended.xml` entry: replies, their parents, resolved comments. */
22
+ readonly threadedIds: ReadonlySet<number>;
23
+ };
24
+ /**
25
+ * Plan both comment parts from the model, in the model's own order.
26
+ *
27
+ * The order is the document's: the order `word/comments.xml` listed the
28
+ * comments in is the order it is written back in, so a save neither reshuffles
29
+ * a reviewer's threads nor hands the next parse a different `comments[]` than
30
+ * the one it read.
31
+ *
32
+ * Planning also mints the `w14:paraId` a thread needs when the model has none —
33
+ * a comment written in the editor has no Word-authored id, and without one the
34
+ * `commentsExtended.xml` link would be dropped. Minting is deterministic
35
+ * (comment id and text derived) so repeated saves mint the same id, and it
36
+ * happens here rather than at each call site because both parts must see it.
37
+ */
38
+ declare const planCommentParts: (comments: readonly Comment[]) => CommentPartPlan;
39
+ /**
40
+ * Serialize a comment plan to comments.xml content. Returns a valid empty
41
+ * `<w:comments/>` document for an empty plan so callers can overwrite an
6
42
  * existing `word/comments.xml` part when the editor has removed the last
7
43
  * comment — leaving the previous file in place would otherwise re-emit
8
44
  * the orphaned comment threads on every save.
9
45
  */
10
- declare function serializeComments(comments: Comment[], sourceBindings?: ReadonlyMap<string, string>): string;
11
- /**
12
- * Assign a deterministic `w14:paraId` to the LAST paragraph of every comment
13
- * that needs a commentsExtended entry (a reply, a reply's parent, or a resolved
14
- * comment) but has none. A document authored or loaded without comment paraIds
15
- * would otherwise have no stable key to thread through commentsExtended.xml, and
16
- * the thread link would be silently dropped. Word-authored ids are preserved;
17
- * only threaded, id-less paragraphs are filled. Deterministic (content- and
18
- * id-derived) so repeated saves mint the SAME id. MUST run before serializing
19
- * BOTH comments.xml and commentsExtended.xml so the two reference the same id.
20
- */
21
- declare function ensureThreadedCommentParaIds(comments: readonly Comment[]): void;
46
+ declare function serializeComments({ order, byId }: CommentPartPlan, sourceBindings?: ReadonlyMap<string, string>): string;
22
47
  /**
23
48
  * Serialize `commentsExtended.xml` (`w15:commentsEx`) for reply threading and
24
49
  * resolved state, or `null` when no comment needs an entry (see
25
50
  * {@link buildCommentExtendedEntries}). This is the part that makes Word render
26
51
  * a comment as a REPLY rather than a separate top-level thread.
27
52
  */
28
- declare function serializeCommentsExtended(comments: readonly Comment[]): string | null;
53
+ declare function serializeCommentsExtended(plan: CommentPartPlan): string | null;
29
54
  //#endregion
30
- export { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended };
55
+ export { CommentPartPlan, planCommentParts, serializeComments, serializeCommentsExtended };
@@ -1,8 +1,9 @@
1
1
  import { deterministicHexId } from "../../utils/hexId.js";
2
+ import { commentThreadParaId } from "../commentThreadKey.js";
2
3
  import { serializeParagraph } from "./paragraphSerializer.js";
3
4
  import { serializePartElement } from "./partNamespaces.js";
4
5
  import { serializeTextFormatting } from "./textFormattingSerializer.js";
5
- import { escapeXml } from "./xmlUtils.js";
6
+ import { escapeXmlAttribute } from "@stll/docx-core";
6
7
  //#region src/docx/serializer/commentSerializer.ts
7
8
  /**
8
9
  * Comment Serializer
@@ -26,9 +27,9 @@ function serializeParagraphWithAnnotationRef(paragraph, formatting) {
26
27
  return xml.replace(/^<w:p(?=[\s>])[^>]*>/u, (openingTag) => `${openingTag}${annotationReference}`);
27
28
  }
28
29
  function serializeComment(comment) {
29
- const attrs = [`w:id="${comment.id}"`, `w:author="${escapeXml(comment.author)}"`];
30
- if (comment.initials !== void 0) attrs.push(`w:initials="${escapeXml(comment.initials)}"`);
31
- if (comment.date) attrs.push(`w:date="${escapeXml(comment.date)}"`);
30
+ const attrs = [`w:id="${comment.id}"`, `w:author="${escapeXmlAttribute(comment.author)}"`];
31
+ if (comment.initials !== void 0) attrs.push(`w:initials="${escapeXmlAttribute(comment.initials)}"`);
32
+ if (comment.date) attrs.push(`w:date="${escapeXmlAttribute(comment.date)}"`);
32
33
  let xml = `<w:comment ${attrs.join(" ")}>`;
33
34
  if (comment.content.length > 0) {
34
35
  xml += serializeParagraphWithAnnotationRef(comment.content[0], comment.annotationReferenceFormatting);
@@ -61,46 +62,6 @@ const COMMENTS_EXTENDED_BASELINE_PREFIXES = [
61
62
  ];
62
63
  const XML_DECLARATION = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
63
64
  /**
64
- * The order every comment part is written in: top-level comments, then replies.
65
- *
66
- * `word/comments.xml` has always been written this way, and the order matters
67
- * beyond that one part: the save rewrites comments.xml, so the next parse hands
68
- * the model back in exactly this order. Any other part built by walking the
69
- * same array — `word/commentsExtended.xml`, and the paraId minting both parts
70
- * key on — has to walk it in the same order, or the first save writes a part
71
- * whose order the second save cannot reproduce and saving is not a fixed point.
72
- */
73
- const commentPartOrder = (comments) => {
74
- const topLevel = [];
75
- const replies = [];
76
- for (const comment of comments) {
77
- const { parentId } = comment;
78
- (parentId === null || parentId === void 0 ? topLevel : replies).push(comment);
79
- }
80
- return [...topLevel, ...replies];
81
- };
82
- /**
83
- * Serialize comments array to comments.xml content. Returns a valid empty
84
- * `<w:comments/>` document for an empty array so callers can overwrite an
85
- * existing `word/comments.xml` part when the editor has removed the last
86
- * comment — leaving the previous file in place would otherwise re-emit
87
- * the orphaned comment threads on every save.
88
- */
89
- function serializeComments(comments, sourceBindings) {
90
- const body = commentPartOrder(comments).map((comment) => serializeComment(comment)).join("");
91
- return XML_DECLARATION + serializePartElement({
92
- partPath: "word/comments.xml",
93
- rootName: "w:comments",
94
- baselinePrefixes: COMMENTS_BASELINE_PREFIXES,
95
- sourceBindings,
96
- body
97
- });
98
- }
99
- /** The `w14:paraId` Word threads a comment by: its LAST paragraph's paraId. */
100
- function commentThreadParaId(comment) {
101
- return comment.content?.at(-1)?.paraId;
102
- }
103
- /**
104
65
  * The ids of comments that need a `commentsExtended.xml` entry: a reply, a
105
66
  * reply's parent (the thread root), or any comment carrying a resolved state.
106
67
  * A plain top-level comment set yields an empty set, so no part is written.
@@ -121,29 +82,71 @@ const commentPlainText = (comment) => {
121
82
  return text;
122
83
  };
123
84
  /**
124
- * Assign a deterministic `w14:paraId` to the LAST paragraph of every comment
125
- * that needs a commentsExtended entry (a reply, a reply's parent, or a resolved
126
- * comment) but has none. A document authored or loaded without comment paraIds
127
- * would otherwise have no stable key to thread through commentsExtended.xml, and
128
- * the thread link would be silently dropped. Word-authored ids are preserved;
129
- * only threaded, id-less paragraphs are filled. Deterministic (content- and
130
- * id-derived) so repeated saves mint the SAME id. MUST run before serializing
131
- * BOTH comments.xml and commentsExtended.xml so the two reference the same id.
85
+ * Plan both comment parts from the model, in the model's own order.
86
+ *
87
+ * The order is the document's: the order `word/comments.xml` listed the
88
+ * comments in is the order it is written back in, so a save neither reshuffles
89
+ * a reviewer's threads nor hands the next parse a different `comments[]` than
90
+ * the one it read.
91
+ *
92
+ * Planning also mints the `w14:paraId` a thread needs when the model has none —
93
+ * a comment written in the editor has no Word-authored id, and without one the
94
+ * `commentsExtended.xml` link would be dropped. Minting is deterministic
95
+ * (comment id and text derived) so repeated saves mint the same id, and it
96
+ * happens here rather than at each call site because both parts must see it.
132
97
  */
133
- function ensureThreadedCommentParaIds(comments) {
134
- const threaded = threadedCommentIds(comments);
135
- if (threaded.size === 0) return;
98
+ const planCommentParts = (comments) => {
99
+ const byId = /* @__PURE__ */ new Map();
100
+ const order = [];
101
+ for (const comment of comments) {
102
+ if (byId.has(comment.id)) continue;
103
+ byId.set(comment.id, comment);
104
+ order.push(comment.id);
105
+ }
106
+ const planned = [...byId.values()];
107
+ const threadedIds = threadedCommentIds(planned);
136
108
  const used = /* @__PURE__ */ new Set();
137
- for (const comment of comments) for (const paragraph of comment.content ?? []) if (paragraph.paraId) used.add(paragraph.paraId.toUpperCase());
138
- for (const comment of commentPartOrder(comments)) {
139
- if (!threaded.has(comment.id)) continue;
109
+ for (const comment of planned) for (const paragraph of comment.content ?? []) if (paragraph.paraId) used.add(paragraph.paraId.toUpperCase());
110
+ const threadParaIdById = /* @__PURE__ */ new Map();
111
+ for (const id of order) {
112
+ const comment = byId.get(id);
113
+ const existing = commentThreadParaId((comment.content ?? []).map(({ paraId }) => paraId));
114
+ if (existing) {
115
+ threadParaIdById.set(id, existing);
116
+ continue;
117
+ }
140
118
  const last = (comment.content ?? []).at(-1);
141
- if (!last || last.paraId) continue;
142
- let paraId = deterministicHexId(`comment:${comment.id}:${commentPlainText(comment)}`);
143
- for (let salt = 1; used.has(paraId.toUpperCase()); salt++) paraId = deterministicHexId(`comment:${comment.id}:${salt}`);
144
- used.add(paraId.toUpperCase());
145
- last.paraId = paraId;
119
+ if (!threadedIds.has(id) || !last) continue;
120
+ let minted = deterministicHexId(`comment:${id}:${commentPlainText(comment)}`);
121
+ for (let salt = 1; used.has(minted.toUpperCase()); salt++) minted = deterministicHexId(`comment:${id}:${salt}`);
122
+ used.add(minted.toUpperCase());
123
+ last.paraId = minted;
124
+ threadParaIdById.set(id, minted);
146
125
  }
126
+ return {
127
+ order,
128
+ byId,
129
+ threadParaIdById,
130
+ threadedIds
131
+ };
132
+ };
133
+ /**
134
+ * Serialize a comment plan to comments.xml content. Returns a valid empty
135
+ * `<w:comments/>` document for an empty plan so callers can overwrite an
136
+ * existing `word/comments.xml` part when the editor has removed the last
137
+ * comment — leaving the previous file in place would otherwise re-emit
138
+ * the orphaned comment threads on every save.
139
+ */
140
+ function serializeComments({ order, byId }, sourceBindings) {
141
+ let body = "";
142
+ for (const id of order) body += serializeComment(byId.get(id));
143
+ return XML_DECLARATION + serializePartElement({
144
+ partPath: "word/comments.xml",
145
+ rootName: "w:comments",
146
+ baselinePrefixes: COMMENTS_BASELINE_PREFIXES,
147
+ sourceBindings,
148
+ body
149
+ });
147
150
  }
148
151
  /**
149
152
  * Build the `commentsExtended.xml` entries for the comments that participate in
@@ -152,29 +155,23 @@ function ensureThreadedCommentParaIds(comments) {
152
155
  * leave any existing part untouched — a plain top-level comment set gets no
153
156
  * `commentsExtended.xml`, matching how Word omits it.
154
157
  *
155
- * A `w15:commentEx` keys on the comment's LAST paragraph paraId; a reply's
156
- * `w15:paraIdParent` points at its parent comment's last-paragraph paraId. Ids
157
- * are guaranteed present by {@link ensureThreadedCommentParaIds}; the `!paraId`
158
- * skip is a last-ditch safety for a malformed model.
158
+ * A `w15:commentEx` keys on the comment's thread paraId; a reply's
159
+ * `w15:paraIdParent` on its parent's. Both come from the plan, so a reply and
160
+ * its parent name each other by `w:id` right up to the attribute.
159
161
  */
160
- function buildCommentExtendedEntries(comments) {
161
- const threaded = threadedCommentIds(comments);
162
- if (threaded.size === 0) return null;
163
- const paraIdByCommentId = /* @__PURE__ */ new Map();
164
- for (const comment of comments) {
165
- const paraId = commentThreadParaId(comment);
166
- if (paraId) paraIdByCommentId.set(comment.id, paraId);
167
- }
162
+ function buildCommentExtendedEntries({ order, byId, threadParaIdById, threadedIds }) {
163
+ if (threadedIds.size === 0) return null;
168
164
  const entries = [];
169
- for (const comment of commentPartOrder(comments)) {
170
- if (!threaded.has(comment.id)) continue;
171
- const paraId = paraIdByCommentId.get(comment.id);
165
+ for (const id of order) {
166
+ if (!threadedIds.has(id)) continue;
167
+ const paraId = threadParaIdById.get(id);
172
168
  if (!paraId) continue;
173
- const parentParaId = comment.parentId !== void 0 ? paraIdByCommentId.get(comment.parentId) : void 0;
169
+ const { parentId, done } = byId.get(id);
170
+ const parentParaId = parentId !== void 0 ? threadParaIdById.get(parentId) : void 0;
174
171
  entries.push({
175
172
  paraId,
176
173
  ...parentParaId !== void 0 ? { paraIdParent: parentParaId } : {},
177
- done: comment.done ?? false
174
+ done: done ?? false
178
175
  });
179
176
  }
180
177
  return entries.length > 0 ? entries : null;
@@ -185,12 +182,12 @@ function buildCommentExtendedEntries(comments) {
185
182
  * {@link buildCommentExtendedEntries}). This is the part that makes Word render
186
183
  * a comment as a REPLY rather than a separate top-level thread.
187
184
  */
188
- function serializeCommentsExtended(comments) {
189
- const entries = buildCommentExtendedEntries(comments);
185
+ function serializeCommentsExtended(plan) {
186
+ const entries = buildCommentExtendedEntries(plan);
190
187
  if (!entries) return null;
191
188
  const body = entries.map((entry) => {
192
- const parentAttr = entry.paraIdParent !== void 0 ? ` w15:paraIdParent="${escapeXml(entry.paraIdParent)}"` : "";
193
- return `<w15:commentEx w15:paraId="${escapeXml(entry.paraId)}"${parentAttr} w15:done="${entry.done ? "1" : "0"}"/>`;
189
+ const parentAttr = entry.paraIdParent !== void 0 ? ` w15:paraIdParent="${escapeXmlAttribute(entry.paraIdParent)}"` : "";
190
+ return `<w15:commentEx w15:paraId="${escapeXmlAttribute(entry.paraId)}"${parentAttr} w15:done="${entry.done ? "1" : "0"}"/>`;
194
191
  }).join("");
195
192
  return XML_DECLARATION + serializePartElement({
196
193
  partPath: "word/commentsExtended.xml",
@@ -201,4 +198,4 @@ function serializeCommentsExtended(comments) {
201
198
  });
202
199
  }
203
200
  //#endregion
204
- export { ensureThreadedCommentParaIds, serializeComments, serializeCommentsExtended };
201
+ export { planCommentParts, serializeComments, serializeCommentsExtended };
@@ -1,5 +1,5 @@
1
1
  import { serializePartElement } from "./partNamespaces.js";
2
- import { escapeXml } from "./xmlUtils.js";
2
+ import { escapeXmlAttribute } from "@stll/docx-core";
3
3
  //#region src/docx/serializer/fontTableSerializer.ts
4
4
  const serializeFontTableXml = (fontTable) => "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n" + serializePartElement({
5
5
  partPath: "word/fontTable.xml",
@@ -10,20 +10,20 @@ const serializeFontTableXml = (fontTable) => "<?xml version=\"1.0\" encoding=\"U
10
10
  });
11
11
  const serializeFont = (font) => {
12
12
  const parts = [];
13
- if (font.altName) parts.push(`<w:altName w:val="${escapeXml(font.altName)}"/>`);
14
- if (font.panose1) parts.push(`<w:panose1 w:val="${escapeXml(font.panose1)}"/>`);
15
- if (font.charset) parts.push(`<w:charset w:val="${escapeXml(font.charset)}"/>`);
13
+ if (font.altName) parts.push(`<w:altName w:val="${escapeXmlAttribute(font.altName)}"/>`);
14
+ if (font.panose1) parts.push(`<w:panose1 w:val="${escapeXmlAttribute(font.panose1)}"/>`);
15
+ if (font.charset) parts.push(`<w:charset w:val="${escapeXmlAttribute(font.charset)}"/>`);
16
16
  if (font.family) parts.push(`<w:family w:val="${font.family}"/>`);
17
17
  if (font.pitch) parts.push(`<w:pitch w:val="${font.pitch}"/>`);
18
18
  if (font.sig) {
19
19
  const attrs = [];
20
20
  for (const [key, value] of Object.entries(font.sig)) {
21
21
  if (value === void 0) continue;
22
- attrs.push(`w:${key}="${escapeXml(value)}"`);
22
+ attrs.push(`w:${key}="${escapeXmlAttribute(value)}"`);
23
23
  }
24
24
  if (attrs.length > 0) parts.push(`<w:sig ${attrs.join(" ")}/>`);
25
25
  }
26
- return `<w:font w:name="${escapeXml(font.name)}">${parts.join("")}</w:font>`;
26
+ return `<w:font w:name="${escapeXmlAttribute(font.name)}">${parts.join("")}</w:font>`;
27
27
  };
28
28
  //#endregion
29
29
  export { serializeFontTableXml };
@@ -7,7 +7,7 @@ import { serializeBlockSdt } from "./blockSdtSerializer.js";
7
7
  import { serializeParagraph } from "./paragraphSerializer.js";
8
8
  import { serializePartElement } from "./partNamespaces.js";
9
9
  import { serializeTable } from "./tableSerializer.js";
10
- import { escapeXml } from "./xmlUtils.js";
10
+ import { escapeXmlAttribute } from "@stll/docx-core";
11
11
  //#region src/docx/serializer/headerFooterSerializer.ts
12
12
  const HEADER_FOOTER_BASELINE_PREFIXES = [
13
13
  "wpc",
@@ -85,7 +85,7 @@ function serializeRawWatermarkIntoHost({ hostXml, rawWatermarkXml }) {
85
85
  if (!rawHost || getLocalName(rawHost.name ?? "") !== "p") return rawWatermarkXml;
86
86
  const watermarkContent = (rawHost.elements ?? []).filter((child) => child.type === "element" && getLocalName(child.name ?? "") !== "pPr").map((child) => captureVerbatimXml(child)).join("");
87
87
  if (!watermarkContent) return rawWatermarkXml;
88
- const namespaceAttributes = Object.entries(rawHost.attributes ?? {}).filter(([name, value]) => value !== void 0 && (name === "xmlns" || name.startsWith("xmlns:"))).map(([name, value]) => ` ${name}="${escapeXml(String(value))}"`).join("");
88
+ const namespaceAttributes = Object.entries(rawHost.attributes ?? {}).filter(([name, value]) => value !== void 0 && (name === "xmlns" || name.startsWith("xmlns:"))).map(([name, value]) => ` ${name}="${escapeXmlAttribute(String(value))}"`).join("");
89
89
  return hostXml.replace(/^<w:p(?=[\s>])/u, `<w:p${namespaceAttributes}`).replace("</w:p>", `${watermarkContent}</w:p>`);
90
90
  }
91
91
  function serializeWatermarkParagraph(hf) {
@@ -101,15 +101,15 @@ function synthesizeTextWatermark(watermark) {
101
101
  const rotation = watermark.diagonal === false ? 0 : 315;
102
102
  const fillcolor = watermark.color && watermark.color !== "auto" ? `#${watermark.color}` : "#C0C0C0";
103
103
  const fontFamily = watermark.font ?? "Calibri";
104
- const text = escapeXml(watermark.text);
105
- return `<w:p><w:r><w:pict><v:shape id="PowerPlusWaterMarkObject1" type="#_x0000_t136" style="position:absolute;margin-left:0;margin-top:0;width:415pt;height:207pt;rotation:${rotation};z-index:-251658240;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="${fillcolor}" stroked="f">${watermark.opacity !== void 0 ? `<v:fill opacity="${watermark.opacity}"/>` : ""}<v:textpath style="font-family:&quot;${escapeXml(fontFamily)}&quot;;font-size:1pt" string="${text}"/></v:shape></w:pict></w:r></w:p>`;
104
+ const text = escapeXmlAttribute(watermark.text);
105
+ return `<w:p><w:r><w:pict><v:shape id="PowerPlusWaterMarkObject1" type="#_x0000_t136" style="position:absolute;margin-left:0;margin-top:0;width:415pt;height:207pt;rotation:${rotation};z-index:-251658240;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin" fillcolor="${fillcolor}" stroked="f">${watermark.opacity !== void 0 ? `<v:fill opacity="${watermark.opacity}"/>` : ""}<v:textpath style="font-family:&quot;${escapeXmlAttribute(fontFamily)}&quot;;font-size:1pt" string="${text}"/></v:shape></w:pict></w:r></w:p>`;
106
106
  }
107
107
  const PICTURE_WATERMARK_DEFAULT_WIDTH_PT = 415;
108
108
  const PICTURE_WATERMARK_DEFAULT_HEIGHT_PT = 207;
109
109
  const PICTURE_WATERMARK_WASHOUT_GAIN = "19661f";
110
110
  const PICTURE_WATERMARK_WASHOUT_BLACKLEVEL = "22938f";
111
111
  function synthesizePictureWatermark(watermark) {
112
- const rId = escapeXml(watermark.imageRId);
112
+ const rId = escapeXmlAttribute(watermark.imageRId);
113
113
  return `<w:p><w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:${watermark.scale === void 0 ? watermark.widthPt ?? PICTURE_WATERMARK_DEFAULT_WIDTH_PT : PICTURE_WATERMARK_DEFAULT_WIDTH_PT * watermark.scale}pt;height:${watermark.scale === void 0 ? watermark.heightPt ?? PICTURE_WATERMARK_DEFAULT_HEIGHT_PT : PICTURE_WATERMARK_DEFAULT_HEIGHT_PT * watermark.scale}pt;z-index:-251658240;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"><v:imagedata r:id="${rId}" o:title=""${watermark.washout === false ? "" : ` gain="${PICTURE_WATERMARK_WASHOUT_GAIN}" blacklevel="${PICTURE_WATERMARK_WASHOUT_BLACKLEVEL}"`}/></v:shape></w:pict></w:r></w:p>`;
114
114
  }
115
115
  //#endregion
@@ -1,6 +1,6 @@
1
- import { escapeXml } from "./xmlUtils.js";
1
+ import { escapeXmlAttribute } from "@stll/docx-core";
2
2
  //#region src/docx/serializer/markupRangeAttributes.ts
3
- const attribute = (name, value) => `${name}="${escapeXml(String(value))}"`;
3
+ const attribute = (name, value) => `${name}="${escapeXmlAttribute(String(value))}"`;
4
4
  const markupRangeAttributes = (marker) => {
5
5
  const attributes = [attribute("w:id", marker.id)];
6
6
  if (marker.displacedByCustomXml !== void 0) attributes.push(attribute("w:displacedByCustomXml", marker.displacedByCustomXml));
@@ -1,6 +1,7 @@
1
1
  import { serializePartElement } from "./partNamespaces.js";
2
2
  import { serializeTextFormatting } from "./textFormattingSerializer.js";
3
- import { escapeXml, intAttr } from "./xmlUtils.js";
3
+ import { intAttr } from "./xmlUtils.js";
4
+ import { escapeXmlAttribute } from "@stll/docx-core";
4
5
  //#region src/docx/serializer/numberingSerializer.ts
5
6
  /**
6
7
  * Serialize a level's paragraph properties — the modeled subset is indentation
@@ -36,11 +37,11 @@ function serializeLevelParagraphProps(pPr) {
36
37
  function serializeLevel(level) {
37
38
  const parts = [];
38
39
  if (level.start !== void 0) parts.push(`<w:start w:val="${intAttr(level.start)}"/>`);
39
- parts.push(`<w:numFmt w:val="${escapeXml(level.numFmt)}"/>`);
40
+ parts.push(`<w:numFmt w:val="${escapeXmlAttribute(level.numFmt)}"/>`);
40
41
  if (level.lvlRestart !== void 0) parts.push(`<w:lvlRestart w:val="${intAttr(level.lvlRestart)}"/>`);
41
42
  if (level.isLgl) parts.push("<w:isLgl/>");
42
43
  if (level.suffix) parts.push(`<w:suff w:val="${level.suffix}"/>`);
43
- parts.push(`<w:lvlText w:val="${escapeXml(level.lvlText)}"/>`);
44
+ parts.push(`<w:lvlText w:val="${escapeXmlAttribute(level.lvlText)}"/>`);
44
45
  if (level.legacy) {
45
46
  const legacyAttrs = [`w:legacy="${level.legacy.legacy ? 1 : 0}"`];
46
47
  if (level.legacy.legacySpace !== void 0) legacyAttrs.push(`w:legacySpace="${intAttr(level.legacy.legacySpace)}"`);
@@ -60,9 +61,9 @@ function serializeLevel(level) {
60
61
  function serializeAbstractNum(abstractNum) {
61
62
  const parts = [];
62
63
  if (abstractNum.multiLevelType) parts.push(`<w:multiLevelType w:val="${abstractNum.multiLevelType}"/>`);
63
- if (abstractNum.name !== void 0) parts.push(`<w:name w:val="${escapeXml(abstractNum.name)}"/>`);
64
- if (abstractNum.styleLink !== void 0) parts.push(`<w:styleLink w:val="${escapeXml(abstractNum.styleLink)}"/>`);
65
- if (abstractNum.numStyleLink !== void 0) parts.push(`<w:numStyleLink w:val="${escapeXml(abstractNum.numStyleLink)}"/>`);
64
+ if (abstractNum.name !== void 0) parts.push(`<w:name w:val="${escapeXmlAttribute(abstractNum.name)}"/>`);
65
+ if (abstractNum.styleLink !== void 0) parts.push(`<w:styleLink w:val="${escapeXmlAttribute(abstractNum.styleLink)}"/>`);
66
+ if (abstractNum.numStyleLink !== void 0) parts.push(`<w:numStyleLink w:val="${escapeXmlAttribute(abstractNum.numStyleLink)}"/>`);
66
67
  const levels = [...abstractNum.levels].sort((a, b) => a.ilvl - b.ilvl);
67
68
  for (const level of levels) parts.push(serializeLevel(level));
68
69
  return `<w:abstractNum w:abstractNumId="${intAttr(abstractNum.abstractNumId)}">${parts.join("")}</w:abstractNum>`;