@beyondwork/docx-react-component 1.0.56 → 1.0.57

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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -0,0 +1,215 @@
1
+ /**
2
+ * parse-fill.ts
3
+ *
4
+ * Shared fill-parsing primitives for DrawingML cascade reads. Covers
5
+ * `a:solidFill`, `a:noFill`, `a:gradFill`, and `a:pattFill`.
6
+ *
7
+ * Exposed for:
8
+ * - `parse-shapes.ts::parseShapeContent` (wps:wsp → ShapeContent.fill) — CO4
9
+ * - `src/io/ooxml/chart/**` (chart-style + series fill cascade) — Lane 5 Stage 2+
10
+ *
11
+ * The `colorType` discriminant on solid-fill keeps `schemeClr` tokens raw so
12
+ * CO1's ThemeColorResolver can resolve them to effective rgb later. Gradient
13
+ * and pattern colors use the same `ColorToken` shape for consistency.
14
+ *
15
+ * Legacy export: `parseSolidFill` is retained for back-compat with existing
16
+ * call sites; new code should prefer `parseFill` which dispatches across all
17
+ * four fill families.
18
+ */
19
+
20
+ export interface XmlElementNode {
21
+ type: "element";
22
+ name: string;
23
+ attributes: Record<string, string>;
24
+ children: XmlNode[];
25
+ }
26
+
27
+ interface XmlTextNode {
28
+ type: "text";
29
+ text: string;
30
+ }
31
+
32
+ type XmlNode = XmlElementNode | XmlTextNode;
33
+
34
+ export interface ColorToken {
35
+ color: string;
36
+ colorType: "srgbClr" | "schemeClr";
37
+ }
38
+
39
+ export type SolidFillResult =
40
+ | { kind: "solid"; color: string; colorType: "srgbClr" | "schemeClr" }
41
+ | { kind: "none" };
42
+
43
+ export interface GradientStop {
44
+ /** Position along the gradient axis in 0..100000 (= 0..100%). */
45
+ pos: number;
46
+ color: string;
47
+ colorType: "srgbClr" | "schemeClr";
48
+ }
49
+
50
+ export type GradientDirection =
51
+ | { kind: "linear"; angle: number; scaled?: boolean } // angle in 60000ths of degree
52
+ | { kind: "path"; path: "circle" | "rect" | "shape" };
53
+
54
+ export interface GradientFillResult {
55
+ kind: "gradient";
56
+ stops: GradientStop[];
57
+ direction: GradientDirection;
58
+ /** Rotate with shape (a:gradFill rotWithShape). Defaults true per OOXML. */
59
+ rotWithShape?: boolean;
60
+ }
61
+
62
+ export interface PatternFillResult {
63
+ kind: "pattern";
64
+ /** Preset pattern token (e.g. "wdDkVert", "pct50"). Raw OOXML value. */
65
+ preset: string;
66
+ fg?: ColorToken;
67
+ bg?: ColorToken;
68
+ }
69
+
70
+ export type FillResult = SolidFillResult | GradientFillResult | PatternFillResult;
71
+
72
+ /**
73
+ * Parse any OOXML fill child of a shape-property element.
74
+ *
75
+ * Returns the first recognised fill variant found among:
76
+ * - `a:noFill`
77
+ * - `a:solidFill`
78
+ * - `a:gradFill`
79
+ * - `a:pattFill`
80
+ *
81
+ * Returns `undefined` when none of the above are direct children.
82
+ */
83
+ export function parseFill(parent: XmlElementNode): FillResult | undefined {
84
+ if (findFirstChild(parent, "noFill")) return { kind: "none" };
85
+ const solid = findFirstChild(parent, "solidFill");
86
+ if (solid) return parseSolidFillInner(solid);
87
+ const grad = findFirstChild(parent, "gradFill");
88
+ if (grad) return parseGradientFill(grad);
89
+ const pat = findFirstChild(parent, "pattFill");
90
+ if (pat) return parsePatternFill(pat);
91
+ return undefined;
92
+ }
93
+
94
+ /**
95
+ * Legacy `parseSolidFill` entry point (still used by `parse-shapes.ts::readFill`).
96
+ * Returns only `SolidFillResult` — callers that want gradient/pattern should
97
+ * migrate to `parseFill`.
98
+ */
99
+ export function parseSolidFill(parent: XmlElementNode): SolidFillResult | undefined {
100
+ const noFill = findFirstChild(parent, "noFill");
101
+ if (noFill) return { kind: "none" };
102
+ const solid = findFirstChild(parent, "solidFill");
103
+ if (!solid) return undefined;
104
+ return parseSolidFillInner(solid);
105
+ }
106
+
107
+ function parseSolidFillInner(solid: XmlElementNode): SolidFillResult | undefined {
108
+ const srgb = findFirstChild(solid, "srgbClr");
109
+ if (srgb?.attributes.val) {
110
+ return {
111
+ kind: "solid",
112
+ color: srgb.attributes.val.toUpperCase(),
113
+ colorType: "srgbClr",
114
+ };
115
+ }
116
+ const scheme = findFirstChild(solid, "schemeClr");
117
+ if (scheme?.attributes.val) {
118
+ return {
119
+ kind: "solid",
120
+ color: scheme.attributes.val,
121
+ colorType: "schemeClr",
122
+ };
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ function parseGradientFill(grad: XmlElementNode): GradientFillResult | undefined {
128
+ const gsLst = findFirstChild(grad, "gsLst");
129
+ if (!gsLst) return undefined;
130
+ const stops: GradientStop[] = [];
131
+ for (const child of gsLst.children) {
132
+ if (child.type !== "element") continue;
133
+ if (localName(child.name) !== "gs") continue;
134
+ const posRaw = child.attributes.pos;
135
+ const pos = posRaw !== undefined ? parseInt(posRaw, 10) : 0;
136
+ const token = extractColorToken(child);
137
+ if (!token) continue;
138
+ stops.push({ pos: Number.isFinite(pos) ? pos : 0, ...token });
139
+ }
140
+ if (stops.length === 0) return undefined;
141
+
142
+ const direction = readGradientDirection(grad);
143
+ const rotWithShapeRaw = grad.attributes.rotWithShape;
144
+ const rotWithShape =
145
+ rotWithShapeRaw === undefined ? undefined : rotWithShapeRaw !== "0" && rotWithShapeRaw !== "false";
146
+
147
+ const result: GradientFillResult = { kind: "gradient", stops, direction };
148
+ if (rotWithShape !== undefined) result.rotWithShape = rotWithShape;
149
+ return result;
150
+ }
151
+
152
+ function readGradientDirection(grad: XmlElementNode): GradientDirection {
153
+ const lin = findFirstChild(grad, "lin");
154
+ if (lin) {
155
+ const ang = parseInt(lin.attributes.ang ?? "0", 10) || 0;
156
+ const scaledRaw = lin.attributes.scaled;
157
+ const scaled = scaledRaw !== undefined ? scaledRaw !== "0" && scaledRaw !== "false" : undefined;
158
+ const d: GradientDirection = { kind: "linear", angle: ang };
159
+ if (scaled !== undefined) d.scaled = scaled;
160
+ return d;
161
+ }
162
+ const path = findFirstChild(grad, "path");
163
+ if (path) {
164
+ const pathKind = path.attributes.path;
165
+ if (pathKind === "circle" || pathKind === "rect" || pathKind === "shape") {
166
+ return { kind: "path", path: pathKind };
167
+ }
168
+ }
169
+ // Default: OOXML implicit linear at 0°
170
+ return { kind: "linear", angle: 0 };
171
+ }
172
+
173
+ function parsePatternFill(pat: XmlElementNode): PatternFillResult | undefined {
174
+ const preset = pat.attributes.prst;
175
+ if (!preset) return undefined;
176
+ const fgEl = findFirstChild(pat, "fgClr");
177
+ const bgEl = findFirstChild(pat, "bgClr");
178
+ const result: PatternFillResult = { kind: "pattern", preset };
179
+ if (fgEl) {
180
+ const t = extractColorToken(fgEl);
181
+ if (t) result.fg = t;
182
+ }
183
+ if (bgEl) {
184
+ const t = extractColorToken(bgEl);
185
+ if (t) result.bg = t;
186
+ }
187
+ return result;
188
+ }
189
+
190
+ function extractColorToken(parent: XmlElementNode): ColorToken | undefined {
191
+ const srgb = findFirstChild(parent, "srgbClr");
192
+ if (srgb?.attributes.val) {
193
+ return { color: srgb.attributes.val.toUpperCase(), colorType: "srgbClr" };
194
+ }
195
+ const scheme = findFirstChild(parent, "schemeClr");
196
+ if (scheme?.attributes.val) {
197
+ return { color: scheme.attributes.val, colorType: "schemeClr" };
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ function findFirstChild(
203
+ node: XmlElementNode,
204
+ local: string,
205
+ ): XmlElementNode | undefined {
206
+ for (const child of node.children) {
207
+ if (child.type === "element" && localName(child.name) === local) return child;
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ function localName(name: string): string {
213
+ const i = name.indexOf(":");
214
+ return i >= 0 ? name.slice(i + 1) : name;
215
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Parse fontTable.xml into a CanonicalFontTable.
3
+ *
4
+ * Reads every <w:font> child of the root <w:fonts> element and materializes
5
+ * name/family/pitch/charset/altName. panose and sig metadata is not needed
6
+ * by consumers and is skipped.
7
+ *
8
+ * Key insight from LO FontTable.cxx: w:family is qualitative ("roman",
9
+ * "swiss", "modern", "script", "decorative") — do not treat as numeric.
10
+ * w:charset="02" marks Symbol fonts; consumers must handle glyph remapping.
11
+ */
12
+
13
+ import type {
14
+ CanonicalFontEntry,
15
+ CanonicalFontTable,
16
+ } from "../../model/canonical-document.ts";
17
+
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"]);
33
+
34
+ export function parseFontTable(xml: string): CanonicalFontTable {
35
+ const root = parseXml(xml);
36
+ const fontsElement = findChildElementOptional(root, "fonts") ?? root;
37
+ const fonts: Record<string, CanonicalFontEntry> = {};
38
+
39
+ for (const child of fontsElement.children) {
40
+ if (child.type !== "element") continue;
41
+ if (localName(child.name) !== "font") continue;
42
+
43
+ const name = child.attributes["w:name"] ?? child.attributes["name"];
44
+ if (!name) continue;
45
+
46
+ const entry: CanonicalFontEntry = { name };
47
+
48
+ for (const sub of child.children) {
49
+ if (sub.type !== "element") continue;
50
+ switch (localName(sub.name)) {
51
+ case "family": {
52
+ const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
53
+ if (raw && KNOWN_FAMILIES.has(raw)) {
54
+ entry.family = raw as CanonicalFontEntry["family"];
55
+ }
56
+ break;
57
+ }
58
+ case "pitch": {
59
+ const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
60
+ if (raw === "fixed" || raw === "variable" || raw === "default") {
61
+ entry.pitch = raw;
62
+ }
63
+ break;
64
+ }
65
+ case "charset": {
66
+ const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
67
+ if (raw !== undefined) {
68
+ const parsed = Number.parseInt(raw, 16);
69
+ if (Number.isFinite(parsed)) entry.charset = parsed;
70
+ }
71
+ break;
72
+ }
73
+ case "altName": {
74
+ const raw = sub.attributes["w:val"] ?? sub.attributes["val"];
75
+ if (raw) entry.altName = raw;
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ fonts[name] = entry;
82
+ }
83
+
84
+ return { fonts };
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // XML helpers — same shape as parse-numbering.ts internals
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function localName(name: string): string {
92
+ const separator = name.indexOf(":");
93
+ return separator >= 0 ? name.slice(separator + 1) : name;
94
+ }
95
+
96
+ function findChildElementOptional(
97
+ node: XmlElementNode,
98
+ childLocalName: string,
99
+ ): XmlElementNode | undefined {
100
+ return node.children.find(
101
+ (entry): entry is XmlElementNode =>
102
+ entry.type === "element" && localName(entry.name) === childLocalName,
103
+ );
104
+ }
105
+
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
+ }
@@ -2,6 +2,7 @@ import type {
2
2
  BlockNode,
3
3
  FootnoteCollection,
4
4
  FootnoteDefinition,
5
+ FootnoteSeparators,
5
6
  InlineNode,
6
7
  ParagraphIndentation,
7
8
  ParagraphNode,
@@ -114,7 +115,12 @@ export function parseFootnotesXml(xml: string): FootnoteCollection {
114
115
  }
115
116
  }
116
117
 
117
- return { footnotes, endnotes };
118
+ const footnoteSeparators = parseFootnoteSeparators(xml);
119
+ return {
120
+ footnotes,
121
+ endnotes,
122
+ ...(Object.keys(footnoteSeparators).length > 0 ? { footnoteSeparators } : {}),
123
+ };
118
124
  }
119
125
 
120
126
  /**
@@ -152,6 +158,51 @@ export function parseEndnotesXml(
152
158
  };
153
159
  }
154
160
 
161
+ /**
162
+ * Extract separator and continuation-separator run XML from footnotes.xml
163
+ * or endnotes.xml. The special notes with `w:type="separator"` and
164
+ * `w:type="continuationSeparator"` contain horizontal-rule content used
165
+ * by the page-chrome renderer at the footnote divider line.
166
+ */
167
+ export function parseFootnoteSeparators(xml: string): FootnoteSeparators {
168
+ const root = parseXml(xml);
169
+ const footnotesEl = findChildElementOptional(root, "footnotes");
170
+ const endnotesEl = findChildElementOptional(root, "endnotes");
171
+ const containerEl = footnotesEl ?? endnotesEl;
172
+ if (!containerEl) return {};
173
+
174
+ let separatorContent: string | undefined;
175
+ let continuationSeparatorContent: string | undefined;
176
+
177
+ for (const child of containerEl.children) {
178
+ if (child.type !== "element") continue;
179
+ const name = localName(child.name);
180
+ if (name !== "footnote" && name !== "endnote") continue;
181
+
182
+ const rawType = child.attributes["w:type"] ?? child.attributes.type ?? "";
183
+ if (rawType !== "separator" && rawType !== "continuationSeparator") continue;
184
+
185
+ const paraEl = findChildElementOptional(child, "p");
186
+ if (!paraEl) continue;
187
+
188
+ const runXml = paraEl.children
189
+ .filter((c): c is XmlElementNode => c.type === "element" && localName(c.name) === "r")
190
+ .map((r) => serializeElementToXml(r))
191
+ .join("");
192
+
193
+ if (rawType === "separator") {
194
+ separatorContent = runXml;
195
+ } else {
196
+ continuationSeparatorContent = runXml;
197
+ }
198
+ }
199
+
200
+ return {
201
+ ...(separatorContent !== undefined ? { separatorContent } : {}),
202
+ ...(continuationSeparatorContent !== undefined ? { continuationSeparatorContent } : {}),
203
+ };
204
+ }
205
+
155
206
  // ---- Internal helpers ----
156
207
 
157
208
  function parseNoteElement(