@beyondwork/docx-react-component 1.0.1 → 1.0.2

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 (172) hide show
  1. package/README.md +44 -104
  2. package/package.json +76 -46
  3. package/src/README.md +85 -0
  4. package/src/api/README.md +22 -0
  5. package/src/api/public-types.ts +525 -0
  6. package/src/compare/diff-engine.ts +530 -0
  7. package/src/compare/export-redlines.ts +162 -0
  8. package/src/compare/snapshot.ts +37 -0
  9. package/src/component-inventory.md +99 -0
  10. package/src/core/README.md +10 -0
  11. package/src/core/commands/README.md +3 -0
  12. package/src/core/commands/formatting-commands.ts +161 -0
  13. package/src/core/commands/image-commands.ts +144 -0
  14. package/src/core/commands/index.ts +1013 -0
  15. package/src/core/commands/list-commands.ts +370 -0
  16. package/src/core/commands/review-commands.ts +108 -0
  17. package/src/core/commands/text-commands.ts +119 -0
  18. package/src/core/schema/README.md +3 -0
  19. package/src/core/schema/text-schema.ts +512 -0
  20. package/src/core/selection/README.md +3 -0
  21. package/src/core/selection/mapping.ts +238 -0
  22. package/src/core/selection/review-anchors.ts +94 -0
  23. package/src/core/state/README.md +3 -0
  24. package/src/core/state/editor-state.ts +580 -0
  25. package/src/core/state/text-transaction.ts +276 -0
  26. package/src/formats/xlsx/io/parse-shared-strings.ts +41 -0
  27. package/src/formats/xlsx/io/parse-sheet.ts +289 -0
  28. package/src/formats/xlsx/io/parse-styles.ts +57 -0
  29. package/src/formats/xlsx/io/parse-workbook.ts +75 -0
  30. package/src/formats/xlsx/io/xlsx-session.ts +306 -0
  31. package/src/formats/xlsx/model/cell.ts +189 -0
  32. package/src/formats/xlsx/model/sheet.ts +244 -0
  33. package/src/formats/xlsx/model/styles.ts +118 -0
  34. package/src/formats/xlsx/model/workbook.ts +449 -0
  35. package/src/index.ts +45 -0
  36. package/src/io/README.md +10 -0
  37. package/src/io/docx-session.ts +1763 -0
  38. package/src/io/export/README.md +3 -0
  39. package/src/io/export/export-session.ts +165 -0
  40. package/src/io/export/minimal-docx.ts +115 -0
  41. package/src/io/export/reattach-preserved-parts.ts +54 -0
  42. package/src/io/export/serialize-comments.ts +876 -0
  43. package/src/io/export/serialize-footnotes.ts +217 -0
  44. package/src/io/export/serialize-headers-footers.ts +200 -0
  45. package/src/io/export/serialize-main-document.ts +982 -0
  46. package/src/io/export/serialize-numbering.ts +97 -0
  47. package/src/io/export/serialize-revisions.ts +389 -0
  48. package/src/io/export/serialize-runtime-revisions.ts +265 -0
  49. package/src/io/export/serialize-tables.ts +147 -0
  50. package/src/io/export/split-review-boundaries.ts +194 -0
  51. package/src/io/normalize/README.md +3 -0
  52. package/src/io/normalize/normalize-text.ts +437 -0
  53. package/src/io/ooxml/README.md +3 -0
  54. package/src/io/ooxml/parse-comments.ts +779 -0
  55. package/src/io/ooxml/parse-complex-content.ts +287 -0
  56. package/src/io/ooxml/parse-fields.ts +438 -0
  57. package/src/io/ooxml/parse-footnotes.ts +403 -0
  58. package/src/io/ooxml/parse-headers-footers.ts +483 -0
  59. package/src/io/ooxml/parse-inline-media.ts +431 -0
  60. package/src/io/ooxml/parse-main-document.ts +1846 -0
  61. package/src/io/ooxml/parse-numbering.ts +425 -0
  62. package/src/io/ooxml/parse-revisions.ts +658 -0
  63. package/src/io/ooxml/parse-shapes.ts +271 -0
  64. package/src/io/ooxml/parse-tables.ts +568 -0
  65. package/src/io/ooxml/parse-theme.ts +314 -0
  66. package/src/io/ooxml/part-manifest.ts +136 -0
  67. package/src/io/ooxml/revision-boundaries.ts +351 -0
  68. package/src/io/opc/README.md +3 -0
  69. package/src/io/opc/corrupt-package.ts +166 -0
  70. package/src/io/opc/docx-package.ts +74 -0
  71. package/src/io/opc/package-reader.ts +320 -0
  72. package/src/io/opc/package-writer.ts +273 -0
  73. package/src/legal/bookmarks.ts +196 -0
  74. package/src/legal/cross-references.ts +356 -0
  75. package/src/legal/defined-terms.ts +203 -0
  76. package/src/model/README.md +3 -0
  77. package/src/model/canonical-document.ts +1911 -0
  78. package/src/model/cds-1.0.0.ts +196 -0
  79. package/src/model/snapshot.ts +393 -0
  80. package/src/preservation/README.md +3 -0
  81. package/src/preservation/markup-compatibility.ts +48 -0
  82. package/src/preservation/opaque-fragment-store.ts +89 -0
  83. package/src/preservation/opaque-region.ts +233 -0
  84. package/src/preservation/package-preservation.ts +120 -0
  85. package/src/preservation/preserved-part-manifest.ts +56 -0
  86. package/src/preservation/relationship-retention.ts +57 -0
  87. package/src/preservation/store.ts +185 -0
  88. package/src/review/README.md +16 -0
  89. package/src/review/store/README.md +3 -0
  90. package/src/review/store/comment-anchors.ts +70 -0
  91. package/src/review/store/comment-remapping.ts +154 -0
  92. package/src/review/store/comment-store.ts +331 -0
  93. package/src/review/store/comment-thread.ts +109 -0
  94. package/src/review/store/revision-actions.ts +394 -0
  95. package/src/review/store/revision-store.ts +303 -0
  96. package/src/review/store/revision-types.ts +168 -0
  97. package/src/review/store/runtime-comment-store.ts +43 -0
  98. package/src/runtime/README.md +3 -0
  99. package/src/runtime/ai-action-policy.ts +764 -0
  100. package/src/runtime/document-runtime.ts +967 -0
  101. package/src/runtime/read-only-diagnostics-runtime.ts +232 -0
  102. package/src/runtime/review-runtime.ts +44 -0
  103. package/src/runtime/revision-runtime.ts +107 -0
  104. package/src/runtime/session-capabilities.ts +138 -0
  105. package/src/runtime/surface-projection.ts +570 -0
  106. package/src/runtime/table-commands.ts +87 -0
  107. package/src/runtime/table-schema.ts +140 -0
  108. package/src/runtime/virtualized-rendering.ts +258 -0
  109. package/src/ui/README.md +30 -0
  110. package/src/ui/WordReviewEditor.tsx +1504 -0
  111. package/src/ui/comments/README.md +3 -0
  112. package/src/ui/compatibility/README.md +3 -0
  113. package/src/ui/editor-surface/README.md +3 -0
  114. package/src/ui/headless/comment-decoration-model.ts +124 -0
  115. package/src/ui/headless/revision-decoration-model.ts +128 -0
  116. package/src/ui/headless/selection-helpers.ts +34 -0
  117. package/src/ui/headless/use-editor-keyboard.ts +98 -0
  118. package/src/ui/review/README.md +3 -0
  119. package/src/ui/shared/revision-filters.ts +31 -0
  120. package/src/ui/status/README.md +3 -0
  121. package/src/ui/theme/README.md +3 -0
  122. package/src/ui/toolbar/README.md +3 -0
  123. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +48 -0
  124. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +44 -0
  125. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +58 -0
  126. package/src/ui-tailwind/chrome/use-before-unload.ts +20 -0
  127. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +139 -0
  128. package/src/ui-tailwind/editor-surface/pm-decorations.ts +98 -0
  129. package/src/ui-tailwind/editor-surface/pm-position-map.ts +123 -0
  130. package/src/ui-tailwind/editor-surface/pm-schema.ts +452 -0
  131. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +327 -0
  132. package/src/ui-tailwind/editor-surface/search-plugin.ts +157 -0
  133. package/src/ui-tailwind/editor-surface/tw-caret.tsx +12 -0
  134. package/src/ui-tailwind/editor-surface/tw-editor-surface.tsx +150 -0
  135. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +118 -0
  136. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +52 -0
  137. package/src/ui-tailwind/editor-surface/tw-paragraph-block.tsx +151 -0
  138. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +215 -0
  139. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +111 -0
  140. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +122 -0
  141. package/src/ui-tailwind/index.ts +61 -0
  142. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +276 -0
  143. package/src/ui-tailwind/review/tw-health-panel.tsx +120 -0
  144. package/src/ui-tailwind/review/tw-review-rail.tsx +120 -0
  145. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +164 -0
  146. package/src/ui-tailwind/status/tw-status-bar.tsx +58 -0
  147. package/src/ui-tailwind/theme/editor-theme.css +190 -0
  148. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +48 -0
  149. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +231 -0
  150. package/src/ui-tailwind/tw-review-workspace.tsx +140 -0
  151. package/src/validation/README.md +3 -0
  152. package/src/validation/compatibility-engine.ts +317 -0
  153. package/src/validation/compatibility-report.ts +160 -0
  154. package/src/validation/diagnostics.ts +203 -0
  155. package/src/validation/import-diagnostics.ts +128 -0
  156. package/src/validation/low-priority-word-surfaces.ts +373 -0
  157. package/dist/chunk-32W6IVQE.js +0 -7725
  158. package/dist/chunk-32W6IVQE.js.map +0 -1
  159. package/dist/index.cjs +0 -23722
  160. package/dist/index.cjs.map +0 -1
  161. package/dist/index.d.cts +0 -7
  162. package/dist/index.d.ts +0 -7
  163. package/dist/index.js +0 -16011
  164. package/dist/index.js.map +0 -1
  165. package/dist/public-types-DqCURAz8.d.cts +0 -1152
  166. package/dist/public-types-DqCURAz8.d.ts +0 -1152
  167. package/dist/tailwind.cjs +0 -8295
  168. package/dist/tailwind.cjs.map +0 -1
  169. package/dist/tailwind.d.cts +0 -323
  170. package/dist/tailwind.d.ts +0 -323
  171. package/dist/tailwind.js +0 -553
  172. package/dist/tailwind.js.map +0 -1
