@beyondwork/docx-react-component 1.0.55 → 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 +43 -32
  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 +192 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -3
  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
@@ -146,6 +146,117 @@ export function extractBookmarksFromBodyXml(bodyXml: string): ParsedBookmarkNode
146
146
  return results;
147
147
  }
148
148
 
149
+ // ─── FieldGroup stream walker ─────────────────────────────────────────────────
150
+
151
+ /**
152
+ * A fully-parsed fldChar triad: begin → instrText* → separate → display → end.
153
+ * Unlike `ParsedComplexFieldNode`, this captures cross-paragraph triads by
154
+ * walking the body-level run list flattened across all block elements.
155
+ *
156
+ * Fail-closed: an incomplete triad (begin with no end) is silently discarded.
157
+ */
158
+ export interface FieldGroup {
159
+ /** Joined instrText content (trimmed). */
160
+ instruction: string;
161
+ /** Raw XML of all runs between the separate and end fldChar runs. */
162
+ displayContent: string;
163
+ /** Byte offset in sourceXml where the begin run starts. */
164
+ start: number;
165
+ /** Byte offset (exclusive) where the end run ends. */
166
+ end: number;
167
+ }
168
+
169
+ /**
170
+ * Stream-walk all block elements in a body XML string and emit one
171
+ * `FieldGroup` per complete fldChar triad. Handles cross-paragraph
172
+ * triads (e.g., TOC fields that span multiple <w:p> siblings).
173
+ *
174
+ * Fail-closed: a begin with no matching end is silently dropped.
175
+ */
176
+ export function streamWalkFieldGroups(bodyXml: string): FieldGroup[] {
177
+ const root = parseXml(bodyXml);
178
+ const bodyEl =
179
+ findFirstChild(root, "body") ??
180
+ findFirstChild(root, "document") ??
181
+ root;
182
+
183
+ const allRuns: XmlElementNode[] = [];
184
+ collectRunsDeep(bodyEl, allRuns);
185
+
186
+ return extractFieldGroupsFromRuns(allRuns, bodyXml);
187
+ }
188
+
189
+ function collectRunsDeep(node: XmlElementNode, out: XmlElementNode[]): void {
190
+ for (const child of node.children) {
191
+ if (child.type !== "element") continue;
192
+ if (localName(child.name) === "r") {
193
+ out.push(child);
194
+ } else {
195
+ collectRunsDeep(child, out);
196
+ }
197
+ }
198
+ }
199
+
200
+ function extractFieldGroupsFromRuns(
201
+ runs: XmlElementNode[],
202
+ sourceXml: string,
203
+ ): FieldGroup[] {
204
+ type State = "idle" | "in-instr" | "in-content";
205
+ let state: State = "idle";
206
+ let instrParts: string[] = [];
207
+ let fieldStart = -1;
208
+ let contentStart = -1;
209
+ const results: FieldGroup[] = [];
210
+
211
+ for (const run of runs) {
212
+ const fldChar = findFirstChildEl(run, "fldChar");
213
+ const instrText = findFirstChildEl(run, "instrText");
214
+
215
+ if (fldChar) {
216
+ const charType = (
217
+ fldChar.attributes["w:fldCharType"] ??
218
+ fldChar.attributes.fldCharType ??
219
+ ""
220
+ ).toLowerCase();
221
+
222
+ if (charType === "begin") {
223
+ state = "in-instr";
224
+ instrParts = [];
225
+ fieldStart = run.start;
226
+ contentStart = -1;
227
+ } else if (charType === "separate" && state === "in-instr") {
228
+ state = "in-content";
229
+ contentStart = run.end;
230
+ } else if (charType === "end") {
231
+ if (state === "in-content" || state === "in-instr") {
232
+ const displayContent =
233
+ contentStart >= 0 && contentStart <= run.start
234
+ ? sourceXml.slice(contentStart, run.start)
235
+ : "";
236
+ results.push({
237
+ instruction: instrParts.join("").trim(),
238
+ displayContent,
239
+ start: fieldStart,
240
+ end: run.end,
241
+ });
242
+ }
243
+ state = "idle";
244
+ instrParts = [];
245
+ fieldStart = -1;
246
+ contentStart = -1;
247
+ }
248
+ } else if (instrText && state === "in-instr") {
249
+ const text = instrText.children
250
+ .filter((c): c is XmlTextNode => c.type === "text")
251
+ .map((c) => c.text)
252
+ .join("");
253
+ instrParts.push(text);
254
+ }
255
+ }
256
+
257
+ return results;
258
+ }
259
+
149
260
  // ─── Element-level parsers (exported for unit testing) ────────────────────────
150
261
 
151
262
  export function parseFldSimple(
@@ -309,7 +420,7 @@ export function buildFieldRegistry(
309
420
  paragraphIndex = pIdx;
310
421
  if (node.type === "field") {
311
422
  const classification = node.fieldFamily
312
- ? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget }
423
+ ? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget, switches: node.switches }
313
424
  : classifyFieldInstruction(node.instruction);
314
425
  const displayText = flattenFieldText(node.children);
315
426
  const entry: FieldRegistryEntry = {
@@ -321,6 +432,7 @@ export function buildFieldRegistry(
321
432
  displayText,
322
433
  paragraphIndex,
323
434
  refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
435
+ ...(classification.switches ? { switches: classification.switches } : {}),
324
436
  };
325
437
  if (classification.supported) {
326
438
  supported.push(entry);
@@ -338,7 +450,7 @@ export function buildFieldRegistry(
338
450
  paragraphIndex = pIdx;
339
451
  if (node.type === "field") {
340
452
  const classification = node.fieldFamily
341
- ? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget }
453
+ ? { family: node.fieldFamily, supported: isSupportedFieldFamily(node.fieldFamily), target: node.fieldTarget, switches: node.switches }
342
454
  : classifyFieldInstruction(node.instruction);
343
455
  const displayText = flattenFieldText(node.children);
344
456
  const entry: FieldRegistryEntry = {
@@ -350,6 +462,7 @@ export function buildFieldRegistry(
350
462
  displayText,
351
463
  paragraphIndex,
352
464
  refreshStatus: node.refreshStatus ?? (classification.supported ? "stale" : "preserve-only"),
465
+ ...(classification.switches ? { switches: classification.switches } : {}),
353
466
  };
354
467
  if (classification.supported) {
355
468
  supported.push(entry);
@@ -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(