@beyondwork/docx-react-component 1.0.48 → 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 (45) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +84 -12
  4. package/src/core/commands/index.ts +9 -1
  5. package/src/core/commands/text-commands.ts +3 -1
  6. package/src/core/selection/anchor-conversion.ts +112 -0
  7. package/src/core/selection/review-anchors.ts +108 -3
  8. package/src/core/state/text-transaction.ts +86 -2
  9. package/src/internal/harness-debug-ports.ts +168 -0
  10. package/src/io/chart-preview-resolver.ts +32 -1
  11. package/src/io/export/serialize-main-document.ts +9 -0
  12. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  13. package/src/io/export/serialize-run-formatting.ts +10 -1
  14. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  15. package/src/io/ooxml/chart/color-palette.ts +101 -0
  16. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  17. package/src/io/ooxml/chart/parse-chart-space.ts +118 -46
  18. package/src/io/ooxml/chart/parse-series.ts +76 -11
  19. package/src/io/ooxml/chart/resolve-color.ts +16 -6
  20. package/src/io/ooxml/chart/types.ts +30 -11
  21. package/src/io/ooxml/parse-complex-content.ts +6 -3
  22. package/src/io/ooxml/parse-main-document.ts +41 -0
  23. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  24. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  25. package/src/io/ooxml/property-grab-bag.ts +211 -0
  26. package/src/model/canonical-document.ts +69 -3
  27. package/src/runtime/collab/index.ts +7 -0
  28. package/src/runtime/collab/runtime-collab-sync.ts +51 -0
  29. package/src/runtime/collab/workflow-shared.ts +247 -0
  30. package/src/runtime/document-locations.ts +1 -9
  31. package/src/runtime/document-outline.ts +1 -9
  32. package/src/runtime/document-runtime.ts +74 -49
  33. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  34. package/src/runtime/surface-projection.ts +94 -36
  35. package/src/runtime/theme-color-resolver.ts +188 -0
  36. package/src/runtime/workflow-markup.ts +7 -18
  37. package/src/ui/WordReviewEditor.tsx +18 -2
  38. package/src/ui/editor-runtime-boundary.ts +36 -0
  39. package/src/ui/headless/selection-helpers.ts +10 -23
  40. package/src/ui/unsupported-previews-policy.ts +23 -0
  41. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  42. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  43. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  44. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  45. 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 primary category and value axes that a type group binds to. The
287
- * group declares its binding via two or more `<c:axId val="…"/>` children
288
- * (typically exactly two: category axis id + value axis id). Shared by
289
- * bar/line/area since they all have the same category-axis + value-axis
290
- * topology.
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
- ): { categoryAxis: CategoryAxis; valueAxis: ValueAxis } {
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: CategoryAxis | undefined;
305
- let valueAxis: ValueAxis | undefined;
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") categoryAxis = ax;
310
- // Date axes can bind where a category axis would go; treat as category.
311
- else if (ax.kind === "date" && !categoryAxis) {
312
- categoryAxis = dateAxisToCategory(ax);
313
- } else if (ax.kind === "value" && !valueAxis) valueAxis = ax;
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
- if (!valueAxis) valueAxis = placeholderValueAxis();
318
- return { categoryAxis, valueAxis };
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
- /** Narrow a date axis into a CategoryAxis shape so bar/line/area models
322
- * can expose a uniform `categoryAxis` field. Date-specific fields are
323
- * dropped here; Stage 2/3 can preserve them via a dedicated route. */
324
- function dateAxisToCategory(ax: Axis & { kind: "date" }): CategoryAxis {
325
- return {
326
- kind: "category",
327
- id: ax.id,
328
- position: ax.position,
329
- visible: ax.visible,
330
- auto: true,
331
- categoryLabels: [],
332
- ...(ax.crossAxisId !== undefined ? { crossAxisId: ax.crossAxisId } : {}),
333
- ...(ax.crossesAt !== undefined ? { crossesAt: ax.crossesAt } : {}),
334
- ...(ax.majorGridlines !== undefined ? { majorGridlines: ax.majorGridlines } : {}),
335
- ...(ax.minorGridlines !== undefined ? { minorGridlines: ax.minorGridlines } : {}),
336
- ...(ax.numberFormat !== undefined ? { numberFormat: ax.numberFormat } : {}),
337
- };
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 } = resolveCategoryValueAxes(groupNode, axes);
394
-
395
- // Promote category labels from the first series onto the axis if they
396
- // aren't already populated (typical for Word-authored charts).
397
- if (categoryAxis.categoryLabels.length === 0 && series.length > 0) {
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 } = resolveCategoryValueAxes(groupNode, axes);
447
- if (categoryAxis.categoryLabels.length === 0 && series.length > 0) {
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
- return {
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 defines 0–360 range; we perform the unit conversion and leave
498
- // clamping to the renderer so out-of-range values stay recoverable.
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 ? firstSliceAngleRaw / OOXML_ANGLE_UNIT : 0;
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 } = resolveCategoryValueAxes(groupNode, axes);
572
- if (categoryAxis.categoryLabels.length === 0 && series.length > 0) {
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
- return {
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
- const cache =
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 (!cache) return [];
391
- const ptCount = readIntVal(findChildOptional(cache, "ptCount")) ?? 0;
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 cache.children) {
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 and pattern fills are not resolved in Stage 1 — they stay
463
- // as `{ kind: "none" }` placeholders so the downstream renderer at least
464
- // produces a consistent, non-crashing surface. Stage 2 replaces this.
465
- if (findChildOptional(spPrNode, "gradFill")) {
466
- return { kind: "none" };
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
- if (findChildOptional(spPrNode, "pattFill")) {
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
- // Rotate hue. val is in OOXML's angle-like units (1/100,000 turn
118
- // degrees = frac * 360). The spec is slightly ambiguous here; our
119
- // interpretation is that hueMod=1.0 is a full rotation.
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 + frac * 360) % 360;
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: 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: 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: 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 list of every ChartModel kind. If a new family is added to
404
- * the union without a matching `kind` literal added here, tsgo fails.
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 _ChartModelKindExhaustive = ChartModel["kind"];
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
- // Bi-directional assignability asserts union equality.
417
- const _kindA: _ChartModelKindExhaustive = "bar" as _ExpectedKinds;
418
- const _kindB: _ExpectedKinds = "bar" as _ChartModelKindExhaustive;
419
- void _kindA;
420
- void _kindB;
436
+
437
+ type _ChartKindCheck = _Equals<ChartModel["kind"], _ExpectedKinds>;
438
+ const _kindExhaustive: _ChartKindCheck = true;
439
+ void _kindExhaustive;