@beyondwork/docx-react-component 1.0.47 → 1.0.48

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +115 -1
  3. package/src/compare/diff-engine.ts +4 -0
  4. package/src/core/commands/add-scope.ts +257 -0
  5. package/src/core/commands/formatting-commands.ts +2 -0
  6. package/src/core/schema/text-schema.ts +95 -1
  7. package/src/core/state/text-transaction.ts +17 -5
  8. package/src/io/chart-preview-resolver.ts +27 -0
  9. package/src/io/docx-session.ts +226 -38
  10. package/src/io/export/serialize-main-document.ts +37 -0
  11. package/src/io/export/serialize-settings.ts +421 -0
  12. package/src/io/export/serialize-styles.ts +10 -0
  13. package/src/io/normalize/normalize-text.ts +1 -0
  14. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  15. package/src/io/ooxml/chart/parse-chart-space.ts +813 -0
  16. package/src/io/ooxml/chart/parse-series.ts +570 -0
  17. package/src/io/ooxml/chart/resolve-color.ts +251 -0
  18. package/src/io/ooxml/chart/types.ts +420 -0
  19. package/src/io/ooxml/parse-block-structure.ts +99 -0
  20. package/src/io/ooxml/parse-complex-content.ts +87 -2
  21. package/src/io/ooxml/parse-main-document.ts +115 -1
  22. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  23. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  24. package/src/io/ooxml/parse-settings.ts +97 -1
  25. package/src/io/ooxml/parse-styles.ts +65 -0
  26. package/src/io/ooxml/parse-theme.ts +2 -127
  27. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  28. package/src/io/ooxml/xml-parser.ts +142 -0
  29. package/src/model/canonical-document.ts +94 -0
  30. package/src/model/scope-markers.ts +144 -0
  31. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  32. package/src/runtime/collab/checkpoint-election.ts +75 -0
  33. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  34. package/src/runtime/collab/checkpoint-store.ts +115 -0
  35. package/src/runtime/collab/event-types.ts +27 -0
  36. package/src/runtime/collab/index.ts +22 -0
  37. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  38. package/src/runtime/collab/runtime-collab-sync.ts +279 -0
  39. package/src/runtime/document-runtime.ts +214 -16
  40. package/src/runtime/editor-surface/capabilities.ts +63 -50
  41. package/src/runtime/layout/layout-engine-version.ts +8 -1
  42. package/src/runtime/prerender/cache-envelope.ts +19 -7
  43. package/src/runtime/prerender/cache-key.ts +25 -14
  44. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  45. package/src/runtime/prerender/customxml-cache.ts +211 -0
  46. package/src/runtime/prerender/customxml-probe.ts +78 -0
  47. package/src/runtime/prerender/prerender-document.ts +74 -7
  48. package/src/runtime/scope-resolver.ts +148 -0
  49. package/src/runtime/scope-tag-registry.ts +10 -0
  50. package/src/runtime/surface-projection.ts +8 -1
  51. package/src/ui/WordReviewEditor.tsx +30 -0
  52. package/src/ui/editor-runtime-boundary.ts +6 -1
  53. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
