@rowsncolumns/toolkit 24.4.5 → 24.5.1

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 (113) hide show
  1. package/dist/cjs/autofilter-condition.d.ts +45 -1
  2. package/dist/cjs/autofilter-condition.d.ts.map +1 -1
  3. package/dist/cjs/autofilter-condition.js +135 -12
  4. package/dist/cjs/excel-parser/helpers.d.ts +15 -1
  5. package/dist/cjs/excel-parser/helpers.d.ts.map +1 -1
  6. package/dist/cjs/excel-parser/helpers.js +119 -5
  7. package/dist/cjs/excel-parser/interface.d.ts +3 -1
  8. package/dist/cjs/excel-parser/interface.d.ts.map +1 -1
  9. package/dist/cjs/excel-parser/plugins/ods/index.d.ts.map +1 -1
  10. package/dist/cjs/excel-parser/plugins/ods/index.js +186 -97
  11. package/dist/cjs/excel-parser/plugins/ods/styles.d.ts +15 -0
  12. package/dist/cjs/excel-parser/plugins/ods/styles.d.ts.map +1 -1
  13. package/dist/cjs/excel-parser/plugins/ods/styles.js +80 -6
  14. package/dist/cjs/excel-parser/plugins/xlsb/index.d.ts.map +1 -1
  15. package/dist/cjs/excel-parser/plugins/xlsb/index.js +107 -103
  16. package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts +77 -1
  17. package/dist/cjs/excel-parser/plugins/xlsx/charts.d.ts.map +1 -1
  18. package/dist/cjs/excel-parser/plugins/xlsx/charts.js +920 -122
  19. package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts +7 -1
  20. package/dist/cjs/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -1
  21. package/dist/cjs/excel-parser/plugins/xlsx/drawing.js +24 -9
  22. package/dist/cjs/excel-parser/plugins/xlsx/form-controls.d.ts +24 -3
  23. package/dist/cjs/excel-parser/plugins/xlsx/form-controls.d.ts.map +1 -1
  24. package/dist/cjs/excel-parser/plugins/xlsx/form-controls.js +25 -3
  25. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts +24 -1
  26. package/dist/cjs/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  27. package/dist/cjs/excel-parser/plugins/xlsx/index.js +561 -394
  28. package/dist/cjs/excel-parser/plugins/xlsx/pivots.d.ts +8 -2
  29. package/dist/cjs/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -1
  30. package/dist/cjs/excel-parser/plugins/xlsx/pivots.js +312 -21
  31. package/dist/cjs/excel-parser/plugins/xlsx/relationships.d.ts +6 -0
  32. package/dist/cjs/excel-parser/plugins/xlsx/relationships.d.ts.map +1 -1
  33. package/dist/cjs/excel-parser/plugins/xlsx/relationships.js +9 -0
  34. package/dist/cjs/excel-parser/plugins/xlsx/theme.d.ts +1 -1
  35. package/dist/cjs/excel-parser/types.d.ts +9 -1
  36. package/dist/cjs/excel-parser/types.d.ts.map +1 -1
  37. package/dist/cjs/excel-parser/workbook.d.ts +1 -0
  38. package/dist/cjs/excel-parser/workbook.d.ts.map +1 -1
  39. package/dist/cjs/excel-parser/workbook.js +5 -0
  40. package/dist/cjs/excel-parser/worker.js +2 -0
  41. package/dist/cjs/exporter/ods/ods-export.d.ts.map +1 -1
  42. package/dist/cjs/exporter/ods/ods-export.js +255 -80
  43. package/dist/cjs/exporter/xlsx/chart-builder.d.ts +7 -1
  44. package/dist/cjs/exporter/xlsx/chart-builder.d.ts.map +1 -1
  45. package/dist/cjs/exporter/xlsx/chart-builder.js +902 -119
  46. package/dist/cjs/exporter/xlsx/form-controls-builder.d.ts +16 -3
  47. package/dist/cjs/exporter/xlsx/form-controls-builder.d.ts.map +1 -1
  48. package/dist/cjs/exporter/xlsx/form-controls-builder.js +30 -8
  49. package/dist/cjs/exporter/xlsx/pivot-builder.d.ts.map +1 -1
  50. package/dist/cjs/exporter/xlsx/pivot-builder.js +206 -162
  51. package/dist/cjs/exporter/xlsx/style-builder.d.ts +8 -2
  52. package/dist/cjs/exporter/xlsx/style-builder.d.ts.map +1 -1
  53. package/dist/cjs/exporter/xlsx/style-builder.js +25 -0
  54. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts +2 -2
  55. package/dist/cjs/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  56. package/dist/cjs/exporter/xlsx/xlsx-export.js +884 -470
  57. package/dist/esm/autofilter-condition.d.ts +45 -1
  58. package/dist/esm/autofilter-condition.d.ts.map +1 -1
  59. package/dist/esm/autofilter-condition.js +131 -11
  60. package/dist/esm/excel-parser/helpers.d.ts +15 -1
  61. package/dist/esm/excel-parser/helpers.d.ts.map +1 -1
  62. package/dist/esm/excel-parser/helpers.js +94 -4
  63. package/dist/esm/excel-parser/interface.d.ts +3 -1
  64. package/dist/esm/excel-parser/interface.d.ts.map +1 -1
  65. package/dist/esm/excel-parser/plugins/ods/index.d.ts.map +1 -1
  66. package/dist/esm/excel-parser/plugins/ods/index.js +105 -22
  67. package/dist/esm/excel-parser/plugins/ods/styles.d.ts +15 -0
  68. package/dist/esm/excel-parser/plugins/ods/styles.d.ts.map +1 -1
  69. package/dist/esm/excel-parser/plugins/ods/styles.js +79 -5
  70. package/dist/esm/excel-parser/plugins/xlsb/index.d.ts.map +1 -1
  71. package/dist/esm/excel-parser/plugins/xlsb/index.js +15 -7
  72. package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts +77 -1
  73. package/dist/esm/excel-parser/plugins/xlsx/charts.d.ts.map +1 -1
  74. package/dist/esm/excel-parser/plugins/xlsx/charts.js +708 -16
  75. package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts +7 -1
  76. package/dist/esm/excel-parser/plugins/xlsx/drawing.d.ts.map +1 -1
  77. package/dist/esm/excel-parser/plugins/xlsx/drawing.js +23 -0
  78. package/dist/esm/excel-parser/plugins/xlsx/form-controls.d.ts +24 -3
  79. package/dist/esm/excel-parser/plugins/xlsx/form-controls.d.ts.map +1 -1
  80. package/dist/esm/excel-parser/plugins/xlsx/form-controls.js +21 -3
  81. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts +24 -1
  82. package/dist/esm/excel-parser/plugins/xlsx/index.d.ts.map +1 -1
  83. package/dist/esm/excel-parser/plugins/xlsx/index.js +192 -54
  84. package/dist/esm/excel-parser/plugins/xlsx/pivots.d.ts +8 -2
  85. package/dist/esm/excel-parser/plugins/xlsx/pivots.d.ts.map +1 -1
  86. package/dist/esm/excel-parser/plugins/xlsx/pivots.js +220 -4
  87. package/dist/esm/excel-parser/plugins/xlsx/relationships.d.ts +6 -0
  88. package/dist/esm/excel-parser/plugins/xlsx/relationships.d.ts.map +1 -1
  89. package/dist/esm/excel-parser/plugins/xlsx/relationships.js +9 -0
  90. package/dist/esm/excel-parser/plugins/xlsx/theme.d.ts +1 -1
  91. package/dist/esm/excel-parser/types.d.ts +9 -1
  92. package/dist/esm/excel-parser/types.d.ts.map +1 -1
  93. package/dist/esm/excel-parser/workbook.d.ts +1 -0
  94. package/dist/esm/excel-parser/workbook.d.ts.map +1 -1
  95. package/dist/esm/excel-parser/workbook.js +4 -0
  96. package/dist/esm/excel-parser/worker.js +2 -0
  97. package/dist/esm/exporter/ods/ods-export.d.ts.map +1 -1
  98. package/dist/esm/exporter/ods/ods-export.js +238 -72
  99. package/dist/esm/exporter/xlsx/chart-builder.d.ts +7 -1
  100. package/dist/esm/exporter/xlsx/chart-builder.d.ts.map +1 -1
  101. package/dist/esm/exporter/xlsx/chart-builder.js +674 -58
  102. package/dist/esm/exporter/xlsx/form-controls-builder.d.ts +16 -3
  103. package/dist/esm/exporter/xlsx/form-controls-builder.d.ts.map +1 -1
  104. package/dist/esm/exporter/xlsx/form-controls-builder.js +29 -8
  105. package/dist/esm/exporter/xlsx/pivot-builder.d.ts.map +1 -1
  106. package/dist/esm/exporter/xlsx/pivot-builder.js +187 -139
  107. package/dist/esm/exporter/xlsx/style-builder.d.ts +8 -2
  108. package/dist/esm/exporter/xlsx/style-builder.d.ts.map +1 -1
  109. package/dist/esm/exporter/xlsx/style-builder.js +28 -0
  110. package/dist/esm/exporter/xlsx/xlsx-export.d.ts +2 -2
  111. package/dist/esm/exporter/xlsx/xlsx-export.d.ts.map +1 -1
  112. package/dist/esm/exporter/xlsx/xlsx-export.js +600 -206
  113. package/package.json +10 -5
