@beyondwork/docx-react-component 1.0.48 → 1.0.50
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 +19 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +103 -12
- package/src/core/commands/index.ts +30 -1
- package/src/core/commands/text-commands.ts +3 -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 +86 -2
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +32 -1
- package/src/io/export/serialize-comments.ts +50 -5
- package/src/io/export/serialize-main-document.ts +9 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- 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-chart-space.ts +118 -46
- package/src/io/ooxml/chart/parse-series.ts +76 -11
- package/src/io/ooxml/chart/resolve-color.ts +16 -6
- package/src/io/ooxml/chart/types.ts +30 -11
- package/src/io/ooxml/parse-complex-content.ts +6 -3
- package/src/io/ooxml/parse-main-document.ts +41 -0
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/paste/word-clipboard.ts +114 -0
- package/src/model/canonical-document.ts +69 -3
- package/src/runtime/collab/index.ts +7 -0
- package/src/runtime/collab/runtime-collab-sync.ts +51 -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 +98 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +11 -1
- package/src/runtime/layout/public-facet.ts +5 -12
- package/src/runtime/render/render-frame-types.ts +14 -0
- package/src/runtime/render/render-kernel.ts +40 -2
- package/src/runtime/structure-ops/fragment-insert.ts +134 -0
- package/src/runtime/surface-projection.ts +94 -36
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +22 -4
- package/src/ui/editor-runtime-boundary.ts +37 -0
- package/src/ui/headless/selection-helpers.ts +10 -23
- 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/editor-surface/pm-command-bridge.ts +60 -5
- 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,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose the final sRGB color for a series in a parsed `ChartModel`.
|
|
3
|
+
*
|
|
4
|
+
* This is the Stage-2 call site that stitches together the three pieces
|
|
5
|
+
* the Stage-2 slices shipped:
|
|
6
|
+
*
|
|
7
|
+
* explicit `c:ser/spPr/fill.color` override (`parse-series.ts`)
|
|
8
|
+
* ↓ (fallback when no override)
|
|
9
|
+
* `paletteColorRef(style.seriesColorMode, seriesIdx)` (`color-palette.ts`)
|
|
10
|
+
* ↓
|
|
11
|
+
* `resolveColor(ref, theme)` (`resolve-color.ts`)
|
|
12
|
+
* ↓
|
|
13
|
+
* `#RRGGBB`
|
|
14
|
+
*
|
|
15
|
+
* Stage 3 + 4 renderers consume `composeSeriesColor` rather than reaching
|
|
16
|
+
* into the three helpers individually. Keeping the composition in one
|
|
17
|
+
* place guarantees the cascade order stays correct and gives us a stable
|
|
18
|
+
* unit-test surface.
|
|
19
|
+
*
|
|
20
|
+
* `deriveResolvedColors(model, theme)` pre-computes the full series list
|
|
21
|
+
* so renderers can render without touching the chart-style table in hot
|
|
22
|
+
* paths.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { getChartStyle, resolveChartStyleId } from "./chart-style-table.ts";
|
|
26
|
+
import { paletteColorRef } from "./color-palette.ts";
|
|
27
|
+
import { resolveColor } from "./resolve-color.ts";
|
|
28
|
+
import type { ChartModel, ColorRef } from "./types.ts";
|
|
29
|
+
import type { ResolvedTheme } from "../../../model/canonical-document.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Compose a single series's final sRGB color.
|
|
33
|
+
*
|
|
34
|
+
* - `model` may be any ChartModel variant. For the `unsupported` /
|
|
35
|
+
* `combo` variants the seriesIdx refers to the top-level series list
|
|
36
|
+
* (bar/line/area/pie/scatter/bubble) flattened in declaration order.
|
|
37
|
+
* Combo callers should pass the sub-group's model directly for the
|
|
38
|
+
* cleanest result.
|
|
39
|
+
* - `seriesIdx` is the index into the model's `series` array (or
|
|
40
|
+
* `categoryLabels` for pie when `varyColors=true`).
|
|
41
|
+
* - `theme` is the resolved workbook theme; pass `{ colors: {} }` for
|
|
42
|
+
* tests that want fallback-only behavior.
|
|
43
|
+
*
|
|
44
|
+
* Returns an always-valid `#RRGGBB` string. Malformed inputs fall through
|
|
45
|
+
* to the resolver's fallback color (#808080).
|
|
46
|
+
*/
|
|
47
|
+
export function composeSeriesColor(
|
|
48
|
+
model: ChartModel,
|
|
49
|
+
theme: ResolvedTheme,
|
|
50
|
+
seriesIdx: number,
|
|
51
|
+
): string {
|
|
52
|
+
// 1. Explicit per-series override takes priority.
|
|
53
|
+
const override = readSeriesOverrideColor(model, seriesIdx);
|
|
54
|
+
if (override) return resolveColor(override, theme);
|
|
55
|
+
|
|
56
|
+
// 2. Fall through to the chart-style's palette.
|
|
57
|
+
const style = getChartStyle(resolveChartStyleId(model.styleId));
|
|
58
|
+
const ref = paletteColorRef(style.seriesColorMode, seriesIdx);
|
|
59
|
+
return resolveColor(ref, theme);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pre-compute the resolved sRGB color for every series in `model`.
|
|
64
|
+
* Returns an empty array for `unsupported` charts. For pie/doughnut the
|
|
65
|
+
* array length matches the number of slices (categoryLabels), not the
|
|
66
|
+
* number of series (pie has exactly one series).
|
|
67
|
+
*/
|
|
68
|
+
export function deriveResolvedColors(
|
|
69
|
+
model: ChartModel,
|
|
70
|
+
theme: ResolvedTheme,
|
|
71
|
+
): string[] {
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
const n = countPaletteSlots(model);
|
|
74
|
+
for (let i = 0; i < n; i++) {
|
|
75
|
+
out.push(composeSeriesColor(model, theme, i));
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Internals
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How many distinct colors the renderer needs. For pie charts with
|
|
86
|
+
* varyColors (the default), we need one color per slice; for all other
|
|
87
|
+
* families, one per series.
|
|
88
|
+
*/
|
|
89
|
+
function countPaletteSlots(model: ChartModel): number {
|
|
90
|
+
switch (model.kind) {
|
|
91
|
+
case "pie":
|
|
92
|
+
return model.varyColors && model.series.length > 0
|
|
93
|
+
? model.series[0]!.values.length
|
|
94
|
+
: model.series.length;
|
|
95
|
+
case "bar":
|
|
96
|
+
case "line":
|
|
97
|
+
case "area":
|
|
98
|
+
case "scatter":
|
|
99
|
+
case "bubble":
|
|
100
|
+
return model.series.length;
|
|
101
|
+
case "combo": {
|
|
102
|
+
let total = 0;
|
|
103
|
+
for (const group of model.groups) total += group.series.length;
|
|
104
|
+
return total;
|
|
105
|
+
}
|
|
106
|
+
case "unsupported":
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read an explicit per-series fill color override. Pie-only: also checks
|
|
113
|
+
* per-slice dataPoint overrides when `seriesIdx` is a slice index.
|
|
114
|
+
*/
|
|
115
|
+
function readSeriesOverrideColor(
|
|
116
|
+
model: ChartModel,
|
|
117
|
+
seriesIdx: number,
|
|
118
|
+
): ColorRef | undefined {
|
|
119
|
+
if (model.kind === "unsupported" || model.kind === "combo") return undefined;
|
|
120
|
+
|
|
121
|
+
// Pie: per-slice dPt.spPr.fill takes priority.
|
|
122
|
+
if (model.kind === "pie") {
|
|
123
|
+
const pieSeries = model.series[0];
|
|
124
|
+
if (!pieSeries) return undefined;
|
|
125
|
+
const dp = pieSeries.dataPoints?.find((d) => d.idx === seriesIdx);
|
|
126
|
+
const dpFill = dp?.spPr?.fill;
|
|
127
|
+
if (dpFill && dpFill.kind === "solid") return dpFill.color;
|
|
128
|
+
// Pie with varyColors=false falls through to the series spPr color.
|
|
129
|
+
if (!model.varyColors) {
|
|
130
|
+
const seriesFill = pieSeries.spPr?.fill;
|
|
131
|
+
if (seriesFill && seriesFill.kind === "solid") return seriesFill.color;
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Bar / line / area / scatter / bubble: read series[idx].spPr.fill.
|
|
137
|
+
const series = model.series[seriesIdx];
|
|
138
|
+
if (!series) return undefined;
|
|
139
|
+
const fill = series.spPr?.fill;
|
|
140
|
+
if (fill && fill.kind === "solid") return fill.color;
|
|
141
|
+
// Gradient and pattern: pick the first stop as a solid approximation
|
|
142
|
+
// until Stage 4 renderers handle them natively.
|
|
143
|
+
if (fill && fill.kind === "gradient" && fill.stops.length > 0) {
|
|
144
|
+
return fill.stops[0]!.color;
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
@@ -49,9 +49,11 @@ import type {
|
|
|
49
49
|
BubbleChartModel,
|
|
50
50
|
BubbleSeries,
|
|
51
51
|
CategoryAxis,
|
|
52
|
+
CategoryLikeAxis,
|
|
52
53
|
ChartCommon,
|
|
53
54
|
ChartModel,
|
|
54
55
|
ComboChartModel,
|
|
56
|
+
DateAxis,
|
|
55
57
|
Legend,
|
|
56
58
|
LineChartModel,
|
|
57
59
|
LineSeries,
|
|
@@ -283,16 +285,28 @@ function parsePlotAreaAxes(plotArea: XmlElementNode): Map<string, Axis> {
|
|
|
283
285
|
}
|
|
284
286
|
|
|
285
287
|
/**
|
|
286
|
-
* Find the
|
|
287
|
-
* group declares its binding via two or more
|
|
288
|
-
* (typically exactly two: category
|
|
289
|
-
*
|
|
290
|
-
*
|
|
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`.
|
|
291
301
|
*/
|
|
292
302
|
function resolveCategoryValueAxes(
|
|
293
303
|
groupNode: XmlElementNode,
|
|
294
304
|
axes: Map<string, Axis>,
|
|
295
|
-
): {
|
|
305
|
+
): {
|
|
306
|
+
categoryAxis: CategoryLikeAxis;
|
|
307
|
+
valueAxis: ValueAxis;
|
|
308
|
+
secondaryValueAxis?: ValueAxis;
|
|
309
|
+
} {
|
|
296
310
|
const ids: string[] = [];
|
|
297
311
|
for (const child of groupNode.children) {
|
|
298
312
|
if (child.type !== "element") continue;
|
|
@@ -301,40 +315,64 @@ function resolveCategoryValueAxes(
|
|
|
301
315
|
if (id) ids.push(id);
|
|
302
316
|
}
|
|
303
317
|
|
|
304
|
-
let categoryAxis:
|
|
305
|
-
|
|
318
|
+
let categoryAxis: CategoryLikeAxis | undefined;
|
|
319
|
+
const valueAxesRaw: ValueAxis[] = [];
|
|
306
320
|
for (const id of ids) {
|
|
307
321
|
const ax = axes.get(id);
|
|
308
322
|
if (!ax) continue;
|
|
309
|
-
if (ax.kind === "category")
|
|
310
|
-
|
|
311
|
-
else if (ax.kind === "date"
|
|
312
|
-
categoryAxis =
|
|
313
|
-
} else if (ax.kind === "value"
|
|
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
|
+
}
|
|
314
330
|
}
|
|
315
331
|
|
|
316
332
|
if (!categoryAxis) categoryAxis = placeholderCategoryAxis();
|
|
317
|
-
|
|
318
|
-
|
|
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;
|
|
319
362
|
}
|
|
320
363
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
};
|
|
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 };
|
|
338
376
|
}
|
|
339
377
|
|
|
340
378
|
function placeholderCategoryAxis(): CategoryAxis {
|
|
@@ -390,11 +428,18 @@ function parseBarGroup(
|
|
|
390
428
|
series.push(parseBarSeriesNode(child));
|
|
391
429
|
}
|
|
392
430
|
|
|
393
|
-
const { categoryAxis, valueAxis } =
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
//
|
|
397
|
-
if
|
|
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
|
+
) {
|
|
398
443
|
categoryAxis.categoryLabels = series[0]!.categories;
|
|
399
444
|
}
|
|
400
445
|
|
|
@@ -413,6 +458,7 @@ function parseBarGroup(
|
|
|
413
458
|
categoryAxis,
|
|
414
459
|
valueAxis,
|
|
415
460
|
};
|
|
461
|
+
if (secondaryValueAxis) model.secondaryValueAxis = secondaryValueAxis;
|
|
416
462
|
return model;
|
|
417
463
|
}
|
|
418
464
|
|
|
@@ -443,12 +489,17 @@ function parseLineGroup(
|
|
|
443
489
|
series.push(parseLineSeriesNode(child));
|
|
444
490
|
}
|
|
445
491
|
|
|
446
|
-
const { categoryAxis, valueAxis } =
|
|
447
|
-
|
|
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
|
+
) {
|
|
448
499
|
categoryAxis.categoryLabels = series[0]!.categories;
|
|
449
500
|
}
|
|
450
501
|
|
|
451
|
-
|
|
502
|
+
const model: LineChartModel = {
|
|
452
503
|
...common,
|
|
453
504
|
kind: "line",
|
|
454
505
|
grouping,
|
|
@@ -458,6 +509,8 @@ function parseLineGroup(
|
|
|
458
509
|
categoryAxis,
|
|
459
510
|
valueAxis,
|
|
460
511
|
};
|
|
512
|
+
if (secondaryValueAxis) model.secondaryValueAxis = secondaryValueAxis;
|
|
513
|
+
return model;
|
|
461
514
|
}
|
|
462
515
|
|
|
463
516
|
/**
|
|
@@ -494,11 +547,14 @@ function parsePieGroup(
|
|
|
494
547
|
: varyColorsRaw !== "0" && varyColorsRaw.toLowerCase() !== "false";
|
|
495
548
|
|
|
496
549
|
// c:firstSliceAngle is in 1/60000 of a degree, clockwise from 12 o'clock.
|
|
497
|
-
// ECMA-376
|
|
498
|
-
//
|
|
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.
|
|
499
553
|
const firstSliceAngleRaw = readIntVal(findChildOptional(groupNode, "firstSliceAngle"));
|
|
500
554
|
const firstSliceAngle =
|
|
501
|
-
firstSliceAngleRaw !== undefined
|
|
555
|
+
firstSliceAngleRaw !== undefined
|
|
556
|
+
? (((firstSliceAngleRaw / OOXML_ANGLE_UNIT) % 360) + 360) % 360
|
|
557
|
+
: 0;
|
|
502
558
|
|
|
503
559
|
// Doughnut hole size is a percent (0–99 typical). Missing on pieChart.
|
|
504
560
|
const holeSizePercent = doughnut
|
|
@@ -568,12 +624,17 @@ function parseAreaGroup(
|
|
|
568
624
|
series.push(parseSeriesBase(child));
|
|
569
625
|
}
|
|
570
626
|
|
|
571
|
-
const { categoryAxis, valueAxis } =
|
|
572
|
-
|
|
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
|
+
) {
|
|
573
634
|
categoryAxis.categoryLabels = series[0]!.categories;
|
|
574
635
|
}
|
|
575
636
|
|
|
576
|
-
|
|
637
|
+
const model: AreaChartModel = {
|
|
577
638
|
...common,
|
|
578
639
|
kind: "area",
|
|
579
640
|
grouping,
|
|
@@ -581,6 +642,8 @@ function parseAreaGroup(
|
|
|
581
642
|
categoryAxis,
|
|
582
643
|
valueAxis,
|
|
583
644
|
};
|
|
645
|
+
if (secondaryValueAxis) model.secondaryValueAxis = secondaryValueAxis;
|
|
646
|
+
return model;
|
|
584
647
|
}
|
|
585
648
|
|
|
586
649
|
// ---------------------------------------------------------------------------
|
|
@@ -655,6 +718,15 @@ function parseComboPlotArea(
|
|
|
655
718
|
}
|
|
656
719
|
groups.push(model);
|
|
657
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
|
+
}
|
|
658
730
|
const thisGroupValueAxisId = model.valueAxis.id;
|
|
659
731
|
if (!primaryValueAxisId) {
|
|
660
732
|
primaryValueAxisId = thisGroupValueAxisId;
|
|
@@ -67,6 +67,7 @@ export function parseSeriesBase(node: XmlElementNode): Series {
|
|
|
67
67
|
const categories = extractStrCache(findChildOptional(node, "cat"));
|
|
68
68
|
const values = extractNumCache(findChildOptional(node, "val"));
|
|
69
69
|
const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
|
|
70
|
+
const dataPoints = parseDataPointOverrides(node);
|
|
70
71
|
|
|
71
72
|
const series: Series = {
|
|
72
73
|
idx,
|
|
@@ -76,13 +77,16 @@ export function parseSeriesBase(node: XmlElementNode): Series {
|
|
|
76
77
|
};
|
|
77
78
|
if (name !== undefined) series.name = name;
|
|
78
79
|
if (spPr) series.spPr = spPr;
|
|
80
|
+
if (dataPoints.length > 0) series.dataPoints = dataPoints;
|
|
79
81
|
return series;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/**
|
|
83
85
|
* Parse a pre-parsed `<c:ser>` element into a `Series`. Thin wrapper over
|
|
84
86
|
* `parseSeriesBase`, kept for call-site readability in bar/column parsing
|
|
85
|
-
* where "bar" makes the intent clear.
|
|
87
|
+
* where "bar" makes the intent clear. Per-data-point overrides (c:dPt) —
|
|
88
|
+
* highlights, invertIfNegative, color overrides — are attached by the
|
|
89
|
+
* base parser so all category-oriented families (bar, area) surface them.
|
|
86
90
|
*/
|
|
87
91
|
export function parseBarSeriesNode(node: XmlElementNode): Series {
|
|
88
92
|
return parseSeriesBase(node);
|
|
@@ -384,14 +388,19 @@ export function extractNumCache(
|
|
|
384
388
|
expected?: number,
|
|
385
389
|
): Array<number | null> {
|
|
386
390
|
if (!container) return [];
|
|
387
|
-
|
|
391
|
+
// Prefer numCache. The strCache fallback is intentional for numRef-
|
|
392
|
+
// backed category axes where Word emits the numeric series through the
|
|
393
|
+
// string-cache path (e.g. a Year axis authored as numbers but displayed
|
|
394
|
+
// as labels). Each stringified value is then parseFloat'd; non-finite
|
|
395
|
+
// strings collapse to null, preserving the sparse-index contract.
|
|
396
|
+
const cacheNode =
|
|
388
397
|
findFirstDescendant(container, "numCache") ??
|
|
389
398
|
findFirstDescendant(container, "strCache");
|
|
390
|
-
if (!
|
|
391
|
-
const ptCount = readIntVal(findChildOptional(
|
|
399
|
+
if (!cacheNode) return [];
|
|
400
|
+
const ptCount = readIntVal(findChildOptional(cacheNode, "ptCount")) ?? 0;
|
|
392
401
|
const length = Math.max(ptCount, expected ?? 0);
|
|
393
402
|
const out: Array<number | null> = new Array(length).fill(null);
|
|
394
|
-
for (const child of
|
|
403
|
+
for (const child of cacheNode.children) {
|
|
395
404
|
if (child.type !== "element" || localName(child.name) !== "pt") continue;
|
|
396
405
|
const idxRaw = child.attributes["idx"];
|
|
397
406
|
const idx = idxRaw ? Number.parseInt(idxRaw, 10) : Number.NaN;
|
|
@@ -459,18 +468,74 @@ function parseFill(spPrNode: XmlElementNode): FillSpec | undefined {
|
|
|
459
468
|
const color = parseColorRef(solid);
|
|
460
469
|
if (color) return { kind: "solid", color };
|
|
461
470
|
}
|
|
462
|
-
// Gradient
|
|
463
|
-
// as
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
471
|
+
// Gradient fill: extract c:gsLst stops so renderers can honor the
|
|
472
|
+
// authored gradient or, as a minimum, fall back to the first stop as a
|
|
473
|
+
// solid color. Stage 4 renderers consume the full stop list.
|
|
474
|
+
const gradNode = findChildOptional(spPrNode, "gradFill");
|
|
475
|
+
if (gradNode) {
|
|
476
|
+
const gradient = parseGradientFill(gradNode);
|
|
477
|
+
if (gradient) return gradient;
|
|
467
478
|
}
|
|
468
|
-
|
|
479
|
+
// Pattern fill: Stage 1 degrades to the foreground color as a solid
|
|
480
|
+
// fallback so bars stay visible. A richer pattern renderer is deferred.
|
|
481
|
+
const pattNode = findChildOptional(spPrNode, "pattFill");
|
|
482
|
+
if (pattNode) {
|
|
483
|
+
const fg = findChildOptional(pattNode, "fgClr");
|
|
484
|
+
if (fg) {
|
|
485
|
+
const color = parseColorRef(fg);
|
|
486
|
+
if (color) return { kind: "solid", color };
|
|
487
|
+
}
|
|
469
488
|
return { kind: "none" };
|
|
470
489
|
}
|
|
471
490
|
return undefined;
|
|
472
491
|
}
|
|
473
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Parse a DrawingML `a:gradFill` element per ECMA-376 §20.1.8.33.
|
|
495
|
+
*
|
|
496
|
+
* Shape: `<a:gradFill><a:gsLst><a:gs pos="N"><a:srgbClr|a:schemeClr…/></a:gs>…</a:gsLst><a:lin ang="…"/></a:gradFill>`.
|
|
497
|
+
* Stops' `pos` attribute is in parts-per-100,000 (0..100000); we normalize
|
|
498
|
+
* to 0..1 so renderers can project onto their own coordinate system.
|
|
499
|
+
*
|
|
500
|
+
* Returns undefined when the gradient has no usable stops so the caller
|
|
501
|
+
* can fall through to the fill chain's subsequent branches. Mal-authored
|
|
502
|
+
* gradients (no gsLst, no color children) degrade gracefully to `none`
|
|
503
|
+
* rather than producing an invalid FillSpec.
|
|
504
|
+
*/
|
|
505
|
+
function parseGradientFill(gradNode: XmlElementNode): FillSpec | undefined {
|
|
506
|
+
const gsLst = findChildOptional(gradNode, "gsLst");
|
|
507
|
+
if (!gsLst) return { kind: "none" };
|
|
508
|
+
const stops: Array<{ pos: number; color: ColorRef }> = [];
|
|
509
|
+
for (const child of gsLst.children) {
|
|
510
|
+
if (child.type !== "element" || localName(child.name) !== "gs") continue;
|
|
511
|
+
const posRaw = child.attributes["pos"];
|
|
512
|
+
const posPpm = posRaw ? Number.parseInt(posRaw, 10) : Number.NaN;
|
|
513
|
+
const color = parseColorRef(child);
|
|
514
|
+
if (!color) continue;
|
|
515
|
+
const pos = Number.isFinite(posPpm)
|
|
516
|
+
? Math.max(0, Math.min(1, posPpm / 100_000))
|
|
517
|
+
: stops.length / 2;
|
|
518
|
+
stops.push({ pos, color });
|
|
519
|
+
}
|
|
520
|
+
if (stops.length === 0) return { kind: "none" };
|
|
521
|
+
|
|
522
|
+
// a:lin carries angle in 1/60,000 of a degree; a:path carries a path
|
|
523
|
+
// type (linear/radial/rect) but no angle. Stage 1 only reads linear.
|
|
524
|
+
let angle: number | undefined;
|
|
525
|
+
const lin = findChildOptional(gradNode, "lin");
|
|
526
|
+
if (lin) {
|
|
527
|
+
const angRaw = lin.attributes["ang"];
|
|
528
|
+
if (angRaw) {
|
|
529
|
+
const n = Number.parseInt(angRaw, 10);
|
|
530
|
+
if (Number.isFinite(n)) angle = n / 60_000;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const gradient: FillSpec = { kind: "gradient", stops };
|
|
535
|
+
if (angle !== undefined) gradient.angle = angle;
|
|
536
|
+
return gradient;
|
|
537
|
+
}
|
|
538
|
+
|
|
474
539
|
function parseStroke(lnNode: XmlElementNode | undefined): StrokeSpec | undefined {
|
|
475
540
|
if (!lnNode) return undefined;
|
|
476
541
|
const stroke: StrokeSpec = {};
|
|
@@ -27,13 +27,20 @@ const OOXML_UNIT = 100_000; // modifier val is in parts per 100,000
|
|
|
27
27
|
* Scheme-alias map. Word often emits aliases that refer to the primary
|
|
28
28
|
* color slots: `tx1` → `dk1`, `bg1` → `lt1`, `tx2` → `dk2`, `bg2` → `lt2`.
|
|
29
29
|
* The mapping is standardized by DrawingML.
|
|
30
|
+
*
|
|
31
|
+
* `phClr` is deliberately NOT aliased — it's a DrawingML "placeholder"
|
|
32
|
+
* color (ECMA-376 §20.1.4.1.22) that the caller is expected to substitute
|
|
33
|
+
* from its own context (e.g. the surrounding shape's accent). When the
|
|
34
|
+
* caller has no substitution, resolving phClr should fall through to the
|
|
35
|
+
* fallback color rather than silently pretend it meant accent1. Prior
|
|
36
|
+
* behavior aliased `phClr` → `accent1`, which silently miscolored every
|
|
37
|
+
* chart that used a placeholder color in its sidecar colors.xml.
|
|
30
38
|
*/
|
|
31
39
|
const SCHEME_ALIASES: Record<string, string> = {
|
|
32
40
|
tx1: "dk1",
|
|
33
41
|
bg1: "lt1",
|
|
34
42
|
tx2: "dk2",
|
|
35
43
|
bg2: "lt2",
|
|
36
|
-
phClr: "accent1", // placeholder color — callers usually supply their own
|
|
37
44
|
};
|
|
38
45
|
|
|
39
46
|
export function resolveColor(ref: ColorRef, theme: ResolvedTheme): string {
|
|
@@ -114,12 +121,15 @@ function applyMod(rgb: Rgb, mod: ColorMod): Rgb {
|
|
|
114
121
|
return hslToRgb(hsl);
|
|
115
122
|
}
|
|
116
123
|
case "hueMod": {
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
124
|
+
// Multiplicative scaling of the hue angle — NOT an additive
|
|
125
|
+
// rotation. ECMA-376 §20.1.2.3.14 and LibreOffice
|
|
126
|
+
// (oox/source/drawingml/color.cxx `lclModValue`) apply `hueMod` as
|
|
127
|
+
// `hue' = hue * (val/100000)`, stored in the hue dimension of HSL
|
|
128
|
+
// and clamped into the valid angle range. We wrap via mod-360 for
|
|
129
|
+
// numeric resilience when the source value exceeds 100% (a legal
|
|
130
|
+
// "expand hue" case).
|
|
120
131
|
const hsl = rgbToHsl(rgb);
|
|
121
|
-
hsl.h = (hsl.h
|
|
122
|
-
if (hsl.h < 0) hsl.h += 360;
|
|
132
|
+
hsl.h = ((hsl.h * frac) % 360 + 360) % 360;
|
|
123
133
|
return hslToRgb(hsl);
|
|
124
134
|
}
|
|
125
135
|
case "shade": {
|
|
@@ -51,6 +51,14 @@ export interface ChartCommon {
|
|
|
51
51
|
rawXml: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Category-like axis binding for bar/line/area charts. Date axes are a
|
|
56
|
+
* legal substitute for a category axis at the XML level (c:dateAx replaces
|
|
57
|
+
* c:catAx) and we preserve the kind here so the renderer can format
|
|
58
|
+
* category labels as dates when the source authored them that way.
|
|
59
|
+
*/
|
|
60
|
+
export type CategoryLikeAxis = CategoryAxis | DateAxis;
|
|
61
|
+
|
|
54
62
|
export interface BarChartModel extends ChartCommon {
|
|
55
63
|
kind: "bar";
|
|
56
64
|
/** "bar" = horizontal; "column" = vertical. */
|
|
@@ -61,7 +69,7 @@ export interface BarChartModel extends ChartCommon {
|
|
|
61
69
|
/** Overlap within a category in percent, -100..100 (c:overlap). */
|
|
62
70
|
overlap: number;
|
|
63
71
|
series: Series[];
|
|
64
|
-
categoryAxis:
|
|
72
|
+
categoryAxis: CategoryLikeAxis;
|
|
65
73
|
valueAxis: ValueAxis;
|
|
66
74
|
/** Present when any series/group targeted the secondary axis. */
|
|
67
75
|
secondaryValueAxis?: ValueAxis;
|
|
@@ -75,7 +83,7 @@ export interface LineChartModel extends ChartCommon {
|
|
|
75
83
|
/** Global marker flag; per-series values can override. */
|
|
76
84
|
marker: boolean;
|
|
77
85
|
series: LineSeries[];
|
|
78
|
-
categoryAxis:
|
|
86
|
+
categoryAxis: CategoryLikeAxis;
|
|
79
87
|
valueAxis: ValueAxis;
|
|
80
88
|
secondaryValueAxis?: ValueAxis;
|
|
81
89
|
}
|
|
@@ -98,8 +106,9 @@ export interface AreaChartModel extends ChartCommon {
|
|
|
98
106
|
kind: "area";
|
|
99
107
|
grouping: "standard" | "stacked" | "percentStacked";
|
|
100
108
|
series: Series[];
|
|
101
|
-
categoryAxis:
|
|
109
|
+
categoryAxis: CategoryLikeAxis;
|
|
102
110
|
valueAxis: ValueAxis;
|
|
111
|
+
secondaryValueAxis?: ValueAxis;
|
|
103
112
|
}
|
|
104
113
|
|
|
105
114
|
export interface ScatterChartModel extends ChartCommon {
|
|
@@ -400,10 +409,21 @@ export interface TextProperties {
|
|
|
400
409
|
// ---------------------------------------------------------------------------
|
|
401
410
|
|
|
402
411
|
/**
|
|
403
|
-
* Compile-time
|
|
404
|
-
*
|
|
412
|
+
* Compile-time assertion that `ChartModel["kind"]` equals the expected
|
|
413
|
+
* literal-union below. If a new variant is added to `ChartModel` without
|
|
414
|
+
* being listed in `_ExpectedKinds`, `_Equals` resolves to `false` and the
|
|
415
|
+
* subsequent `true` assignment fails at compile time. The previous
|
|
416
|
+
* dual-assign-cast pattern did not actually enforce union equality — any
|
|
417
|
+
* new kind silently passed through.
|
|
418
|
+
*
|
|
419
|
+
* Reference: the standard "exact type equality" trick using contravariance
|
|
420
|
+
* of conditional-type generic functions.
|
|
405
421
|
*/
|
|
406
|
-
type
|
|
422
|
+
type _Equals<X, Y> =
|
|
423
|
+
(<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2)
|
|
424
|
+
? true
|
|
425
|
+
: false;
|
|
426
|
+
|
|
407
427
|
type _ExpectedKinds =
|
|
408
428
|
| "bar"
|
|
409
429
|
| "line"
|
|
@@ -413,8 +433,7 @@ type _ExpectedKinds =
|
|
|
413
433
|
| "bubble"
|
|
414
434
|
| "combo"
|
|
415
435
|
| "unsupported";
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
void
|
|
420
|
-
void _kindB;
|
|
436
|
+
|
|
437
|
+
type _ChartKindCheck = _Equals<ChartModel["kind"], _ExpectedKinds>;
|
|
438
|
+
const _kindExhaustive: _ChartKindCheck = true;
|
|
439
|
+
void _kindExhaustive;
|