@beyondwork/docx-react-component 1.0.47 → 1.0.49

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