@@ -1,6 +1,8 @@
1
1
  import { generateSelectionsFromFormula } from "@rowsncolumns/utils";
2
2
  import { uuid } from "@rowsncolumns/utils";
3
+ import { XMLParser } from "fast-xml-parser";
3
4
  import { get } from "lodash-es";
5
+ import { anchorPlacementFromEditAs } from "./drawing";
4
6
  /**
5
7
  * Pick the data sheetId for a parsed selection. Honours the explicit
6
8
  * sheet prefix in the chart's formula reference (cross-sheet charts);
@@ -83,6 +85,26 @@ export async function processChart(chartTarget, drawingPath, anchor, sheetId, zi
83
85
  roundedCornersVal === true
84
86
  ? true
85
87
  : undefined;
88
+ // Chart Styles gallery id: `<c14:style>` (Office 2010+, id + 100)
89
+ // inside mc:Choice wins over the `<c:style>` fallback. Excel writes
90
+ // 2 / 102 on an unstyled chart, so that default stays implicit.
91
+ const chartStyle = extractChartStyle(chartSpaceRoot);
92
+ // PivotChart binding: `<c:pivotSource><c:name>` names the pivot; the
93
+ // id is resolved once the workbook's pivots are parsed
94
+ // (`bindPivotChartsToPivotTables`). `<c:pivotFmts>` and the c14
95
+ // field-button options ride along on the spec.
96
+ const pivotSourceName = extractPivotSourceName(chartSpaceRoot);
97
+ const pivotSourceFormatId = extractPivotSourceFormatId(chartSpaceRoot);
98
+ const pivotFormats = extractPivotFormats(chart);
99
+ if (pivotFormats)
100
+ attachPivotFormatBodies(pivotFormats, chartContent);
101
+ const pivotFieldButtons = extractPivotFieldButtons(chartSpaceRoot);
102
+ // Format Chart Area fill / border from the chartSpace `<c:spPr>`.
103
+ // Only literal `<a:srgbClr>` counts: Excel's default is a theme
104
+ // `bg1` fill, which must not pin every imported chart to white.
105
+ const chartSpacePr = get(chartSpaceRoot, "c:spPr") ?? get(chartSpaceRoot, "spPr");
106
+ const chartAreaFill = extractSrgbHex(get(chartSpacePr, "a:solidFill") ?? get(chartSpacePr, "solidFill"));
107
+ const chartAreaBorder = extractSrgbHex(get(chartSpacePr, "a:ln.a:solidFill") ?? get(chartSpacePr, "ln.solidFill"));
86
108
  const chartId = uuid();
87
109
  // Extract chart title (text + formatting)
88
110
  const title = extractChartTitle(chart);
@@ -106,6 +128,10 @@ export async function processChart(chartTarget, drawingPath, anchor, sheetId, zi
106
128
  const scatterStyle = chartType === "scatter" ? extractScatterStyle(plotArea) : undefined;
107
129
  // Extract hidden dimension strategy from plotVisOnly
108
130
  const hiddenDimensionStrategy = extractHiddenDimensionStrategy(chart);
131
+ // "Hidden and Empty Cells": only the non-default choices are recorded
132
+ // (Excel writes `gap` and `dispNaAsBlank="1"` into every file).
133
+ const emptyCells = extractEmptyCellsPolicy(chart);
134
+ const showNaAsEmpty = extractShowNaAsEmpty(chart);
109
135
  // Extract position from anchor
110
136
  const position = extractChartPosition(anchor, sheetId, sheetDimensions);
111
137
  if (!position) {
@@ -116,6 +142,10 @@ export async function processChart(chartTarget, drawingPath, anchor, sheetId, zi
116
142
  const dataLabels = extractDataLabelsOptions(plotArea, chartType);
117
143
  const { valueAxisOptions, secondaryValueAxisOptions, categoryAxisOptions, } = extractAxisOptions(plotArea);
118
144
  const doughnutHoleSize = chartType === "doughnut" ? extractDoughnutHoleSize(plotArea) : undefined;
145
+ // Exploded pie / doughnut — the per-series <c:explosion> percentage.
146
+ const explosion = chartType === "pie" || chartType === "doughnut"
147
+ ? extractExplosion(plotArea, chartType)
148
+ : undefined;
119
149
  // Pie-of-pie / bar-of-pie — <c:ofPieChart> degrades to chartType
120
150
  // "pie" with sourceChartType "OF_PIE" plus the split fields the
121
151
  // renderer uses to draw the composite layout.
@@ -135,6 +165,11 @@ export async function processChart(chartTarget, drawingPath, anchor, sheetId, zi
135
165
  const barLayout = chartType === "bar" || chartType === "column"
136
166
  ? extractBarLayoutOptions(plotArea)
137
167
  : undefined;
168
+ // Line-only — <c:grouping> (stacked / percentStacked) and the
169
+ // chart-level <c:marker> flag behind Excel's "Line with Markers".
170
+ const lineLayout = chartType === "line" ? extractLineChartOptions(plotArea, series) : undefined;
171
+ // Stock-only — HLC / OHLC / VHLC / VOHLC from the series layout.
172
+ const stockVariant = chartType === "stock" ? extractStockVariant(plotArea) : undefined;
138
173
  return {
139
174
  chartId,
140
175
  spec: {
@@ -145,6 +180,8 @@ export async function processChart(chartTarget, drawingPath, anchor, sheetId, zi
145
180
  series,
146
181
  dataRange,
147
182
  hiddenDimensionStrategy,
183
+ ...(emptyCells && emptyCells !== "gaps" ? { emptyCells } : {}),
184
+ ...(showNaAsEmpty === false ? { showNaAsEmpty } : {}),
148
185
  // Scatter/bubble must conform to BubbleChartSpec which requires
149
186
  // `bubbleLabels`. We don't currently extract per-point labels
150
187
  // from OOXML (rare in real files); set an empty array so the
@@ -167,6 +204,7 @@ export async function processChart(chartTarget, drawingPath, anchor, sheetId, zi
167
204
  ...(typeof doughnutHoleSize === "number"
168
205
  ? { doughnutHoleSize }
169
206
  : {}),
207
+ ...(typeof explosion === "number" ? { explosion } : {}),
170
208
  ...(ofPieOptions
171
209
  ? { sourceChartType: "OF_PIE", ...ofPieOptions }
172
210
  : {}),
@@ -184,9 +222,23 @@ export async function processChart(chartTarget, drawingPath, anchor, sheetId, zi
184
222
  ...(typeof barLayout?.gapWidth === "number"
185
223
  ? { gapWidth: barLayout.gapWidth }
186
224
  : {}),
225
+ ...(lineLayout?.stackedType
226
+ ? { stackedType: lineLayout.stackedType }
227
+ : {}),
228
+ ...(lineLayout?.showMarkers ? { showMarkers: true } : {}),
229
+ ...(stockVariant ? { stockVariant } : {}),
187
230
  ...(roundedCorners ? { roundedCorners } : {}),
231
+ ...(chartStyle !== undefined ? { chartStyle } : {}),
232
+ ...(chartAreaFill ? { backgroundColor: chartAreaFill } : {}),
233
+ ...(pivotSourceName ? { [PIVOT_SOURCE_NAME_KEY]: pivotSourceName } : {}),
234
+ ...(pivotSourceFormatId
235
+ ? { [PIVOT_SOURCE_FORMAT_ID_KEY]: pivotSourceFormatId }
236
+ : {}),
237
+ ...(pivotFormats ? { pivotFormats } : {}),
238
+ ...(pivotFieldButtons ? { pivotFieldButtons } : {}),
188
239
  },
189
240
  position,
241
+ ...(chartAreaBorder ? { borderColor: chartAreaBorder } : {}),
190
242
  id: chartId,
191
243
  type: "chart",
192
244
  };
@@ -256,8 +308,20 @@ function getSeriesElements(plotArea, chartType) {
256
308
  ]);
257
309
  case "radar":
258
310
  return getWithFallback(plotArea, ["c:radarChart.c:ser", "radarChart.ser"]);
259
- case "stock":
260
- return getWithFallback(plotArea, ["c:stockChart.c:ser", "stockChart.ser"]);
311
+ case "stock": {
312
+ // VHLC / VOHLC: the volume series lives in a sibling <c:barChart>
313
+ // and comes first (utils/stock.ts STOCK_ROLES); the price series
314
+ // follow in the <c:stockChart>.
315
+ const toArray = (value) => value === undefined || value === null
316
+ ? []
317
+ : Array.isArray(value)
318
+ ? value
319
+ : [value];
320
+ return [
321
+ ...toArray(getWithFallback(plotArea, ["c:barChart.c:ser", "barChart.ser"])),
322
+ ...toArray(getWithFallback(plotArea, ["c:stockChart.c:ser", "stockChart.ser"])),
323
+ ];
324
+ }
261
325
  case "scatter":
262
326
  return getWithFallback(plotArea, [
263
327
  "c:scatterChart.c:ser",
@@ -456,6 +520,51 @@ export function extractHiddenDimensionStrategy(chart) {
456
520
  const skipHidden = plotVisOnly === "1" || plotVisOnly === "true" || plotVisOnly === true;
457
521
  return skipHidden ? "SKIP_HIDDEN_ROWS_AND_COLUMNS" : "SHOW_ALL";
458
522
  }
523
+ /**
524
+ * `<c:dispBlanksAs val="gap|zero|span"/>` — Excel's "Show empty cells as"
525
+ * (Select Data → Hidden and Empty Cells). `span` is "Connect data points
526
+ * with line".
527
+ */
528
+ export function extractEmptyCellsPolicy(chart) {
529
+ const val = getWithFallback(chart, [
530
+ "c:dispBlanksAs.a$.val",
531
+ "dispBlanksAs.a$.val",
532
+ ]);
533
+ switch (val) {
534
+ case "gap":
535
+ return "gaps";
536
+ case "zero":
537
+ return "zero";
538
+ case "span":
539
+ return "connect";
540
+ default:
541
+ return undefined;
542
+ }
543
+ }
544
+ /**
545
+ * Excel 2016+ "Show #N/A as an empty cell":
546
+ * `<c:chart><c:extLst><c:ext uri="{56B9EC1D-…}"><c16r3:dataDisplayOptions16>
547
+ * <c16r3:dispNaAsBlank val="0|1"/>`. Undefined when the file predates the
548
+ * option (Excel then treats #N/A the legacy way — as if `val="0"`, but the
549
+ * spec leaves the default to the renderer).
550
+ */
551
+ export function extractShowNaAsEmpty(chart) {
552
+ const extLst = getWithFallback(chart, ["c:extLst", "extLst"]);
553
+ if (!extLst)
554
+ return undefined;
555
+ const extNode = getWithFallback(extLst, ["c:ext", "ext"]);
556
+ const exts = Array.isArray(extNode) ? extNode : extNode ? [extNode] : [];
557
+ for (const ext of exts) {
558
+ const val = getWithFallback(ext, [
559
+ "c16r3:dataDisplayOptions16.c16r3:dispNaAsBlank.a$.val",
560
+ "dataDisplayOptions16.dispNaAsBlank.a$.val",
561
+ ]);
562
+ if (val !== undefined) {
563
+ return val === "1" || val === "true" || val === true;
564
+ }
565
+ }
566
+ return undefined;
567
+ }
459
568
  /**
460
569
  * Read the chart title's text formatting. Excel chart titles can be
461
570
  * styled two ways:
@@ -467,6 +576,46 @@ export function extractHiddenDimensionStrategy(chart) {
467
576
  * the more explicit signal). Returns `undefined` when neither carries
468
577
  * formatting so the renderer falls back to its theme defaults.
469
578
  */