@@ -0,0 +1,431 @@
1
+ import type { OpcRelationship } from "./part-manifest.ts";
2
+ import { normalizePartPath, resolveRelationshipTarget } from "./part-manifest.ts";
3
+
4
+ export interface InlineMediaPart {
5
+ path: string;
6
+ contentType: string;
7
+ }
8
+
9
+ export interface ParsedInlineMedia {
10
+ type: "image";
11
+ mediaId: string;
12
+ relationshipId: string;
13
+ packagePartName: string;
14
+ contentType?: string;
15
+ filename: string;
16
+ altText?: string;
17
+ display?: "inline" | "floating";
18
+ floating?: {
19
+ horizontalPosition?: {
20
+ relativeFrom?: string;
21
+ align?: string;
22
+ offset?: number;
23
+ };
24
+ verticalPosition?: {
25
+ relativeFrom?: string;
26
+ align?: string;
27
+ offset?: number;
28
+ };
29
+ wrap?: "none" | "square" | "tight" | "through" | "topAndBottom";
30
+ behindDoc?: boolean;
31
+ layoutInCell?: boolean;
32
+ allowOverlap?: boolean;
33
+ };
34
+ }
35
+
36
+ interface XmlElementNode {
37
+ type: "element";
38
+ name: string;
39
+ attributes: Record<string, string>;
40
+ children: XmlNode[];
41
+ }
42
+
43
+ interface XmlTextNode {
44
+ type: "text";
45
+ text: string;
46
+ }
47
+
48
+ type XmlNode = XmlElementNode | XmlTextNode;
49
+
50
+ export function parseInlineMediaXml(
51
+ xml: string,
52
+ relationships: readonly OpcRelationship[],
53
+ mediaParts: ReadonlyMap<string, InlineMediaPart> = new Map(),
54
+ sourcePartPath = "/word/document.xml",
55
+ ): ParsedInlineMedia[] {
56
+ const root = parseXml(xml);
57
+ const relationshipMap = new Map(relationships.map((relationship) => [relationship.id, relationship]));
58
+ const drawings = findDescendants(root, "drawing");
59
+ const media: ParsedInlineMedia[] = [];
60
+
61
+ for (const drawing of drawings) {
62
+ const inline = findFirstDescendant(drawing, "inline");
63
+ const anchor = findFirstDescendant(drawing, "anchor");
64
+ const container = anchor ?? inline;
65
+ const blip = findFirstDescendant(drawing, "blip");
66
+ if (!container || !blip) {
67
+ continue;
68
+ }
69
+
70
+ const relationshipId = blip.attributes["r:embed"] ?? blip.attributes.embed;
71
+ if (!relationshipId) {
72
+ continue;
73
+ }
74
+
75
+ const relationship = relationshipMap.get(relationshipId);
76
+ if (!relationship || !relationship.type.endsWith("/image")) {
77
+ continue;
78
+ }
79
+
80
+ const packagePartName = normalizePartPath(
81
+ resolveRelationshipTarget(sourcePartPath, relationship),
82
+ );
83
+ const mediaPart = mediaParts.get(packagePartName);
84
+ const docProperties = findFirstDescendant(container, "docPr");
85
+ const altText = readAltText(docProperties);
86
+ const floating = anchor ? readFloatingProperties(anchor) : undefined;
87
+
88
+ media.push({
89
+ type: "image",
90
+ mediaId: `media:${packagePartName.slice(1)}`,
91
+ relationshipId,
92
+ packagePartName,
93
+ ...(mediaPart ? { contentType: mediaPart.contentType } : {}),
94
+ filename: packagePartName.slice(packagePartName.lastIndexOf("/") + 1),
95
+ ...(altText ? { altText } : {}),
96
+ ...(anchor ? { display: "floating" as const } : {}),
97
+ ...(floating ? { floating } : {}),
98
+ });
99
+ }
100
+
101
+ return media;
102
+ }
103
+
104
+ function readFloatingProperties(
105
+ node: XmlElementNode,
106
+ ): NonNullable<ParsedInlineMedia["floating"]> | undefined {
107
+ const horizontalPosition = findFirstDescendant(node, "positionH");
108
+ const verticalPosition = findFirstDescendant(node, "positionV");
109
+ const wrap =
110
+ findFirstDescendant(node, "wrapNone") ??
111
+ findFirstDescendant(node, "wrapSquare") ??
112
+ findFirstDescendant(node, "wrapTight") ??
113
+ findFirstDescendant(node, "wrapThrough") ??
114
+ findFirstDescendant(node, "wrapTopAndBottom");
115
+
116
+ const properties: NonNullable<ParsedInlineMedia["floating"]> = {};
117
+ const horizontalAxis = readAxisPosition(horizontalPosition);
118
+ const verticalAxis = readAxisPosition(verticalPosition);
119
+ if (horizontalAxis) {
120
+ properties.horizontalPosition = horizontalAxis;
121
+ }
122
+ if (verticalAxis) {
123
+ properties.verticalPosition = verticalAxis;
124
+ }
125
+ if (wrap) {
126
+ const wrapName = localName(wrap.name);
127
+ properties.wrap =
128
+ wrapName === "wrapSquare"
129
+ ? "square"
130
+ : wrapName === "wrapTight"
131
+ ? "tight"
132
+ : wrapName === "wrapThrough"
133
+ ? "through"
134
+ : wrapName === "wrapTopAndBottom"
135
+ ? "topAndBottom"
136
+ : "none";
137
+ }
138
+
139
+ properties.behindDoc = readBooleanAttribute(node, "behindDoc");
140
+ properties.layoutInCell = readBooleanAttribute(node, "layoutInCell");
141
+ properties.allowOverlap = readBooleanAttribute(node, "allowOverlap");
142
+
143
+ return Object.values(properties).some((value) => value !== undefined) ? properties : undefined;
144
+ }
145
+
146
+ function readAxisPosition(
147
+ node: XmlElementNode | undefined,
148
+ ): NonNullable<NonNullable<ParsedInlineMedia["floating"]>["horizontalPosition"]> | undefined {
149
+ if (!node) {
150
+ return undefined;
151
+ }
152
+
153
+ const align = findFirstDescendant(node, "align");
154
+ const posOffset = findFirstDescendant(node, "posOffset");
155
+ const position: NonNullable<NonNullable<ParsedInlineMedia["floating"]>["horizontalPosition"]> = {
156
+ ...(readOptionalAttribute(node, "relativeFrom") ? { relativeFrom: readOptionalAttribute(node, "relativeFrom") } : {}),
157
+ ...(align && extractText(align).trim() ? { align: extractText(align).trim() } : {}),
158
+ };
159
+
160
+ if (posOffset) {
161
+ const offset = Number.parseInt(extractText(posOffset).trim(), 10);
162
+ if (Number.isFinite(offset)) {
163
+ position.offset = offset;
164
+ }
165
+ }
166
+
167
+ return Object.keys(position).length > 0 ? position : undefined;
168
+ }
169
+
170
+ function readAltText(node: XmlElementNode | undefined): string | undefined {
171
+ if (!node) {
172
+ return undefined;
173
+ }
174
+
175
+ const description = node.attributes.descr?.trim();
176
+ if (description) {
177
+ return description;
178
+ }
179
+
180
+ const title = node.attributes.title?.trim();
181
+ if (title) {
182
+ return title;
183
+ }
184
+
185
+ const name = node.attributes.name?.trim();
186
+ return name || undefined;
187
+ }
188
+
189
+ function findFirstDescendant(node: XmlElementNode, local: string): XmlElementNode | undefined {
190
+ for (const child of node.children) {
191
+ if (child.type !== "element") {
192
+ continue;
193
+ }
194
+
195
+ if (localName(child.name) === local) {
196
+ return child;
197
+ }
198
+
199
+ const nested = findFirstDescendant(child, local);
200
+ if (nested) {
201
+ return nested;
202
+ }
203
+ }
204
+
205
+ return undefined;
206
+ }
207
+
208
+ function findDescendants(node: XmlElementNode, local: string): XmlElementNode[] {
209
+ const results: XmlElementNode[] = [];
210
+
211
+ for (const child of node.children) {
212
+ if (child.type !== "element") {
213
+ continue;
214
+ }
215
+
216
+ if (localName(child.name) === local) {
217
+ results.push(child);
218
+ }
219
+
220
+ results.push(...findDescendants(child, local));
221
+ }
222
+
223
+ return results;
224
+ }
225
+
226
+ function localName(name: string): string {
227
+ const separatorIndex = name.indexOf(":");
228
+ return separatorIndex >= 0 ? name.slice(separatorIndex + 1) : name;
229
+ }
230
+
231
+ function extractText(node: XmlElementNode): string {
232
+ return node.children.map((child) => child.type === "text" ? child.text : extractText(child)).join("");
233
+ }
234
+
235
+ function readOptionalAttribute(node: XmlElementNode, name: string): string | undefined {
236
+ return node.attributes[`wp:${name}`]
237
+ ?? node.attributes[`w:${name}`]
238
+ ?? node.attributes[name];
239
+ }
240
+
241
+ function readBooleanAttribute(node: XmlElementNode, name: string): boolean | undefined {
242
+ const value = readOptionalAttribute(node, name);
243
+ if (value === undefined) {
244
+ return undefined;
245
+ }
246
+ return value !== "0" && value !== "false" && value !== "off";
247
+ }
248
+
249
+ function parseXml(xml: string): XmlElementNode {
250
+ const root: XmlElementNode = {
251
+ type: "element",
252
+ name: "__root__",
253
+ attributes: {},
254
+ children: [],
255
+ };
256
+ const stack: XmlElementNode[] = [root];
257
+ let cursor = 0;
258
+
259
+ while (cursor < xml.length) {
260
+ if (xml.startsWith("<!--", cursor)) {
261
+ const end = xml.indexOf("-->", cursor);
262
+ cursor = end >= 0 ? end + 3 : xml.length;
263
+ continue;
264
+ }
265
+
266
+ if (xml.startsWith("<?", cursor)) {
267
+ const end = xml.indexOf("?>", cursor);
268
+ cursor = end >= 0 ? end + 2 : xml.length;
269
+ continue;
270
+ }
271
+
272
+ if (xml[cursor] !== "<") {
273
+ const nextTag = xml.indexOf("<", cursor);
274
+ const end = nextTag >= 0 ? nextTag : xml.length;
275
+ const text = decodeXmlEntities(xml.slice(cursor, end));
276
+ if (text.length > 0) {
277
+ stack[stack.length - 1]?.children.push({ type: "text", text });
278
+ }
279
+ cursor = end;
280
+ continue;
281
+ }
282
+
283
+ if (xml[cursor + 1] === "/") {
284
+ const end = xml.indexOf(">", cursor);
285
+ const current = stack.pop();
286
+ const name = xml.slice(cursor + 2, end).trim();
287
+ if (!current || localName(current.name) !== localName(name)) {
288
+ throw new Error(`Malformed XML: unexpected closing tag </${name}>.`);
289
+ }
290
+ cursor = end + 1;
291
+ continue;
292
+ }
293
+
294
+ const tagEnd = findTagEnd(xml, cursor);
295
+ const tagBody = xml.slice(cursor + 1, tagEnd);
296
+ const selfClosing = /\/\s*$/.test(tagBody);
297
+ const { name, attributes } = parseTag(tagBody.replace(/\/\s*$/, "").trim());
298
+ const element: XmlElementNode = {
299
+ type: "element",
300
+ name,
301
+ attributes,
302
+ children: [],
303
+ };
304
+ stack[stack.length - 1]?.children.push(element);
305
+
306
+ if (!selfClosing) {
307
+ stack.push(element);
308
+ }
309
+
310
+ cursor = tagEnd + 1;
311
+ }
312
+
313
+ if (stack.length !== 1) {
314
+ throw new Error("Malformed XML: unclosed element.");
315
+ }
316
+
317
+ return root;
318
+ }
319
+
320
+ function parseTag(tagBody: string): { name: string; attributes: Record<string, string> } {
321
+ let cursor = 0;
322
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
323
+ cursor += 1;
324
+ }
325
+
326
+ const nameStart = cursor;
327
+ while (cursor < tagBody.length && !/\s/.test(tagBody[cursor] ?? "")) {
328
+ cursor += 1;
329
+ }
330
+ const name = tagBody.slice(nameStart, cursor);
331
+ const attributes: Record<string, string> = {};
332
+
333
+ while (cursor < tagBody.length) {
334
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
335
+ cursor += 1;
336
+ }
337
+ if (cursor >= tagBody.length) {
338
+ break;
339
+ }
340
+
341
+ const keyStart = cursor;
342
+ while (cursor < tagBody.length && !/[\s=]/.test(tagBody[cursor] ?? "")) {
343
+ cursor += 1;
344
+ }
345
+ const key = tagBody.slice(keyStart, cursor);
346
+
347
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
348
+ cursor += 1;
349
+ }
350
+
351
+ if (tagBody[cursor] !== "=") {
352
+ attributes[key] = "";
353
+ continue;
354
+ }
355
+ cursor += 1;
356
+
357
+ while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
358
+ cursor += 1;
359
+ }
360
+
361
+ const quote = tagBody[cursor];
362
+ if (quote !== `"` && quote !== `'`) {
363
+ throw new Error(`Malformed XML attribute ${key}.`);
364
+ }
365
+ cursor += 1;
366
+
367
+ const valueStart = cursor;
368
+ while (cursor < tagBody.length && tagBody[cursor] !== quote) {
369
+ cursor += 1;
370
+ }
371
+ attributes[key] = decodeXmlEntities(tagBody.slice(valueStart, cursor));
372
+ cursor += 1;
373
+ }
374
+
375
+ return { name, attributes };
376
+ }
377
+
378
+ function findTagEnd(xml: string, start: number): number {
379
+ let cursor = start + 1;
380
+ let quote: string | null = null;
381
+
382
+ while (cursor < xml.length) {
383
+ const current = xml[cursor];
384
+ if (quote) {
385
+ if (current === quote) {
386
+ quote = null;
387
+ }
388
+ cursor += 1;
389
+ continue;
390
+ }
391
+
392
+ if (current === `"` || current === `'`) {
393
+ quote = current;
394
+ cursor += 1;
395
+ continue;
396
+ }
397
+
398
+ if (current === ">") {
399
+ return cursor;
400
+ }
401
+
402
+ cursor += 1;
403
+ }
404
+
405
+ throw new Error("Malformed XML: missing >.");
406
+ }
407
+
408
+ function decodeXmlEntities(value: string): string {
409
+ return value.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (match, entity) => {
410
+ switch (entity) {
411
+ case "amp":
412
+ return "&";
413
+ case "lt":
414
+ return "<";
415
+ case "gt":
416
+ return ">";
417
+ case "quot":
418
+ return `"`;
419
+ case "apos":
420
+ return "'";
421
+ default:
422
+ if (entity.startsWith("#x")) {
423
+ return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
424
+ }
425
+ if (entity.startsWith("#")) {
426
+ return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
427
+ }
428
+ return match;
429
+ }
430
+ });
431
+ }