@beyondwork/docx-react-component 1.0.47 → 1.0.48

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +115 -1
  3. package/src/compare/diff-engine.ts +4 -0
  4. package/src/core/commands/add-scope.ts +257 -0
  5. package/src/core/commands/formatting-commands.ts +2 -0
  6. package/src/core/schema/text-schema.ts +95 -1
  7. package/src/core/state/text-transaction.ts +17 -5
  8. package/src/io/chart-preview-resolver.ts +27 -0
  9. package/src/io/docx-session.ts +226 -38
  10. package/src/io/export/serialize-main-document.ts +37 -0
  11. package/src/io/export/serialize-settings.ts +421 -0
  12. package/src/io/export/serialize-styles.ts +10 -0
  13. package/src/io/normalize/normalize-text.ts +1 -0
  14. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  15. package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
  16. package/src/io/ooxml/chart/parse-series.ts +570 -0
  17. package/src/io/ooxml/chart/resolve-color.ts +251 -0
  18. package/src/io/ooxml/chart/types.ts +420 -0
  19. package/src/io/ooxml/parse-block-structure.ts +99 -0
  20. package/src/io/ooxml/parse-complex-content.ts +87 -2
  21. package/src/io/ooxml/parse-main-document.ts +115 -1
  22. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  23. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  24. package/src/io/ooxml/parse-settings.ts +97 -1
  25. package/src/io/ooxml/parse-styles.ts +65 -0
  26. package/src/io/ooxml/parse-theme.ts +2 -127
  27. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  28. package/src/io/ooxml/xml-parser.ts +142 -0
  29. package/src/model/canonical-document.ts +94 -0
  30. package/src/model/scope-markers.ts +144 -0
  31. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  32. package/src/runtime/collab/checkpoint-election.ts +75 -0
  33. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  34. package/src/runtime/collab/checkpoint-store.ts +115 -0
  35. package/src/runtime/collab/event-types.ts +27 -0
  36. package/src/runtime/collab/index.ts +22 -0
  37. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  38. package/src/runtime/collab/runtime-collab-sync.ts +279 -0
  39. package/src/runtime/document-runtime.ts +214 -16
  40. package/src/runtime/editor-surface/capabilities.ts +63 -50
  41. package/src/runtime/layout/layout-engine-version.ts +8 -1
  42. package/src/runtime/prerender/cache-envelope.ts +19 -7
  43. package/src/runtime/prerender/cache-key.ts +25 -14
  44. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  45. package/src/runtime/prerender/customxml-cache.ts +211 -0
  46. package/src/runtime/prerender/customxml-probe.ts +78 -0
  47. package/src/runtime/prerender/prerender-document.ts +74 -7
  48. package/src/runtime/scope-resolver.ts +148 -0
  49. package/src/runtime/scope-tag-registry.ts +10 -0
  50. package/src/runtime/surface-projection.ts +8 -1
  51. package/src/ui/WordReviewEditor.tsx +30 -0
  52. package/src/ui/editor-runtime-boundary.ts +6 -1
  53. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Top-level chart-space parser (Stage 1, bar/column end-to-end).