579
+ /**
580
+ * `<c:chartSpace>` → the Chart Styles id, from `<c14:style>` in the
581
+ * mc:Choice (id + 100) or the plain `<c:style>`. Excel's unstyled default
582
+ * (2) is returned as `undefined` so it stays implicit on the spec.
583
+ */
584
+ function extractChartStyle(chartSpaceRoot) {
585
+ const alternates = [
586
+ getWithFallback(chartSpaceRoot, ["mc:AlternateContent", "AlternateContent"]),
587
+ ]
588
+ .flat()
589
+ .filter(Boolean);
590
+ let raw;
591
+ for (const alternate of alternates) {
592
+ const c14 = getWithFallback(alternate, [
593
+ "mc:Choice.c14:style.a$.val",
594
+ "Choice.c14:style.a$.val",
595
+ "Choice.style.a$.val",
596
+ ]);
597
+ if (c14 !== undefined) {
598
+ const value = Number(c14);
599
+ raw = value > 100 ? value - 100 : value;
600
+ break;
601
+ }
602
+ }
603
+ if (raw === undefined) {
604
+ raw = getWithFallback(chartSpaceRoot, ["c:style.a$.val", "style.a$.val"]);
605
+ }
606
+ const style = Number(raw);
607
+ if (!Number.isInteger(style) || style < 1 || style > 48 || style === 2) {
608
+ return undefined;
609
+ }
610
+ return style;
611
+ }
612
+ /** `<a:solidFill><a:srgbClr val="RRGGBB"/>` → `#RRGGBB`; theme fills are ignored. */
613
+ function extractSrgbHex(solidFill) {
614
+ const raw = get(solidFill, "a:srgbClr.a$.val") ?? get(solidFill, "srgbClr.a$.val");
615
+ return typeof raw === "string" && /^[0-9a-fA-F]{6}$/.test(raw)
616
+ ? `#${raw.toUpperCase()}`
617
+ : undefined;
618
+ }
470
619
  function extractChartTitleTextFormat(chart) {
471
620
  const title = get(chart, "c:title") ?? get(chart, "title");
472
621
  if (!title)
@@ -478,6 +627,310 @@ function extractChartTitleTextFormat(chart) {
478
627
  const txPr = get(title, "c:txPr") ?? get(title, "txPr");
479
628
  return extractTextProperties(txPr);
480
629
  }
630
+ /**
631
+ * Transient spec key carrying `<c:pivotSource><c:name>` until the workbook's
632
+ * pivots are known; `bindPivotChartsToPivotTables` turns it into
633
+ * `pivotTableId` and removes it.
634
+ */
635
+ export const PIVOT_SOURCE_NAME_KEY = "__pivotSourceName";
636
+ /**
637
+ * Transient spec key carrying `<c:pivotSource><c:fmtId>` alongside the
638
+ * name; becomes `pivotSource.formatId` once the chart binds to its pivot.
639
+ */
640
+ export const PIVOT_SOURCE_FORMAT_ID_KEY = "__pivotSourceFormatId";
641
+ /**
642
+ * `<c:pivotSource><c:fmtId val="3"/>` — the id the pivot's `<chartFormats>`
643
+ * name this chart by. `0` is the exporter's default and stays implicit.
644
+ */
645
+ export function extractPivotSourceFormatId(chartSpaceRoot) {
646
+ const raw = getWithFallback(chartSpaceRoot, [
647
+ "c:pivotSource.c:fmtId.a$.val",
648
+ "pivotSource.fmtId.a$.val",
649
+ ]);
650
+ const id = toInt(raw);
651
+ return Number.isFinite(id) && id > 0 ? id : undefined;
652
+ }
653
+ /** `<c:pivotSource><c:name>[Book1.xlsx]Sheet1!PivotTable1</c:name>` → the name text. */
654
+ export function extractPivotSourceName(chartSpaceRoot) {
655
+ const raw = getWithFallback(chartSpaceRoot, [
656
+ "c:pivotSource.c:name.t$",
657
+ "c:pivotSource.c:name",
658
+ "pivotSource.name.t$",
659
+ "pivotSource.name",
660
+ ]);
661
+ const name = typeof raw === "string" ? raw.trim() : undefined;
662
+ return name ? name : undefined;
663
+ }
664
+ /**
665
+ * Split a pivotSource name into its sheet and pivot-table parts. The
666
+ * `[workbook]` prefix is dropped (it names the file the chart lived in);
667
+ * the sheet may be quoted (`'My Sheet'!PivotTable1`).
668
+ */
669
+ export function parsePivotSourceName(name) {
670
+ const withoutBook = name.replace(/^\[[^\]]*\]/, "");
671
+ const bang = withoutBook.lastIndexOf("!");
672
+ if (bang === -1) {
673
+ return withoutBook ? { pivotName: withoutBook } : undefined;
674
+ }
675
+ const pivotName = withoutBook.slice(bang + 1);
676
+ if (!pivotName)
677
+ return undefined;
678
+ let sheetTitle = withoutBook.slice(0, bang);
679
+ if (sheetTitle.startsWith("'") && sheetTitle.endsWith("'")) {
680
+ sheetTitle = sheetTitle.slice(1, -1).replace(/''/g, "'");
681
+ }
682
+ return { sheetTitle: sheetTitle || undefined, pivotName };
683
+ }
684
+ /**
685
+ * Resolve every imported chart's `<c:pivotSource>` name to the id of the
686
+ * imported PivotTable it names (matched by pivot name, and by the pivot's
687
+ * target sheet when the name carries one). A name that matches no pivot
688
+ * leaves a plain chart — Excel converts a PivotChart whose pivot is gone
689
+ * to a static chart too. The transient key is always removed.
690
+ */
691
+ export function bindPivotChartsToPivotTables(charts, pivotTables, sheetTitleById) {
692
+ for (const chart of charts) {
693
+ const spec = chart.spec;
694
+ const raw = spec[PIVOT_SOURCE_NAME_KEY];
695
+ const formatId = spec[PIVOT_SOURCE_FORMAT_ID_KEY];
696
+ delete spec[PIVOT_SOURCE_FORMAT_ID_KEY];
697
+ if (raw === undefined)
698
+ continue;
699
+ delete spec[PIVOT_SOURCE_NAME_KEY];
700
+ const parsed = typeof raw === "string" ? parsePivotSourceName(raw) : undefined;
701
+ if (!parsed)
702
+ continue;
703
+ const byName = pivotTables.filter((pivot) => (pivot.name ?? `PivotTable${pivot.pivotId}`) === parsed.pivotName);
704
+ const match = byName.find((pivot) => parsed.sheetTitle === undefined ||
705
+ sheetTitleById.get(pivot.targetPosition?.sheetId) === parsed.sheetTitle) ?? (parsed.sheetTitle === undefined ? byName[0] : undefined);
706
+ if (!match)
707
+ continue;
708
+ spec.pivotTableId = match.pivotId;
709
+ if (!spec.pivotSource) {
710
+ spec.pivotSource = { categoryAxis: "rows" };
711
+ }
712
+ if (typeof formatId === "number") {
713
+ spec.pivotSource.formatId = formatId;
714
+ }
715
+ }
716
+ }
717
+ /**
718
+ * `<c:chart><c:pivotFmts><c:pivotFmt><c:idx val/>…` — the per pivot-item
719
+ * formats Excel re-applies on a re-plot; only the index and a literal
720
+ * fill colour are kept.
721
+ */
722
+ export function extractPivotFormats(chart) {
723
+ const raw = getWithFallback(chart, ["c:pivotFmts.c:pivotFmt", "pivotFmts.pivotFmt"]);
724
+ if (raw === undefined)
725
+ return undefined;
726
+ const list = Array.isArray(raw) ? raw : [raw];
727
+ const formats = [];
728
+ for (const fmt of list) {
729
+ const idx = toInt(getWithFallback(fmt, ["c:idx.a$.val", "idx.a$.val"]));
730
+ const fill = getWithFallback(fmt, [
731
+ "c:spPr.a:solidFill.a:srgbClr.a$.val",
732
+ "spPr.solidFill.srgbClr.a$.val",
733
+ ]);
734
+ formats.push({
735
+ index: idx,
736
+ ...(typeof fill === "string" && fill ? { color: `#${fill.toUpperCase()}` } : {}),
737
+ });
738
+ }
739
+ return formats.length ? formats : undefined;
740
+ }
741
+ /**
742
+ * Namespace URI → the prefix Excel writes for it. Bodies are stored with these
743
+ * canonical prefixes so a file that declared, say, `xmlns:c="…"` as `cs:` still
744
+ * round-trips through an exporter that declares `c:` / `a:` at the root.
745
+ */
746
+ export const CHART_XML_PREFIXES = {
747
+ "http://schemas.openxmlformats.org/drawingml/2006/chart": "c",
748
+ "http://schemas.openxmlformats.org/drawingml/2006/main": "a",
749
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships": "r",
750
+ "http://schemas.openxmlformats.org/markup-compatibility/2006": "mc",
751
+ "http://schemas.microsoft.com/office/drawing/2007/8/2/chart": "c14",
752
+ "http://schemas.microsoft.com/office/drawing/2012/chart": "c15",
753
+ "http://schemas.microsoft.com/office/drawing/2014/chart": "c16",
754
+ "http://schemas.microsoft.com/office/drawing/2015/06/chart": "c16r2",
755
+ "http://schemas.microsoft.com/office/drawing/2017/03/chart": "c16r3",
756
+ "http://schemas.microsoft.com/office/drawing/2010/main": "a14",
757
+ "http://schemas.microsoft.com/office/drawing/2014/main": "a16",
758
+ };
759
+ // Ordered parse of a chart part — the object parse the plugin uses drops the
760
+ // order of differently named siblings, which DrawingML schemas fix
761
+ // (`<a:gsLst>` before `<a:lin>`, `<a:bodyPr>` before `<a:p>`), so the verbatim
762
+ // bodies are read from a `preserveOrder` pass instead.
763
+ const orderedChartParser = new XMLParser({
764
+ ignoreAttributes: false,
765
+ preserveOrder: true,
766
+ attributeNamePrefix: "",
767
+ textNodeName: "t$",
768
+ trimValues: false,
769
+ processEntities: false,
770
+ });
771
+ const localName = (tag) => tag.slice(tag.indexOf(":") + 1);
772
+ /**
773
+ * `preserveOrder` node → `ChartXmlElement`, prefixes canonicalised through the
774
+ * `xmlns:*` declarations seen so far (`declared`: prefix → URI); namespace
775
+ * declarations themselves are dropped — the exporters re-emit them.
776
+ */
777
+ const toChartXmlElement = (node, declared) => {
778
+ const tag = Object.keys(node).find((k) => k !== ":@");
779
+ if (!tag)
780
+ return undefined;
781
+ const rawAttrs = (node[":@"] ?? {});
782
+ const scope = new Map(declared);
783
+ for (const [k, v] of Object.entries(rawAttrs)) {
784
+ if (k === "xmlns")
785
+ scope.set("", String(v));
786
+ else if (k.startsWith("xmlns:"))
787
+ scope.set(k.slice(6), String(v));
788
+ }
789
+ const canonical = (name) => {
790
+ const at = name.indexOf(":");
791
+ const prefix = at === -1 ? "" : name.slice(0, at);
792
+ const uri = scope.get(prefix);
793
+ const known = uri ? CHART_XML_PREFIXES[uri] : undefined;
794
+ if (!known)
795
+ return name;
796
+ return `${known}:${at === -1 ? name : name.slice(at + 1)}`;
797
+ };
798
+ const attributes = {};
799
+ for (const [k, v] of Object.entries(rawAttrs)) {
800
+ if (k === "xmlns" || k.startsWith("xmlns:"))
801
+ continue;
802
+ attributes[k.includes(":") ? canonical(k) : k] = String(v);
803
+ }
804
+ const element = { tag: canonical(tag) };
805
+ if (Object.keys(attributes).length)
806
+ element.attributes = attributes;
807
+ const kids = Array.isArray(node[tag]) ? node[tag] : [];
808
+ const children = [];
809
+ let text;
810
+ for (const kid of kids) {
811
+ if (!kid || typeof kid !== "object")
812
+ continue;
813
+ if ("t$" in kid) {
814
+ text = (text ?? "") + String(kid.t$);
815
+ continue;
816
+ }
817
+ const child = toChartXmlElement(kid, scope);
818
+ if (child)
819
+ children.push(child);
820
+ }
821
+ if (children.length)
822
+ element.children = children;
823
+ else if (text !== undefined && text !== "")
824
+ element.text = text;
825
+ return element;
826
+ };
827
+ /**
828
+ * Read every `<c:pivotFmt>` body (its children after `<c:idx>`) verbatim from
829
+ * the chart part and attach it to the matching `pivotFormats` entry by index
830
+ * (rowsncolumns/spreadsheet#668 C-83). Gradient fills, theme colours, markers
831
+ * and data-label formatting Excel keeps per pivot item survive the round-trip
832
+ * that `color` (a literal solid fill) alone cannot carry.
833
+ */
834
+ export function attachPivotFormatBodies(formats, chartContent) {
835
+ let ordered;
836
+ try {
837
+ ordered = orderedChartParser.parse(chartContent);
838
+ }
839
+ catch {
840
+ return;
841
+ }
842
+ const findPivotFmts = (nodes, declared) => {
843
+ for (const node of nodes) {
844
+ if (!node || typeof node !== "object")
845
+ continue;
846
+ const tag = Object.keys(node).find((k) => k !== ":@");
847
+ // Text nodes (`{ t$ }`) have no element children to search.
848
+ if (!tag || !Array.isArray(node[tag]))
849
+ continue;
850
+ const scope = new Map(declared);
851
+ for (const [k, v] of Object.entries((node[":@"] ?? {}))) {
852
+ if (k === "xmlns")
853
+ scope.set("", String(v));
854
+ else if (k.startsWith("xmlns:"))
855
+ scope.set(k.slice(6), String(v));
856
+ }
857
+ if (localName(tag) === "pivotFmts")
858
+ return { node, declared: scope };
859
+ const found = findPivotFmts((node[tag] ?? []), scope);
860
+ if (found)
861
+ return found;
862
+ }
863
+ return undefined;
864
+ };
865
+ const found = findPivotFmts(ordered, new Map());
866
+ if (!found)
867
+ return;
868
+ const fmtsTag = Object.keys(found.node).find((k) => k !== ":@");
869
+ const byIndex = new Map();
870
+ for (const fmt of (found.node[fmtsTag] ?? [])) {
871
+ if (!fmt || typeof fmt !== "object")
872
+ continue;
873
+ const fmtTag = Object.keys(fmt).find((k) => k !== ":@");
874
+ if (!fmtTag || localName(fmtTag) !== "pivotFmt" || !Array.isArray(fmt[fmtTag]))
875
+ continue;
876
+ const kids = fmt[fmtTag];
877
+ let index;
878
+ const body = [];
879
+ for (const kid of kids) {
880
+ if (!kid || typeof kid !== "object")
881
+ continue;
882
+ const kidTag = Object.keys(kid).find((k) => k !== ":@");
883
+ if (!kidTag)
884
+ continue;
885
+ if (localName(kidTag) === "idx") {
886
+ index = toInt(kid[":@"]?.val);
887
+ continue;
888
+ }
889
+ const element = toChartXmlElement(kid, found.declared);
890
+ if (element)
891
+ body.push(element);
892
+ }
893
+ if (index !== undefined && body.length)
894
+ byIndex.set(index, body);
895
+ }
896
+ for (const format of formats) {
897
+ const body = byIndex.get(format.index);
898
+ if (body)
899
+ format.body = body;
900
+ }
901
+ }
902
+ const PIVOT_OPTIONS_EXT_URI = "{781A3756-C4B2-4CAC-9D66-4F8BD8637D16}";
903
+ /**
904
+ * Excel's Field Buttons choices — `<c14:pivotOptions>` in the chartSpace
905
+ * `<c:extLst>`: `dropZoneFilter` / `dropZoneCategories` / `dropZoneData` /
906
+ * `dropZoneSeries` / `dropZonesVisible`, each `1` unless hidden. Only the
907
+ * hidden ones are recorded (every button shows by default).
908
+ */
909
+ export function extractPivotFieldButtons(chartSpaceRoot) {
910
+ const extList = getWithFallback(chartSpaceRoot, ["c:extLst.c:ext", "extLst.ext"]);
911
+ if (extList === undefined)
912
+ return undefined;
913
+ const exts = Array.isArray(extList) ? extList : [extList];
914
+ const ext = exts.find((e) => e?.a$?.uri === PIVOT_OPTIONS_EXT_URI);
915
+ const options = getWithFallback(ext, ["c14:pivotOptions", "pivotOptions"]);
916
+ if (!options)
917
+ return undefined;
918
+ const isHidden = (element) => {
919
+ const val = getWithFallback(options, [
920
+ `c14:${element}.a$.val`,
921
+ `${element}.a$.val`,
922
+ ]);
923
+ return val === "0" || val === "false" || val === false;
924
+ };
925
+ const buttons = {
926
+ ...(isHidden("dropZonesVisible") ? { visible: false } : {}),
927
+ ...(isHidden("dropZoneFilter") ? { reportFilter: false } : {}),
928
+ ...(isHidden("dropZoneCategories") ? { axis: false } : {}),
929
+ ...(isHidden("dropZoneSeries") ? { legend: false } : {}),
930
+ ...(isHidden("dropZoneData") ? { value: false } : {}),
931
+ };
932
+ return Object.keys(buttons).length ? buttons : undefined;
933
+ }
481
934
  export function extractChartTitle(chart) {
482
935
  // Try different possible paths for chart title
483
936
  const title =
@@ -532,12 +985,19 @@ export function determineChartType(chart) {
532
985
  const c15Type = detectC15ChartType(plotArea);
533
986
  if (c15Type)
534
987
  return c15Type;
535
- const barChart = getWithFallback(plotArea, [
988
+ // Excel's VHLC / VOHLC stock charts pair a <c:barChart> (the volume
989
+ // series) with the <c:stockChart>, so the stock group must win over the
990
+ // bar scan below.
991
+ if (getWithFallback(plotArea, ["c:stockChart", "stockChart"]))
992
+ return "stock";
993
+ // A chart whose secondary axis group is split into its own container
994
+ // carries an array here; the primary group comes first.
995
+ const barChart = firstGroup(getWithFallback(plotArea, [
536
996
  "c:barChart",
537
997
  "barChart",
538
998
  "c:bar3DChart",
539
999
  "bar3DChart",
540
- ]);
1000
+ ]));
541
1001
  if (barChart) {
542
1002
  const direction = get(barChart, "c:barDir.a$.val") ||
543
1003
  get(barChart, "c:barDir.a$.t$") ||
@@ -585,8 +1045,6 @@ export function determineChartType(chart) {
585
1045
  return "area";
586
1046
  if (getWithFallback(plotArea, ["c:radarChart", "radarChart"]))
587
1047
  return "radar";
588
- if (getWithFallback(plotArea, ["c:stockChart", "stockChart"]))
589
- return "stock";
590
1048
  // Scatter and bubble — both use c:xVal/c:yVal series shape (not
591
1049
  // c:cat/c:val like other charts). Bubble additionally has c:bubbleSize.
592
1050
  if (getWithFallback(plotArea, ["c:scatterChart", "scatterChart"]))
@@ -778,6 +1236,77 @@ const CHART_TYPE_CONTAINERS = [
778
1236
  isXY: true,
779
1237
  },
780
1238
  ];
1239
+ /**
1240
+ * A series' position in the chart — `<c:order>` (Excel's plot / legend
1241
+ * order), falling back to `<c:idx>`; NaN when the series carries neither.
1242
+ */
1243
+ function seriesOrderOf(ser) {
1244
+ const raw = get(ser, "c:order.a$.val") ??
1245
+ get(ser, "order.a$.val") ??
1246
+ get(ser, "c:idx.a$.val") ??
1247
+ get(ser, "idx.a$.val");
1248
+ const n = Number(raw);
1249
+ return raw === undefined || raw === null || raw === "" ? NaN : n;
1250
+ }
1251
+ /**
1252
+ * Put the collected series back in Excel's `<c:order>`: a chart split into
1253
+ * several containers (one per chart type and per axis group) lists them
1254
+ * container by container, which is not the authored order. Only sorts
1255
+ * when every series carries an order (older / hand-written files without
1256
+ * one keep their document order).
1257
+ */
1258
+ function sortSeriesByOrder(series, orders) {
1259
+ if (series.length < 2 || orders.length !== series.length)
1260
+ return series;
1261
+ if (orders.some((order) => !Number.isFinite(order)))
1262
+ return series;
1263
+ return series
1264
+ .map((entry, index) => ({ entry, order: orders[index], index }))
1265
+ .sort((a, b) => a.order - b.order || a.index - b.index)
1266
+ .map(({ entry }) => entry);
1267
+ }
1268
+ /**
1269
+ * The first chart-type container of a group that may be split into one
1270
+ * container per axis group (primary first, then the secondary group).
1271
+ */
1272
+ function firstGroup(node) {
1273
+ return Array.isArray(node) ? node[0] : node;
1274
+ }
1275
+ /** `<c:axId val>` values of an axis element or a chart-type container. */
1276
+ function axisIdsOf(node) {
1277
+ const raw = getWithFallback(node, ["c:axId", "axId"]);
1278
+ if (!raw)
1279
+ return [];
1280
+ const list = Array.isArray(raw) ? raw : [raw];
1281
+ return list
1282
+ .map((entry) => entry?.a$?.val ?? entry?.a$?.t$ ?? entry)
1283
+ .filter((id) => id != null)
1284
+ .map(String);
1285
+ }
1286
+ /**
1287
+ * The axis ids of the plot area's SECONDARY axis group — every `<c:valAx>`,
1288
+ * `<c:catAx>` and `<c:dateAx>` after the first of its kind. Excel keeps one
1289
+ * category axis and one value axis per axis group; a chart-type container
1290
+ * whose `<c:axId>`s name any of these ids plots its series on the
1291
+ * secondary group (Excel's "Plot Series On: Secondary Axis").
1292
+ */
1293
+ function secondaryAxisGroupIds(plotArea) {
1294
+ const ids = new Set();
1295
+ for (const key of ["c:valAx", "valAx", "c:catAx", "catAx", "c:dateAx", "dateAx"]) {
1296
+ const axes = get(plotArea, key);
1297
+ if (!axes)
1298
+ continue;
1299
+ const list = Array.isArray(axes) ? axes : [axes];
1300
+ list.slice(1).forEach((axis) => axisIdsOf(axis).forEach((id) => ids.add(id)));
1301
+ }
1302
+ return ids;
1303
+ }
1304
+ /** True when a chart-type container's `<c:axId>`s bind it to the secondary axis group. */
1305
+ function isSecondaryGroupContainer(container, secondaryIds) {
1306
+ if (!secondaryIds.size)
1307
+ return false;
1308
+ return axisIdsOf(container).some((id) => secondaryIds.has(id));
1309
+ }
781
1310
  /**
782
1311
  * Resolve a chart-formula range string (e.g.
783
1312
  * `'Data for the Chart'!$A$7:$A$26`) into the sources array our chart
@@ -838,9 +1367,13 @@ function extractInlineValues(node) {
838
1367
  }
839
1368
  export function extractChartSeries(plotArea, chartType, sheetId, resolveSheetId) {
840
1369
  const series = [];
1370
+ const orders = [];
841
1371
  let seriesIndex = 0;
842
- // Iterate through all chart type containers to support combo charts
843
- for (const container of CHART_TYPE_CONTAINERS) {
1372
+ const secondaryIds = secondaryAxisGroupIds(plotArea);
1373
+ // Iterate through all chart type containers to support combo charts.
1374
+ // A stock chart's <c:barChart> is its volume series, not a combo member
1375
+ // — the stock path below reads both groups in role order.
1376
+ for (const container of chartType === "stock" ? [] : CHART_TYPE_CONTAINERS) {
844
1377
  const chartElement = getWithFallback(plotArea, container.paths);
845
1378
  if (!chartElement)
846
1379
  continue;
@@ -849,6 +1382,9 @@ export function extractChartSeries(plotArea, chartType, sheetId, resolveSheetId)
849
1382
  ? chartElement
850
1383
  : [chartElement];
851
1384
  for (const singleChartElement of chartElements) {
1385
+ // Series in a container bound to the secondary axis group plot on
1386
+ // the secondary axes (Excel's "Plot Series On: Secondary Axis").
1387
+ const useSecondaryAxis = isSecondaryGroupContainer(singleChartElement, secondaryIds);
852
1388
  // Determine the actual chart type for this container
853
1389
  let seriesChartType = container.chartType;
854
1390
  if (container.isBar) {
@@ -911,6 +1447,7 @@ export function extractChartSeries(plotArea, chartType, sheetId, resolveSheetId)
911
1447
  const categorySources = serCatRef
912
1448
  ? resolveSourcesFromRef(serCatRef, sheetId, resolveSheetId)
913
1449
  : [];
1450
+ orders.push(seriesOrderOf(ser));
914
1451
  series.push({
915
1452
  dataLabel: seriesTitle || `Series ${seriesIndex + 1}`,
916
1453
  ...(nameRef ? { dataLabelRef: nameRef } : {}),
@@ -926,6 +1463,7 @@ export function extractChartSeries(plotArea, chartType, sheetId, resolveSheetId)
926
1463
  // aren't part of combo mixing — leave it unset there so the
927
1464
  // renderer falls back to the chart-level chartType.
928
1465
  ...(container.isXY ? {} : { seriesChartType: seriesChartType }),
1466
+ ...(useSecondaryAxis ? { useSecondaryAxis: true } : {}),
929
1467
  sources,
930
1468
  ...(inlineValues && inlineValues.length ? { inlineValues } : {}),
931
1469
  ...(trendlines && trendlines.length ? { trendlines } : {}),
@@ -978,6 +1516,7 @@ export function extractChartSeries(plotArea, chartType, sheetId, resolveSheetId)
978
1516
  const categorySources = serCatRef
979
1517
  ? resolveSourcesFromRef(serCatRef, sheetId, resolveSheetId)
980
1518
  : [];
1519
+ orders.push(seriesOrderOf(ser));
981
1520
  series.push({
982
1521
  dataLabel: seriesTitle || `Series ${index + 1}`,
983
1522
  ...(nameRef ? { dataLabelRef: nameRef } : {}),
@@ -997,7 +1536,9 @@ export function extractChartSeries(plotArea, chartType, sheetId, resolveSheetId)
997
1536
  });
998
1537
  }
999
1538
  }
1000
- return series;
1539
+ // Stock charts read their price roles in document order; every other
1540
+ // chart lists its series in Excel's <c:order>.
1541
+ return chartType === "stock" ? series : sortSeriesByOrder(series, orders);
1001
1542
  }
1002
1543
  /**
1003
1544
  * Extract per-data-point color overrides — `<c:dPt>` array inside
@@ -1247,10 +1788,12 @@ function extractTextProperties(txPr) {
1247
1788
  * renderer keeps its defaults.
1248
1789
  */
1249
1790
  function extractBarLayoutOptions(plotArea) {
1250
- const bar = get(plotArea, "c:barChart") ??
1791
+ // The primary axis group's container when the secondary group is split
1792
+ // into its own (Excel writes one container per axis group).
1793
+ const bar = firstGroup(get(plotArea, "c:barChart") ??
1251
1794
  get(plotArea, "barChart") ??
1252
1795
  get(plotArea, "c:bar3DChart") ??
1253
- get(plotArea, "bar3DChart");
1796
+ get(plotArea, "bar3DChart"));
1254
1797
  if (!bar)
1255
1798
  return undefined;
1256
1799
  // `<c:grouping val="clustered|stacked|percentStacked|standard"/>`.
@@ -1284,6 +1827,66 @@ function extractBarLayoutOptions(plotArea) {
1284
1827
  result.gapWidth = gapWidthVal;
1285
1828
  return Object.keys(result).length ? result : undefined;
1286
1829
  }
1830
+ /**
1831
+ * Read `<c:lineChart>` / `<c:line3DChart>` `<c:grouping>` (standard /
1832
+ * stacked / percentStacked → `stackedType`) and the chart-level
1833
+ * `<c:marker val>` flag. Excel writes `<c:marker val="1"/>` on every line
1834
+ * chart and hides a plain "Line"'s markers per series through
1835
+ * `<c:marker><c:symbol val="none"/>`, so `showMarkers` is only reported
1836
+ * when the flag is set and at least one series is not "none".
1837
+ */
1838
+ function extractLineChartOptions(plotArea, series) {
1839
+ // The primary axis group's container when the secondary group is split
1840
+ // into its own (Excel writes one container per axis group).
1841
+ const line = firstGroup(get(plotArea, "c:lineChart") ??
1842
+ get(plotArea, "lineChart") ??
1843
+ get(plotArea, "c:line3DChart") ??
1844
+ get(plotArea, "line3DChart"));
1845
+ if (!line)
1846
+ return undefined;
1847
+ const groupingVal = get(line, "c:grouping.a$.val") ??
1848
+ get(line, "grouping.a$.val") ??
1849
+ get(line, "c:grouping.a$.t$") ??
1850
+ get(line, "grouping.a$.t$");
1851
+ const result = {};
1852
+ switch (groupingVal) {
1853
+ case "stacked":
1854
+ result.stackedType = "STACKED";
1855
+ break;
1856
+ case "percentStacked":
1857
+ result.stackedType = "PERCENT_STACKED";
1858
+ break;
1859
+ case "standard":
1860
+ result.stackedType = "UNSTACKED";
1861
+ break;
1862
+ }
1863
+ const markerVal = get(line, "c:marker.a$.val") ?? get(line, "marker.a$.val");
1864
+ const markerFlag = markerVal === "1" ||
1865
+ markerVal === 1 ||
1866
+ markerVal === "true" ||
1867
+ markerVal === true;
1868
+ if (markerFlag &&
1869
+ series.length > 0 &&
1870
+ series.some((entry) => entry.marker?.symbol !== "none")) {
1871
+ result.showMarkers = true;
1872
+ }
1873
+ return Object.keys(result).length ? result : undefined;
1874
+ }
1875
+ /**
1876
+ * Which of Excel's four stock layouts a `<c:stockChart>` is: a sibling
1877
+ * `<c:barChart>` carries the volume series (V-variants), and four price
1878
+ * series (open/high/low/close) instead of three mean OHLC.
1879
+ */
1880
+ function extractStockVariant(plotArea) {
1881
+ const prices = getWithFallback(plotArea, [
1882
+ "c:stockChart.c:ser",
1883
+ "stockChart.ser",
1884
+ ]);
1885
+ const priceCount = Array.isArray(prices) ? prices.length : prices ? 1 : 0;
1886
+ const hasVolume = Boolean(getWithFallback(plotArea, ["c:barChart", "barChart"]));
1887
+ const body = priceCount >= 4 ? "OHLC" : "HLC";
1888
+ return hasVolume ? `V${body}` : body;
1889
+ }
1287
1890
  /**
1288
1891
  * Extract chart-level `<c:scatterStyle val=…>`. Excel default is
1289
1892
  * "lineMarker" (straight lines + markers). Returns undefined for
@@ -1537,6 +2140,12 @@ function extractSeriesColorInfo(ser) {
1537
2140
  }
1538
2141
  export function extractChartDomains(plotArea, chartType, sheetId, resolveSheetId) {
1539
2142
  const domains = [];
2143
+ // Non-XY: Excel keeps one category axis per axis group. The primary
2144
+ // group's first `<c:cat>` is domains[0]; when a container bound to the
2145
+ // secondary axis group carries a different `<c:cat>`, that list is
2146
+ // domains[1] — the secondary group's category axis.
2147
+ const secondaryIds = secondaryAxisGroupIds(plotArea);
2148
+ let primaryRef;
1540
2149
  // Search all chart type containers for domains. For XY charts the
1541
2150
  // "domain" is the X values (c:xVal); for everything else it's the
1542
2151
  // category axis (c:cat). Per-container loop covers combo charts.
@@ -1557,23 +2166,42 @@ export function extractChartDomains(plotArea, chartType, sheetId, resolveSheetId
1557
2166
  const seriesArray = Array.isArray(seriesElements)
1558
2167
  ? seriesElements
1559
2168
  : [seriesElements];
2169
+ const secondaryGroup = !container.isXY &&
2170
+ isSecondaryGroupContainer(singleChartElement, secondaryIds);
1560
2171
  for (const ser of seriesArray) {
1561
2172
  const domainRef = container.isXY
1562
2173
  ? getWithFallback(ser, X_VAL_REF_PATHS)
1563
2174
  : getWithFallback(ser, CAT_REF_PATHS);
1564
2175
  if (domainRef) {
2176
+ if (!container.isXY && domains.length) {
2177
+ // Primary already found: only a secondary-group container
2178
+ // with a different category list adds domains[1].
2179
+ if (secondaryGroup && domainRef !== primaryRef) {
2180
+ const sources = resolveSourcesFromRef(domainRef, sheetId, resolveSheetId);
2181
+ if (sources.length) {
2182
+ domains.push({ sources });
2183
+ return domains;
2184
+ }
2185
+ }
2186
+ break;
2187
+ }
1565
2188
  const sources = resolveSourcesFromRef(domainRef, sheetId, resolveSheetId);
1566
2189
  if (sources.length) {
1567
2190
  domains.push({ sources });
1568
2191
  // XY charts may carry per-series domains that differ — keep
1569
2192
  // the first one we encounter for the dataRange anchor, but
1570
2193
  // continue collecting so the renderer has a per-series X
1571
- // when it needs one. For non-XY, first-found is enough.
1572
- if (!container.isXY)
1573
- return domains;
2194
+ // when it needs one. For non-XY, remember the primary ref
2195
+ // and keep scanning for a secondary group's own list.
2196
+ if (!container.isXY) {
2197
+ primaryRef = domainRef;
2198
+ if (!secondaryIds.size)
2199
+ return domains;
2200
+ break;
2201
+ }
1574
2202
  }
1575
2203
  }
1576
- else if (!container.isXY) {
2204
+ else if (!container.isXY && !domains.length) {
1577
2205
  // No cell ref — check for an inline `<c:strLit>`/`<c:numLit>`
1578
2206
  // category list (e.g. `["Estimated", "Actual"]` embedded
1579
2207
  // directly in the chart XML). Same for the series val side,
@@ -1650,6 +2278,28 @@ export function extractChartPosition(anchor, sheetId, sheetDimensions) {
1650
2278
  // Handle oneCellAnchor
1651
2279
  const oneCellFrom = getWithFallback(anchor, ["xdr:from", "from"]) ?? undefined;
1652
2280
  const hasTwoCellTarget = getWithFallback(anchor, ["xdr:to", "to"]) !== undefined;
2281
+ // absoluteAnchor: `<xdr:pos>` + `<xdr:ext>` — "Don't move or size with
2282
+ // cells"; kept as A1-relative pixel offsets (#668 C-06)
2283
+ const absolutePos = getWithFallback(anchor, ["xdr:pos", "pos"]);
2284
+ if (!oneCellFrom && absolutePos) {
2285
+ const posAttributes = absolutePos.a$ ?? absolutePos;
2286
+ const extNode = getWithFallback(anchor, ["xdr:ext", "ext"]);
2287
+ const extAttributes = (extNode && (extNode.a$ ?? extNode)) || undefined;
2288
+ const emuToPx = (value, fallback) => value !== undefined && value !== null
2289
+ ? Math.round(parseInt(String(value), 10) / 9525)
2290
+ : fallback;
2291
+ return {
2292
+ sheetId,
2293
+ overlayPosition: {
2294
+ anchorCell: { rowIndex: 1, columnIndex: 1 },
2295
+ offsetXPixels: emuToPx(posAttributes?.x, 0),
2296
+ offsetYPixels: emuToPx(posAttributes?.y, 0),
2297
+ widthPixels: emuToPx(extAttributes?.cx, 400),
2298
+ heightPixels: emuToPx(extAttributes?.cy, 300),
2299
+ placement: "absolute",
2300
+ },
2301
+ };
2302
+ }
1653
2303
  if (oneCellFrom && !hasTwoCellTarget) {
1654
2304
  const extNode = getWithFallback(anchor, ["xdr:ext", "ext"]);
1655
2305
  const extAttributes = (extNode && (extNode.a$ ?? extNode)) || undefined;
@@ -1675,6 +2325,7 @@ export function extractChartPosition(anchor, sheetId, sheetDimensions) {
1675
2325
  offsetYPixels: Math.round(rowOff / 9525),
1676
2326
  widthPixels: width,
1677
2327
  heightPixels: height,
2328
+ placement: "moveOnly",
1678
2329
  },
1679
2330
  };
1680
2331
  }
@@ -1722,6 +2373,10 @@ export function extractChartPosition(anchor, sheetId, sheetDimensions) {
1722
2373
  heightPx += Math.round(toRowOff / 9525);
1723
2374
  const width = Math.max(widthPx, 100);
1724
2375
  const height = Math.max(heightPx, 100);
2376
+ // `editAs` is the placement mode; the default "Move and size with
2377
+ // cells" keeps `to` as the second anchor so the frame follows the
2378
+ // cells between the corners (#668 C-06)
2379
+ const placement = anchorPlacementFromEditAs(getWithFallback(anchor, ["a$.editAs"]));
1725
2380
  return {
1726
2381
  sheetId,
1727
2382
  overlayPosition: {
@@ -1733,6 +2388,13 @@ export function extractChartPosition(anchor, sheetId, sheetDimensions) {
1733
2388
  offsetYPixels: Math.round(rowOff / 9525),
1734
2389
  widthPixels: width,
1735
2390
  heightPixels: height,
2391
+ ...(placement === "moveAndSize"
2392
+ ? {
2393
+ toCell: { rowIndex: toRow + 1, columnIndex: toCol + 1 },
2394
+ toOffsetXPixels: Math.round(toColOff / 9525),
2395
+ toOffsetYPixels: Math.round(toRowOff / 9525),
2396
+ }
2397
+ : { placement }),
1736
2398
  },
1737
2399
  };
1738
2400
  }
@@ -1839,7 +2501,7 @@ function extractDataLabelsOptions(plotArea, chartType) {
1839
2501
  const keys = containerKeys[chartType] ?? [];
1840
2502
  let container;
1841
2503
  for (const key of keys) {
1842
- container = container ?? get(plotArea, key);
2504
+ container = container ?? firstGroup(get(plotArea, key));
1843
2505
  }
1844
2506
  const dLbls = get(container, "c:dLbls") ??
1845
2507
  get(container, "dLbls") ??
@@ -1925,6 +2587,17 @@ function readAxis(axis) {
1925
2587
  const textFormat = extractTextProperties(get(axis, "c:txPr") ?? get(axis, "txPr"));
1926
2588
  if (textFormat)
1927
2589
  result.textFormat = textFormat;
2590
+ // <c:numFmt formatCode="…" sourceLinked="0"/> — an explicit tick-label
2591
+ // format. `sourceLinked="1"` (Excel's default) means the axis inherits
2592
+ // the source cells' format, which the renderer resolves at draw time,
2593
+ // so it is intentionally NOT copied into the spec.
2594
+ const numFmt = get(axis, "c:numFmt.a$") ?? get(axis, "numFmt.a$");
2595
+ const formatCode = numFmt?.formatCode ?? numFmt?.t$;
2596
+ if (typeof formatCode === "string" &&
2597
+ formatCode &&
2598
+ !readBoolean(numFmt?.sourceLinked)) {
2599
+ result.numberFormatPattern = formatCode;
2600
+ }
1928
2601
  return Object.keys(result).length ? result : undefined;
1929
2602
  }
1930
2603
  export function extractAxisOptions(plotArea) {
@@ -1982,6 +2655,25 @@ function extractDoughnutHoleSize(plotArea) {
1982
2655
  return undefined;
1983
2656
  return Math.min(0.9, Math.max(0, holeSize / 100));
1984
2657
  }
2658
+ /**
2659
+ * Exploded pie / doughnut — `<c:ser><c:explosion val="25"/>` is the
2660
+ * slice offset as a percentage of the radius. Excel writes it on every
2661
+ * series of an exploded chart; the spec keeps one chart-level value, so
2662
+ * the largest authored explosion wins. Undefined when no series is
2663
+ * exploded (Excel omits the element for a plain pie).
2664
+ */
2665
+ function extractExplosion(plotArea, chartType) {
2666
+ const sers = getSeriesElements(plotArea, chartType);
2667
+ const list = Array.isArray(sers) ? sers : sers ? [sers] : [];
2668
+ let explosion;
2669
+ for (const ser of list) {
2670
+ const value = readNumber(get(ser, "c:explosion.a$.val") ?? get(ser, "explosion.a$.val"));
2671
+ if (typeof value === "number" && value > 0) {
2672
+ explosion = Math.max(explosion ?? 0, value);
2673
+ }
2674
+ }
2675
+ return explosion;
2676
+ }
1985
2677
  // -------------------------- Phase 5.2 helpers --------------------------
1986
2678
  const mapTrendlineType = (val) => {
1987
2679
  switch (val) {