@beyondwork/docx-react-component 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
@@ -0,0 +1,635 @@
1
+ /**
2
+ * Parse `<c:ser>` elements and their numCache/strCache bodies.
3
+ *
4
+ * The sparse-point semantics are important: `<c:numCache>` carries a
5
+ * `c:ptCount` attribute and zero or more `<c:pt idx="N">` children; any
6
+ * index between 0 and ptCount that does not appear represents a blank
7
+ * (null) value. Consumers rely on that layout to render gaps / stacked
8
+ * zeros / "span" lines per the chart family's c:dispBlanksAs setting.
9
+ *
10
+ * Callers that already have the series element in hand should use
11
+ * `parseBarSeriesNode` to avoid re-parsing the XML.
12
+ */
13
+
14
+ import {
15
+ findChildOptional,
16
+ findFirstDescendant,
17
+ localName,
18
+ readFloatVal,
19
+ readIntVal,
20
+ readOnOff,
21
+ textContent,
22
+ } from "../xml-attr-helpers.ts";
23
+ import type { XmlElementNode } from "../xml-element.ts";
24
+ import { parseXml } from "../xml-parser.ts";
25
+
26
+ import type {
27
+ BubbleSeries,
28
+ ColorMod,
29
+ ColorRef,
30
+ DataPointOverride,
31
+ FillSpec,
32
+ LineSeries,
33
+ MarkerSpec,
34
+ PieSeries,
35
+ ScatterSeries,
36
+ Series,
37
+ ShapeProperties,
38
+ StrokeSpec,
39
+ } from "./types.ts";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Series
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Parse a `<c:ser>` XML fragment into a `Series`. */
46
+ export function parseBarSeries(serXml: string): Series {
47
+ const root = parseXml(serXml);
48
+ const node = findFirstDescendant(root, "ser");
49
+ if (!node) {
50
+ return emptySeries();
51
+ }
52
+ return parseBarSeriesNode(node);
53
+ }
54
+
55
+ /**
56
+ * Parse the `Series` fields that every chart family shares: idx, order,
57
+ * name (c:tx), categories (c:cat), values (c:val), and the explicit
58
+ * fill/stroke overrides from c:spPr.
59
+ *
60
+ * Family-specific parsers (line markers, pie explosion, scatter xValues,
61
+ * etc.) wrap this and attach additional fields to the base shape.
62
+ */
63
+ export function parseSeriesBase(node: XmlElementNode): Series {
64
+ const idx = readIntVal(findChildOptional(node, "idx")) ?? 0;
65
+ const order = readIntVal(findChildOptional(node, "order")) ?? 0;
66
+ const name = readSeriesName(findChildOptional(node, "tx"));
67
+ const categories = extractStrCache(findChildOptional(node, "cat"));
68
+ const values = extractNumCache(findChildOptional(node, "val"));
69
+ const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
70
+ const dataPoints = parseDataPointOverrides(node);
71
+
72
+ const series: Series = {
73
+ idx,
74
+ order,
75
+ categories,
76
+ values,
77
+ };
78
+ if (name !== undefined) series.name = name;
79
+ if (spPr) series.spPr = spPr;
80
+ if (dataPoints.length > 0) series.dataPoints = dataPoints;
81
+ return series;
82
+ }
83
+
84
+ /**
85
+ * Parse a pre-parsed `<c:ser>` element into a `Series`. Thin wrapper over
86
+ * `parseSeriesBase`, kept for call-site readability in bar/column parsing
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.
90
+ */
91
+ export function parseBarSeriesNode(node: XmlElementNode): Series {
92
+ return parseSeriesBase(node);
93
+ }
94
+
95
+ function emptySeries(): Series {
96
+ return { idx: 0, order: 0, categories: [], values: [] };
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Line series
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** Parse a `<c:ser>` XML fragment from a lineChart into a `LineSeries`. */
104
+ export function parseLineSeries(serXml: string): LineSeries {
105
+ const root = parseXml(serXml);
106
+ const node = findFirstDescendant(root, "ser");
107
+ if (!node) {
108
+ return emptySeries();
109
+ }
110
+ return parseLineSeriesNode(node);
111
+ }
112
+
113
+ /**
114
+ * Parse a pre-parsed `<c:ser>` element from a lineChart. Returns the common
115
+ * `Series` fields plus optional per-series smooth + marker overrides. Omits
116
+ * the optional keys entirely when absent so strict-type consumers can
117
+ * distinguish "not specified" from "explicitly false / none".
118
+ */
119
+ export function parseLineSeriesNode(node: XmlElementNode): LineSeries {
120
+ const base = parseSeriesBase(node);
121
+ const smoothNode = findChildOptional(node, "smooth");
122
+ const smooth = readOnOff(smoothNode);
123
+ const marker = parseMarkerSpec(findChildOptional(node, "marker"));
124
+
125
+ const series: LineSeries = { ...base };
126
+ if (smooth !== undefined) series.smooth = smooth;
127
+ if (marker) series.marker = marker;
128
+ return series;
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Marker
133
+ // ---------------------------------------------------------------------------
134
+
135
+ const MARKER_SYMBOLS: ReadonlySet<MarkerSpec["symbol"]> = new Set<
136
+ MarkerSpec["symbol"]
137
+ >([
138
+ "circle",
139
+ "square",
140
+ "diamond",
141
+ "triangle",
142
+ "x",
143
+ "star",
144
+ "dot",
145
+ "dash",
146
+ "plus",
147
+ "picture",
148
+ "none",
149
+ "auto",
150
+ ]);
151
+
152
+ /**
153
+ * Parse a `<c:marker>` element. Returns undefined when the node is absent.
154
+ * Returns a `MarkerSpec` with `symbol: "auto"` when the element is present
155
+ * but declares no symbol — OOXML's default for a marker with no subclass.
156
+ * Unknown symbol values also collapse to `"auto"` so renderers never see
157
+ * an unrecognised enum.
158
+ */
159
+ export function parseMarkerSpec(
160
+ markerNode: XmlElementNode | undefined,
161
+ ): MarkerSpec | undefined {
162
+ if (!markerNode) return undefined;
163
+ const symbolRaw = findChildOptional(markerNode, "symbol")?.attributes["val"];
164
+ const symbol: MarkerSpec["symbol"] =
165
+ symbolRaw && isMarkerSymbol(symbolRaw) ? symbolRaw : "auto";
166
+ const size = readIntVal(findChildOptional(markerNode, "size"));
167
+ const spPr = parseShapeProperties(findChildOptional(markerNode, "spPr"));
168
+
169
+ const marker: MarkerSpec = { symbol };
170
+ if (size !== undefined) marker.size = size;
171
+ if (spPr) marker.spPr = spPr;
172
+ return marker;
173
+ }
174
+
175
+ function isMarkerSymbol(value: string): value is MarkerSpec["symbol"] {
176
+ return MARKER_SYMBOLS.has(value as MarkerSpec["symbol"]);
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Pie series
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** Parse a `<c:ser>` XML fragment from a pieChart/doughnutChart into `PieSeries`. */
184
+ export function parsePieSeries(serXml: string): PieSeries {
185
+ const root = parseXml(serXml);
186
+ const node = findFirstDescendant(root, "ser");
187
+ if (!node) {
188
+ return { idx: 0, order: 0, values: [] };
189
+ }
190
+ return parsePieSeriesNode(node);
191
+ }
192
+
193
+ /**
194
+ * Parse a pre-parsed `<c:ser>` element from a pieChart/doughnutChart.
195
+ *
196
+ * PieSeries extends SeriesBase (idx/order/name/spPr) with:
197
+ * - values: the numeric cache from c:val/c:numRef/c:numCache (sparse, nulls preserved).
198
+ * - explosion: series-level default offset percent from c:explosion val.
199
+ * - dataPoints: optional per-slice overrides (c:dPt[idx]/{spPr,explosion,bubble3D}).
200
+ *
201
+ * Categories for pie live on `PieChartModel.categoryLabels`, not on the
202
+ * series — the group parser reads them from the first series' c:cat.
203
+ */
204
+ export function parsePieSeriesNode(node: XmlElementNode): PieSeries {
205
+ const idx = readIntVal(findChildOptional(node, "idx")) ?? 0;
206
+ const order = readIntVal(findChildOptional(node, "order")) ?? 0;
207
+ const name = readSeriesName(findChildOptional(node, "tx"));
208
+ const values = extractNumCache(findChildOptional(node, "val"));
209
+ const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
210
+ const explosion = readIntVal(findChildOptional(node, "explosion"));
211
+ const dataPoints = parseDataPointOverrides(node);
212
+
213
+ const series: PieSeries = { idx, order, values };
214
+ if (name !== undefined) series.name = name;
215
+ if (spPr) series.spPr = spPr;
216
+ if (explosion !== undefined) series.explosion = explosion;
217
+ if (dataPoints.length > 0) series.dataPoints = dataPoints;
218
+ return series;
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Data-point overrides (c:dPt)
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * Walk `<c:dPt>` children of a `<c:ser>` element and return typed overrides.
227
+ *
228
+ * Each override must have a `<c:idx val="N">` child; malformed entries (no
229
+ * idx) are skipped silently — the well-formed entries still apply. Optional
230
+ * children captured: spPr (fill/stroke override), explosion (pie slice
231
+ * offset percent), bubble3D (tolerated on non-bubble charts), marker (line
232
+ * variants), invertIfNegative (bar variants).
233
+ */
234
+ // ---------------------------------------------------------------------------
235
+ // Scatter series
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /** Parse a `<c:ser>` XML fragment from a scatterChart into `ScatterSeries`. */
239
+ export function parseScatterSeries(serXml: string): ScatterSeries {
240
+ const root = parseXml(serXml);
241
+ const node = findFirstDescendant(root, "ser");
242
+ if (!node) {
243
+ return { idx: 0, order: 0, xValues: [], yValues: [] };
244
+ }
245
+ return parseScatterSeriesNode(node);
246
+ }
247
+
248
+ /**
249
+ * Parse a pre-parsed `<c:ser>` element from a scatterChart.
250
+ *
251
+ * ScatterSeries extends SeriesBase (idx/order/name/spPr) with:
252
+ * - xValues, yValues: parallel numeric caches from c:xVal and c:yVal.
253
+ * - smooth: per-series override (c:smooth).
254
+ * - marker: optional MarkerSpec (c:marker).
255
+ *
256
+ * Categories don't apply to scatter — both axes are numeric.
257
+ */
258
+ export function parseScatterSeriesNode(node: XmlElementNode): ScatterSeries {
259
+ const idx = readIntVal(findChildOptional(node, "idx")) ?? 0;
260
+ const order = readIntVal(findChildOptional(node, "order")) ?? 0;
261
+ const name = readSeriesName(findChildOptional(node, "tx"));
262
+ const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
263
+ const xValues = extractNumCache(findChildOptional(node, "xVal"));
264
+ const yValues = extractNumCache(findChildOptional(node, "yVal"));
265
+ const smooth = readOnOff(findChildOptional(node, "smooth"));
266
+ const marker = parseMarkerSpec(findChildOptional(node, "marker"));
267
+
268
+ const series: ScatterSeries = { idx, order, xValues, yValues };
269
+ if (name !== undefined) series.name = name;
270
+ if (spPr) series.spPr = spPr;
271
+ if (smooth !== undefined) series.smooth = smooth;
272
+ if (marker) series.marker = marker;
273
+ return series;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Bubble series
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Parse a `<c:ser>` XML fragment from a bubbleChart into `BubbleSeries`. */
281
+ export function parseBubbleSeries(serXml: string): BubbleSeries {
282
+ const root = parseXml(serXml);
283
+ const node = findFirstDescendant(root, "ser");
284
+ if (!node) {
285
+ return { idx: 0, order: 0, xValues: [], yValues: [], sizes: [] };
286
+ }
287
+ return parseBubbleSeriesNode(node);
288
+ }
289
+
290
+ /**
291
+ * Parse a pre-parsed `<c:ser>` element from a bubbleChart.
292
+ *
293
+ * BubbleSeries extends SeriesBase with x/y caches plus a third
294
+ * `sizes` channel from c:bubbleSize. Each triple (x, y, size) is one
295
+ * bubble; all three arrays are parallel-indexed, nulls preserved.
296
+ */
297
+ export function parseBubbleSeriesNode(node: XmlElementNode): BubbleSeries {
298
+ const idx = readIntVal(findChildOptional(node, "idx")) ?? 0;
299
+ const order = readIntVal(findChildOptional(node, "order")) ?? 0;
300
+ const name = readSeriesName(findChildOptional(node, "tx"));
301
+ const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
302
+ const xValues = extractNumCache(findChildOptional(node, "xVal"));
303
+ const yValues = extractNumCache(findChildOptional(node, "yVal"));
304
+ const sizes = extractNumCache(findChildOptional(node, "bubbleSize"));
305
+
306
+ const series: BubbleSeries = { idx, order, xValues, yValues, sizes };
307
+ if (name !== undefined) series.name = name;
308
+ if (spPr) series.spPr = spPr;
309
+ return series;
310
+ }
311
+
312
+ export function parseDataPointOverrides(
313
+ serNode: XmlElementNode,
314
+ ): DataPointOverride[] {
315
+ const overrides: DataPointOverride[] = [];
316
+ for (const child of serNode.children) {
317
+ if (child.type !== "element" || localName(child.name) !== "dPt") continue;
318
+ const idxNode = findChildOptional(child, "idx");
319
+ const idx = readIntVal(idxNode);
320
+ if (idx === undefined) continue;
321
+
322
+ const override: DataPointOverride = { idx };
323
+ const spPr = parseShapeProperties(findChildOptional(child, "spPr"));
324
+ if (spPr) override.spPr = spPr;
325
+ const marker = parseMarkerSpec(findChildOptional(child, "marker"));
326
+ if (marker) override.marker = marker;
327
+ const explosion = readIntVal(findChildOptional(child, "explosion"));
328
+ if (explosion !== undefined) override.explosion = explosion;
329
+ const invert = readOnOff(findChildOptional(child, "invertIfNegative"));
330
+ if (invert !== undefined) override.invertIfNegative = invert;
331
+ const bubble3D = readOnOff(findChildOptional(child, "bubble3D"));
332
+ if (bubble3D !== undefined) override.bubble3D = bubble3D;
333
+
334
+ overrides.push(override);
335
+ }
336
+ return overrides;
337
+ }
338
+
339
+ function readSeriesName(txNode: XmlElementNode | undefined): string | undefined {
340
+ if (!txNode) return undefined;
341
+
342
+ // Case 1: <c:tx><c:strRef><c:strCache>…<c:v>Name</c:v>…
343
+ const strRef = findChildOptional(txNode, "strRef");
344
+ if (strRef) {
345
+ const cache = findFirstDescendant(strRef, "strCache");
346
+ if (cache) {
347
+ for (const child of cache.children) {
348
+ if (child.type !== "element" || localName(child.name) !== "pt") continue;
349
+ const v = findChildOptional(child, "v");
350
+ if (v) return textContent(v);
351
+ }
352
+ }
353
+ // strCache missing — fall back to any c:v descendant under the strRef.
354
+ const directV = findFirstDescendant(strRef, "v");
355
+ if (directV) return textContent(directV);
356
+ }
357
+
358
+ // Case 2: <c:tx><c:v>Name</c:v> (inline literal).
359
+ const direct = findChildOptional(txNode, "v");
360
+ if (direct) return textContent(direct);
361
+
362
+ // Case 3: <c:tx><c:rich><a:p><a:r><a:t>Name</a:t>… — plain-text collapse.
363
+ const rich = findChildOptional(txNode, "rich");
364
+ if (rich) {
365
+ const firstT = findFirstDescendant(rich, "t");
366
+ if (firstT) return textContent(firstT);
367
+ }
368
+
369
+ return undefined;
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Cache extraction
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /**
377
+ * Extract a sparse numeric cache. Returns an array of `ptCount` length
378
+ * filled with `null`, with each `<c:pt idx="N"><c:v>value</c:v></c:pt>`
379
+ * populating index N. Values that fail to parse as finite numbers become
380
+ * `null` as well.
381
+ *
382
+ * The `expected` parameter lets callers bump the array length when
383
+ * `c:ptCount` is absent (e.g. when the cache is a companion to another
384
+ * series whose length is known).
385
+ */
386
+ export function extractNumCache(
387
+ container: XmlElementNode | undefined,
388
+ expected?: number,
389
+ ): Array<number | null> {
390
+ if (!container) return [];
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 =
397
+ findFirstDescendant(container, "numCache") ??
398
+ findFirstDescendant(container, "strCache");
399
+ if (!cacheNode) return [];
400
+ const ptCount = readIntVal(findChildOptional(cacheNode, "ptCount")) ?? 0;
401
+ const length = Math.max(ptCount, expected ?? 0);
402
+ const out: Array<number | null> = new Array(length).fill(null);
403
+ for (const child of cacheNode.children) {
404
+ if (child.type !== "element" || localName(child.name) !== "pt") continue;
405
+ const idxRaw = child.attributes["idx"];
406
+ const idx = idxRaw ? Number.parseInt(idxRaw, 10) : Number.NaN;
407
+ if (!Number.isFinite(idx) || idx < 0 || idx >= out.length) continue;
408
+ const vNode = findChildOptional(child, "v");
409
+ if (!vNode) continue;
410
+ const n = Number.parseFloat(textContent(vNode));
411
+ if (Number.isFinite(n)) out[idx] = n;
412
+ }
413
+ return out;
414
+ }
415
+
416
+ /**
417
+ * Extract a sparse string cache. Mirrors `extractNumCache` but fills
418
+ * missing indices with empty strings. Falls back to `numCache` if the
419
+ * container only has numeric data (so a numRef-backed category axis still
420
+ * yields stringified labels).
421
+ */
422
+ export function extractStrCache(
423
+ container: XmlElementNode | undefined,
424
+ ): string[] {
425
+ if (!container) return [];
426
+ const cache =
427
+ findFirstDescendant(container, "strCache") ??
428
+ findFirstDescendant(container, "numCache");
429
+ if (!cache) return [];
430
+ const ptCount = readIntVal(findChildOptional(cache, "ptCount")) ?? 0;
431
+ const out: string[] = new Array(ptCount).fill("");
432
+ for (const child of cache.children) {
433
+ if (child.type !== "element" || localName(child.name) !== "pt") continue;
434
+ const idxRaw = child.attributes["idx"];
435
+ const idx = idxRaw ? Number.parseInt(idxRaw, 10) : Number.NaN;
436
+ if (!Number.isFinite(idx) || idx < 0 || idx >= out.length) continue;
437
+ const vNode = findChildOptional(child, "v");
438
+ if (!vNode) continue;
439
+ out[idx] = textContent(vNode);
440
+ }
441
+ return out;
442
+ }
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // Shape properties (spPr) — fills and strokes
446
+ // ---------------------------------------------------------------------------
447
+
448
+ export function parseShapeProperties(
449
+ spPrNode: XmlElementNode | undefined,
450
+ ): ShapeProperties | undefined {
451
+ if (!spPrNode) return undefined;
452
+ const fill = parseFill(spPrNode);
453
+ const stroke = parseStroke(findChildOptional(spPrNode, "ln"));
454
+ if (!fill && !stroke) return undefined;
455
+ const result: ShapeProperties = {};
456
+ if (fill) result.fill = fill;
457
+ if (stroke) result.stroke = stroke;
458
+ return result;
459
+ }
460
+
461
+ function parseFill(spPrNode: XmlElementNode): FillSpec | undefined {
462
+ // Explicit fill-none sentinel.
463
+ if (findChildOptional(spPrNode, "noFill")) {
464
+ return { kind: "none" };
465
+ }
466
+ const solid = findChildOptional(spPrNode, "solidFill");
467
+ if (solid) {
468
+ const color = parseColorRef(solid);
469
+ if (color) return { kind: "solid", color };
470
+ }
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;
478
+ }
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
+ }
488
+ return { kind: "none" };
489
+ }
490
+ return undefined;
491
+ }
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
+
539
+ function parseStroke(lnNode: XmlElementNode | undefined): StrokeSpec | undefined {
540
+ if (!lnNode) return undefined;
541
+ const stroke: StrokeSpec = {};
542
+ const widthAttr = lnNode.attributes["w"];
543
+ if (widthAttr) {
544
+ const w = Number.parseInt(widthAttr, 10);
545
+ if (Number.isFinite(w)) stroke.widthEmu = w;
546
+ }
547
+ if (findChildOptional(lnNode, "noFill")) {
548
+ stroke.noFill = true;
549
+ }
550
+ const solid = findChildOptional(lnNode, "solidFill");
551
+ if (solid) {
552
+ const color = parseColorRef(solid);
553
+ if (color) stroke.color = color;
554
+ }
555
+ const prstDash = findChildOptional(lnNode, "prstDash");
556
+ if (prstDash) {
557
+ const dashVal = prstDash.attributes["val"];
558
+ switch (dashVal) {
559
+ case "solid":
560
+ case "dash":
561
+ case "dashDot":
562
+ case "lgDash":
563
+ case "lgDashDot":
564
+ case "sysDash":
565
+ case "sysDashDot":
566
+ stroke.dash = dashVal;
567
+ break;
568
+ default:
569
+ break;
570
+ }
571
+ }
572
+ if (
573
+ stroke.widthEmu === undefined &&
574
+ stroke.noFill === undefined &&
575
+ stroke.color === undefined &&
576
+ stroke.dash === undefined
577
+ ) {
578
+ return undefined;
579
+ }
580
+ return stroke;
581
+ }
582
+
583
+ /**
584
+ * Parse a color reference child out of a fill wrapper (solidFill, stroke
585
+ * solidFill, etc.). Supports srgbClr and schemeClr with the full OOXML
586
+ * modifier set (lumMod, lumOff, shade, tint, satMod, hueMod, alpha).
587
+ */
588
+ function parseColorRef(wrapper: XmlElementNode): ColorRef | undefined {
589
+ for (const child of wrapper.children) {
590
+ if (child.type !== "element") continue;
591
+ const local = localName(child.name);
592
+ if (local === "srgbClr") {
593
+ const val = child.attributes["val"];
594
+ if (!val) continue;
595
+ return { kind: "srgb", value: `#${val.toUpperCase()}` };
596
+ }
597
+ if (local === "schemeClr") {
598
+ const val = child.attributes["val"];
599
+ if (!val) continue;
600
+ const mods = parseColorMods(child);
601
+ const ref: ColorRef = { kind: "scheme", value: val };
602
+ if (mods.length > 0) ref.mods = mods;
603
+ return ref;
604
+ }
605
+ if (local === "sysClr") {
606
+ // Take the resolved lastClr value; treat as sRGB to stay in-band.
607
+ const lastClr = child.attributes["lastClr"];
608
+ if (lastClr) return { kind: "srgb", value: `#${lastClr.toUpperCase()}` };
609
+ }
610
+ }
611
+ return undefined;
612
+ }
613
+
614
+ function parseColorMods(colorNode: XmlElementNode): ColorMod[] {
615
+ const mods: ColorMod[] = [];
616
+ for (const child of colorNode.children) {
617
+ if (child.type !== "element") continue;
618
+ const local = localName(child.name);
619
+ if (
620
+ local !== "lumMod" &&
621
+ local !== "lumOff" &&
622
+ local !== "shade" &&
623
+ local !== "tint" &&
624
+ local !== "satMod" &&
625
+ local !== "hueMod" &&
626
+ local !== "alpha"
627
+ ) {
628
+ continue;
629
+ }
630
+ const value = readFloatVal(child);
631
+ if (value === undefined) continue;
632
+ mods.push({ kind: local, value });
633
+ }
634
+ return mods;
635
+ }