@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.
- package/README.md +16 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +199 -13
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/commands/index.ts +9 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/selection/anchor-conversion.ts +112 -0
- package/src/core/selection/review-anchors.ts +108 -3
- package/src/core/state/text-transaction.ts +103 -7
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +59 -1
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +46 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/chart-style-table.ts +543 -0
- package/src/io/ooxml/chart/color-palette.ts +101 -0
- package/src/io/ooxml/chart/compose-series-color.ts +147 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
- package/src/io/ooxml/chart/parse-series.ts +635 -0
- package/src/io/ooxml/chart/resolve-color.ts +261 -0
- package/src/io/ooxml/chart/types.ts +439 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +90 -2
- package/src/io/ooxml/parse-main-document.ts +156 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +160 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +29 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +330 -0
- package/src/runtime/collab/workflow-shared.ts +247 -0
- package/src/runtime/document-locations.ts +1 -9
- package/src/runtime/document-outline.ts +1 -9
- package/src/runtime/document-runtime.ts +288 -65
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +102 -37
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +48 -2
- package/src/ui/editor-runtime-boundary.ts +42 -1
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
- package/src/ui/unsupported-previews-policy.ts +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
- 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
|
+
}
|