@@ -0,0 +1,570 @@
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
+
71
+ const series: Series = {
72
+ idx,
73
+ order,
74
+ categories,
75
+ values,
76
+ };
77
+ if (name !== undefined) series.name = name;
78
+ if (spPr) series.spPr = spPr;
79
+ return series;
80
+ }
81
+
82
+ /**
83
+ * Parse a pre-parsed `<c:ser>` element into a `Series`. Thin wrapper over
84
+ * `parseSeriesBase`, kept for call-site readability in bar/column parsing
85
+ * where "bar" makes the intent clear.
86
+ */
87
+ export function parseBarSeriesNode(node: XmlElementNode): Series {
88
+ return parseSeriesBase(node);
89
+ }
90
+
91
+ function emptySeries(): Series {
92
+ return { idx: 0, order: 0, categories: [], values: [] };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Line series
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** Parse a `<c:ser>` XML fragment from a lineChart into a `LineSeries`. */
100
+ export function parseLineSeries(serXml: string): LineSeries {
101
+ const root = parseXml(serXml);
102
+ const node = findFirstDescendant(root, "ser");
103
+ if (!node) {
104
+ return emptySeries();
105
+ }
106
+ return parseLineSeriesNode(node);
107
+ }
108
+
109
+ /**
110
+ * Parse a pre-parsed `<c:ser>` element from a lineChart. Returns the common
111
+ * `Series` fields plus optional per-series smooth + marker overrides. Omits
112
+ * the optional keys entirely when absent so strict-type consumers can
113
+ * distinguish "not specified" from "explicitly false / none".
114
+ */
115
+ export function parseLineSeriesNode(node: XmlElementNode): LineSeries {
116
+ const base = parseSeriesBase(node);
117
+ const smoothNode = findChildOptional(node, "smooth");
118
+ const smooth = readOnOff(smoothNode);
119
+ const marker = parseMarkerSpec(findChildOptional(node, "marker"));
120
+
121
+ const series: LineSeries = { ...base };
122
+ if (smooth !== undefined) series.smooth = smooth;
123
+ if (marker) series.marker = marker;
124
+ return series;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Marker
129
+ // ---------------------------------------------------------------------------
130
+
131
+ const MARKER_SYMBOLS: ReadonlySet<MarkerSpec["symbol"]> = new Set<
132
+ MarkerSpec["symbol"]
133
+ >([
134
+ "circle",
135
+ "square",
136
+ "diamond",
137
+ "triangle",
138
+ "x",
139
+ "star",
140
+ "dot",
141
+ "dash",
142
+ "plus",
143
+ "picture",
144
+ "none",
145
+ "auto",
146
+ ]);
147
+
148
+ /**
149
+ * Parse a `<c:marker>` element. Returns undefined when the node is absent.
150
+ * Returns a `MarkerSpec` with `symbol: "auto"` when the element is present
151
+ * but declares no symbol — OOXML's default for a marker with no subclass.
152
+ * Unknown symbol values also collapse to `"auto"` so renderers never see
153
+ * an unrecognised enum.
154
+ */
155
+ export function parseMarkerSpec(
156
+ markerNode: XmlElementNode | undefined,
157
+ ): MarkerSpec | undefined {
158
+ if (!markerNode) return undefined;
159
+ const symbolRaw = findChildOptional(markerNode, "symbol")?.attributes["val"];
160
+ const symbol: MarkerSpec["symbol"] =
161
+ symbolRaw && isMarkerSymbol(symbolRaw) ? symbolRaw : "auto";
162
+ const size = readIntVal(findChildOptional(markerNode, "size"));
163
+ const spPr = parseShapeProperties(findChildOptional(markerNode, "spPr"));
164
+
165
+ const marker: MarkerSpec = { symbol };
166
+ if (size !== undefined) marker.size = size;
167
+ if (spPr) marker.spPr = spPr;
168
+ return marker;
169
+ }
170
+
171
+ function isMarkerSymbol(value: string): value is MarkerSpec["symbol"] {
172
+ return MARKER_SYMBOLS.has(value as MarkerSpec["symbol"]);
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Pie series
177
+ // ---------------------------------------------------------------------------
178
+
179
+ /** Parse a `<c:ser>` XML fragment from a pieChart/doughnutChart into `PieSeries`. */
180
+ export function parsePieSeries(serXml: string): PieSeries {
181
+ const root = parseXml(serXml);
182
+ const node = findFirstDescendant(root, "ser");
183
+ if (!node) {
184
+ return { idx: 0, order: 0, values: [] };
185
+ }
186
+ return parsePieSeriesNode(node);
187
+ }
188
+
189
+ /**
190
+ * Parse a pre-parsed `<c:ser>` element from a pieChart/doughnutChart.
191
+ *
192
+ * PieSeries extends SeriesBase (idx/order/name/spPr) with:
193
+ * - values: the numeric cache from c:val/c:numRef/c:numCache (sparse, nulls preserved).
194
+ * - explosion: series-level default offset percent from c:explosion val.
195
+ * - dataPoints: optional per-slice overrides (c:dPt[idx]/{spPr,explosion,bubble3D}).
196
+ *
197
+ * Categories for pie live on `PieChartModel.categoryLabels`, not on the
198
+ * series — the group parser reads them from the first series' c:cat.
199
+ */
200
+ export function parsePieSeriesNode(node: XmlElementNode): PieSeries {
201
+ const idx = readIntVal(findChildOptional(node, "idx")) ?? 0;
202
+ const order = readIntVal(findChildOptional(node, "order")) ?? 0;
203
+ const name = readSeriesName(findChildOptional(node, "tx"));
204
+ const values = extractNumCache(findChildOptional(node, "val"));
205
+ const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
206
+ const explosion = readIntVal(findChildOptional(node, "explosion"));
207
+ const dataPoints = parseDataPointOverrides(node);
208
+
209
+ const series: PieSeries = { idx, order, values };
210
+ if (name !== undefined) series.name = name;
211
+ if (spPr) series.spPr = spPr;
212
+ if (explosion !== undefined) series.explosion = explosion;
213
+ if (dataPoints.length > 0) series.dataPoints = dataPoints;
214
+ return series;
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Data-point overrides (c:dPt)
219
+ // ---------------------------------------------------------------------------
220
+
221
+ /**
222
+ * Walk `<c:dPt>` children of a `<c:ser>` element and return typed overrides.
223
+ *
224
+ * Each override must have a `<c:idx val="N">` child; malformed entries (no
225
+ * idx) are skipped silently — the well-formed entries still apply. Optional
226
+ * children captured: spPr (fill/stroke override), explosion (pie slice
227
+ * offset percent), bubble3D (tolerated on non-bubble charts), marker (line
228
+ * variants), invertIfNegative (bar variants).
229
+ */
230
+ // ---------------------------------------------------------------------------
231
+ // Scatter series
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /** Parse a `<c:ser>` XML fragment from a scatterChart into `ScatterSeries`. */
235
+ export function parseScatterSeries(serXml: string): ScatterSeries {
236
+ const root = parseXml(serXml);
237
+ const node = findFirstDescendant(root, "ser");
238
+ if (!node) {
239
+ return { idx: 0, order: 0, xValues: [], yValues: [] };
240
+ }
241
+ return parseScatterSeriesNode(node);
242
+ }
243
+
244
+ /**
245
+ * Parse a pre-parsed `<c:ser>` element from a scatterChart.
246
+ *
247
+ * ScatterSeries extends SeriesBase (idx/order/name/spPr) with:
248
+ * - xValues, yValues: parallel numeric caches from c:xVal and c:yVal.
249
+ * - smooth: per-series override (c:smooth).
250
+ * - marker: optional MarkerSpec (c:marker).
251
+ *
252
+ * Categories don't apply to scatter — both axes are numeric.
253
+ */
254
+ export function parseScatterSeriesNode(node: XmlElementNode): ScatterSeries {
255
+ const idx = readIntVal(findChildOptional(node, "idx")) ?? 0;
256
+ const order = readIntVal(findChildOptional(node, "order")) ?? 0;
257
+ const name = readSeriesName(findChildOptional(node, "tx"));
258
+ const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
259
+ const xValues = extractNumCache(findChildOptional(node, "xVal"));
260
+ const yValues = extractNumCache(findChildOptional(node, "yVal"));
261
+ const smooth = readOnOff(findChildOptional(node, "smooth"));
262
+ const marker = parseMarkerSpec(findChildOptional(node, "marker"));
263
+
264
+ const series: ScatterSeries = { idx, order, xValues, yValues };
265
+ if (name !== undefined) series.name = name;
266
+ if (spPr) series.spPr = spPr;
267
+ if (smooth !== undefined) series.smooth = smooth;
268
+ if (marker) series.marker = marker;
269
+ return series;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Bubble series
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /** Parse a `<c:ser>` XML fragment from a bubbleChart into `BubbleSeries`. */
277
+ export function parseBubbleSeries(serXml: string): BubbleSeries {
278
+ const root = parseXml(serXml);
279
+ const node = findFirstDescendant(root, "ser");
280
+ if (!node) {
281
+ return { idx: 0, order: 0, xValues: [], yValues: [], sizes: [] };
282
+ }
283
+ return parseBubbleSeriesNode(node);
284
+ }
285
+
286
+ /**
287
+ * Parse a pre-parsed `<c:ser>` element from a bubbleChart.
288
+ *
289
+ * BubbleSeries extends SeriesBase with x/y caches plus a third
290
+ * `sizes` channel from c:bubbleSize. Each triple (x, y, size) is one
291
+ * bubble; all three arrays are parallel-indexed, nulls preserved.
292
+ */
293
+ export function parseBubbleSeriesNode(node: XmlElementNode): BubbleSeries {
294
+ const idx = readIntVal(findChildOptional(node, "idx")) ?? 0;
295
+ const order = readIntVal(findChildOptional(node, "order")) ?? 0;
296
+ const name = readSeriesName(findChildOptional(node, "tx"));
297
+ const spPr = parseShapeProperties(findChildOptional(node, "spPr"));
298
+ const xValues = extractNumCache(findChildOptional(node, "xVal"));
299
+ const yValues = extractNumCache(findChildOptional(node, "yVal"));
300
+ const sizes = extractNumCache(findChildOptional(node, "bubbleSize"));
301
+
302
+ const series: BubbleSeries = { idx, order, xValues, yValues, sizes };
303
+ if (name !== undefined) series.name = name;
304
+ if (spPr) series.spPr = spPr;
305
+ return series;
306
+ }
307
+
308
+ export function parseDataPointOverrides(
309
+ serNode: XmlElementNode,
310
+ ): DataPointOverride[] {
311
+ const overrides: DataPointOverride[] = [];
312
+ for (const child of serNode.children) {
313
+ if (child.type !== "element" || localName(child.name) !== "dPt") continue;
314
+ const idxNode = findChildOptional(child, "idx");
315
+ const idx = readIntVal(idxNode);
316
+ if (idx === undefined) continue;
317
+
318
+ const override: DataPointOverride = { idx };
319
+ const spPr = parseShapeProperties(findChildOptional(child, "spPr"));
320
+ if (spPr) override.spPr = spPr;
321
+ const marker = parseMarkerSpec(findChildOptional(child, "marker"));
322
+ if (marker) override.marker = marker;
323
+ const explosion = readIntVal(findChildOptional(child, "explosion"));
324
+ if (explosion !== undefined) override.explosion = explosion;
325
+ const invert = readOnOff(findChildOptional(child, "invertIfNegative"));
326
+ if (invert !== undefined) override.invertIfNegative = invert;
327
+ const bubble3D = readOnOff(findChildOptional(child, "bubble3D"));
328
+ if (bubble3D !== undefined) override.bubble3D = bubble3D;
329
+
330
+ overrides.push(override);
331
+ }
332
+ return overrides;
333
+ }
334
+
335
+ function readSeriesName(txNode: XmlElementNode | undefined): string | undefined {
336
+ if (!txNode) return undefined;
337
+
338
+ // Case 1: <c:tx><c:strRef><c:strCache>…<c:v>Name</c:v>…
339
+ const strRef = findChildOptional(txNode, "strRef");
340
+ if (strRef) {
341
+ const cache = findFirstDescendant(strRef, "strCache");
342
+ if (cache) {
343
+ for (const child of cache.children) {
344
+ if (child.type !== "element" || localName(child.name) !== "pt") continue;
345
+ const v = findChildOptional(child, "v");
346
+ if (v) return textContent(v);
347
+ }
348
+ }
349
+ // strCache missing — fall back to any c:v descendant under the strRef.
350
+ const directV = findFirstDescendant(strRef, "v");
351
+ if (directV) return textContent(directV);
352
+ }
353
+
354
+ // Case 2: <c:tx><c:v>Name</c:v> (inline literal).
355
+ const direct = findChildOptional(txNode, "v");
356
+ if (direct) return textContent(direct);
357
+
358
+ // Case 3: <c:tx><c:rich><a:p><a:r><a:t>Name</a:t>… — plain-text collapse.
359
+ const rich = findChildOptional(txNode, "rich");
360
+ if (rich) {
361
+ const firstT = findFirstDescendant(rich, "t");
362
+ if (firstT) return textContent(firstT);
363
+ }
364
+
365
+ return undefined;
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Cache extraction
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /**
373
+ * Extract a sparse numeric cache. Returns an array of `ptCount` length
374
+ * filled with `null`, with each `<c:pt idx="N"><c:v>value</c:v></c:pt>`
375
+ * populating index N. Values that fail to parse as finite numbers become
376
+ * `null` as well.
377
+ *
378
+ * The `expected` parameter lets callers bump the array length when
379
+ * `c:ptCount` is absent (e.g. when the cache is a companion to another
380
+ * series whose length is known).
381
+ */
382
+ export function extractNumCache(
383
+ container: XmlElementNode | undefined,
384
+ expected?: number,
385
+ ): Array<number | null> {
386
+ if (!container) return [];
387
+ const cache =
388
+ findFirstDescendant(container, "numCache") ??
389
+ findFirstDescendant(container, "strCache");
390
+ if (!cache) return [];
391
+ const ptCount = readIntVal(findChildOptional(cache, "ptCount")) ?? 0;
392
+ const length = Math.max(ptCount, expected ?? 0);
393
+ const out: Array<number | null> = new Array(length).fill(null);
394
+ for (const child of cache.children) {
395
+ if (child.type !== "element" || localName(child.name) !== "pt") continue;
396
+ const idxRaw = child.attributes["idx"];
397
+ const idx = idxRaw ? Number.parseInt(idxRaw, 10) : Number.NaN;
398
+ if (!Number.isFinite(idx) || idx < 0 || idx >= out.length) continue;
399
+ const vNode = findChildOptional(child, "v");
400
+ if (!vNode) continue;
401
+ const n = Number.parseFloat(textContent(vNode));
402
+ if (Number.isFinite(n)) out[idx] = n;
403
+ }
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * Extract a sparse string cache. Mirrors `extractNumCache` but fills
409
+ * missing indices with empty strings. Falls back to `numCache` if the
410
+ * container only has numeric data (so a numRef-backed category axis still
411
+ * yields stringified labels).
412
+ */
413
+ export function extractStrCache(
414
+ container: XmlElementNode | undefined,
415
+ ): string[] {
416
+ if (!container) return [];
417
+ const cache =
418
+ findFirstDescendant(container, "strCache") ??
419
+ findFirstDescendant(container, "numCache");
420
+ if (!cache) return [];
421
+ const ptCount = readIntVal(findChildOptional(cache, "ptCount")) ?? 0;
422
+ const out: string[] = new Array(ptCount).fill("");
423
+ for (const child of cache.children) {
424
+ if (child.type !== "element" || localName(child.name) !== "pt") continue;
425
+ const idxRaw = child.attributes["idx"];
426
+ const idx = idxRaw ? Number.parseInt(idxRaw, 10) : Number.NaN;
427
+ if (!Number.isFinite(idx) || idx < 0 || idx >= out.length) continue;
428
+ const vNode = findChildOptional(child, "v");
429
+ if (!vNode) continue;
430
+ out[idx] = textContent(vNode);
431
+ }
432
+ return out;
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // Shape properties (spPr) — fills and strokes
437
+ // ---------------------------------------------------------------------------
438
+
439
+ export function parseShapeProperties(
440
+ spPrNode: XmlElementNode | undefined,
441
+ ): ShapeProperties | undefined {
442
+ if (!spPrNode) return undefined;
443
+ const fill = parseFill(spPrNode);
444
+ const stroke = parseStroke(findChildOptional(spPrNode, "ln"));
445
+ if (!fill && !stroke) return undefined;
446
+ const result: ShapeProperties = {};
447
+ if (fill) result.fill = fill;
448
+ if (stroke) result.stroke = stroke;
449
+ return result;
450
+ }
451
+
452
+ function parseFill(spPrNode: XmlElementNode): FillSpec | undefined {
453
+ // Explicit fill-none sentinel.
454
+ if (findChildOptional(spPrNode, "noFill")) {
455
+ return { kind: "none" };
456
+ }
457
+ const solid = findChildOptional(spPrNode, "solidFill");
458
+ if (solid) {
459
+ const color = parseColorRef(solid);
460
+ if (color) return { kind: "solid", color };
461
+ }
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" };
467
+ }
468
+ if (findChildOptional(spPrNode, "pattFill")) {
469
+ return { kind: "none" };
470
+ }
471
+ return undefined;
472
+ }
473
+
474
+ function parseStroke(lnNode: XmlElementNode | undefined): StrokeSpec | undefined {
475
+ if (!lnNode) return undefined;
476
+ const stroke: StrokeSpec = {};
477
+ const widthAttr = lnNode.attributes["w"];
478
+ if (widthAttr) {
479
+ const w = Number.parseInt(widthAttr, 10);
480
+ if (Number.isFinite(w)) stroke.widthEmu = w;
481
+ }
482
+ if (findChildOptional(lnNode, "noFill")) {
483
+ stroke.noFill = true;
484
+ }
485
+ const solid = findChildOptional(lnNode, "solidFill");
486
+ if (solid) {
487
+ const color = parseColorRef(solid);
488
+ if (color) stroke.color = color;
489
+ }
490
+ const prstDash = findChildOptional(lnNode, "prstDash");
491
+ if (prstDash) {
492
+ const dashVal = prstDash.attributes["val"];
493
+ switch (dashVal) {
494
+ case "solid":
495
+ case "dash":
496
+ case "dashDot":
497
+ case "lgDash":
498
+ case "lgDashDot":
499
+ case "sysDash":
500
+ case "sysDashDot":
501
+ stroke.dash = dashVal;
502
+ break;
503
+ default:
504
+ break;
505
+ }
506
+ }
507
+ if (
508
+ stroke.widthEmu === undefined &&
509
+ stroke.noFill === undefined &&
510
+ stroke.color === undefined &&
511
+ stroke.dash === undefined
512
+ ) {
513
+ return undefined;
514
+ }
515
+ return stroke;
516
+ }
517
+
518
+ /**
519
+ * Parse a color reference child out of a fill wrapper (solidFill, stroke
520
+ * solidFill, etc.). Supports srgbClr and schemeClr with the full OOXML
521
+ * modifier set (lumMod, lumOff, shade, tint, satMod, hueMod, alpha).
522
+ */
523
+ function parseColorRef(wrapper: XmlElementNode): ColorRef | undefined {
524
+ for (const child of wrapper.children) {
525
+ if (child.type !== "element") continue;
526
+ const local = localName(child.name);
527
+ if (local === "srgbClr") {
528
+ const val = child.attributes["val"];
529
+ if (!val) continue;
530
+ return { kind: "srgb", value: `#${val.toUpperCase()}` };
531
+ }
532
+ if (local === "schemeClr") {
533
+ const val = child.attributes["val"];
534
+ if (!val) continue;
535
+ const mods = parseColorMods(child);
536
+ const ref: ColorRef = { kind: "scheme", value: val };
537
+ if (mods.length > 0) ref.mods = mods;
538
+ return ref;
539
+ }
540
+ if (local === "sysClr") {
541
+ // Take the resolved lastClr value; treat as sRGB to stay in-band.
542
+ const lastClr = child.attributes["lastClr"];
543
+ if (lastClr) return { kind: "srgb", value: `#${lastClr.toUpperCase()}` };
544
+ }
545
+ }
546
+ return undefined;
547
+ }
548
+
549
+ function parseColorMods(colorNode: XmlElementNode): ColorMod[] {
550
+ const mods: ColorMod[] = [];
551
+ for (const child of colorNode.children) {
552
+ if (child.type !== "element") continue;
553
+ const local = localName(child.name);
554
+ if (
555
+ local !== "lumMod" &&
556
+ local !== "lumOff" &&
557
+ local !== "shade" &&
558
+ local !== "tint" &&
559
+ local !== "satMod" &&
560
+ local !== "hueMod" &&
561
+ local !== "alpha"
562
+ ) {
563
+ continue;
564
+ }
565
+ const value = readFloatVal(child);
566
+ if (value === undefined) continue;
567
+ mods.push({ kind: local, value });
568
+ }
569
+ return mods;
570
+ }