3
+ *
4
+ * Dispatches `<c:chartSpace>` XML into a `ChartModel` discriminated union.
5
+ * This slice implements the `barChart` / `bar3DChart` branch in full; every
6
+ * other chart family returns an `UnsupportedChartModel` with a specific
7
+ * `reason` (either `not-yet-implemented` for families a subsequent slice
8
+ * will fill in, or a permanent-defer reason like `stock` / `surface`).
9
+ *
10
+ * Design notes:
11
+ *
12
+ * - Every return path preserves the raw input XML on `model.rawXml`. The
13
+ * export path re-emits the original drawing verbatim regardless of
14
+ * renderer coverage, so the round-trip guarantee survives even when a
15
+ * chart family isn't parsed yet.
16
+ * - The outermost try/catch catches unexpected parse failures and returns
17
+ * `{ kind: "unsupported", reason: "parse-error", detail: err.message }`.
18
+ * Callers never observe a thrown exception from parseChartSpace.
19
+ * - 3D variants (bar3DChart) intentionally collapse to the 2D model. Stage
20
+ * 1 does not distinguish 3D from 2D; renderer stages may re-introduce
21
+ * the distinction later if needed.
22
+ */
23
+
24
+ import {
25
+ findChildOptional,
26
+ findFirstDescendant,
27
+ localName,
28
+ readIntVal,
29
+ textContent,
30
+ } from "../xml-attr-helpers.ts";
31
+ import type { XmlElementNode } from "../xml-element.ts";
32
+ import { parseXml } from "../xml-parser.ts";
33
+
34
+ import { parseAxisNode } from "./parse-axis.ts";
35
+ import {
36
+ extractStrCache,
37
+ parseBarSeriesNode,
38
+ parseBubbleSeriesNode,
39
+ parseLineSeriesNode,
40
+ parsePieSeriesNode,
41
+ parseScatterSeriesNode,
42
+ parseSeriesBase,
43
+ parseShapeProperties,
44
+ } from "./parse-series.ts";
45
+ import type {
46
+ AreaChartModel,
47
+ Axis,
48
+ BarChartModel,
49
+ BubbleChartModel,
50
+ BubbleSeries,
51
+ CategoryAxis,
52
+ ChartCommon,
53
+ ChartModel,
54
+ ComboChartModel,
55
+ Legend,
56
+ LineChartModel,
57
+ LineSeries,
58
+ PieChartModel,
59
+ PieSeries,
60
+ ScatterChartModel,
61
+ ScatterSeries,
62
+ Series,
63
+ Title,
64
+ UnsupportedChartModel,
65
+ UnsupportedReason,
66
+ ValueAxis,
67
+ } from "./types.ts";
68
+
69
+ // The set of OOXML chart-type elements we recognise. Everything else under
70
+ // `c:plotArea` is ignored (c:layout, c:dTable, c:valAx, etc.) when scanning
71
+ // for type groups.
72
+ const TYPE_GROUP_LOCAL_NAMES = new Set<string>([
73
+ "barChart",
74
+ "bar3DChart",
75
+ "lineChart",
76
+ "line3DChart",
77
+ "pieChart",
78
+ "pie3DChart",
79
+ "doughnutChart",
80
+ "areaChart",
81
+ "area3DChart",
82
+ "scatterChart",
83
+ "bubbleChart",
84
+ "stockChart",
85
+ "radarChart",
86
+ "surfaceChart",
87
+ "surface3DChart",
88
+ "ofPieChart",
89
+ ]);
90
+
91
+ const AXIS_LOCAL_NAMES = new Set<string>(["catAx", "valAx", "dateAx", "serAx"]);
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Entry point
95
+ // ---------------------------------------------------------------------------
96
+
97
+ export function parseChartSpace(rawXml: string): ChartModel {
98
+ try {
99
+ const root = parseXml(rawXml);
100
+ const chartSpace = findFirstDescendant(root, "chartSpace");
101
+ if (!chartSpace) {
102
+ return makeUnsupported("no-plot-area", rawXml, "No <c:chartSpace> root");
103
+ }
104
+ const chart = findChildOptional(chartSpace, "chart");
105
+ if (!chart) {
106
+ return makeUnsupported("no-plot-area", rawXml, "No <c:chart> child");
107
+ }
108
+ const plotArea = findChildOptional(chart, "plotArea");
109
+ if (!plotArea) {
110
+ return makeUnsupported("no-plot-area", rawXml, "No <c:plotArea> child");
111
+ }
112
+
113
+ const typeGroups = plotArea.children.filter(
114
+ (c): c is XmlElementNode =>
115
+ c.type === "element" && TYPE_GROUP_LOCAL_NAMES.has(localName(c.name)),
116
+ );
117
+ if (typeGroups.length === 0) {
118
+ return makeUnsupported("no-plot-area", rawXml, "No chart-type group in plotArea");
119
+ }
120
+
121
+ const common = buildChartCommon(chart, rawXml);
122
+
123
+ const axes = parsePlotAreaAxes(plotArea);
124
+
125
+ if (typeGroups.length > 1) {
126
+ return parseComboPlotArea(typeGroups, axes, common, rawXml);
127
+ }
128
+
129
+ const group = typeGroups[0]!;
130
+ const groupLocal = localName(group.name);
131
+
132
+ switch (groupLocal) {
133
+ case "barChart":
134
+ case "bar3DChart":
135
+ return parseBarGroup(group, axes, common, rawXml);
136
+
137
+ case "lineChart":
138
+ case "line3DChart":
139
+ return parseLineGroup(group, axes, common);
140
+
141
+ case "areaChart":
142
+ case "area3DChart":
143
+ return parseAreaGroup(group, axes, common);
144
+
145
+ case "pieChart":
146
+ case "pie3DChart":
147
+ case "doughnutChart":
148
+ return parsePieGroup(group, common, groupLocal === "doughnutChart");
149
+
150
+ case "scatterChart":
151
+ return parseScatterGroup(group, axes, common);
152
+
153
+ case "bubbleChart":
154
+ return parseBubbleGroup(group, axes, common);
155
+
156
+ case "radarChart":
157
+ return makeUnsupported(
158
+ "not-yet-implemented",
159
+ rawXml,
160
+ `Chart family ${groupLocal} not yet implemented`,
161
+ common,
162
+ );
163
+
164
+ case "stockChart":
165
+ return makeUnsupported("stock", rawXml, "Stock chart not supported", common);
166
+
167
+ case "surfaceChart":
168
+ case "surface3DChart":
169
+ return makeUnsupported("surface", rawXml, "Surface chart not supported", common);
170
+
171
+ case "ofPieChart":
172
+ // Pie-of-pie / bar-of-pie — deferred with pie family.
173
+ return makeUnsupported(
174
+ "not-yet-implemented",
175
+ rawXml,
176
+ "ofPieChart not yet implemented",
177
+ common,
178
+ );
179
+
180
+ default:
181
+ return makeUnsupported(
182
+ "parse-error",
183
+ rawXml,
184
+ `Unrecognised chart-type element ${groupLocal}`,
185
+ common,
186
+ );
187
+ }
188
+ } catch (err) {
189
+ const message = err instanceof Error ? err.message : String(err);
190
+ return makeUnsupported("parse-error", rawXml, message);
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Chart-common fields (title, legend, plotVisOnly, dispBlanksAs, styleId)
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function buildChartCommon(
199
+ chart: XmlElementNode,
200
+ rawXml: string,
201
+ ): ChartCommon {
202
+ const plotVisOnlyNode = findChildOptional(chart, "plotVisOnly");
203
+ const plotVisOnly =
204
+ plotVisOnlyNode === undefined
205
+ ? true
206
+ : plotVisOnlyNode.attributes["val"] !== "0";
207
+
208
+ const dispBlanksRaw = findChildOptional(chart, "dispBlanksAs")?.attributes["val"];
209
+ const dispBlanksAs: ChartCommon["dispBlanksAs"] =
210
+ dispBlanksRaw === "zero" || dispBlanksRaw === "span"
211
+ ? dispBlanksRaw
212
+ : "gap";
213
+
214
+ const styleNode = findChildOptional(chart, "style");
215
+ const styleId = styleNode ? readIntVal(styleNode) : undefined;
216
+
217
+ const title = parseTitle(findChildOptional(chart, "title"));
218
+ const legend = parseLegend(findChildOptional(chart, "legend"));
219
+
220
+ const common: ChartCommon = {
221
+ plotVisOnly,
222
+ dispBlanksAs,
223
+ rawXml,
224
+ };
225
+ if (title) common.title = title;
226
+ if (legend) common.legend = legend;
227
+ if (styleId !== undefined) common.styleId = styleId;
228
+ return common;
229
+ }
230
+
231
+ function parseTitle(titleNode: XmlElementNode | undefined): Title | undefined {
232
+ if (!titleNode) return undefined;
233
+ let text: string | undefined;
234
+ const tx = findChildOptional(titleNode, "tx");
235
+ if (tx) {
236
+ const rich = findChildOptional(tx, "rich");
237
+ if (rich) {
238
+ const parts: string[] = [];
239
+ const walk = (children: XmlElementNode["children"]): void => {
240
+ for (const c of children) {
241
+ if (c.type !== "element") continue;
242
+ if (localName(c.name) === "t") parts.push(textContent(c));
243
+ else walk(c.children);
244
+ }
245
+ };
246
+ walk(rich.children);
247
+ text = parts.join("");
248
+ } else {
249
+ const v = findChildOptional(tx, "v");
250
+ if (v) text = textContent(v);
251
+ }
252
+ }
253
+ const overlay = findChildOptional(titleNode, "overlay")?.attributes["val"] === "1";
254
+ const title: Title = { overlay };
255
+ if (text) title.text = text;
256
+ return title;
257
+ }
258
+
259
+ function parseLegend(legendNode: XmlElementNode | undefined): Legend | undefined {
260
+ if (!legendNode) return undefined;
261
+ const posRaw = findChildOptional(legendNode, "legendPos")?.attributes["val"] ?? "r";
262
+ const position: Legend["position"] =
263
+ posRaw === "b" || posRaw === "t" || posRaw === "l" || posRaw === "r" || posRaw === "tr"
264
+ ? posRaw
265
+ : "r";
266
+ const overlay = findChildOptional(legendNode, "overlay")?.attributes["val"] === "1";
267
+ return { position, overlay };
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Axes
272
+ // ---------------------------------------------------------------------------
273
+
274
+ function parsePlotAreaAxes(plotArea: XmlElementNode): Map<string, Axis> {
275
+ const out = new Map<string, Axis>();
276
+ for (const child of plotArea.children) {
277
+ if (child.type !== "element") continue;
278
+ if (!AXIS_LOCAL_NAMES.has(localName(child.name))) continue;
279
+ const axis = parseAxisNode(child);
280
+ if (axis.id) out.set(axis.id, axis);
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /**
286
+ * Find the primary category and value axes that a type group binds to. The
287
+ * group declares its binding via two or more `<c:axId val="…"/>` children
288
+ * (typically exactly two: category axis id + value axis id). Shared by
289
+ * bar/line/area since they all have the same category-axis + value-axis
290
+ * topology.
291
+ */
292
+ function resolveCategoryValueAxes(
293
+ groupNode: XmlElementNode,
294
+ axes: Map<string, Axis>,
295
+ ): { categoryAxis: CategoryAxis; valueAxis: ValueAxis } {
296
+ const ids: string[] = [];
297
+ for (const child of groupNode.children) {
298
+ if (child.type !== "element") continue;
299
+ if (localName(child.name) !== "axId") continue;
300
+ const id = child.attributes["val"];
301
+ if (id) ids.push(id);
302
+ }
303
+
304
+ let categoryAxis: CategoryAxis | undefined;
305
+ let valueAxis: ValueAxis | undefined;
306
+ for (const id of ids) {
307
+ const ax = axes.get(id);
308
+ if (!ax) continue;
309
+ if (ax.kind === "category") categoryAxis = ax;
310
+ // Date axes can bind where a category axis would go; treat as category.
311
+ else if (ax.kind === "date" && !categoryAxis) {
312
+ categoryAxis = dateAxisToCategory(ax);
313
+ } else if (ax.kind === "value" && !valueAxis) valueAxis = ax;
314
+ }
315
+
316
+ if (!categoryAxis) categoryAxis = placeholderCategoryAxis();
317
+ if (!valueAxis) valueAxis = placeholderValueAxis();
318
+ return { categoryAxis, valueAxis };
319
+ }
320
+
321
+ /** Narrow a date axis into a CategoryAxis shape so bar/line/area models
322
+ * can expose a uniform `categoryAxis` field. Date-specific fields are
323
+ * dropped here; Stage 2/3 can preserve them via a dedicated route. */
324
+ function dateAxisToCategory(ax: Axis & { kind: "date" }): CategoryAxis {
325
+ return {
326
+ kind: "category",
327
+ id: ax.id,
328
+ position: ax.position,
329
+ visible: ax.visible,
330
+ auto: true,
331
+ categoryLabels: [],
332
+ ...(ax.crossAxisId !== undefined ? { crossAxisId: ax.crossAxisId } : {}),
333
+ ...(ax.crossesAt !== undefined ? { crossesAt: ax.crossesAt } : {}),
334
+ ...(ax.majorGridlines !== undefined ? { majorGridlines: ax.majorGridlines } : {}),
335
+ ...(ax.minorGridlines !== undefined ? { minorGridlines: ax.minorGridlines } : {}),
336
+ ...(ax.numberFormat !== undefined ? { numberFormat: ax.numberFormat } : {}),
337
+ };
338
+ }
339
+
340
+ function placeholderCategoryAxis(): CategoryAxis {
341
+ return {
342
+ kind: "category",
343
+ id: "",
344
+ position: "b",
345
+ visible: true,
346
+ auto: true,
347
+ categoryLabels: [],
348
+ };
349
+ }
350
+
351
+ function placeholderValueAxis(): ValueAxis {
352
+ return {
353
+ kind: "value",
354
+ id: "",
355
+ position: "l",
356
+ visible: true,
357
+ reverse: false,
358
+ };
359
+ }
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Bar / column
363
+ // ---------------------------------------------------------------------------
364
+
365
+ function parseBarGroup(
366
+ groupNode: XmlElementNode,
367
+ axes: Map<string, Axis>,
368
+ common: ChartCommon,
369
+ rawXml: string,
370
+ ): BarChartModel {
371
+ void rawXml; // rawXml is already carried on common
372
+ const dirRaw = findChildOptional(groupNode, "barDir")?.attributes["val"] ?? "col";
373
+ const direction: BarChartModel["direction"] = dirRaw === "bar" ? "bar" : "column";
374
+
375
+ const groupingRaw = findChildOptional(groupNode, "grouping")?.attributes["val"];
376
+ const grouping: BarChartModel["grouping"] =
377
+ groupingRaw === "stacked" ||
378
+ groupingRaw === "percentStacked" ||
379
+ groupingRaw === "standard"
380
+ ? groupingRaw
381
+ : "clustered";
382
+
383
+ const gapWidth = readIntVal(findChildOptional(groupNode, "gapWidth")) ?? 150;
384
+ const overlap = readIntVal(findChildOptional(groupNode, "overlap")) ?? 0;
385
+
386
+ const series: Series[] = [];
387
+ for (const child of groupNode.children) {
388
+ if (child.type !== "element") continue;
389
+ if (localName(child.name) !== "ser") continue;
390
+ series.push(parseBarSeriesNode(child));
391
+ }
392
+
393
+ const { categoryAxis, valueAxis } = resolveCategoryValueAxes(groupNode, axes);
394
+
395
+ // Promote category labels from the first series onto the axis if they
396
+ // aren't already populated (typical for Word-authored charts).
397
+ if (categoryAxis.categoryLabels.length === 0 && series.length > 0) {
398
+ categoryAxis.categoryLabels = series[0]!.categories;
399
+ }
400
+
401
+ // spPr on the group itself (rare, but legal — e.g. global series fill
402
+ // override) is not part of BarChartModel today; ignored.
403
+ void parseShapeProperties;
404
+
405
+ const model: BarChartModel = {
406
+ ...common,
407
+ kind: "bar",
408
+ direction,
409
+ grouping,
410
+ gapWidth,
411
+ overlap,
412
+ series,
413
+ categoryAxis,
414
+ valueAxis,
415
+ };
416
+ return model;
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Line
421
+ // ---------------------------------------------------------------------------
422
+
423
+ function parseLineGroup(
424
+ groupNode: XmlElementNode,
425
+ axes: Map<string, Axis>,
426
+ common: ChartCommon,
427
+ ): LineChartModel {
428
+ const groupingRaw = findChildOptional(groupNode, "grouping")?.attributes["val"];
429
+ const grouping: LineChartModel["grouping"] =
430
+ groupingRaw === "stacked" || groupingRaw === "percentStacked"
431
+ ? groupingRaw
432
+ : "standard";
433
+
434
+ // c:smooth / c:marker at the GROUP level are booleans (val="0" | "1").
435
+ // Per-series <c:smooth>/<c:marker> under each <c:ser> override.
436
+ const smooth = readGroupBoolean(groupNode, "smooth");
437
+ const marker = readGroupBoolean(groupNode, "marker");
438
+
439
+ const series: LineSeries[] = [];
440
+ for (const child of groupNode.children) {
441
+ if (child.type !== "element") continue;
442
+ if (localName(child.name) !== "ser") continue;
443
+ series.push(parseLineSeriesNode(child));
444
+ }
445
+
446
+ const { categoryAxis, valueAxis } = resolveCategoryValueAxes(groupNode, axes);
447
+ if (categoryAxis.categoryLabels.length === 0 && series.length > 0) {
448
+ categoryAxis.categoryLabels = series[0]!.categories;
449
+ }
450
+
451
+ return {
452
+ ...common,
453
+ kind: "line",
454
+ grouping,
455
+ smooth,
456
+ marker,
457
+ series,
458
+ categoryAxis,
459
+ valueAxis,
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Read a boolean flag from a group-level child element with a `val`
465
+ * attribute. Unset → false (matching Word's default for lineChart
466
+ * c:smooth and c:marker).
467
+ */
468
+ function readGroupBoolean(groupNode: XmlElementNode, local: string): boolean {
469
+ const node = findChildOptional(groupNode, local);
470
+ if (!node) return false;
471
+ const raw = node.attributes["val"];
472
+ if (raw === undefined) return true; // bare <c:smooth/> treated as true
473
+ return raw !== "0" && raw.toLowerCase() !== "false";
474
+ }
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // Pie / Doughnut
478
+ // ---------------------------------------------------------------------------
479
+
480
+ /** OOXML's angle unit is 1/60000 of a degree (sixtyThousandths). */
481
+ const OOXML_ANGLE_UNIT = 60000;
482
+
483
+ function parsePieGroup(
484
+ groupNode: XmlElementNode,
485
+ common: ChartCommon,
486
+ doughnut: boolean,
487
+ ): PieChartModel {
488
+ // Pie's varyColors default is true (unlike bar/line/area where it's
489
+ // false). When the flag is absent we assume the Word default.
490
+ const varyColorsRaw = findChildOptional(groupNode, "varyColors")?.attributes["val"];
491
+ const varyColors =
492
+ varyColorsRaw === undefined
493
+ ? true
494
+ : varyColorsRaw !== "0" && varyColorsRaw.toLowerCase() !== "false";
495
+
496
+ // c:firstSliceAngle is in 1/60000 of a degree, clockwise from 12 o'clock.
497
+ // ECMA-376 defines 0–360 range; we perform the unit conversion and leave
498
+ // clamping to the renderer so out-of-range values stay recoverable.
499
+ const firstSliceAngleRaw = readIntVal(findChildOptional(groupNode, "firstSliceAngle"));
500
+ const firstSliceAngle =
501
+ firstSliceAngleRaw !== undefined ? firstSliceAngleRaw / OOXML_ANGLE_UNIT : 0;
502
+
503
+ // Doughnut hole size is a percent (0–99 typical). Missing on pieChart.
504
+ const holeSizePercent = doughnut
505
+ ? readIntVal(findChildOptional(groupNode, "holeSize"))
506
+ : undefined;
507
+
508
+ // Collect series — pieChart typically has exactly one, but ECMA-376
509
+ // technically allows more (pie-of-pie uses 2 but that's the ofPieChart
510
+ // element, not pieChart; remaining scope is deferred).
511
+ const series: PieSeries[] = [];
512
+ let categoryLabels: string[] = [];
513
+ for (const child of groupNode.children) {
514
+ if (child.type !== "element" || localName(child.name) !== "ser") continue;
515
+ const pieSeries = parsePieSeriesNode(child);
516
+ series.push(pieSeries);
517
+ // Category labels come from the first series' c:cat cache.
518
+ if (categoryLabels.length === 0) {
519
+ const cat = findChildOptional(child, "cat");
520
+ categoryLabels = extractCategoryLabels(cat);
521
+ }
522
+ }
523
+
524
+ const model: PieChartModel = {
525
+ ...common,
526
+ kind: "pie",
527
+ doughnut,
528
+ firstSliceAngle,
529
+ varyColors,
530
+ series,
531
+ categoryLabels,
532
+ };
533
+ if (holeSizePercent !== undefined) model.holeSizePercent = holeSizePercent;
534
+ return model;
535
+ }
536
+
537
+ /**
538
+ * Extract category labels from a `<c:cat>` container. Reuses the sparse-
539
+ * point string-cache extractor from parse-series.ts so the algorithm has a
540
+ * single source of truth.
541
+ */
542
+ function extractCategoryLabels(
543
+ catNode: XmlElementNode | undefined,
544
+ ): string[] {
545
+ if (!catNode) return [];
546
+ return extractStrCache(catNode);
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // Area
551
+ // ---------------------------------------------------------------------------
552
+
553
+ function parseAreaGroup(
554
+ groupNode: XmlElementNode,
555
+ axes: Map<string, Axis>,
556
+ common: ChartCommon,
557
+ ): AreaChartModel {
558
+ const groupingRaw = findChildOptional(groupNode, "grouping")?.attributes["val"];
559
+ const grouping: AreaChartModel["grouping"] =
560
+ groupingRaw === "stacked" || groupingRaw === "percentStacked"
561
+ ? groupingRaw
562
+ : "standard";
563
+
564
+ const series: Series[] = [];
565
+ for (const child of groupNode.children) {
566
+ if (child.type !== "element") continue;
567
+ if (localName(child.name) !== "ser") continue;
568
+ series.push(parseSeriesBase(child));
569
+ }
570
+
571
+ const { categoryAxis, valueAxis } = resolveCategoryValueAxes(groupNode, axes);
572
+ if (categoryAxis.categoryLabels.length === 0 && series.length > 0) {
573
+ categoryAxis.categoryLabels = series[0]!.categories;
574
+ }
575
+
576
+ return {
577
+ ...common,
578
+ kind: "area",
579
+ grouping,
580
+ series,
581
+ categoryAxis,
582
+ valueAxis,
583
+ };
584
+ }
585
+
586
+ // ---------------------------------------------------------------------------
587
+ // Combo (multi-group plot area)
588
+ // ---------------------------------------------------------------------------
589
+
590
+ /**
591
+ * The cartesian chart families that can compose into a combo: bar, line,
592
+ * area. Pie/doughnut/scatter/bubble don't share the category+value axis
593
+ * topology, so a plot area that mixes them falls back to unsupported.
594
+ */
595
+ const COMBO_SUPPORTED_LOCAL_NAMES = new Set<string>([
596
+ "barChart",
597
+ "bar3DChart",
598
+ "lineChart",
599
+ "line3DChart",
600
+ "areaChart",
601
+ "area3DChart",
602
+ ]);
603
+
604
+ function parseComboPlotArea(
605
+ typeGroups: XmlElementNode[],
606
+ axes: Map<string, Axis>,
607
+ common: ChartCommon,
608
+ rawXml: string,
609
+ ): ChartModel {
610
+ // Reject combos that contain non-cartesian families; a bar + pie plot
611
+ // area is not a combo we can reason about with the current ComboChart
612
+ // model (groups array only accepts bar/line/area).
613
+ for (const g of typeGroups) {
614
+ if (!COMBO_SUPPORTED_LOCAL_NAMES.has(localName(g.name))) {
615
+ return makeUnsupported(
616
+ "not-yet-implemented",
617
+ rawXml,
618
+ `Combo with ${localName(g.name)} — only bar/line/area combos supported`,
619
+ common,
620
+ );
621
+ }
622
+ }
623
+
624
+ // Track which value axes are bound across the combo. A combo is "dual-
625
+ // axis" when a group references a value axis distinct from the one the
626
+ // first group uses.
627
+ let primaryValueAxisId: string | undefined;
628
+ let hasSecondaryAxis = false;
629
+ const groups: ComboChartModel["groups"] = [];
630
+
631
+ for (const group of typeGroups) {
632
+ const groupLocal = localName(group.name);
633
+ let model: BarChartModel | LineChartModel | AreaChartModel;
634
+ switch (groupLocal) {
635
+ case "barChart":
636
+ case "bar3DChart":
637
+ model = parseBarGroup(group, axes, common, rawXml);
638
+ break;
639
+ case "lineChart":
640
+ case "line3DChart":
641
+ model = parseLineGroup(group, axes, common);
642
+ break;
643
+ case "areaChart":
644
+ case "area3DChart":
645
+ model = parseAreaGroup(group, axes, common);
646
+ break;
647
+ default:
648
+ // Should be unreachable — gate above filters non-cartesian.
649
+ return makeUnsupported(
650
+ "parse-error",
651
+ rawXml,
652
+ `Combo: unexpected group ${groupLocal}`,
653
+ common,
654
+ );
655
+ }
656
+ groups.push(model);
657
+
658
+ const thisGroupValueAxisId = model.valueAxis.id;
659
+ if (!primaryValueAxisId) {
660
+ primaryValueAxisId = thisGroupValueAxisId;
661
+ } else if (
662
+ thisGroupValueAxisId &&
663
+ thisGroupValueAxisId !== primaryValueAxisId
664
+ ) {
665
+ hasSecondaryAxis = true;
666
+ }
667
+ }
668
+
669
+ return {
670
+ ...common,
671
+ kind: "combo",
672
+ groups,
673
+ hasSecondaryAxis,
674
+ };
675
+ }
676
+
677
+ // ---------------------------------------------------------------------------
678
+ // Scatter
679
+ // ---------------------------------------------------------------------------
680
+
681
+ const SCATTER_STYLES: ReadonlySet<ScatterChartModel["style"]> = new Set<
682
+ ScatterChartModel["style"]
683
+ >(["line", "lineMarker", "marker", "smooth", "smoothMarker"]);
684
+
685
+ function parseScatterGroup(
686
+ groupNode: XmlElementNode,
687
+ axes: Map<string, Axis>,
688
+ common: ChartCommon,
689
+ ): ScatterChartModel {
690
+ const styleRaw = findChildOptional(groupNode, "scatterStyle")?.attributes["val"];
691
+ const style: ScatterChartModel["style"] =
692
+ styleRaw && SCATTER_STYLES.has(styleRaw as ScatterChartModel["style"])
693
+ ? (styleRaw as ScatterChartModel["style"])
694
+ : "marker";
695
+
696
+ const series: ScatterSeries[] = [];
697
+ for (const child of groupNode.children) {
698
+ if (child.type !== "element" || localName(child.name) !== "ser") continue;
699
+ series.push(parseScatterSeriesNode(child));
700
+ }
701
+
702
+ const { xAxis, yAxis } = resolveXYValueAxes(groupNode, axes);
703
+
704
+ return {
705
+ ...common,
706
+ kind: "scatter",
707
+ style,
708
+ series,
709
+ xAxis,
710
+ yAxis,
711
+ };
712
+ }
713
+
714
+ // ---------------------------------------------------------------------------
715
+ // Bubble
716
+ // ---------------------------------------------------------------------------
717
+
718
+ function parseBubbleGroup(
719
+ groupNode: XmlElementNode,
720
+ axes: Map<string, Axis>,
721
+ common: ChartCommon,
722
+ ): BubbleChartModel {
723
+ // c:bubble3D is a group-level flag (distinct from c:dPt/c:bubble3D).
724
+ // We render as 2D regardless, but capture the flag for agent parity.
725
+ const bubble3DRaw = findChildOptional(groupNode, "bubble3D")?.attributes["val"];
726
+ const bubble3D = bubble3DRaw === "1" || bubble3DRaw?.toLowerCase() === "true";
727
+
728
+ const series: BubbleSeries[] = [];
729
+ for (const child of groupNode.children) {
730
+ if (child.type !== "element" || localName(child.name) !== "ser") continue;
731
+ series.push(parseBubbleSeriesNode(child));
732
+ }
733
+
734
+ const { xAxis, yAxis } = resolveXYValueAxes(groupNode, axes);
735
+
736
+ return {
737
+ ...common,
738
+ kind: "bubble",
739
+ bubble3D,
740
+ series,
741
+ xAxis,
742
+ yAxis,
743
+ };
744
+ }
745
+
746
+ /**
747
+ * Scatter and bubble charts bind to two VALUE axes (not category + value
748
+ * like bar/line/area). The group still declares its binding via two c:axId
749
+ * children; we resolve each to a ValueAxis, falling back to a placeholder
750
+ * if the axis wasn't found or wasn't a value axis.
751
+ *
752
+ * Axis placement (which is x vs y) is determined by position attribute:
753
+ * "b" or "t" → x-axis (horizontal), "l" or "r" → y-axis (vertical).
754
+ * If both axes share the same orientation the first-declared gets x.
755
+ */
756
+ function resolveXYValueAxes(
757
+ groupNode: XmlElementNode,
758
+ axes: Map<string, Axis>,
759
+ ): { xAxis: ValueAxis; yAxis: ValueAxis } {
760
+ const ids: string[] = [];
761
+ for (const child of groupNode.children) {
762
+ if (child.type !== "element") continue;
763
+ if (localName(child.name) !== "axId") continue;
764
+ const id = child.attributes["val"];
765
+ if (id) ids.push(id);
766
+ }
767
+
768
+ let xAxis: ValueAxis | undefined;
769
+ let yAxis: ValueAxis | undefined;
770
+ for (const id of ids) {
771
+ const ax = axes.get(id);
772
+ if (!ax || ax.kind !== "value") continue;
773
+ if (ax.position === "b" || ax.position === "t") {
774
+ if (!xAxis) xAxis = ax;
775
+ else if (!yAxis) yAxis = ax;
776
+ } else {
777
+ if (!yAxis) yAxis = ax;
778
+ else if (!xAxis) xAxis = ax;
779
+ }
780
+ }
781
+
782
+ if (!xAxis) xAxis = placeholderValueAxis();
783
+ if (!yAxis) yAxis = placeholderValueAxis();
784
+ // Normalise the placeholder positions so the returned pair is visually
785
+ // distinguishable (b for x, l for y) when both were placeholders.
786
+ if (xAxis.position !== "b" && xAxis.position !== "t") xAxis = { ...xAxis, position: "b" };
787
+ if (yAxis.position !== "l" && yAxis.position !== "r") yAxis = { ...yAxis, position: "l" };
788
+ return { xAxis, yAxis };
789
+ }
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // Unsupported
793
+ // ---------------------------------------------------------------------------
794
+
795
+ function makeUnsupported(
796
+ reason: UnsupportedReason,
797
+ rawXml: string,
798
+ detail: string,
799
+ common?: ChartCommon,
800
+ ): UnsupportedChartModel {
801
+ const base: ChartCommon = common ?? {
802
+ plotVisOnly: true,
803
+ dispBlanksAs: "gap",
804
+ rawXml,
805
+ };
806
+ return {
807
+ ...base,
808
+ rawXml, // always keep the original regardless of common.rawXml
809
+ kind: "unsupported",
810
+ reason,
811
+ detail,
812
+ };
813
+ }