@beyondwork/docx-react-component 1.0.41 → 1.0.43

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 (118) hide show
  1. package/package.json +38 -37
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/editor-state-types.ts +110 -0
  6. package/src/api/external-custody-types.ts +74 -0
  7. package/src/api/participants-types.ts +18 -0
  8. package/src/api/public-types.ts +541 -5
  9. package/src/api/scope-metadata-resolver-types.ts +88 -0
  10. package/src/core/commands/formatting-commands.ts +1 -1
  11. package/src/core/commands/index.ts +601 -9
  12. package/src/core/search/search-text.ts +15 -2
  13. package/src/index.ts +131 -1
  14. package/src/io/docx-session.ts +672 -2
  15. package/src/io/export/escape-xml-attribute.ts +26 -0
  16. package/src/io/export/external-send.ts +188 -0
  17. package/src/io/export/serialize-comments.ts +13 -16
  18. package/src/io/export/serialize-footnotes.ts +17 -24
  19. package/src/io/export/serialize-headers-footers.ts +17 -24
  20. package/src/io/export/serialize-main-document.ts +59 -62
  21. package/src/io/export/serialize-numbering.ts +20 -27
  22. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  23. package/src/io/export/serialize-tables.ts +8 -15
  24. package/src/io/export/table-properties-xml.ts +25 -32
  25. package/src/io/import/external-reimport.ts +40 -0
  26. package/src/io/load-scheduler.ts +230 -0
  27. package/src/io/normalize/normalize-text.ts +83 -0
  28. package/src/io/ooxml/bw-xml.ts +244 -0
  29. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  30. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  31. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  32. package/src/io/ooxml/external-custody-payload.ts +102 -0
  33. package/src/io/ooxml/participants-payload.ts +97 -0
  34. package/src/io/ooxml/payload-signature.ts +112 -0
  35. package/src/io/ooxml/workflow-payload-validator.ts +367 -0
  36. package/src/io/ooxml/workflow-payload.ts +317 -7
  37. package/src/runtime/awareness-identity.ts +173 -0
  38. package/src/runtime/collab/event-types.ts +27 -0
  39. package/src/runtime/collab-session-bridge.ts +157 -0
  40. package/src/runtime/collab-session-facet.ts +193 -0
  41. package/src/runtime/collab-session.ts +273 -0
  42. package/src/runtime/comment-negotiation-sync.ts +91 -0
  43. package/src/runtime/comment-negotiation.ts +158 -0
  44. package/src/runtime/comment-presentation.ts +223 -0
  45. package/src/runtime/document-runtime.ts +639 -124
  46. package/src/runtime/editor-state-channel.ts +544 -0
  47. package/src/runtime/editor-state-integration.ts +217 -0
  48. package/src/runtime/external-send-runtime.ts +117 -0
  49. package/src/runtime/layout/docx-font-loader.ts +11 -30
  50. package/src/runtime/layout/index.ts +2 -0
  51. package/src/runtime/layout/inert-layout-facet.ts +4 -0
  52. package/src/runtime/layout/layout-engine-instance.ts +139 -14
  53. package/src/runtime/layout/page-graph.ts +79 -7
  54. package/src/runtime/layout/paginated-layout-engine.ts +441 -48
  55. package/src/runtime/layout/public-facet.ts +585 -14
  56. package/src/runtime/layout/table-row-split.ts +316 -0
  57. package/src/runtime/markdown-sanitizer.ts +132 -0
  58. package/src/runtime/participants.ts +134 -0
  59. package/src/runtime/perf-counters.ts +28 -0
  60. package/src/runtime/render/render-frame-types.ts +17 -0
  61. package/src/runtime/render/render-kernel.ts +172 -29
  62. package/src/runtime/resign-payload.ts +120 -0
  63. package/src/runtime/surface-projection.ts +10 -5
  64. package/src/runtime/tamper-gate.ts +157 -0
  65. package/src/runtime/workflow-markup.ts +80 -16
  66. package/src/runtime/workflow-rail-segments.ts +244 -5
  67. package/src/ui/WordReviewEditor.tsx +654 -45
  68. package/src/ui/editor-command-bag.ts +14 -0
  69. package/src/ui/editor-runtime-boundary.ts +111 -11
  70. package/src/ui/editor-shell-view.tsx +21 -0
  71. package/src/ui/editor-surface-controller.tsx +5 -0
  72. package/src/ui/headless/selection-helpers.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  74. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  75. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  76. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  77. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  78. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  79. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  80. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  81. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  82. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  83. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  84. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  85. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  86. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  87. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  88. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  89. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  90. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +106 -0
  91. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  92. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  93. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  94. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  95. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  96. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  97. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  98. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  99. package/src/ui-tailwind/editor-surface/pm-schema.ts +167 -17
  100. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  101. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  102. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  103. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +10 -256
  104. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  105. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  106. package/src/ui-tailwind/index.ts +37 -1
  107. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  108. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  109. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  110. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  111. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  112. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  113. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  114. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  115. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  116. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  117. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  118. package/src/ui-tailwind/tw-review-workspace.tsx +455 -118
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Escape a string for safe use inside a double-quoted XML attribute value.
3
+ *
4
+ * Handles the minimum set XML 1.0 §2.3 requires to survive an attribute
5
+ * parser: `&` (otherwise any `&foo;` sequence becomes an entity reference),
6
+ * `<` (not permitted in any attribute value), and `"` (the delimiter
7
+ * itself). Escaping `>` is not strictly required but matches the pattern
8
+ * already shipped by every serializer in this directory — keep doing it
9
+ * so the output remains byte-identical to the previous per-file copies.
10
+ *
11
+ * Rationale: before this helper existed, eight files under `src/io/export/`
12
+ * carried private copies of the same 3-or-4-line function
13
+ * (`serialize-{comments,footnotes,headers-footers,numbering,main-document,
14
+ * runtime-revisions,tables}.ts` + `table-properties-xml.ts`). Two of them
15
+ * had a subtle variant (`escapeXml(value).replace('"', '&quot;')`) that
16
+ * composed with a local `escapeXml` copy; the end state was equivalent for
17
+ * every observed input, and this single implementation keeps that
18
+ * behaviour while removing seven copies.
19
+ */
20
+ export function escapeXmlAttribute(value: string): string {
21
+ return value
22
+ .replace(/&/gu, "&amp;")
23
+ .replace(/</gu, "&lt;")
24
+ .replace(/>/gu, "&gt;")
25
+ .replace(/"/gu, "&quot;");
26
+ }
@@ -0,0 +1,188 @@
1
+ import type {
2
+ CommentNegotiationSnapshot,
3
+ } from "../../api/comment-negotiation-types.ts";
4
+ import type {
5
+ CommentPresentationSnapshot,
6
+ } from "../../api/comment-presentation-types.ts";
7
+ import type {
8
+ ExternalCustody,
9
+ ExternalCustodyResolver,
10
+ } from "../../api/external-custody-types.ts";
11
+ import type { ParticipantRoster } from "../../api/participants-types.ts";
12
+
13
+ export interface SendToExternalArgs {
14
+ /** Snapshot taken from the runtime before the call. */
15
+ presentation: CommentPresentationSnapshot;
16
+ negotiation: CommentNegotiationSnapshot;
17
+ participants: ParticipantRoster;
18
+
19
+ /** Current runtime role — author-only action. */
20
+ role: "author" | "reviewer" | "observer";
21
+ /** Blocks send if the runtime flagged the payload as tampered. */
22
+ metadataIntegrity: "unsigned" | "verified" | "tampered";
23
+
24
+ /** Origin document identity, used to populate the custody receipt. */
25
+ originDocumentId: string;
26
+ originPayloadId: string;
27
+ /** sha256:{hex} of canonicalized word/document.xml at send time. */
28
+ originContentHash: string;
29
+
30
+ /** Host-provided archive+restore contract. */
31
+ resolver: ExternalCustodyResolver;
32
+ recipient: string;
33
+ sentBy: string;
34
+ archiveRef: string;
35
+
36
+ /** Optional uuid + clock overrides for deterministic tests. */
37
+ custodyId?: string;
38
+ now?: string;
39
+ }
40
+
41
+ export interface SendToExternalResult {
42
+ custody: ExternalCustody;
43
+ kept: {
44
+ presentation: CommentPresentationSnapshot;
45
+ negotiation: CommentNegotiationSnapshot;
46
+ participants: ParticipantRoster;
47
+ };
48
+ stripped: {
49
+ commentIds: string[];
50
+ participantIds: string[];
51
+ };
52
+ }
53
+
54
+ export type SendToExternalBlock =
55
+ | { ok: false; reason: "collab_role_restricted" }
56
+ | { ok: false; reason: "metadata_tampered" }
57
+ | { ok: true; result: SendToExternalResult };
58
+
59
+ /**
60
+ * Pure pipeline: classify comments by audience, split the snapshots
61
+ * into kept vs. archived, hand archived content to the resolver, and
62
+ * return the kept snapshots + custody receipt for the caller to
63
+ * serialize into the supplier-bound docx.
64
+ *
65
+ * The caller is responsible for: serializing the kept snapshots back
66
+ * into `bw:extensions`, rewriting `word/comments.xml` (and the three
67
+ * companion parts + `word/document.xml` anchor runs) to drop the
68
+ * stripped `commentIds`, appending the custody receipt, and re-signing
69
+ * via the central `resignPayload()` hook.
70
+ */
71
+ export async function sendToExternal(
72
+ args: SendToExternalArgs,
73
+ ): Promise<SendToExternalBlock> {
74
+ if (args.role !== "author") {
75
+ return { ok: false, reason: "collab_role_restricted" };
76
+ }
77
+ if (args.metadataIntegrity === "tampered") {
78
+ return { ok: false, reason: "metadata_tampered" };
79
+ }
80
+
81
+ const strippedPresentation = args.presentation.entries.filter(
82
+ (e) => e.audience === "internal",
83
+ );
84
+ const keptPresentation = args.presentation.entries.filter(
85
+ (e) => e.audience !== "internal",
86
+ );
87
+ const strippedCommentIds = strippedPresentation.map((e) => e.commentId);
88
+ const strippedCommentIdSet = new Set(strippedCommentIds);
89
+
90
+ const strippedNegotiation = args.negotiation.entries.filter((e) =>
91
+ strippedCommentIdSet.has(e.commentId),
92
+ );
93
+ const keptNegotiation = args.negotiation.entries.filter(
94
+ (e) => !strippedCommentIdSet.has(e.commentId),
95
+ );
96
+
97
+ const referencedUserIds = collectReferencedUserIds(
98
+ keptPresentation,
99
+ keptNegotiation,
100
+ );
101
+ const strippedParticipants = args.participants.entries.filter(
102
+ (p) => !referencedUserIds.has(p.userId),
103
+ );
104
+ const keptParticipants = args.participants.entries.filter((p) =>
105
+ referencedUserIds.has(p.userId),
106
+ );
107
+ const strippedParticipantIds = strippedParticipants.map((p) => p.userId);
108
+
109
+ const custody: ExternalCustody = {
110
+ schemaVersion: 1,
111
+ custodyId: args.custodyId ?? newCustodyId(),
112
+ originDocumentId: args.originDocumentId,
113
+ originPayloadId: args.originPayloadId,
114
+ originContentHash: args.originContentHash,
115
+ sentAt: args.now ?? new Date().toISOString(),
116
+ sentBy: args.sentBy,
117
+ recipient: args.recipient,
118
+ archiveRef: args.archiveRef,
119
+ strippedCommentIds,
120
+ strippedParticipantIds,
121
+ };
122
+
123
+ await args.resolver.archive({
124
+ custodyId: custody.custodyId,
125
+ strippedPresentation,
126
+ strippedNegotiation,
127
+ strippedParticipants,
128
+ originContentHash: args.originContentHash,
129
+ });
130
+
131
+ return {
132
+ ok: true,
133
+ result: {
134
+ custody,
135
+ kept: {
136
+ presentation: { schemaVersion: 1, entries: keptPresentation },
137
+ negotiation: { schemaVersion: 1, entries: keptNegotiation },
138
+ participants: { schemaVersion: 1, entries: keptParticipants },
139
+ },
140
+ stripped: {
141
+ commentIds: strippedCommentIds,
142
+ participantIds: strippedParticipantIds,
143
+ },
144
+ },
145
+ };
146
+ }
147
+
148
+ function collectReferencedUserIds(
149
+ presentation: CommentPresentationSnapshot["entries"],
150
+ negotiation: CommentNegotiationSnapshot["entries"],
151
+ ): Set<string> {
152
+ const ids = new Set<string>();
153
+ for (const entry of presentation) {
154
+ for (const m of entry.mentions) ids.add(m.userId);
155
+ for (const r of entry.reactions) ids.add(r.authorId);
156
+ }
157
+ for (const entry of negotiation) {
158
+ for (const v of entry.votes) ids.add(v.authorId);
159
+ for (const a of entry.requiredApprovers) ids.add(a);
160
+ for (const p of entry.counterProposals) ids.add(p.authorId);
161
+ for (const h of entry.history) ids.add(h.actorId);
162
+ if (entry.lockedBy) ids.add(entry.lockedBy);
163
+ }
164
+ return ids;
165
+ }
166
+
167
+ function newCustodyId(): string {
168
+ if (typeof globalThis.crypto?.randomUUID === "function") {
169
+ return globalThis.crypto.randomUUID();
170
+ }
171
+ // Cryptographic fallback. custodyId is used as a lookup key against the
172
+ // host archive that holds stripped internal comments — a predictable id
173
+ // would let an attacker enumerate or guess other sessions' archives.
174
+ // WebCrypto's getRandomValues is in Node 18+ and every modern browser;
175
+ // throw if unavailable rather than fall back to Math.random().
176
+ if (typeof globalThis.crypto?.getRandomValues !== "function") {
177
+ throw new Error(
178
+ "newCustodyId: WebCrypto (crypto.getRandomValues) is required — refusing " +
179
+ "to fall back to Math.random() for a security-sensitive identifier.",
180
+ );
181
+ }
182
+ const bytes = new Uint8Array(16);
183
+ globalThis.crypto.getRandomValues(bytes);
184
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40;
185
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
186
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
187
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
188
+ }
@@ -1,6 +1,7 @@
1
1
  import type { CommentEntry, CommentThread } from "../../review/store/comment-store.ts";
2
2
  import type { RevisionParagraphBoundary } from "../ooxml/revision-boundaries.ts";
3
3
  import type { ImportedCommentDefinition } from "../ooxml/parse-comments.ts";
4
+ import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
4
5
 
5
6
  interface XmlElementNode {
6
7
  type: "element";
@@ -210,7 +211,7 @@ export function serializeMergedCommentsXml(
210
211
  .sort((left, right) => left.localeCompare(right))
211
212
  .map(
212
213
  (authorId) =>
213
- `<w15:person w15:author="${escapeAttribute(authorId)}" />`,
214
+ `<w15:person w15:author="${escapeXmlAttribute(authorId)}" />`,
214
215
  ),
215
216
  `</w15:people>`,
216
217
  ].join("\n")
@@ -289,17 +290,17 @@ export function serializeCommentAnchorsIntoDocumentXml(
289
290
  pushInsertion(
290
291
  insertions,
291
292
  startIndex,
292
- `<w:commentRangeStart w:id="${escapeAttribute(exportCommentId)}"/>`,
293
+ `<w:commentRangeStart w:id="${escapeXmlAttribute(exportCommentId)}"/>`,
293
294
  );
294
295
  pushInsertion(
295
296
  insertions,
296
297
  endIndex,
297
- `<w:commentRangeEnd w:id="${escapeAttribute(exportCommentId)}"/>`,
298
+ `<w:commentRangeEnd w:id="${escapeXmlAttribute(exportCommentId)}"/>`,
298
299
  );
299
300
  pushInsertion(
300
301
  insertions,
301
302
  endIndex,
302
- `<w:r><w:commentReference w:id="${escapeAttribute(exportCommentId)}"/></w:r>`,
303
+ `<w:r><w:commentReference w:id="${escapeXmlAttribute(exportCommentId)}"/></w:r>`,
303
304
  );
304
305
  serializedCommentIds.push(thread.commentId);
305
306
  }
@@ -462,12 +463,12 @@ function serializeThreadEntries(entries: readonly SerializableCommentEntry[]): s
462
463
  }
463
464
 
464
465
  function serializeCommentEntry(entry: SerializableCommentEntry): string {
465
- const author = escapeAttribute(entry.entry.authorId);
466
- const createdAt = escapeAttribute(entry.entry.createdAt);
466
+ const author = escapeXmlAttribute(entry.entry.authorId);
467
+ const createdAt = escapeXmlAttribute(entry.entry.createdAt);
467
468
  const initials = entry.entry.metadata?.initials;
468
469
  const paragraphXml = serializeCommentParagraphs(entry.entry.body, entry.paraId);
469
470
 
470
- return `<w:comment w:id="${escapeAttribute(entry.exportCommentId)}"${initials ? ` w:initials="${escapeAttribute(initials)}"` : ""} w:author="${author}" w:date="${createdAt}">${paragraphXml}</w:comment>`;
471
+ return `<w:comment w:id="${escapeXmlAttribute(entry.exportCommentId)}"${initials ? ` w:initials="${escapeXmlAttribute(initials)}"` : ""} w:author="${author}" w:date="${createdAt}">${paragraphXml}</w:comment>`;
471
472
  }
472
473
 
473
474
  function serializeCommentParagraphs(body: string, paraId: string): string {
@@ -477,7 +478,7 @@ function serializeCommentParagraphs(body: string, paraId: string): string {
477
478
  .map((paragraph, index) => {
478
479
  const attributes =
479
480
  index === 0
480
- ? ` w14:paraId="${escapeAttribute(paraId)}" w14:textId="${escapeAttribute(textId)}"`
481
+ ? ` w14:paraId="${escapeXmlAttribute(paraId)}" w14:textId="${escapeXmlAttribute(textId)}"`
481
482
  : "";
482
483
  return `<w:p${attributes}><w:r>${serializeText(paragraph)}</w:r></w:p>`;
483
484
  })
@@ -500,13 +501,13 @@ function serializePreservedCommentExtension(
500
501
  definition: ImportedCommentDefinition,
501
502
  ): string {
502
503
  const doneValue = definition.isDone ? "true" : "false";
503
- return `<w15:commentEx w15:paraId="${escapeAttribute(definition.paraId!)}"${definition.parentParaId ? ` w15:paraIdParent="${escapeAttribute(definition.parentParaId)}"` : ""} w15:done="${doneValue}" />`;
504
+ return `<w15:commentEx w15:paraId="${escapeXmlAttribute(definition.paraId!)}"${definition.parentParaId ? ` w15:paraIdParent="${escapeXmlAttribute(definition.parentParaId)}"` : ""} w15:done="${doneValue}" />`;
504
505
  }
505
506
 
506
507
  function serializeCommentExtension(
507
508
  entry: SerializableCommentEntry,
508
509
  ): string | undefined {
509
- return `<w15:commentEx w15:paraId="${escapeAttribute(entry.paraId)}"${entry.isRoot ? "" : ` w15:paraIdParent="${escapeAttribute(findRootParaId(entry.thread, entry.paraId))}"`} w15:done="${entry.isRoot && entry.thread.status === "resolved" ? "true" : "false"}" />`;
510
+ return `<w15:commentEx w15:paraId="${escapeXmlAttribute(entry.paraId)}"${entry.isRoot ? "" : ` w15:paraIdParent="${escapeXmlAttribute(findRootParaId(entry.thread, entry.paraId))}"`} w15:done="${entry.isRoot && entry.thread.status === "resolved" ? "true" : "false"}" />`;
510
511
  }
511
512
 
512
513
  function serializePreservedCommentIds(
@@ -515,7 +516,7 @@ function serializePreservedCommentIds(
515
516
  return definitions
516
517
  .map((definition) =>
517
518
  definition.paraId && definition.durableId
518
- ? `<w16cid:commentId w16cid:paraId="${escapeAttribute(definition.paraId)}" w16cid:durableId="${escapeAttribute(definition.durableId)}" />`
519
+ ? `<w16cid:commentId w16cid:paraId="${escapeXmlAttribute(definition.paraId)}" w16cid:durableId="${escapeXmlAttribute(definition.durableId)}" />`
519
520
  : undefined,
520
521
  )
521
522
  .filter((xml): xml is string => Boolean(xml));
@@ -528,7 +529,7 @@ function serializeCommentDurableId(
528
529
  return undefined;
529
530
  }
530
531
 
531
- return `<w16cid:commentId w16cid:paraId="${escapeAttribute(entry.paraId)}" w16cid:durableId="${escapeAttribute(entry.durableId)}" />`;
532
+ return `<w16cid:commentId w16cid:paraId="${escapeXmlAttribute(entry.paraId)}" w16cid:durableId="${escapeXmlAttribute(entry.durableId)}" />`;
532
533
  }
533
534
 
534
535
  function getRootEntry(thread: CommentThread): CommentEntry | undefined {
@@ -794,10 +795,6 @@ function escapeXml(value: string): string {
794
795
  .replace(/>/g, "&gt;");
795
796
  }
796
797
 
797
- function escapeAttribute(value: string): string {
798
- return escapeXml(value).replace(/"/g, "&quot;");
799
- }
800
-
801
798
  function openingTagLength(xml: string, start: number): number {
802
799
  const end = xml.indexOf(">", start);
803
800
  if (end < 0) {
@@ -17,6 +17,7 @@ import {
17
17
  serializeTableRowPropertiesXml,
18
18
  } from "./table-properties-xml.ts";
19
19
  import { twip } from "./twip.ts";
20
+ import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
20
21
 
21
22
  export const WORD_FOOTNOTES_CONTENT_TYPE =
22
23
  "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml";
@@ -104,7 +105,7 @@ function serializeNoteDefinition(
104
105
  .join("");
105
106
 
106
107
  const body = blocks || `<w:p><w:r><w:t></w:t></w:r></w:p>`;
107
- const noteXml = `<${tag} w:id="${escapeAttribute(definition.noteId)}">${body}</${tag}>`;
108
+ const noteXml = `<${tag} w:id="${escapeXmlAttribute(definition.noteId)}">${body}</${tag}>`;
108
109
  if (revisions.length === 0) {
109
110
  return noteXml;
110
111
  }
@@ -188,17 +189,17 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
188
189
  const parts: string[] = [];
189
190
 
190
191
  if (paragraph.styleId) {
191
- parts.push(`<w:pStyle w:val="${escapeAttribute(paragraph.styleId)}"/>`);
192
+ parts.push(`<w:pStyle w:val="${escapeXmlAttribute(paragraph.styleId)}"/>`);
192
193
  }
193
194
  if (paragraph.alignment) {
194
- parts.push(`<w:jc w:val="${escapeAttribute(paragraph.alignment)}"/>`);
195
+ parts.push(`<w:jc w:val="${escapeXmlAttribute(paragraph.alignment)}"/>`);
195
196
  }
196
197
  if (paragraph.spacing) {
197
198
  const attrs: string[] = [];
198
199
  if (paragraph.spacing.before !== undefined) attrs.push(`w:before="${twip(paragraph.spacing.before)}"`);
199
200
  if (paragraph.spacing.after !== undefined) attrs.push(`w:after="${twip(paragraph.spacing.after)}"`);
200
201
  if (paragraph.spacing.line !== undefined) attrs.push(`w:line="${twip(paragraph.spacing.line)}"`);
201
- if (paragraph.spacing.lineRule) attrs.push(`w:lineRule="${escapeAttribute(paragraph.spacing.lineRule)}"`);
202
+ if (paragraph.spacing.lineRule) attrs.push(`w:lineRule="${escapeXmlAttribute(paragraph.spacing.lineRule)}"`);
202
203
  if (attrs.length > 0) {
203
204
  parts.push(`<w:spacing ${attrs.join(" ")}/>`);
204
205
  }
@@ -233,8 +234,8 @@ function serializeInlineNode(node: InlineNode): string {
233
234
  case "footnote_ref": {
234
235
  const refElement =
235
236
  node.noteKind === "footnote"
236
- ? `<w:footnoteReference w:id="${escapeAttribute(node.noteId)}"/>`
237
- : `<w:endnoteReference w:id="${escapeAttribute(node.noteId)}"/>`;
237
+ ? `<w:footnoteReference w:id="${escapeXmlAttribute(node.noteId)}"/>`
238
+ : `<w:endnoteReference w:id="${escapeXmlAttribute(node.noteId)}"/>`;
238
239
  const styleVal =
239
240
  node.noteKind === "footnote"
240
241
  ? "FootnoteReference"
@@ -242,9 +243,9 @@ function serializeInlineNode(node: InlineNode): string {
242
243
  return `<w:r><w:rPr><w:rStyle w:val="${styleVal}"/></w:rPr>${refElement}</w:r>`;
243
244
  }
244
245
  case "bookmark_start":
245
- return `<w:bookmarkStart w:id="${escapeAttribute(node.bookmarkId)}" w:name="${escapeAttribute(node.name)}"/>`;
246
+ return `<w:bookmarkStart w:id="${escapeXmlAttribute(node.bookmarkId)}" w:name="${escapeXmlAttribute(node.name)}"/>`;
246
247
  case "bookmark_end":
247
- return `<w:bookmarkEnd w:id="${escapeAttribute(node.bookmarkId)}"/>`;
248
+ return `<w:bookmarkEnd w:id="${escapeXmlAttribute(node.bookmarkId)}"/>`;
248
249
  case "field":
249
250
  if (node.children && node.children.length > 0) {
250
251
  const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
@@ -257,10 +258,10 @@ function serializeInlineNode(node: InlineNode): string {
257
258
  `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
258
259
  );
259
260
  }
260
- return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
261
+ return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
261
262
  }
262
263
  if (node.fieldType === "simple") {
263
- return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
264
+ return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}"/>`;
264
265
  }
265
266
  return (
266
267
  `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
@@ -282,10 +283,10 @@ function serializeInlineNode(node: InlineNode): string {
282
283
  `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
283
284
  );
284
285
  }
285
- return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
286
+ return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
286
287
  }
287
288
  if (node.fieldType === "simple") {
288
- return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
289
+ return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}"/>`;
289
290
  }
290
291
  return (
291
292
  `<w:r><w:fldChar w:fldCharType="begin"/></w:r>` +
@@ -327,14 +328,14 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
327
328
  break;
328
329
  case "fontFamily":
329
330
  parts.push(
330
- `<w:rFonts w:ascii="${escapeAttribute(mark.val)}" w:hAnsi="${escapeAttribute(mark.val)}"/>`,
331
+ `<w:rFonts w:ascii="${escapeXmlAttribute(mark.val)}" w:hAnsi="${escapeXmlAttribute(mark.val)}"/>`,
331
332
  );
332
333
  break;
333
334
  case "fontSize":
334
335
  parts.push(`<w:sz w:val="${twip(mark.val)}"/>`);
335
336
  break;
336
337
  case "textColor":
337
- parts.push(`<w:color w:val="${escapeAttribute(mark.color)}"/>`);
338
+ parts.push(`<w:color w:val="${escapeXmlAttribute(mark.color)}"/>`);
338
339
  break;
339
340
  case "smallCaps":
340
341
  parts.push("<w:smallCaps/>");
@@ -371,23 +372,15 @@ function escapeXml(text: string): string {
371
372
  .replace(/>/g, "&gt;");
372
373
  }
373
374
 
374
- function escapeAttribute(value: string): string {
375
- return value
376
- .replace(/&/g, "&amp;")
377
- .replace(/</g, "&lt;")
378
- .replace(/>/g, "&gt;")
379
- .replace(/"/g, "&quot;");
380
- }
381
-
382
375
  function serializeHyperlinkNode(node: Extract<InlineNode, { type: "hyperlink" }>): string {
383
376
  const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
384
377
  if (node.href.startsWith("#")) {
385
- return `<w:hyperlink w:anchor="${escapeAttribute(node.href.slice(1))}">${childrenXml}</w:hyperlink>`;
378
+ return `<w:hyperlink w:anchor="${escapeXmlAttribute(node.href.slice(1))}">${childrenXml}</w:hyperlink>`;
386
379
  }
387
380
  if (!/^rId[A-Za-z0-9._-]+$/u.test(node.href)) {
388
381
  throw new Error("Cannot safely serialize URL-backed note hyperlinks without relationship context.");
389
382
  }
390
- return `<w:hyperlink r:id="${escapeAttribute(node.href)}">${childrenXml}</w:hyperlink>`;
383
+ return `<w:hyperlink r:id="${escapeXmlAttribute(node.href)}">${childrenXml}</w:hyperlink>`;
391
384
  }
392
385
 
393
386
  function buildTableCellPropertiesXml(cell: TableCellNode): string {
@@ -17,6 +17,7 @@ import {
17
17
  serializeTableRowPropertiesXml,
18
18
  } from "./table-properties-xml.ts";
19
19
  import { twip } from "./twip.ts";
20
+ import { escapeXmlAttribute } from "./escape-xml-attribute.ts";
20
21
 
21
22
  export const WORD_HEADER_CONTENT_TYPE =
22
23
  "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml";
@@ -178,7 +179,7 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
178
179
  const parts: string[] = [];
179
180
 
180
181
  if (paragraph.styleId) {
181
- parts.push(`<w:pStyle w:val="${escapeAttribute(paragraph.styleId)}"/>`);
182
+ parts.push(`<w:pStyle w:val="${escapeXmlAttribute(paragraph.styleId)}"/>`);
182
183
  }
183
184
  if (paragraph.spacing) {
184
185
  const s = paragraph.spacing;
@@ -186,7 +187,7 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
186
187
  if (s.before !== undefined) attrs.push(`w:before="${twip(s.before)}"`);
187
188
  if (s.after !== undefined) attrs.push(`w:after="${twip(s.after)}"`);
188
189
  if (s.line !== undefined) attrs.push(`w:line="${twip(s.line)}"`);
189
- if (s.lineRule) attrs.push(`w:lineRule="${escapeAttribute(s.lineRule)}"`);
190
+ if (s.lineRule) attrs.push(`w:lineRule="${escapeXmlAttribute(s.lineRule)}"`);
190
191
  if (attrs.length > 0) parts.push(`<w:spacing ${attrs.join(" ")}/>`);
191
192
  }
192
193
  if (paragraph.indentation) {
@@ -199,11 +200,11 @@ function buildParagraphPropertiesXml(paragraph: ParagraphNode): string {
199
200
  if (attrs.length > 0) parts.push(`<w:ind ${attrs.join(" ")}/>`);
200
201
  }
201
202
  if (paragraph.alignment) {
202
- parts.push(`<w:jc w:val="${escapeAttribute(paragraph.alignment)}"/>`);
203
+ parts.push(`<w:jc w:val="${escapeXmlAttribute(paragraph.alignment)}"/>`);
203
204
  }
204
205
  if (paragraph.tabStops && paragraph.tabStops.length > 0) {
205
206
  const tabsXml = paragraph.tabStops.map((tab) => {
206
- const leaderAttr = tab.leader ? ` w:leader="${escapeAttribute(tab.leader)}"` : "";
207
+ const leaderAttr = tab.leader ? ` w:leader="${escapeXmlAttribute(tab.leader)}"` : "";
207
208
  return `<w:tab w:val="${tab.align}" w:pos="${twip(tab.position)}"${leaderAttr}/>`;
208
209
  }).join("");
209
210
  parts.push(`<w:tabs>${tabsXml}</w:tabs>`);
@@ -228,14 +229,14 @@ function serializeInlineNode(node: InlineNode): string {
228
229
  case "footnote_ref": {
229
230
  const refElement =
230
231
  node.noteKind === "footnote"
231
- ? `<w:footnoteReference w:id="${escapeAttribute(node.noteId)}"/>`
232
- : `<w:endnoteReference w:id="${escapeAttribute(node.noteId)}"/>`;
232
+ ? `<w:footnoteReference w:id="${escapeXmlAttribute(node.noteId)}"/>`
233
+ : `<w:endnoteReference w:id="${escapeXmlAttribute(node.noteId)}"/>`;
233
234
  return `<w:r><w:rPr><w:rStyle w:val="${node.noteKind === "footnote" ? "FootnoteReference" : "EndnoteReference"}"/></w:rPr>${refElement}</w:r>`;
234
235
  }
235
236
  case "bookmark_start":
236
- return `<w:bookmarkStart w:id="${escapeAttribute(node.bookmarkId)}" w:name="${escapeAttribute(node.name)}"/>`;
237
+ return `<w:bookmarkStart w:id="${escapeXmlAttribute(node.bookmarkId)}" w:name="${escapeXmlAttribute(node.name)}"/>`;
237
238
  case "bookmark_end":
238
- return `<w:bookmarkEnd w:id="${escapeAttribute(node.bookmarkId)}"/>`;
239
+ return `<w:bookmarkEnd w:id="${escapeXmlAttribute(node.bookmarkId)}"/>`;
239
240
  case "field":
240
241
  if (node.children && node.children.length > 0) {
241
242
  const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
@@ -248,10 +249,10 @@ function serializeInlineNode(node: InlineNode): string {
248
249
  `<w:r><w:fldChar w:fldCharType="end"/></w:r>`
249
250
  );
250
251
  }
251
- return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
252
+ return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}">${childrenXml}</w:fldSimple>`;
252
253
  }
253
254
  if (node.fieldType === "simple") {
254
- return `<w:fldSimple w:instr="${escapeAttribute(node.instruction)}"/>`;
255
+ return `<w:fldSimple w:instr="${escapeXmlAttribute(node.instruction)}"/>`;
255
256
  }
256
257
  return `<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">${escapeXml(node.instruction)}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>`;
257
258
  case "hyperlink":
@@ -301,19 +302,19 @@ function buildRunPropertiesXml(marks: TextMark[] | undefined): string {
301
302
  parts.push("<w:dstrike/>");
302
303
  break;
303
304
  case "fontFamily":
304
- parts.push(`<w:rFonts w:ascii="${escapeAttribute(mark.val)}" w:hAnsi="${escapeAttribute(mark.val)}"/>`);
305
+ parts.push(`<w:rFonts w:ascii="${escapeXmlAttribute(mark.val)}" w:hAnsi="${escapeXmlAttribute(mark.val)}"/>`);
305
306
  break;
306
307
  case "fontSize":
307
308
  parts.push(`<w:sz w:val="${twip(mark.val)}"/>`);
308
309
  break;
309
310
  case "textColor":
310
- parts.push(`<w:color w:val="${escapeAttribute(mark.color)}"/>`);
311
+ parts.push(`<w:color w:val="${escapeXmlAttribute(mark.color)}"/>`);
311
312
  break;
312
313
  case "highlight":
313
- parts.push(`<w:highlight w:val="${escapeAttribute(mark.val)}"/>`);
314
+ parts.push(`<w:highlight w:val="${escapeXmlAttribute(mark.val)}"/>`);
314
315
  break;
315
316
  case "backgroundColor":
316
- parts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${escapeAttribute(mark.color)}"/>`);
317
+ parts.push(`<w:shd w:val="clear" w:color="auto" w:fill="${escapeXmlAttribute(mark.color)}"/>`);
317
318
  break;
318
319
  case "smallCaps":
319
320
  parts.push("<w:smallCaps/>");
@@ -345,23 +346,15 @@ function escapeXml(text: string): string {
345
346
  .replace(/>/g, "&gt;");
346
347
  }
347
348
 
348
- function escapeAttribute(value: string): string {
349
- return value
350
- .replace(/&/g, "&amp;")
351
- .replace(/</g, "&lt;")
352
- .replace(/>/g, "&gt;")
353
- .replace(/"/g, "&quot;");
354
- }
355
-
356
349
  function serializeHyperlinkNode(node: Extract<InlineNode, { type: "hyperlink" }>): string {
357
350
  const childrenXml = node.children.map((child) => serializeInlineNode(child)).join("");
358
351
  if (node.href.startsWith("#")) {
359
- return `<w:hyperlink w:anchor="${escapeAttribute(node.href.slice(1))}">${childrenXml}</w:hyperlink>`;
352
+ return `<w:hyperlink w:anchor="${escapeXmlAttribute(node.href.slice(1))}">${childrenXml}</w:hyperlink>`;
360
353
  }
361
354
  if (!/^rId[A-Za-z0-9._-]+$/u.test(node.href)) {
362
355
  throw new Error("Cannot safely serialize URL-backed header/footer hyperlinks without relationship context.");
363
356
  }
364
- return `<w:hyperlink r:id="${escapeAttribute(node.href)}">${childrenXml}</w:hyperlink>`;
357
+ return `<w:hyperlink r:id="${escapeXmlAttribute(node.href)}">${childrenXml}</w:hyperlink>`;
365
358
  }
366
359
 
367
360
  function buildTableCellPropertiesXml(cell: TableCellNode): string {