@beyondwork/docx-react-component 1.0.41 → 1.0.42

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 (88) hide show
  1. package/package.json +13 -1
  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/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
package/src/index.ts CHANGED
@@ -8,7 +8,114 @@ export {
8
8
  EDITOR_SESSION_STATE_VERSION,
9
9
  } from "./api/session-state.ts";
10
10
  // R2 — issue metadata id for scope-card-overlay P1.
11
- export { ISSUE_METADATA_ID } from "./api/public-types.ts";
11
+ // K1-light review-action metadata id for scope-card-overlay P2.
12
+ export { ISSUE_METADATA_ID, REVIEW_ACTION_METADATA_ID } from "./api/public-types.ts";
13
+ // P17 — metadata persistence error class.
14
+ export { MetadataResolverMissingError } from "./api/public-types.ts";
15
+
16
+ // Collab substrate (P1 – P8f + P14). See docs/plans/collab-master-plan.md
17
+ // for the shipped-slice table. Surfaces are stable for host integration;
18
+ // the chrome preset + markdown renderer land in P9 / P10.
19
+ export { createCollabSession } from "./runtime/collab-session.ts";
20
+ export { createCollabSessionBridge } from "./runtime/collab-session-bridge.ts";
21
+ export { createCollabSessionFacet } from "./runtime/collab-session-facet.ts";
22
+ export { createTamperGate } from "./runtime/tamper-gate.ts";
23
+ export { resignPayload } from "./runtime/resign-payload.ts";
24
+ export {
25
+ setLocalIdentity,
26
+ clearLocalIdentity,
27
+ getPresenceSnapshot,
28
+ getCollabPosture,
29
+ } from "./runtime/awareness-identity.ts";
30
+ export { runtimeSendToExternal } from "./runtime/external-send-runtime.ts";
31
+ export { sendToExternal } from "./io/export/external-send.ts";
32
+ export { maybeRestoreFromExternalCustody } from "./io/import/external-reimport.ts";
33
+ export {
34
+ signWorkflowPayloadXml,
35
+ verifyWorkflowPayloadXml,
36
+ createHmacSigner,
37
+ createHmacVerifier,
38
+ } from "./io/ooxml/payload-signature.ts";
39
+ export type {
40
+ // Collab types
41
+ CollabSession,
42
+ CollabSessionOptions,
43
+ CollabSessionEventOrIntegrity,
44
+ AttachPayloadArgs,
45
+ SendToExternalCallArgs,
46
+ } from "./runtime/collab-session.ts";
47
+ export type {
48
+ CollabSessionBridge,
49
+ CollabSessionEvent,
50
+ CreateCollabSessionBridgeOptions,
51
+ } from "./runtime/collab-session-bridge.ts";
52
+ export type {
53
+ CollabSessionFacet,
54
+ DispatchContext,
55
+ DispatchResult,
56
+ } from "./runtime/collab-session-facet.ts";
57
+ export type {
58
+ TamperGate,
59
+ TamperGateEvent,
60
+ MetadataIntegrity,
61
+ TamperGateArgs,
62
+ AttachArgs as TamperGateAttachArgs,
63
+ GuardResult as TamperGateGuardResult,
64
+ } from "./runtime/tamper-gate.ts";
65
+ export type {
66
+ RuntimeSendToExternalArgs,
67
+ RuntimeSendToExternalResult,
68
+ } from "./runtime/external-send-runtime.ts";
69
+ export type {
70
+ CommentNegotiationAction,
71
+ CommentNegotiationEntry,
72
+ CommentNegotiationSnapshot,
73
+ CommentNegotiationState,
74
+ CommentNegotiationActionType,
75
+ NegotiationHistoryRow,
76
+ NegotiationVote,
77
+ NegotiationCounterProposal,
78
+ NegotiationBlockReason,
79
+ NegotiationRole,
80
+ CollabBlockReason,
81
+ } from "./api/comment-negotiation-types.ts";
82
+ export type {
83
+ CommentPresentation,
84
+ CommentPresentationAction,
85
+ CommentPresentationSnapshot,
86
+ CommentPresentationReply,
87
+ CommentBody,
88
+ CommentAttachment,
89
+ CommentAudience,
90
+ CommentLabel,
91
+ CommentMention,
92
+ CommentReaction,
93
+ } from "./api/comment-presentation-types.ts";
94
+ export type {
95
+ Participant,
96
+ ParticipantRoster,
97
+ ParticipantRole,
98
+ AuthorKind,
99
+ } from "./api/participants-types.ts";
100
+ export type {
101
+ AwarenessIdentity,
102
+ AwarenessPeer,
103
+ CollabPosture,
104
+ PresenceSnapshot,
105
+ TransportStatus,
106
+ } from "./api/awareness-identity-types.ts";
107
+ export type {
108
+ ExternalCustody,
109
+ ExternalCustodyResolver,
110
+ ExternalCustodyArchivePayload,
111
+ ExternalCustodyRestoredContent,
112
+ } from "./api/external-custody-types.ts";
113
+ export type {
114
+ PayloadSignature,
115
+ PayloadSigner,
116
+ PayloadVerifier,
117
+ SignatureAlgorithm,
118
+ } from "./io/ooxml/payload-signature.ts";
12
119
  export type {
13
120
  LoadRequest,
14
121
  LoadSourcePolicy,
@@ -113,6 +220,11 @@ export type {
113
220
  IssueMetadataValue,
114
221
  ScopeIssueAction,
115
222
  ScopeCardModel,
223
+ // R3 — suggestion groups (scope-card-overlay P2)
224
+ SuggestionGroup,
225
+ // K1-light — review-action audit trail (scope-card-overlay P2)
226
+ ReviewActionKind,
227
+ ReviewActionMetadataValue,
116
228
  WorkflowBlockedCommandReason,
117
229
  WorkflowScopeSnapshot,
118
230
  InteractionGuardSnapshot,
@@ -150,4 +262,9 @@ export type {
150
262
  RuntimeContextAnalyticsUnavailableField,
151
263
  RuntimeContextAnalyticsProvenance,
152
264
  RuntimeContextAnalyticsSnapshot,
265
+ // P17 — metadata persistence types (schema 1.1)
266
+ MetadataPersistenceMode,
267
+ ScopeMetadataPersistence,
268
+ ScopeMetadataResolver,
269
+ ScopeMetadataStorageRef,
153
270
  } from "./api/public-types.ts";
@@ -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 {