@beyondwork/docx-react-component 1.0.58 → 1.0.60

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 +2 -2
  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 +980 -10
  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 +4 -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 +72 -42
  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/workflow-payload.ts +6 -1
  69. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  70. package/src/io/ooxml/xml-parser.ts +183 -0
  71. package/src/legal/bookmarks.ts +1 -1
  72. package/src/legal/cross-references.ts +1 -1
  73. package/src/legal/defined-terms.ts +1 -1
  74. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  75. package/src/legal/signature-blocks.ts +1 -1
  76. package/src/model/canonical-document.ts +159 -6
  77. package/src/model/chart-types.ts +439 -0
  78. package/src/model/snapshot.ts +5 -1
  79. package/src/review/store/comment-remapping.ts +24 -11
  80. package/src/review/store/revision-actions.ts +482 -2
  81. package/src/review/store/revision-store.ts +15 -0
  82. package/src/review/store/revision-types.ts +76 -0
  83. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  84. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  85. package/src/runtime/diagnostics/build-diagnostic.ts +153 -0
  86. package/src/runtime/diagnostics/code-metadata-table.ts +230 -0
  87. package/src/runtime/document-runtime.ts +821 -54
  88. package/src/runtime/document-search.ts +115 -0
  89. package/src/runtime/edit-ops/index.ts +18 -2
  90. package/src/runtime/footnote-resolver.ts +130 -0
  91. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  92. package/src/runtime/layout/layout-engine-version.ts +37 -1
  93. package/src/runtime/layout/page-graph.ts +14 -1
  94. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  95. package/src/runtime/numbering-prefix.ts +17 -0
  96. package/src/runtime/query-scopes.ts +108 -10
  97. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  98. package/src/runtime/revision-runtime.ts +27 -1
  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 +290 -21
  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 +187 -43
  107. package/src/ui/editor-runtime-boundary.ts +10 -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 +0 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +1 -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 +87 -25
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +9 -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 +29 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -10,6 +10,14 @@ import type {
10
10
  TabStop,
11
11
  } from "../../model/canonical-document.ts";
12
12
  import { readRunProperties } from "./parse-run-formatting.ts";
13
+ import type { OpcRelationship } from "./part-manifest.ts";
14
+ import { normalizePartPath, resolveRelationshipTarget } from "./part-manifest.ts";
15
+ import { serializeXmlElementToString } from "./xml-element-serialize.ts";
16
+ import { parseXml } from "./xml-parser.ts";
17
+ import { localName, readStringAttr } from "./xml-attr-helpers.ts";
18
+
19
+ const TAB_ALIGN_VOCAB = new Set<TabStop["align"]>(["left", "center", "right", "decimal", "num", "bar", "clear"]);
20
+ const TAB_LEADER_VOCAB = new Set<TabStop["leader"]>(["none", "dot", "hyphen", "underscore", "heavy", "middleDot"]);
13
21
 
