@beyondwork/docx-react-component 1.0.57 → 1.0.59

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 (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +1149 -8
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +120 -39
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +165 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +544 -35
  87. package/src/runtime/document-search.ts +176 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +183 -0
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/scope-resolver.ts +60 -0
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +293 -18
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +258 -44
  107. package/src/ui/editor-runtime-boundary.ts +13 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +23 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +158 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +105 -17
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +13 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +52 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -17,6 +17,9 @@
17
17
  * four fill families.
18
18
  */
19
19
 
20
+ import { GRADIENT_STOP_UNITS } from "../../runtime/units.ts";
21
+ import { localName } from "./xml-attr-helpers.ts";
22
+
20
23
  export interface XmlElementNode {
21
24
  type: "element";
22
25
  name: string;
@@ -57,6 +60,11 @@ export interface GradientFillResult {
57
60
  direction: GradientDirection;
58
61
  /** Rotate with shape (a:gradFill rotWithShape). Defaults true per OOXML. */
59
62
  rotWithShape?: boolean;
63
+ /**
64
+ * Phase 4.5 G5 — `a:gradFill flip` attribute ("x" | "y" | "xy") mirrors the
65
+ * gradient along the given axis/axes. Absent when no flip is specified.
66
+ */
67
+ flip?: "x" | "y" | "xy";
60
68
  }
61
69
 
62
70
  export interface PatternFillResult {
@@ -127,28 +135,104 @@ function parseSolidFillInner(solid: XmlElementNode): SolidFillResult | undefined
127
135
  function parseGradientFill(grad: XmlElementNode): GradientFillResult | undefined {
128
136
  const gsLst = findFirstChild(grad, "gsLst");
129
137
  if (!gsLst) return undefined;
130
- const stops: GradientStop[] = [];
138
+
139
+ // Phase 4.5 G6 — collect stops with optional `pos` (null = unspecified);
140
+ // post-pass interpolates the nulls across neighbors rather than collapsing
141
+ // them all to 0. Matches real-world Word behavior where middle stops may
142
+ // omit pos and are laid out evenly between specified neighbors.
143
+ interface RawStop {
144
+ pos: number | null;
145
+ color: string;
146
+ colorType: "srgbClr" | "schemeClr";
147
+ }
148
+ const raw: RawStop[] = [];
131
149
  for (const child of gsLst.children) {
132
150
  if (child.type !== "element") continue;
133
151
  if (localName(child.name) !== "gs") continue;
134
152
  const posRaw = child.attributes.pos;
135
- const pos = posRaw !== undefined ? parseInt(posRaw, 10) : 0;
153
+ let pos: number | null;
154
+ if (posRaw === undefined || posRaw === "") {
155
+ pos = null;
156
+ } else {
157
+ const n = parseInt(posRaw, 10);
158
+ pos = Number.isFinite(n) ? n : null;
159
+ }
136
160
  const token = extractColorToken(child);
137
161
  if (!token) continue;
138
- stops.push({ pos: Number.isFinite(pos) ? pos : 0, ...token });
162
+ raw.push({ pos, ...token });
139
163
  }
140
- if (stops.length === 0) return undefined;
164
+ if (raw.length === 0) return undefined;
165
+
166
+ const stops = interpolateStopPositions(raw);
141
167
 
142
168
  const direction = readGradientDirection(grad);
143
169
  const rotWithShapeRaw = grad.attributes.rotWithShape;
144
170
  const rotWithShape =
145
171
  rotWithShapeRaw === undefined ? undefined : rotWithShapeRaw !== "0" && rotWithShapeRaw !== "false";
172
+ const flipRaw = grad.attributes.flip;
173
+ const flip = flipRaw === "x" || flipRaw === "y" || flipRaw === "xy" ? flipRaw : undefined;
146
174
 
147
175
  const result: GradientFillResult = { kind: "gradient", stops, direction };
148
176
  if (rotWithShape !== undefined) result.rotWithShape = rotWithShape;
177
+ if (flip !== undefined) result.flip = flip;
149
178
  return result;
150
179
  }
151
180
 
181
+ /**
182
+ * Phase 4.5 G6 — fill `null` positions by linear interpolation across
183
+ * defined neighbors. Leading nulls default to 0; trailing nulls default to
184
+ * 100000 (OOXML gsLst pos unit). Runs of nulls between known positions get
185
+ * evenly spaced.
186
+ */
187
+ function interpolateStopPositions(raw: ReadonlyArray<{
188
+ pos: number | null;
189
+ color: string;
190
+ colorType: "srgbClr" | "schemeClr";
191
+ }>): GradientStop[] {
192
+ const resolved: number[] = new Array(raw.length);
193
+ // Forward pass with leading-null default of 0
194
+ let lastKnownIndex = -1;
195
+ for (let i = 0; i < raw.length; i++) {
196
+ if (raw[i]?.pos !== null) {
197
+ resolved[i] = raw[i]!.pos as number;
198
+ // Fill intervening nulls (lastKnownIndex..i exclusive) by interpolation
199
+ if (lastKnownIndex >= 0 && i - lastKnownIndex > 1) {
200
+ const start = resolved[lastKnownIndex]!;
201
+ const end = resolved[i]!;
202
+ for (let j = lastKnownIndex + 1; j < i; j++) {
203
+ resolved[j] = start + ((end - start) * (j - lastKnownIndex)) / (i - lastKnownIndex);
204
+ }
205
+ } else if (lastKnownIndex < 0 && i > 0) {
206
+ // Leading nulls before first known pos — default to 0, then interp
207
+ for (let j = 0; j < i; j++) {
208
+ resolved[j] = (resolved[i]! * j) / i;
209
+ }
210
+ }
211
+ lastKnownIndex = i;
212
+ }
213
+ }
214
+ // Trailing nulls after the last known pos — default toward GRADIENT_STOP_UNITS
215
+ if (lastKnownIndex < raw.length - 1) {
216
+ const start = lastKnownIndex >= 0 ? resolved[lastKnownIndex]! : 0;
217
+ const end = GRADIENT_STOP_UNITS;
218
+ const count = raw.length - 1 - Math.max(lastKnownIndex, 0);
219
+ for (let j = Math.max(lastKnownIndex, 0) + 1; j < raw.length; j++) {
220
+ resolved[j] = start + ((end - start) * (j - Math.max(lastKnownIndex, 0))) / (count);
221
+ }
222
+ }
223
+ // All-null case: evenly distribute 0..GRADIENT_STOP_UNITS
224
+ if (lastKnownIndex < 0) {
225
+ for (let i = 0; i < raw.length; i++) {
226
+ resolved[i] = raw.length === 1 ? 0 : (GRADIENT_STOP_UNITS * i) / (raw.length - 1);
227
+ }
228
+ }
229
+ return raw.map((r, i) => ({
230
+ pos: Math.round(resolved[i]!),
231
+ color: r.color,
232
+ colorType: r.colorType,
233
+ }));
234
+ }
235
+
152
236
  function readGradientDirection(grad: XmlElementNode): GradientDirection {
153
237
  const lin = findFirstChild(grad, "lin");
154
238
  if (lin) {
@@ -209,7 +293,3 @@ function findFirstChild(
209
293
  return undefined;
210
294
  }
211
295
 
212
- function localName(name: string): string {
213
- const i = name.indexOf(":");
214
- return i >= 0 ? name.slice(i + 1) : name;
215
- }
@@ -14,22 +14,11 @@ import type {
14
14
  CanonicalFontEntry,
15
15
  CanonicalFontTable,
16
16
  } from "../../model/canonical-document.ts";
17
+ import type { XmlElementNode } from "./xml-element.ts";
18
+ import { parseXml } from "./xml-parser.ts";
19
+ import { localName } from "./xml-attr-helpers.ts";
17
20
 
18
- interface XmlElementNode {
19
- type: "element";
20
- name: string;
21
- attributes: Record<string, string>;
22
- children: XmlNode[];
23
- }
24
-
25
- interface XmlTextNode {
26
- type: "text";
27
- text: string;
28
- }
29
-
30
- type XmlNode = XmlElementNode | XmlTextNode;
31
-
32
- const KNOWN_FAMILIES = new Set(["roman", "swiss", "modern", "script", "decorative"]);
21
+ const KNOWN_FAMILIES = new Set<CanonicalFontEntry["family"]>(["roman", "swiss", "modern", "script", "decorative"]);
33
22
 
34
23
  export function parseFontTable(xml: string): CanonicalFontTable {
35
24
  const root = parseXml(xml);
@@ -50,7 +39,7 @@ export function parseFontTable(xml: string): CanonicalFontTable {
50
39
  switch (localName(sub.name)) {
51
40
  case "family": {
52
41
  const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
53
- if (raw && KNOWN_FAMILIES.has(raw)) {
42
+ if (raw && KNOWN_FAMILIES.has(raw as CanonicalFontEntry["family"])) {
54
43
  entry.family = raw as CanonicalFontEntry["family"];
55
44
  }
56
45
  break;
@@ -88,10 +77,6 @@ export function parseFontTable(xml: string): CanonicalFontTable {
88
77
  // XML helpers — same shape as parse-numbering.ts internals
89
78
  // ---------------------------------------------------------------------------
90
79
 
91
- function localName(name: string): string {
92
- const separator = name.indexOf(":");
93
- return separator >= 0 ? name.slice(separator + 1) : name;
94
- }
95
80
 
96
81
  function findChildElementOptional(
97
82
  node: XmlElementNode,
@@ -103,88 +88,3 @@ function findChildElementOptional(
103
88
  );
104
89
  }
105
90
 
106
- function parseXml(xml: string): XmlElementNode {
107
- const root: XmlElementNode = {
108
- type: "element",
109
- name: "__root__",
110
- attributes: {},
111
- children: [],
112
- };
113
- const stack: XmlElementNode[] = [root];
114
- let cursor = 0;
115
-
116
- while (cursor < xml.length) {
117
- if (xml.startsWith("<!--", cursor)) {
118
- const end = xml.indexOf("-->", cursor);
119
- cursor = end >= 0 ? end + 3 : xml.length;
120
- continue;
121
- }
122
- if (xml.startsWith("<?", cursor)) {
123
- const end = xml.indexOf("?>", cursor);
124
- cursor = end >= 0 ? end + 2 : xml.length;
125
- continue;
126
- }
127
- if (xml[cursor] !== "<") {
128
- const nextTag = xml.indexOf("<", cursor);
129
- cursor = nextTag >= 0 ? nextTag : xml.length;
130
- continue;
131
- }
132
- if (xml[cursor + 1] === "/") {
133
- const end = xml.indexOf(">", cursor);
134
- if (end < 0) throw new Error("Malformed XML: missing closing >.");
135
- stack.pop();
136
- cursor = end + 1;
137
- continue;
138
- }
139
-
140
- const tagEnd = findTagEnd(xml, cursor);
141
- const tagBody = xml.slice(cursor + 1, tagEnd);
142
- const selfClosing = /\/\s*$/.test(tagBody);
143
- const { name, attributes } = parseTag(tagBody.replace(/\/\s*$/, "").trim());
144
-
145
- const element: XmlElementNode = {
146
- type: "element",
147
- name,
148
- attributes,
149
- children: [],
150
- };
151
- stack[stack.length - 1]?.children.push(element);
152
- if (!selfClosing) stack.push(element);
153
- cursor = tagEnd + 1;
154
- }
155
-
156
- return root;
157
- }
158
-
159
- function findTagEnd(xml: string, start: number): number {
160
- let cursor = start + 1;
161
- let inQuote: '"' | "'" | null = null;
162
- while (cursor < xml.length) {
163
- const ch = xml[cursor];
164
- if (inQuote) {
165
- if (ch === inQuote) inQuote = null;
166
- } else {
167
- if (ch === '"' || ch === "'") inQuote = ch;
168
- else if (ch === ">") return cursor;
169
- }
170
- cursor++;
171
- }
172
- throw new Error("Malformed XML: missing closing >.");
173
- }
174
-
175
- function parseTag(body: string): { name: string; attributes: Record<string, string> } {
176
- const attributes: Record<string, string> = {};
177
- let cursor = 0;
178
- while (cursor < body.length && !/\s/.test(body[cursor]!)) cursor++;
179
- const name = body.slice(0, cursor);
180
-
181
- const attrRegex = /([\w:\-.]+)\s*=\s*"([^"]*)"|([\w:\-.]+)\s*=\s*'([^']*)'/g;
182
- let match: RegExpExecArray | null;
183
- while ((match = attrRegex.exec(body)) !== null) {
184
- const key = match[1] ?? match[3]!;
185
- const value = match[2] ?? match[4] ?? "";
186
- attributes[key] = value;
187
- }
188
-
189
- return { name, attributes };
190
- }
@@ -44,6 +44,8 @@ import {
44
44
  readTableStyleId,
45
45
  readTableWidth,
46
46
  } from "./parse-tables.ts";
47
+ import { parseXmlWithOffsets as parseXml } from "./xml-parser.ts";
48
+ import { localName, readStringAttr } from "./xml-attr-helpers.ts";
47
49
 
48
50
  // ---- XML node types (inline, no external dep) ----
49
51
 
@@ -77,7 +79,7 @@ const SPECIAL_NOTE_TYPES = new Set(["separator", "continuationSeparator"]);
77
79
  * Also accepts endnotes.xml (<w:endnotes> root).
78
80
  */
79
81
  export function parseFootnotesXml(xml: string): FootnoteCollection {
80
- const root = parseXml(xml);
82
+ const root = parseXml(xml) as XmlElementNode;
81
83
 
82
84
  const footnotesElement = findChildElementOptional(root, "footnotes");
83
85
  const endnotesElement = findChildElementOptional(root, "endnotes");
@@ -131,7 +133,7 @@ export function parseEndnotesXml(
131
133
  xml: string,
132
134
  existing?: FootnoteCollection,
133
135
  ): FootnoteCollection {
134
- const root = parseXml(xml);
136
+ const root = parseXml(xml) as XmlElementNode;
135
137
  const endnotesElement = findChildElementOptional(root, "endnotes");
136
138
  const endnotes: Record<string, FootnoteDefinition> = {
137
139
  ...(existing?.endnotes ?? {}),
@@ -165,7 +167,7 @@ export function parseEndnotesXml(
165
167
  * by the page-chrome renderer at the footnote divider line.
166
168
  */
167
169
  export function parseFootnoteSeparators(xml: string): FootnoteSeparators {
168
- const root = parseXml(xml);
170
+ const root = parseXml(xml) as XmlElementNode;
169
171
  const footnotesEl = findChildElementOptional(root, "footnotes");
170
172
  const endnotesEl = findChildElementOptional(root, "endnotes");
171
173
  const containerEl = footnotesEl ?? endnotesEl;
@@ -179,7 +181,7 @@ export function parseFootnoteSeparators(xml: string): FootnoteSeparators {
179
181
  const name = localName(child.name);
180
182
  if (name !== "footnote" && name !== "endnote") continue;
181
183
 
182
- const rawType = child.attributes["w:type"] ?? child.attributes.type ?? "";
184
+ const rawType = readStringAttr(child, "w:type") ?? "";
183
185
  if (rawType !== "separator" && rawType !== "continuationSeparator") continue;
184
186
 
185
187
  const paraEl = findChildElementOptional(child, "p");
@@ -210,9 +212,9 @@ function parseNoteElement(
210
212
  kind: "footnote" | "endnote",
211
213
  ): FootnoteDefinition | undefined {
212
214
  const rawId =
213
- element.attributes["w:id"] ?? element.attributes.id ?? "";
215
+ readStringAttr(element, "w:id") ?? "";
214
216
  const rawType =
215
- element.attributes["w:type"] ?? element.attributes.type ?? "";
217
+ readStringAttr(element, "w:type") ?? "";
216
218
 
217
219
  if (!rawId || SPECIAL_NOTE_IDS.has(rawId) || SPECIAL_NOTE_TYPES.has(rawType)) {
218
220
  return undefined;
@@ -347,7 +349,7 @@ function appendRunNodes(
347
349
 
348
350
  const name = localName(child.name);
349
351
  if (name === "fldChar") {
350
- const fldType = child.attributes["w:fldCharType"] ?? child.attributes.fldCharType;
352
+ const fldType = readStringAttr(child, "w:fldCharType");
351
353
  if (fldType === "begin") {
352
354
  activeComplexField = { instruction: "", children: [], mode: "instruction" };
353
355
  } else if (fldType === "separate" && activeComplexField) {
@@ -414,13 +416,13 @@ function parseRunElement(rElement: XmlElementNode): InlineNode[] {
414
416
  // The in-note reference marker (superscript) - skip, rendered by the host
415
417
  } else if (name === "footnoteReference") {
416
418
  const noteId =
417
- child.attributes["w:id"] ?? child.attributes.id ?? "";
419
+ readStringAttr(child, "w:id") ?? "";
418
420
  if (noteId) {
419
421
  nodes.push({ type: "footnote_ref", noteId, noteKind: "footnote" });
420
422
  }
421
423
  } else if (name === "endnoteReference") {
422
424
  const noteId =
423
- child.attributes["w:id"] ?? child.attributes.id ?? "";
425
+ readStringAttr(child, "w:id") ?? "";
424
426
  if (noteId) {
425
427
  nodes.push({ type: "footnote_ref", noteId, noteKind: "endnote" });
426
428
  }
@@ -459,7 +461,7 @@ function parseRunChildNode(
459
461
  }
460
462
  if (name === "footnoteReference") {
461
463
  const noteId =
462
- child.attributes["w:id"] ?? child.attributes.id ?? "";
464
+ readStringAttr(child, "w:id") ?? "";
463
465
  if (noteId) {
464
466
  return { type: "footnote_ref", noteId, noteKind: "footnote" };
465
467
  }
@@ -467,7 +469,7 @@ function parseRunChildNode(
467
469
  }
468
470
  if (name === "endnoteReference") {
469
471
  const noteId =
470
- child.attributes["w:id"] ?? child.attributes.id ?? "";
472
+ readStringAttr(child, "w:id") ?? "";
471
473
  if (noteId) {
472
474
  return { type: "footnote_ref", noteId, noteKind: "endnote" };
473
475
  }
@@ -506,12 +508,12 @@ function parseHyperlinkElement(element: XmlElementNode): Extract<InlineNode, { t
506
508
  function parseBookmarkElement(
507
509
  element: XmlElementNode,
508
510
  ): Extract<InlineNode, { type: "bookmark_start" | "bookmark_end" }> {
509
- const bookmarkId = element.attributes["w:id"] ?? element.attributes.id ?? "0";
511
+ const bookmarkId = readStringAttr(element, "w:id") ?? "0";
510
512
  if (localName(element.name) === "bookmarkStart") {
511
513
  return {
512
514
  type: "bookmark_start",
513
515
  bookmarkId,
514
- name: element.attributes["w:name"] ?? element.attributes.name ?? "",
516
+ name: readStringAttr(element, "w:name") ?? "",
515
517
  };
516
518
  }
517
519
 
@@ -555,8 +557,7 @@ function createFieldNode(
555
557
 
556
558
  function readFieldInstruction(element: XmlElementNode): string | undefined {
557
559
  const instruction =
558
- element.attributes["w:instr"] ??
559
- element.attributes.instr ??
560
+ readStringAttr(element, "w:instr") ??
560
561
  extractTextContent(element);
561
562
  return instruction.trim().length > 0 ? instruction : undefined;
562
563
  }
@@ -575,7 +576,7 @@ function parseRunProperties(rElement: XmlElementNode): TextMark[] {
575
576
  }
576
577
 
577
578
  const name = localName(child.name);
578
- const val = child.attributes["w:val"] ?? child.attributes.val ?? "true";
579
+ const val = readStringAttr(child, "w:val") ?? "true";
579
580
 
580
581
  switch (name) {
581
582
  case "b":
@@ -603,7 +604,7 @@ function parseRunProperties(rElement: XmlElementNode): TextMark[] {
603
604
  break;
604
605
  }
605
606
  case "sz": {
606
- const szVal = child.attributes["w:val"] ?? child.attributes.val;
607
+ const szVal = readStringAttr(child, "w:val");
607
608
  if (szVal) {
608
609
  const size = Number.parseInt(szVal, 10);
609
610
  if (Number.isFinite(size) && size > 0) marks.push({ type: "fontSize", val: size });
@@ -611,7 +612,7 @@ function parseRunProperties(rElement: XmlElementNode): TextMark[] {
611
612
  break;
612
613
  }
613
614
  case "color": {
614
- const colorVal = child.attributes["w:val"] ?? child.attributes.val;
615
+ const colorVal = readStringAttr(child, "w:val");
615
616
  // A.9: preserve "auto" verbatim for round-trip.
616
617
  if (colorVal) marks.push({ type: "textColor", color: colorVal });
617
618
  break;
@@ -632,13 +633,13 @@ function readParagraphSpacing(pPr: XmlElementNode): ParagraphSpacing | undefined
632
633
  const spacingNode = findChildElementOptional(pPr, "spacing");
633
634
  if (!spacingNode) return undefined;
634
635
  const result: ParagraphSpacing = {};
635
- const before = spacingNode.attributes["w:before"] ?? spacingNode.attributes.before;
636
+ const before = readStringAttr(spacingNode, "w:before");
636
637
  if (before) result.before = Number.parseInt(before, 10);
637
- const after = spacingNode.attributes["w:after"] ?? spacingNode.attributes.after;
638
+ const after = readStringAttr(spacingNode, "w:after");
638
639
  if (after) result.after = Number.parseInt(after, 10);
639
- const line = spacingNode.attributes["w:line"] ?? spacingNode.attributes.line;
640
+ const line = readStringAttr(spacingNode, "w:line");
640
641
  if (line) result.line = Number.parseInt(line, 10);
641
- const lineRule = spacingNode.attributes["w:lineRule"] ?? spacingNode.attributes.lineRule;
642
+ const lineRule = readStringAttr(spacingNode, "w:lineRule");
642
643
  if (lineRule === "auto" || lineRule === "exact" || lineRule === "atLeast") {
643
644
  result.lineRule = lineRule;
644
645
  }
@@ -649,13 +650,13 @@ function readParagraphIndentation(pPr: XmlElementNode): ParagraphIndentation | u
649
650
  const indNode = findChildElementOptional(pPr, "ind");
650
651
  if (!indNode) return undefined;
651
652
  const result: ParagraphIndentation = {};
652
- const left = indNode.attributes["w:left"] ?? indNode.attributes.left;
653
+ const left = readStringAttr(indNode, "w:left");
653
654
  if (left) result.left = Number.parseInt(left, 10);
654
- const right = indNode.attributes["w:right"] ?? indNode.attributes.right;
655
+ const right = readStringAttr(indNode, "w:right");
655
656
  if (right) result.right = Number.parseInt(right, 10);
656
- const firstLine = indNode.attributes["w:firstLine"] ?? indNode.attributes.firstLine;
657
+ const firstLine = readStringAttr(indNode, "w:firstLine");
657
658
  if (firstLine) result.firstLine = Number.parseInt(firstLine, 10);
658
- const hanging = indNode.attributes["w:hanging"] ?? indNode.attributes.hanging;
659
+ const hanging = readStringAttr(indNode, "w:hanging");
659
660
  if (hanging) result.hanging = Number.parseInt(hanging, 10);
660
661
  return Object.keys(result).length > 0 ? result : undefined;
661
662
  }
@@ -680,10 +681,6 @@ function findChildElementOptional(
680
681
  );
681
682
  }
682
683
 
683
- function localName(name: string): string {
684
- const idx = name.indexOf(":");
685
- return idx >= 0 ? name.slice(idx + 1) : name;
686
- }
687
684
 
688
685
  // ---- Simple secondary-story table support ----
689
686
 
@@ -864,7 +861,7 @@ function parseSimpleTableCell(tcElement: XmlElementNode): TableCellNode {
864
861
 
865
862
  const vmEl = findChildElementOptional(child, "vMerge");
866
863
  if (vmEl) {
867
- const vmVal = vmEl.attributes["w:val"] ?? vmEl.attributes.val ?? "continue";
864
+ const vmVal = readStringAttr(vmEl, "w:val") ?? "continue";
868
865
  verticalMerge = vmVal === "restart" ? "restart" : "continue";
869
866
  }
870
867
  width = readCellWidth(child);
@@ -931,124 +928,3 @@ function escapeXmlText(text: string): string {
931
928
  .replace(/</g, "&lt;")
932
929
  .replace(/>/g, "&gt;");
933
930
  }
934
-
935
- // ---- Minimal XML parser (same pattern as parse-numbering.ts) ----
936
-
937
- function parseXml(xml: string): XmlElementNode {
938
- const root: XmlElementNode = {
939
- type: "element",
940
- name: "__root__",
941
- attributes: {},
942
- children: [],
943
- start: 0,
944
- end: xml.length,
945
- };
946
- const stack: XmlElementNode[] = [root];
947
- let cursor = 0;
948
-
949
- while (cursor < xml.length) {
950
- if (xml.startsWith("<!--", cursor)) {
951
- const end = xml.indexOf("-->", cursor);
952
- cursor = end >= 0 ? end + 3 : xml.length;
953
- continue;
954
- }
955
-
956
- if (xml.startsWith("<?", cursor)) {
957
- const end = xml.indexOf("?>", cursor);
958
- cursor = end >= 0 ? end + 2 : xml.length;
959
- continue;
960
- }
961
-
962
- if (xml.startsWith("<![CDATA[", cursor)) {
963
- const end = xml.indexOf("]]>", cursor);
964
- const textEnd = end >= 0 ? end : xml.length;
965
- stack[stack.length - 1]?.children.push({
966
- type: "text",
967
- text: xml.slice(cursor + 9, textEnd),
968
- start: cursor,
969
- end: end >= 0 ? end + 3 : xml.length,
970
- });
971
- cursor = end >= 0 ? end + 3 : xml.length;
972
- continue;
973
- }
974
-
975
- if (xml[cursor] !== "<") {
976
- const nextTag = xml.indexOf("<", cursor);
977
- const end = nextTag >= 0 ? nextTag : xml.length;
978
- const text = decodeXmlEntities(xml.slice(cursor, end));
979
- if (text.trim().length > 0 || (text.length > 0 && stack.length > 1)) {
980
- stack[stack.length - 1]?.children.push({ type: "text", text, start: cursor, end });
981
- }
982
- cursor = end;
983
- continue;
984
- }
985
-
986
- if (xml[cursor + 1] === "/") {
987
- const end = xml.indexOf(">", cursor);
988
- if (end < 0) break;
989
- const current = stack.pop();
990
- if (current) {
991
- current.end = end + 1;
992
- }
993
- cursor = end + 1;
994
- continue;
995
- }
996
-
997
- const tagEnd = xml.indexOf(">", cursor);
998
- if (tagEnd < 0) break;
999
-
1000
- const tagContent = xml.slice(cursor + 1, tagEnd);
1001
- const selfClosing = tagContent.endsWith("/");
1002
- const normalized = selfClosing ? tagContent.slice(0, -1).trimEnd() : tagContent;
1003
-
1004
- const spaceIndex = normalized.search(/\s/);
1005
- const tagName = spaceIndex >= 0 ? normalized.slice(0, spaceIndex) : normalized;
1006
- const attrString = spaceIndex >= 0 ? normalized.slice(spaceIndex + 1) : "";
1007
- const attributes = parseAttributes(attrString);
1008
-
1009
- const element: XmlElementNode = {
1010
- type: "element",
1011
- name: tagName,
1012
- attributes,
1013
- children: [],
1014
- start: cursor,
1015
- end: tagEnd + 1,
1016
- };
1017
-
1018
- stack[stack.length - 1]?.children.push(element);
1019
-
1020
- if (!selfClosing) {
1021
- stack.push(element);
1022
- }
1023
-
1024
- cursor = tagEnd + 1;
1025
- }
1026
-
1027
- return root;
1028
- }
1029
-
1030
- function parseAttributes(attrString: string): Record<string, string> {
1031
- const attrs: Record<string, string> = {};
1032
- const pattern = /([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*("([^"]*)"|'([^']*)')/gu;
1033
- for (const match of attrString.matchAll(pattern)) {
1034
- const name = match[1];
1035
- const value = match[3] ?? match[4] ?? "";
1036
- if (name) {
1037
- attrs[name] = decodeXmlEntities(value);
1038
- }
1039
- }
1040
- return attrs;
1041
- }
1042
-
1043
- function decodeXmlEntities(text: string): string {
1044
- return text
1045
- .replace(/&amp;/g, "&")
1046
- .replace(/&lt;/g, "<")
1047
- .replace(/&gt;/g, ">")
1048
- .replace(/&quot;/g, '"')
1049
- .replace(/&apos;/g, "'")
1050
- .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)))
1051
- .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) =>
1052
- String.fromCodePoint(Number.parseInt(hex, 16)),
1053
- );
1054
- }