14
22
  export interface ParsedParagraphNumberingReference {
15
23
  paragraphIndex: number;
@@ -31,7 +39,12 @@ interface XmlTextNode {
31
39
 
32
40
  type XmlNode = XmlElementNode | XmlTextNode;
33
41
 
34
- export function parseNumberingXml(xml: string): NumberingCatalog {
42
+ export interface ParseNumberingContext {
43
+ relationships?: readonly OpcRelationship[];
44
+ partPath?: string;
45
+ }
46
+
47
+ export function parseNumberingXml(xml: string, context?: ParseNumberingContext): NumberingCatalog {
35
48
  const root = parseXml(xml);
36
49
  const numberingElement = findChildElement(root, "numbering");
37
50
  const abstractDefinitions: NumberingCatalog["abstractDefinitions"] = {};
@@ -48,40 +61,35 @@ export function parseNumberingXml(xml: string): NumberingCatalog {
48
61
  // as a catalog entry keyed by numPicBulletId; raw XML is preserved for
49
62
  // byte-identical round-trip.
50
63
  if (localName(child.name) === "numPicBullet") {
51
- const rawId =
52
- child.attributes["w:numPicBulletId"] ?? child.attributes.numPicBulletId;
64
+ const rawId = readStringAttr(child, "w:numPicBulletId");
53
65
  if (rawId) {
54
- numPicBullets[rawId] = readNumPicBullet(child, rawId);
66
+ numPicBullets[rawId] = readNumPicBullet(child, rawId, context);
55
67
  }
56
68
  continue;
57
69
  }
58
70
 
59
71
  switch (localName(child.name)) {
60
72
  case "abstractNum": {
61
- const rawId = child.attributes["w:abstractNumId"] ?? child.attributes.abstractNumId;
73
+ const rawId = readStringAttr(child, "w:abstractNumId");
62
74
  if (!rawId) {
63
75
  continue;
64
76
  }
65
77
 
66
78
  const abstractNumberingId = toCanonicalAbstractNumberingId(rawId);
67
79
  const nsidEl = findChildElementOptional(child, "nsid");
68
- const nsid = nsidEl ? (nsidEl.attributes["w:val"] ?? nsidEl.attributes.val) : undefined;
80
+ const nsid = nsidEl ? readStringAttr(nsidEl, "w:val") : undefined;
69
81
  const mltEl = findChildElementOptional(child, "multiLevelType");
70
- const mltRaw = mltEl ? (mltEl.attributes["w:val"] ?? mltEl.attributes.val) : undefined;
82
+ const mltRaw = mltEl ? readStringAttr(mltEl, "w:val") : undefined;
71
83
  const multiLevelType =
72
84
  mltRaw === "singleLevel" || mltRaw === "multilevel" || mltRaw === "hybridMultilevel"
73
85
  ? mltRaw
74
86
  : undefined;
75
87
  const tmplEl = findChildElementOptional(child, "tmpl");
76
- const tplc = tmplEl ? (tmplEl.attributes["w:val"] ?? tmplEl.attributes.val) : undefined;
88
+ const tplc = tmplEl ? readStringAttr(tmplEl, "w:val") : undefined;
77
89
  const styleLinkEl = findChildElementOptional(child, "styleLink");
78
- const styleLink = styleLinkEl
79
- ? (styleLinkEl.attributes["w:val"] ?? styleLinkEl.attributes.val)
80
- : undefined;
90
+ const styleLink = styleLinkEl ? readStringAttr(styleLinkEl, "w:val") : undefined;
81
91
  const numStyleLinkEl = findChildElementOptional(child, "numStyleLink");
82
- const numStyleLink = numStyleLinkEl
83
- ? (numStyleLinkEl.attributes["w:val"] ?? numStyleLinkEl.attributes.val)
84
- : undefined;
92
+ const numStyleLink = numStyleLinkEl ? readStringAttr(numStyleLinkEl, "w:val") : undefined;
85
93
  abstractDefinitions[abstractNumberingId] = {
86
94
  abstractNumberingId,
87
95
  levels: readLevels(child),
@@ -94,10 +102,9 @@ export function parseNumberingXml(xml: string): NumberingCatalog {
94
102
  break;
95
103
  }
96
104
  case "num": {
97
- const rawId = child.attributes["w:numId"] ?? child.attributes.numId;
105
+ const rawId = readStringAttr(child, "w:numId");
98
106
  const abstractReference = findChildElementOptional(child, "abstractNumId");
99
- const rawAbstractId =
100
- abstractReference?.attributes["w:val"] ?? abstractReference?.attributes.val;
107
+ const rawAbstractId = abstractReference ? readStringAttr(abstractReference, "w:val") : undefined;
101
108
 
102
109
  if (!rawId || !rawAbstractId) {
103
110
  continue;
@@ -126,17 +133,24 @@ export function parseNumberingXml(xml: string): NumberingCatalog {
126
133
  * inner `wp:extent` when present (drawingML path); the raw XML is
127
134
  * preserved verbatim so the export round-trips byte-for-byte regardless
128
135
  * of whether the bullet uses drawing or VML markup.
136
+ *
137
+ * Word 2016+ wraps the bullet content in mc:AlternateContent; we prefer
138
+ * the mc:Choice branch (DrawingML) and fall back to mc:Fallback (VML).
129
139
  */
130
140
  function readNumPicBullet(
131
141
  node: XmlElementNode,
132
142
  numPicBulletId: string,
143
+ context?: ParseNumberingContext,
133
144
  ): NumPicBullet {
134
145
  let widthEmu: number | undefined;
135
146
  let heightEmu: number | undefined;
147
+ let mediaId: string | undefined;
148
+
149
+ // Unwrap mc:AlternateContent if present (Word 2016+).
150
+ const contentNode = unwrapNumPicBulletContent(node);
136
151
 
137
- // Walk drawing → inline/anchor → extent for the EMU dimensions. VML
138
- // paths skip this branch; rendering will fall back to a default size.
139
- const drawing = findChildElementOptional(node, "drawing");
152
+ // DrawingML path: walk drawing → inline/anchor → extent for EMU dims.
153
+ const drawing = findChildElementOptional(contentNode, "drawing");
140
154
  if (drawing) {
141
155
  const inline = findChildElementOptional(drawing, "inline");
142
156
  const anchor = findChildElementOptional(drawing, "anchor");
@@ -149,54 +163,65 @@ function readNumPicBullet(
149
163
  if (cx !== undefined) widthEmu = cx;
150
164
  if (cy !== undefined) heightEmu = cy;
151
165
  }
166
+
167
+ // Resolve mediaId via blip r:embed → relationships map.
168
+ // Walk: inline/anchor → a:graphic → a:graphicData → pic:pic → pic:blipFill → a:blip
169
+ if (context?.relationships && context.relationships.length > 0) {
170
+ const graphic = findChildElementOptional(envelope, "graphic");
171
+ const graphicData = graphic ? findChildElementOptional(graphic, "graphicData") : undefined;
172
+ const pic = graphicData ? findChildElementOptional(graphicData, "pic") : undefined;
173
+ const blipFill = pic ? findChildElementOptional(pic, "blipFill") : undefined;
174
+ const blip = blipFill ? findChildElementOptional(blipFill, "blip") : undefined;
175
+ const rEmbed = blip?.attributes["r:embed"] ?? blip?.attributes.embed;
176
+ if (rEmbed) {
177
+ const rel = context.relationships.find((r) => r.id === rEmbed);
178
+ if (rel) {
179
+ const packagePartName = normalizePartPath(
180
+ resolveRelationshipTarget(context.partPath ?? "/word/numbering.xml", rel),
181
+ );
182
+ mediaId = `media:${packagePartName.slice(1)}`;
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ // VML path: <w:pict> → <v:shape> → <v:imagedata r:id="rIdN">.
190
+ // Also parses width/height from the VML style attribute (1 pt = 12700 EMU).
191
+ if (mediaId === undefined) {
192
+ const pict = findChildElementOptional(contentNode, "pict");
193
+ if (pict) {
194
+ const shape = findChildElementOptional(pict, "shape");
195
+ const imagedata = shape ? findChildElementOptional(shape, "imagedata") : undefined;
196
+ const rId = imagedata?.attributes["r:id"] ?? imagedata?.attributes.id;
197
+ if (rId && context?.relationships && context.relationships.length > 0) {
198
+ const rel = context.relationships.find((r) => r.id === rId);
199
+ if (rel) {
200
+ const packagePartName = normalizePartPath(
201
+ resolveRelationshipTarget(context.partPath ?? "/word/numbering.xml", rel),
202
+ );
203
+ mediaId = `media:${packagePartName.slice(1)}`;
204
+ }
205
+ }
206
+ if (widthEmu === undefined && shape?.attributes.style) {
207
+ const style = shape.attributes.style;
208
+ const wMatch = /width:\s*([\d.]+)pt/.exec(style);
209
+ const hMatch = /height:\s*([\d.]+)pt/.exec(style);
210
+ if (wMatch) widthEmu = Math.round(parseFloat(wMatch[1]) * 12700);
211
+ if (hMatch) heightEmu = Math.round(parseFloat(hMatch[1]) * 12700);
212
+ }
152
213
  }
153
214
  }
154
215
 
155
216
  return {
156
217
  numPicBulletId,
157
- rawXml: reconstructElementXml(node),
218
+ rawXml: serializeXmlElementToString(node),
158
219
  ...(widthEmu !== undefined ? { widthEmu } : {}),
159
220
  ...(heightEmu !== undefined ? { heightEmu } : {}),
221
+ ...(mediaId !== undefined ? { mediaId } : {}),
160
222
  };
161
223
  }
162
224
 
163
- /**
164
- * Best-effort reconstruction of the source XML for a `<w:numPicBullet>`.
165
- * Mirrors `buildGrabBagSourceChildFromParsed` in property-grab-bag.ts —
166
- * attribute/element semantic content round-trips, whitespace between
167
- * elements + quote styles normalize. For the Lane 3b scope this is the
168
- * right trade-off: picture-bullet catalog entries usually round-trip
169
- * once-per-document and their mediaId lookup is what matters.
170
- */
171
- function reconstructElementXml(node: XmlElementNode): string {
172
- const attrs = Object.entries(node.attributes)
173
- .map(([name, value]) => ` ${name}="${escapeAttr(value)}"`)
174
- .join("");
175
- if (node.children.length === 0) {
176
- return `<${node.name}${attrs}/>`;
177
- }
178
- const body = node.children
179
- .map((child) => {
180
- if (child.type === "text") return escapeText(child.text);
181
- if (child.type === "element") return reconstructElementXml(child);
182
- return "";
183
- })
184
- .join("");
185
- return `<${node.name}${attrs}>${body}</${node.name}>`;
186
- }
187
-
188
- function escapeAttr(value: string): string {
189
- return value
190
- .replace(/&/gu, "&amp;")
191
- .replace(/</gu, "&lt;")
192
- .replace(/>/gu, "&gt;")
193
- .replace(/"/gu, "&quot;");
194
- }
195
-
196
- function escapeText(value: string): string {
197
- return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
198
- }
199
-
200
225
  export function parseParagraphNumberingReferences(
201
226
  documentXml: string,
202
227
  ): ParsedParagraphNumberingReference[] {
@@ -219,8 +244,8 @@ export function parseParagraphNumberingReferences(
219
244
  if (numberingProperties) {
220
245
  const levelNode = findChildElementOptional(numberingProperties, "ilvl");
221
246
  const instanceNode = findChildElementOptional(numberingProperties, "numId");
222
- const rawLevel = levelNode?.attributes["w:val"] ?? levelNode?.attributes.val;
223
- const rawInstanceId = instanceNode?.attributes["w:val"] ?? instanceNode?.attributes.val;
247
+ const rawLevel = levelNode ? readStringAttr(levelNode, "w:val") : undefined;
248
+ const rawInstanceId = instanceNode ? readStringAttr(instanceNode, "w:val") : undefined;
224
249
 
225
250
  if (rawLevel !== undefined && rawInstanceId) {
226
251
  const level = parseInteger(rawLevel);
@@ -256,7 +281,7 @@ function readLevels(abstractNode: XmlElementNode): NumberingLevelDefinition[] {
256
281
  continue;
257
282
  }
258
283
 
259
- const rawLevel = child.attributes["w:ilvl"] ?? child.attributes.ilvl;
284
+ const rawLevel = readStringAttr(child, "w:ilvl");
260
285
  const level = rawLevel === undefined ? undefined : parseInteger(rawLevel);
261
286
  if (level === undefined) {
262
287
  continue;
@@ -280,14 +305,14 @@ function readOverrides(numNode: XmlElementNode): NumberingLevelOverride[] {
280
305
  continue;
281
306
  }
282
307
 
283
- const rawLevel = child.attributes["w:ilvl"] ?? child.attributes.ilvl;
308
+ const rawLevel = readStringAttr(child, "w:ilvl");
284
309
  const level = rawLevel === undefined ? undefined : parseInteger(rawLevel);
285
310
  if (level === undefined) {
286
311
  continue;
287
312
  }
288
313
 
289
314
  const startOverrideNode = findChildElementOptional(child, "startOverride");
290
- const rawStart = startOverrideNode?.attributes["w:val"] ?? startOverrideNode?.attributes.val;
315
+ const rawStart = startOverrideNode ? readStringAttr(startOverrideNode, "w:val") : undefined;
291
316
  const startAt = rawStart === undefined ? undefined : parseInteger(rawStart);
292
317
  const levelDefinitionNode = findChildElementOptional(child, "lvl");
293
318
  const levelDefinition = levelDefinitionNode
@@ -308,7 +333,7 @@ function readLevelDefinition(
308
333
  levelNode: XmlElementNode,
309
334
  fallbackLevel?: number,
310
335
  ): NumberingLevelDefinition | undefined {
311
- const rawLevel = levelNode.attributes["w:ilvl"] ?? levelNode.attributes.ilvl;
336
+ const rawLevel = readStringAttr(levelNode, "w:ilvl");
312
337
  const level = rawLevel === undefined ? fallbackLevel : parseInteger(rawLevel);
313
338
  if (level === undefined) {
314
339
  return undefined;
@@ -318,16 +343,21 @@ function readLevelDefinition(
318
343
  const formatNode = findChildElementOptional(levelNode, "numFmt");
319
344
  const textNode = findChildElementOptional(levelNode, "lvlText");
320
345
  const paragraphStyleNode = findChildElementOptional(levelNode, "pStyle");
321
- const rawStart = startNode?.attributes["w:val"] ?? startNode?.attributes.val;
346
+ const rawStart = startNode ? readStringAttr(startNode, "w:val") : undefined;
322
347
  const startAt = rawStart === undefined ? undefined : parseInteger(rawStart);
323
- const format = formatNode?.attributes["w:val"] ?? formatNode?.attributes.val ?? "decimal";
324
- const text = textNode?.attributes["w:val"] ?? textNode?.attributes.val ?? `%${level + 1}.`;
325
- const paragraphStyleId =
326
- paragraphStyleNode?.attributes["w:val"] ?? paragraphStyleNode?.attributes.val;
348
+ const format = (formatNode ? readStringAttr(formatNode, "w:val") : undefined) ?? "decimal";
349
+ // ECMA-376 §17.9.11: w:null="1" means no text displayed; treat as empty string.
350
+ const lvlTextIsNull =
351
+ (textNode?.attributes["w:null"] ?? textNode?.attributes["null"]) === "1";
352
+ const text = lvlTextIsNull
353
+ ? ""
354
+ : ((textNode ? readStringAttr(textNode, "w:val") : undefined) ??
355
+ (format === "bullet" ? "•" : `%${level + 1}.`));
356
+ const paragraphStyleId = paragraphStyleNode ? readStringAttr(paragraphStyleNode, "w:val") : undefined;
327
357
  const isLegalNode = findChildElementOptional(levelNode, "isLgl");
328
358
  const isLegalNumbering = readIsLegalNumberingValue(isLegalNode);
329
359
  const suffixNode = findChildElementOptional(levelNode, "suff");
330
- const suffixVal = suffixNode?.attributes["w:val"] ?? suffixNode?.attributes.val;
360
+ const suffixVal = suffixNode ? readStringAttr(suffixNode, "w:val") : undefined;
331
361
  const suffix =
332
362
  suffixVal === "space" || suffixVal === "nothing"
333
363
  ? suffixVal
@@ -336,13 +366,12 @@ function readLevelDefinition(
336
366
  : undefined;
337
367
  const paragraphGeometry = readLevelParagraphGeometry(levelNode);
338
368
  const lvlRestartNode = findChildElementOptional(levelNode, "lvlRestart");
339
- const rawRestart = lvlRestartNode?.attributes["w:val"] ?? lvlRestartNode?.attributes.val;
369
+ const rawRestart = lvlRestartNode ? readStringAttr(lvlRestartNode, "w:val") : undefined;
340
370
  const restartAfterLevel = rawRestart !== undefined ? parseInteger(rawRestart) : undefined;
341
371
  const rPrNode = findChildElementOptional(levelNode, "rPr");
342
372
  const runProperties = readRunProperties(rPrNode);
343
373
  const lvlPicBulletNode = findChildElementOptional(levelNode, "lvlPicBulletId");
344
- const picBulletId =
345
- lvlPicBulletNode?.attributes["w:val"] ?? lvlPicBulletNode?.attributes.val;
374
+ const picBulletId = lvlPicBulletNode ? readStringAttr(lvlPicBulletNode, "w:val") : undefined;
346
375
 
347
376
  return {
348
377
  level,
@@ -355,7 +384,7 @@ function readLevelDefinition(
355
384
  ...(paragraphGeometry ? { paragraphGeometry } : {}),
356
385
  ...(runProperties ? { runProperties } : {}),
357
386
  ...(restartAfterLevel !== undefined ? { restartAfterLevel } : {}),
358
- ...(picBulletId ? { picBulletId } : {}),
387
+ ...(picBulletId !== undefined ? { picBulletId } : {}),
359
388
  };
360
389
  }
361
390
 
@@ -363,7 +392,7 @@ function readLevelOverrideDefinition(
363
392
  levelNode: XmlElementNode,
364
393
  fallbackLevel?: number,
365
394
  ): NumberingLevelOverrideDefinition | undefined {
366
- const rawLevel = levelNode.attributes["w:ilvl"] ?? levelNode.attributes.ilvl;
395
+ const rawLevel = readStringAttr(levelNode, "w:ilvl");
367
396
  const level = rawLevel === undefined ? fallbackLevel : parseInteger(rawLevel);
368
397
  if (level === undefined) {
369
398
  return undefined;
@@ -373,16 +402,19 @@ function readLevelOverrideDefinition(
373
402
  const formatNode = findChildElementOptional(levelNode, "numFmt");
374
403
  const textNode = findChildElementOptional(levelNode, "lvlText");
375
404
  const paragraphStyleNode = findChildElementOptional(levelNode, "pStyle");
376
- const rawStart = startNode?.attributes["w:val"] ?? startNode?.attributes.val;
405
+ const rawStart = startNode ? readStringAttr(startNode, "w:val") : undefined;
377
406
  const startAt = rawStart === undefined ? undefined : parseInteger(rawStart);
378
- const format = formatNode?.attributes["w:val"] ?? formatNode?.attributes.val;
379
- const text = textNode?.attributes["w:val"] ?? textNode?.attributes.val;
380
- const paragraphStyleId =
381
- paragraphStyleNode?.attributes["w:val"] ?? paragraphStyleNode?.attributes.val;
407
+ const format = formatNode ? readStringAttr(formatNode, "w:val") : undefined;
408
+ const lvlTextIsNull =
409
+ (textNode?.attributes["w:null"] ?? textNode?.attributes["null"]) === "1";
410
+ const text = lvlTextIsNull
411
+ ? ""
412
+ : (textNode ? readStringAttr(textNode, "w:val") : undefined);
413
+ const paragraphStyleId = paragraphStyleNode ? readStringAttr(paragraphStyleNode, "w:val") : undefined;
382
414
  const isLegalNode = findChildElementOptional(levelNode, "isLgl");
383
415
  const isLegalNumbering = readIsLegalNumberingValue(isLegalNode);
384
416
  const suffixNode = findChildElementOptional(levelNode, "suff");
385
- const suffixVal = suffixNode?.attributes["w:val"] ?? suffixNode?.attributes.val;
417
+ const suffixVal = suffixNode ? readStringAttr(suffixNode, "w:val") : undefined;
386
418
  const suffix =
387
419
  suffixVal === "space" || suffixVal === "nothing"
388
420
  ? suffixVal
@@ -391,10 +423,12 @@ function readLevelOverrideDefinition(
391
423
  : undefined;
392
424
  const paragraphGeometry = readLevelParagraphGeometry(levelNode);
393
425
  const lvlRestartNode = findChildElementOptional(levelNode, "lvlRestart");
394
- const rawRestart = lvlRestartNode?.attributes["w:val"] ?? lvlRestartNode?.attributes.val;
426
+ const rawRestart = lvlRestartNode ? readStringAttr(lvlRestartNode, "w:val") : undefined;
395
427
  const restartAfterLevel = rawRestart !== undefined ? parseInteger(rawRestart) : undefined;
396
428
  const rPrNode = findChildElementOptional(levelNode, "rPr");
397
429
  const runProperties = readRunProperties(rPrNode);
430
+ const lvlPicBulletNode = findChildElementOptional(levelNode, "lvlPicBulletId");
431
+ const picBulletId = lvlPicBulletNode ? readStringAttr(lvlPicBulletNode, "w:val") : undefined;
398
432
 
399
433
  const hasExplicitFields =
400
434
  startAt !== undefined ||
@@ -405,7 +439,8 @@ function readLevelOverrideDefinition(
405
439
  suffix !== undefined ||
406
440
  paragraphGeometry !== undefined ||
407
441
  runProperties !== undefined ||
408
- restartAfterLevel !== undefined;
442
+ restartAfterLevel !== undefined ||
443
+ picBulletId !== undefined;
409
444
 
410
445
  if (!hasExplicitFields) {
411
446
  return undefined;
@@ -422,6 +457,7 @@ function readLevelOverrideDefinition(
422
457
  ...(paragraphGeometry ? { paragraphGeometry } : {}),
423
458
  ...(runProperties ? { runProperties } : {}),
424
459
  ...(restartAfterLevel !== undefined ? { restartAfterLevel } : {}),
460
+ ...(picBulletId !== undefined ? { picBulletId } : {}),
425
461
  };
426
462
  }
427
463
 
@@ -456,11 +492,7 @@ function readLevelJustification(
456
492
  return undefined;
457
493
  }
458
494
 
459
- const rawValue = (
460
- justificationNode.attributes["w:val"] ??
461
- justificationNode.attributes.val ??
462
- ""
463
- ).toLowerCase();
495
+ const rawValue = (readStringAttr(justificationNode, "w:val") ?? "").toLowerCase();
464
496
 
465
497
  if (rawValue === "start") {
466
498
  return "left";
@@ -488,18 +520,10 @@ function readParagraphIndentation(node: XmlElementNode): ParagraphIndentation |
488
520
  }
489
521
 
490
522
  const indentation: ParagraphIndentation = {};
491
- const left =
492
- indNode.attributes["w:start"] ??
493
- indNode.attributes.start ??
494
- indNode.attributes["w:left"] ??
495
- indNode.attributes.left;
496
- const right =
497
- indNode.attributes["w:end"] ??
498
- indNode.attributes.end ??
499
- indNode.attributes["w:right"] ??
500
- indNode.attributes.right;
501
- const firstLine = indNode.attributes["w:firstLine"] ?? indNode.attributes.firstLine;
502
- const hanging = indNode.attributes["w:hanging"] ?? indNode.attributes.hanging;
523
+ const left = readStringAttr(indNode, "w:start") ?? readStringAttr(indNode, "w:left");
524
+ const right = readStringAttr(indNode, "w:end") ?? readStringAttr(indNode, "w:right");
525
+ const firstLine = readStringAttr(indNode, "w:firstLine");
526
+ const hanging = readStringAttr(indNode, "w:hanging");
503
527
 
504
528
  if (left !== undefined) {
505
529
  const value = Number.parseInt(left, 10);
@@ -537,10 +561,10 @@ function readParagraphSpacing(node: XmlElementNode): ParagraphSpacing | undefine
537
561
  }
538
562
 
539
563
  const spacing: ParagraphSpacing = {};
540
- const before = spacingNode.attributes["w:before"] ?? spacingNode.attributes.before;
541
- const after = spacingNode.attributes["w:after"] ?? spacingNode.attributes.after;
542
- const line = spacingNode.attributes["w:line"] ?? spacingNode.attributes.line;
543
- const lineRule = spacingNode.attributes["w:lineRule"] ?? spacingNode.attributes.lineRule;
564
+ const before = readStringAttr(spacingNode, "w:before");
565
+ const after = readStringAttr(spacingNode, "w:after");
566
+ const line = readStringAttr(spacingNode, "w:line");
567
+ const lineRule = readStringAttr(spacingNode, "w:lineRule");
544
568
 
545
569
  if (before !== undefined) {
546
570
  const value = Number.parseInt(before, 10);
@@ -587,9 +611,9 @@ function readParagraphTabStops(node: XmlElementNode): TabStop[] | undefined {
587
611
  continue;
588
612
  }
589
613
 
590
- const pos = child.attributes["w:pos"] ?? child.attributes.pos;
591
- const val = (child.attributes["w:val"] ?? child.attributes.val ?? "left").toLowerCase();
592
- const leader = (child.attributes["w:leader"] ?? child.attributes.leader ?? "none").toLowerCase();
614
+ const pos = readStringAttr(child, "w:pos");
615
+ const val = (readStringAttr(child, "w:val") ?? "left").toLowerCase();
616
+ const leader = (readStringAttr(child, "w:leader") ?? "none").toLowerCase();
593
617
  if (pos === undefined) {
594
618
  continue;
595
619
  }
@@ -599,21 +623,10 @@ function readParagraphTabStops(node: XmlElementNode): TabStop[] | undefined {
599
623
  continue;
600
624
  }
601
625
 
602
- const align = (["left", "center", "right", "decimal", "num", "bar", "clear"] as const).includes(
603
- val as TabStop["align"],
604
- )
605
- ? (val as TabStop["align"])
606
- : "left";
607
- const leaderValue =
608
- leader === "none" ||
609
- leader === "dot" ||
610
- leader === "hyphen" ||
611
- leader === "underscore" ||
612
- leader === "heavy"
613
- ? (leader as Exclude<TabStop["leader"], "middleDot">)
614
- : leader === "middledot"
615
- ? "middleDot"
616
- : undefined;
626
+ const alignCandidate = val as TabStop["align"];
627
+ const align: TabStop["align"] = TAB_ALIGN_VOCAB.has(alignCandidate) ? alignCandidate : "left";
628
+ const leaderNorm = leader === "middledot" ? "middleDot" : (leader as TabStop["leader"]);
629
+ const leaderValue: TabStop["leader"] | undefined = TAB_LEADER_VOCAB.has(leaderNorm) ? leaderNorm : undefined;
617
630
 
618
631
  tabStops.push({
619
632
  position,
@@ -632,11 +645,7 @@ function readIsLegalNumberingValue(
632
645
  return undefined;
633
646
  }
634
647
 
635
- const rawValue = (
636
- isLegalNode.attributes["w:val"] ??
637
- isLegalNode.attributes.val ??
638
- "1"
639
- ).toLowerCase();
648
+ const rawValue = (readStringAttr(isLegalNode, "w:val") ?? "1").toLowerCase();
640
649
 
641
650
  if (rawValue === "0" || rawValue === "false" || rawValue === "off") {
642
651
  return false;
@@ -664,11 +673,26 @@ function findChildElementOptional(
664
673
  );
665
674
  }
666
675
 
667
- function localName(name: string): string {
668
- const separatorIndex = name.indexOf(":");
669
- return separatorIndex >= 0 ? name.slice(separatorIndex + 1) : name;
676
+ /**
677
+ * If the numPicBullet node has an mc:AlternateContent child (Word 2016+),
678
+ * return the mc:Choice branch when it contains recognizable bullet content
679
+ * (w:drawing or w:pict), otherwise fall back to mc:Fallback or the original
680
+ * node. This mirrors parse-drawing.ts's pickAlternateContentBranch strategy.
681
+ */
682
+ function unwrapNumPicBulletContent(node: XmlElementNode): XmlElementNode {
683
+ const alt = findChildElementOptional(node, "AlternateContent");
684
+ if (!alt) return node;
685
+ const choice = findChildElementOptional(alt, "Choice");
686
+ const fallback = findChildElementOptional(alt, "Fallback");
687
+ if (choice) {
688
+ const hasDrawing = findChildElementOptional(choice, "drawing") !== undefined;
689
+ const hasPict = findChildElementOptional(choice, "pict") !== undefined;
690
+ if (hasDrawing || hasPict) return choice;
691
+ }
692
+ return fallback ?? node;
670
693
  }
671
694
 
695
+
672
696
  function parseInteger(value: string): number | undefined {
673
697
  if (!/^-?\d+$/.test(value)) {
674
698
  return undefined;
@@ -676,208 +700,3 @@ function parseInteger(value: string): number | undefined {
676
700
 
677
701
  return Number.parseInt(value, 10);
678
702
  }
679
-
680
- function parseXml(xml: string): XmlElementNode {
681
- const root: XmlElementNode = {
682
- type: "element",
683
- name: "__root__",
684
- attributes: {},
685
- children: [],
686
- };
687
- const stack: XmlElementNode[] = [root];
688
- let cursor = 0;
689
-
690
- while (cursor < xml.length) {
691
- if (xml.startsWith("<!--", cursor)) {
692
- const end = xml.indexOf("-->", cursor);
693
- cursor = end >= 0 ? end + 3 : xml.length;
694
- continue;
695
- }
696
-
697
- if (xml.startsWith("<?", cursor)) {
698
- const end = xml.indexOf("?>", cursor);
699
- cursor = end >= 0 ? end + 2 : xml.length;
700
- continue;
701
- }
702
-
703
- if (xml.startsWith("<![CDATA[", cursor)) {
704
- const end = xml.indexOf("]]>", cursor);
705
- const textEnd = end >= 0 ? end : xml.length;
706
- stack[stack.length - 1]?.children.push({
707
- type: "text",
708
- text: xml.slice(cursor + 9, textEnd),
709
- });
710
- cursor = end >= 0 ? end + 3 : xml.length;
711
- continue;
712
- }
713
-
714
- if (xml[cursor] !== "<") {
715
- const nextTag = xml.indexOf("<", cursor);
716
- const end = nextTag >= 0 ? nextTag : xml.length;
717
- const text = decodeXmlEntities(xml.slice(cursor, end));
718
- if (text.length > 0) {
719
- stack[stack.length - 1]?.children.push({
720
- type: "text",
721
- text,
722
- });
723
- }
724
- cursor = end;
725
- continue;
726
- }
727
-
728
- if (xml[cursor + 1] === "/") {
729
- const end = xml.indexOf(">", cursor);
730
- if (end < 0) {
731
- throw new Error("Malformed XML: missing closing >.");
732
- }
733
-
734
- const name = xml.slice(cursor + 2, end).trim();
735
- const current = stack.pop();
736
- if (!current || localName(current.name) !== localName(name)) {
737
- throw new Error(`Malformed XML: unexpected closing tag </${name}>.`);
738
- }
739
-
740
- cursor = end + 1;
741
- continue;
742
- }
743
-
744
- const tagEnd = findTagEnd(xml, cursor);
745
- const tagBody = xml.slice(cursor + 1, tagEnd);
746
- const selfClosing = /\/\s*$/.test(tagBody);
747
- const { name, attributes } = parseTag(tagBody.replace(/\/\s*$/, "").trim());
748
- const element: XmlElementNode = {
749
- type: "element",
750
- name,
751
- attributes,
752
- children: [],
753
- };
754
- stack[stack.length - 1]?.children.push(element);
755
-
756
- if (!selfClosing) {
757
- stack.push(element);
758
- }
759
-
760
- cursor = tagEnd + 1;
761
- }
762
-
763
- if (stack.length !== 1) {
764
- throw new Error("Malformed XML: unclosed element.");
765
- }
766
-
767
- return root;
768
- }
769
-
770
- function parseTag(tagBody: string): { name: string; attributes: Record<string, string> } {
771
- let cursor = 0;
772
- while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
773
- cursor += 1;
774
- }
775
-
776
- const nameStart = cursor;
777
- while (cursor < tagBody.length && !/\s/.test(tagBody[cursor] ?? "")) {
778
- cursor += 1;
779
- }
780
-
781
- const name = tagBody.slice(nameStart, cursor);
782
- const attributes: Record<string, string> = {};
783
-
784
- while (cursor < tagBody.length) {
785
- while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
786
- cursor += 1;
787
- }
788
- if (cursor >= tagBody.length) {
789
- break;
790
- }
791
-
792
- const keyStart = cursor;
793
- while (cursor < tagBody.length && !/[\s=]/.test(tagBody[cursor] ?? "")) {
794
- cursor += 1;
795
- }
796
- const key = tagBody.slice(keyStart, cursor);
797
-
798
- while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
799
- cursor += 1;
800
- }
801
-
802
- if (tagBody[cursor] !== "=") {
803
- attributes[key] = "";
804
- continue;
805
- }
806
- cursor += 1;
807
-
808
- while (cursor < tagBody.length && /\s/.test(tagBody[cursor] ?? "")) {
809
- cursor += 1;
810
- }
811
-
812
- const quote = tagBody[cursor];
813
- if (quote !== `"` && quote !== `'`) {
814
- throw new Error(`Malformed XML attribute ${key}.`);
815
- }
816
- cursor += 1;
817
-
818
- const valueStart = cursor;
819
- while (cursor < tagBody.length && tagBody[cursor] !== quote) {
820
- cursor += 1;
821
- }
822
- const rawValue = tagBody.slice(valueStart, cursor);
823
- attributes[key] = decodeXmlEntities(rawValue);
824
- cursor += 1;
825
- }
826
-
827
- return { name, attributes };
828
- }
829
-
830
- function findTagEnd(xml: string, start: number): number {
831
- let cursor = start + 1;
832
- let quote: string | null = null;
833
-
834
- while (cursor < xml.length) {
835
- const current = xml[cursor];
836
- if (quote) {
837
- if (current === quote) {
838
- quote = null;
839
- }
840
- cursor += 1;
841
- continue;
842
- }
843
-
844
- if (current === `"` || current === `'`) {
845
- quote = current;
846
- cursor += 1;
847
- continue;
848
- }
849
-
850
- if (current === ">") {
851
- return cursor;
852
- }
853
-
854
- cursor += 1;
855
- }
856
-
857
- throw new Error("Malformed XML: missing >.");
858
- }
859
-
860
- function decodeXmlEntities(value: string): string {
861
- return value.replace(/&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g, (match, entity) => {
862
- switch (entity) {
863
- case "amp":
864
- return "&";
865
- case "lt":
866
- return "<";
867
- case "gt":
868
- return ">";
869
- case "quot":
870
- return `"`;
871
- case "apos":
872
- return "'";
873
- default:
874
- if (entity.startsWith("#x")) {
875
- return String.fromCodePoint(Number.parseInt(entity.slice(2), 16));
876
- }
877
- if (entity.startsWith("#")) {
878
- return String.fromCodePoint(Number.parseInt(entity.slice(1), 10));
879
- }
880
- return match;
881
- }
882
- });
883
- }