pptx-rs2 0.5.0

Rust 版本的 PowerPoint .pptx 读写库, 对标 python-pptx
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
//! 图表(Chart)OOXML 模型 —— 最小可用集(TODO-004)。
//!
//! 本模块定义 `<c:chartSpace>` / `<c:chart>` / `<c:plotArea>` 等元素的强类型模型,
//! 当前支持**柱状图 / 条形图 / 折线图 / 饼图 / 散点图 / 面积图**六种类型,且数据采用
//! `<c:numCache>` 内嵌缓存方式(**不依赖**嵌入 Excel)。
//!
//! # 与 python-pptx 的对应
//!
//! - `pptx.chart.Chart` ←→ [`Chart`];
//! - `pptx.chart.ChartData` ←→ [`ChartData`];
//! - `pptx.chart.Series` ←→ [`ChartSeries`];
//! - `pptx.chart.Category` ←→ [`ChartCategory`];
//! - `pptx.enum.chart.XL_CHART_TYPE` ←→ [`ChartType`]。
//!
//! # 设计要点
//!
//! - 数据**全部内嵌**在 chartN.xml 中(`<c:numCache>` / `<c:strCache>`),
//!   避免引入嵌入式 Excel 计算链;
//! - 关系 `rId` 指向 `/ppt/charts/chartN.xml` part;
//! - 类型枚举覆盖 6 种常用集,未来扩展到雷达/气泡等只需追加变体。

use crate::oxml::color::Color;
use crate::oxml::sppr::{Fill, Line};
use crate::oxml::writer::XmlWriter;

/// 图表类型。
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub enum ChartType {
    /// 簇状柱形图(`barChart` with `barDir="col"`)。
    Column,
    /// 簇状条形图(`barChart` with `barDir="bar"`)。
    Bar,
    /// 折线图(`lineChart`)。
    Line,
    /// 饼图(`pieChart`)。
    Pie,
    /// 散点图(`scatterChart`)。系列需提供 `x_values`,使用两个 valAx。
    Scatter,
    /// 面积图(`areaChart`)。
    Area,
    /// 雷达图(`radarChart`)。每个类别一个辐射轴,多系列叠加。
    Radar,
    /// 气泡图(`bubbleChart`)。系列需提供 `x_values` + `bubble_sizes`,使用两个 valAx。
    Bubble,
}

impl ChartType {
    /// 返回 OOXML `<c:chartSpace>` 内 `plotArea` 下的图表元素名(如 `c:barChart`)。
    pub fn chart_element(self) -> &'static str {
        match self {
            ChartType::Column | ChartType::Bar => "c:barChart",
            ChartType::Line => "c:lineChart",
            ChartType::Pie => "c:pieChart",
            ChartType::Scatter => "c:scatterChart",
            ChartType::Area => "c:areaChart",
            ChartType::Radar => "c:radarChart",
            ChartType::Bubble => "c:bubbleChart",
        }
    }

    /// `barChart` 的 `barDir` 属性值。仅对 Column/Bar 有意义。
    pub fn bar_dir(self) -> &'static str {
        match self {
            ChartType::Column => "col",
            ChartType::Bar => "bar",
            _ => "",
        }
    }

    /// 是否为散点图(特殊处理:使用 c:xVal/c:yVal + 两个 valAx)。
    pub fn is_scatter(self) -> bool {
        matches!(self, ChartType::Scatter)
    }

    /// 是否为 X-Y 坐标图(散点图或气泡图)。
    ///
    /// 这两类都使用 `c:xVal` / `c:yVal` + 两个 `valAx`,区别仅在于气泡图额外有 `c:bubbleSize`。
    pub fn is_xy_chart(self) -> bool {
        matches!(self, ChartType::Scatter | ChartType::Bubble)
    }

    /// 是否为气泡图(额外需要 `bubble_sizes` 字段)。
    pub fn is_bubble(self) -> bool {
        matches!(self, ChartType::Bubble)
    }
}

/// 数据标签位置(`<c:dLblPos val="..."/>`)。
///
/// OOXML ST_DLblPos 枚举,控制数据标签相对于数据点的显示位置。
/// 不同图表类型支持的位置子集不同(参见 ECMA-376 第 21.2.2.40 节)。
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
pub enum LabelPosition {
    /// 最佳位置(由 PowerPoint 自动选择)。适用于大多数图表类型。
    #[default]
    BestFit,
    /// 数据点上方(柱形/折线/面积图)。
    Above,
    /// 数据点下方(柱形/折线/面积图)。
    Below,
    /// 数据点中心(柱形/饼图)。
    Center,
    /// 基线(柱形图底部)。
    Base,
    /// 数据点内侧(柱形/饼图,标签在柱内)。
    InsideBase,
    /// 数据点内侧端(柱形图顶部内侧)。
    InsideEnd,
    /// 数据点外侧端(柱形图顶部外侧)。
    OutsideEnd,
    /// 左侧(折线图/散点图)。
    Left,
    /// 右侧(折线图/散点图)。
    Right,
    /// 饼图扇区外侧。
    OutsideEndPie,
    /// 饼图扇区内侧。
    InsideEndPie,
}

impl LabelPosition {
    /// 转 OOXML `<c:dLblPos val="..."/>` 属性值。
    pub fn as_str(self) -> &'static str {
        match self {
            LabelPosition::BestFit => "bestFit",
            LabelPosition::Above => "t",
            LabelPosition::Below => "b",
            LabelPosition::Center => "ctr",
            LabelPosition::Base => "inBase",
            LabelPosition::InsideBase => "inBase",
            LabelPosition::InsideEnd => "inEnd",
            LabelPosition::OutsideEnd => "outEnd",
            LabelPosition::Left => "l",
            LabelPosition::Right => "r",
            LabelPosition::OutsideEndPie => "outEnd",
            LabelPosition::InsideEndPie => "inEnd",
        }
    }

    /// 从 OOXML 属性值解析(不区分大小写),未知值返回 `None`。
    ///
    /// 注:方法名为 `parse` 而非 `from_str`,以避免与 `std::str::FromStr` trait 冲突。
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "bestFit" => Some(LabelPosition::BestFit),
            "t" => Some(LabelPosition::Above),
            "b" => Some(LabelPosition::Below),
            "ctr" => Some(LabelPosition::Center),
            "inBase" => Some(LabelPosition::Base),
            "inEnd" => Some(LabelPosition::InsideEnd),
            "outEnd" => Some(LabelPosition::OutsideEnd),
            "l" => Some(LabelPosition::Left),
            "r" => Some(LabelPosition::Right),
            _ => None,
        }
    }
}

/// 数据标签配置(`<c:dLbls>`),对应 OOXML CT_DLbls。
///
/// 控制数据点数值/类别/系列名等标签的显示。可挂在图表级(`<c:barChart>` 内)
/// 或系列级(`<c:ser>` 内)——系列级会覆盖图表级。
///
/// # 字段语义
///
/// 所有 `show_*` 字段为 `Option<bool>`:
/// - `None`:不写出该 `<c:showXxx>` 元素(继承上层或 PowerPoint 默认);
/// - `Some(true)`:写出 `<c:showXxx val="1"/>`;
/// - `Some(false)`:写出 `<c:showXxx val="0"/>`。
///
/// # 与 python-pptx 的对应
///
/// - python-pptx `plot.data_labels` ←→ 图表级 `ChartData.data_labels`;
/// - python-pptx `series.data_labels` ←→ 系列级 `ChartSeries.data_labels`。
#[derive(Clone, Debug, Default)]
pub struct DataLabels {
    /// 显示数值(`<c:showVal val="1"/>`)。最常用的数据标签。
    pub show_val: Option<bool>,
    /// 显示类别名(`<c:showCatName val="1"/>`)。
    pub show_cat_name: Option<bool>,
    /// 显示系列名(`<c:showSerName val="1"/>`)。
    pub show_ser_name: Option<bool>,
    /// 显示图例键(`<c:showLegendKey val="1"/>`,图例色块前缀)。
    pub show_legend_key: Option<bool>,
    /// 显示百分比(`<c:showPercent val="1"/>`,主要用于饼图)。
    pub show_percent: Option<bool>,
    /// 显示气泡尺寸(`<c:showBubbleSize val="1"/>`,仅气泡图)。
    pub show_bubble_size: Option<bool>,
    /// 标签位置(`<c:dLblPos val="..."/>`)。
    pub position: Option<LabelPosition>,
    /// 分隔符(`<c:separator val="..."/>`,如 `", "` / `"\n"`)。
    pub separator: Option<String>,
    /// 数字格式(`<c:numFmt formatCode="..." sourceLinked="0"/>`)。
    pub num_fmt: Option<String>,
}

impl DataLabels {
    /// 新建一个仅显示数值的数据标签配置(最常用场景)。
    pub fn show_values() -> Self {
        DataLabels {
            show_val: Some(true),
            ..Default::default()
        }
    }

    /// 新建一个显示百分比的饼图数据标签配置。
    pub fn show_percent_pie() -> Self {
        DataLabels {
            show_percent: Some(true),
            ..Default::default()
        }
    }

    /// 写出 `<c:dLbls>` 元素到 XmlWriter。
    ///
    /// OOXML 元素顺序约束(ECMA-376 21.2.2.40):
    /// `numFmt` → `spacing` → `position` → `separator` → `showLegendKey` →
    /// `showVal` → `showCatName` → `showSerName` → `showPercent` →
    /// `showBubbleSize` → `dLbl` → `dLblPos` → ...
    ///
    /// 实际 PowerPoint 实践中常用顺序:`showVal` → `showCatName` → ... → `dLblPos` → `separator` → `numFmt`。
    /// 本实现采用 PowerPoint 实践顺序以保证兼容性。
    pub fn write_xml(&self, w: &mut XmlWriter) {
        w.open("c:dLbls");
        if let Some(b) = self.show_legend_key {
            w.empty_with("c:showLegendKey", &[("val", if b { "1" } else { "0" })]);
        }
        if let Some(b) = self.show_val {
            w.empty_with("c:showVal", &[("val", if b { "1" } else { "0" })]);
        }
        if let Some(b) = self.show_cat_name {
            w.empty_with("c:showCatName", &[("val", if b { "1" } else { "0" })]);
        }
        if let Some(b) = self.show_ser_name {
            w.empty_with("c:showSerName", &[("val", if b { "1" } else { "0" })]);
        }
        if let Some(b) = self.show_percent {
            w.empty_with("c:showPercent", &[("val", if b { "1" } else { "0" })]);
        }
        if let Some(b) = self.show_bubble_size {
            w.empty_with("c:showBubbleSize", &[("val", if b { "1" } else { "0" })]);
        }
        if let Some(pos) = self.position {
            w.empty_with("c:dLblPos", &[("val", pos.as_str())]);
        }
        if let Some(sep) = &self.separator {
            w.empty_with("c:separator", &[("val", sep.as_str())]);
        }
        if let Some(fmt) = &self.num_fmt {
            w.empty_with(
                "c:numFmt",
                &[("formatCode", fmt.as_str()), ("sourceLinked", "0")],
            );
        }
        w.close("c:dLbls");
    }

    /// 是否所有字段都为 `None`(即写出后只有空 `<c:dLbls/>`,无意义)。
    pub fn is_empty(&self) -> bool {
        self.show_val.is_none()
            && self.show_cat_name.is_none()
            && self.show_ser_name.is_none()
            && self.show_legend_key.is_none()
            && self.show_percent.is_none()
            && self.show_bubble_size.is_none()
            && self.position.is_none()
            && self.separator.is_none()
            && self.num_fmt.is_none()
    }
}

/// 图例位置(`<c:legendPos val="..."/>`)。
///
/// 对应 OOXML ST_LegendPos 枚举(ECMA-376 21.2.3.24)。
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
pub enum LegendPosition {
    /// 底部(`b`)。PowerPoint 习惯默认位置。
    #[default]
    Bottom,
    /// 顶部(`t`)。
    Top,
    /// 右侧(`r`)。
    Right,
    /// 左侧(`l`)。
    Left,
}

impl LegendPosition {
    /// 转 OOXML `<c:legendPos val="..."/>` 属性值。
    pub fn as_str(self) -> &'static str {
        match self {
            LegendPosition::Bottom => "b",
            LegendPosition::Top => "t",
            LegendPosition::Right => "r",
            LegendPosition::Left => "l",
        }
    }
}

/// 图例配置(`<c:legend>`)。
///
/// 控制 `plotArea` 之后的图例显示。`ChartData.legend` 为 `None` 时不输出
/// `<c:legend>` 元素(关闭图例);为 `Some(Legend{...})` 时按配置输出。
///
/// # 与 python-pptx 的对应
///
/// - python-pptx `chart.has_legend` ←→ `ChartData.legend.is_some()`;
/// - python-pptx `chart.legend.position` ←→ `Legend.position`。
#[derive(Clone, Debug, Default)]
pub struct Legend {
    /// 图例位置(`<c:legendPos val="..."/>`)。
    pub position: LegendPosition,
    /// 是否覆盖绘图区(`<c:overlay val="0|1"/>`)。
    /// - `false`(默认):不覆盖,绘图区为图例让出空间;
    /// - `true`:图例覆盖在绘图区上方。
    pub overlay: bool,
}

/// 网格线配置(`<c:majorGridlines>` / `<c:minorGridlines>`)。
///
/// 挂在 `c:catAx` / `c:valAx` 内,控制坐标轴的主/次网格线显示。
/// 默认 `major=false, minor=false`(不显示网格线,与 PowerPoint 默认一致)。
#[derive(Clone, Debug, Default)]
pub struct Gridlines {
    /// 是否显示主网格线(`<c:majorGridlines/>`)。
    pub major: bool,
    /// 是否显示次网格线(`<c:minorGridlines/>`)。
    pub minor: bool,
}

/// 轴标题(`<c:title>` 内的纯文本)。
///
/// 挂在 `c:catAx` / `c:valAx` 内,作为坐标轴的标题文本。
/// `ChartData.category_axis_title` / `ChartData.value_axis_title` 为 `None` 时不输出。
#[derive(Clone, Debug, Default)]
pub struct AxisTitle {
    /// 标题文本。
    pub text: String,
}

impl AxisTitle {
    /// 新建一个轴标题。
    pub fn new(text: impl Into<String>) -> Self {
        AxisTitle { text: text.into() }
    }
}

/// 图表数据:一维类别轴 + 多个数据系列。
#[derive(Clone, Debug, Default)]
pub struct ChartData {
    /// 类别(X 轴标签或饼图扇区标签)。
    ///
    /// **散点图忽略此字段**——散点图的 X 坐标由 `ChartSeries.x_values` 提供。
    pub categories: Vec<ChartCategory>,
    /// 数据系列列表(每个系列一条曲线/一组柱子/一个饼图扇区集合)。
    pub series: Vec<ChartSeries>,
    /// 图表标题。`None` 表示无标题。
    pub title: Option<String>,
    /// 图表级数据标签(`<c:dLbls>`,写在 `<c:barChart>` 等图表元素内、`<c:ser>` 之前)。
    ///
    /// 系列级 [`ChartSeries::data_labels`] 会覆盖此处的图表级配置。
    /// `None` 表示不写出图表级 `<c:dLbls>`(PowerPoint 使用默认:不显示标签)。
    pub data_labels: Option<DataLabels>,
    /// 图例配置(`<c:legend>`)。`None` = 不输出 `<c:legend>`(关闭图例)。
    ///
    /// 默认 `None`(无图例)。如需图例,设置为 `Some(Legend::default())`(底部图例)。
    pub legend: Option<Legend>,
    /// 类别轴(catAx / 底部 valAx)网格线。默认无网格线。
    pub category_axis_gridlines: Gridlines,
    /// 数值轴(valAx / 左侧)网格线。默认无网格线。
    pub value_axis_gridlines: Gridlines,
    /// 类别轴标题(`<c:catAx><c:title>`)。`None` = 无标题。
    pub category_axis_title: Option<AxisTitle>,
    /// 数值轴标题(`<c:valAx><c:title>`)。`None` = 无标题。
    pub value_axis_title: Option<AxisTitle>,
    /// 次坐标轴图表类型(组合图模式,模块 D)。
    ///
    /// `Some(chart_type)` + 系列中 `secondary_axis=true` 的项 → 该系列写入第二个
    /// 图表元素(如 `c:lineChart`),与主图表元素(如 `c:barChart`)共用类别轴但
    /// 绑定次 valAx(axId=444444444)。
    ///
    /// `None`(默认)时所有系列写入主图表元素(即使 `secondary_axis=true`,仅绑定次轴 id)。
    pub secondary_chart_type: Option<ChartType>,
}

/// 单个类别(X 轴标签或饼图扇区标签)。
#[derive(Clone, Debug, Default)]
pub struct ChartCategory {
    /// 类别名称。
    pub name: String,
}

impl ChartCategory {
    /// 新建一个类别。
    pub fn new(name: impl Into<String>) -> Self {
        ChartCategory { name: name.into() }
    }
}

/// 系列样式(`<c:spPr>`,写在 `<c:ser>` 内 `<c:tx>` 之后、`<c:cat>`/`<c:xVal>` 之前)。
///
/// 控制系列的颜色/填充/边框,对应 python-pptx `series.format.fill` / `series.format.line`。
/// `None` 时(默认)不写出 `<c:spPr>`,PowerPoint 使用主题色自动分配。
///
/// # 与 python-pptx 的对应
///
/// - `pptx.chart.series.format.fill` ←→ [`SeriesFormat::fill`];
/// - `pptx.chart.series.format.line` ←→ [`SeriesFormat::line`]。
#[derive(Clone, Debug, Default)]
pub struct SeriesFormat {
    /// 填充(`<a:solidFill>` / `<a:noFill>` / `<a:gradFill>` 等)。
    /// 复用 [`crate::oxml::sppr::Fill`],`Fill::Inherit`(默认)时不写出填充元素。
    pub fill: Fill,
    /// 边框(`<a:ln>`)。`None` 时不写出 `<a:ln>` 元素。
    pub line: Option<Line>,
}

impl SeriesFormat {
    /// 新建一个实色填充的系列样式(最常用场景:指定系列颜色)。
    ///
    /// # 参数
    /// - `color`:系列颜色(如 `Color::RGB(RGBColor(0x4F, 0x81, 0xBD))`)。
    pub fn solid_fill(color: Color) -> Self {
        SeriesFormat {
            fill: Fill::Solid(color),
            line: None,
        }
    }

    /// 写出 `<c:spPr>` 元素到 XmlWriter。
    ///
    /// OOXML 顺序(ECMA-376 21.2.2.197 `c:ser` 子元素顺序):
    /// `idx` → `order` → `tx` → **`spPr`** → `txPr` → `marker` → `invertIfNegative` →
    /// `pictureOptions` → `dPt` → `dLbls` → `trendline` → `errBars` → `cat`/`xVal` → `val`/`yVal` → ...
    ///
    /// 仅当 `fill` 非 `Inherit` 或 `line` 非 `None` 时写出;否则跳过(不输出空 `<c:spPr/>`)。
    ///
    /// # 返回值
    /// - `true`:已写出 `<c:spPr>` 元素;
    /// - `false`:跳过(fill 为 Inherit 且 line 为 None)。
    pub fn write_xml(&self, w: &mut XmlWriter) -> bool {
        let has_fill = !matches!(self.fill, Fill::Inherit);
        let has_line = self.line.is_some();
        if !has_fill && !has_line {
            return false;
        }
        w.open("c:spPr");
        self.fill.write_xml(w);
        if let Some(ln) = &self.line {
            ln.write_xml(w);
        }
        w.close("c:spPr");
        true
    }
}

/// 数据系列(一条折线 / 一组柱子 / 一组饼图扇区 / 一组散点 / 一组气泡)。
#[derive(Clone, Debug, Default)]
pub struct ChartSeries {
    /// 系列名称(图例上显示)。
    pub name: String,
    /// 数值列表,长度应与 `ChartData.categories` 一致。
    ///
    /// 对散点图/气泡图,此字段表示 **Y 坐标**值,需配合 `x_values` 使用。
    pub values: Vec<f64>,
    /// 散点图/气泡图的 **X 坐标**值列表。仅 `ChartType::Scatter` / `Bubble` 使用,长度应与 `values` 一致。
    ///
    /// `None` 或空表示该系列非 X-Y 坐标图;散点图系列应通过 [`ChartSeries::new_scatter`]
    /// 构造以同时提供 `x_values` 与 `values`(Y 坐标)。
    pub x_values: Option<Vec<f64>>,
    /// 气泡图的**气泡尺寸**列表。仅 `ChartType::Bubble` 使用,长度应与 `values` 一致。
    ///
    /// `None` 表示非气泡图;气泡图系列应通过 [`ChartSeries::new_bubble`] 构造。
    pub bubble_sizes: Option<Vec<f64>>,
    /// 系列级数据标签(`<c:dLbls>`,写在 `<c:ser>` 内 `<c:val>` / `<c:yVal>` 之后)。
    ///
    /// 系列级配置会覆盖图表级 [`ChartData::data_labels`]。`None` 表示继承图表级配置。
    pub data_labels: Option<DataLabels>,
    /// 是否将该系列绑定到**次坐标轴**(右侧 Y 轴)。
    ///
    /// - `false`(默认):系列绑定主轴(axId=222222222)。
    /// - `true`:系列绑定次轴(axId=444444444),`Chart::to_xml` 会额外写出
    ///   次轴定义(含 `<c:crosses val="max"/>`),PowerPoint 将其显示在右侧。
    ///
    /// 典型场景:主轴显示柱形图,次轴显示折线图(双轴组合图)。
    /// **注意**:饼图/散点图/气泡图不支持次坐标轴(写出时该字段被忽略)。
    pub secondary_axis: bool,
    /// 系列样式(`<c:spPr>`)。`None`(默认)时不写出,PowerPoint 自动分配主题色。
    ///
    /// 通过 [`ChartSeries::new`] 等构造函数创建的系列默认无样式;
    /// 使用 [`ChartShape::set_series_format`] 或直接设置此字段可指定颜色/填充/边框。
    pub format: Option<SeriesFormat>,
}

impl ChartSeries {
    /// 新建一个数据系列(非散点图/气泡图)。
    ///
    /// `values` 为 Y 值列表(柱/条/线/面积/雷达图)或扇区数值(饼图)。
    pub fn new(name: impl Into<String>, values: Vec<f64>) -> Self {
        ChartSeries {
            name: name.into(),
            values,
            x_values: None,
            bubble_sizes: None,
            data_labels: None,
            secondary_axis: false,
            format: None,
        }
    }

    /// 新建一个散点图数据系列(提供 X 与 Y 坐标)。
    ///
    /// `x_values` 与 `y_values` 长度应一致;不一致时以较短的为准(写出时按 `y_values` 长度迭代)。
    ///
    /// # 参数
    /// - `name`:系列名称(图例显示)。
    /// - `x_values`:X 坐标列表。
    /// - `y_values`:Y 坐标列表(与 [`ChartSeries::values`] 同字段)。
    pub fn new_scatter(name: impl Into<String>, x_values: Vec<f64>, y_values: Vec<f64>) -> Self {
        ChartSeries {
            name: name.into(),
            values: y_values,
            x_values: Some(x_values),
            bubble_sizes: None,
            data_labels: None,
            secondary_axis: false,
            format: None,
        }
    }

    /// 新建一个气泡图数据系列(提供 X、Y 坐标与气泡尺寸)。
    ///
    /// 三个列表长度应一致;不一致时以 `y_values` 长度为准迭代。
    ///
    /// # 参数
    /// - `name`:系列名称(图例显示)。
    /// - `x_values`:X 坐标列表。
    /// - `y_values`:Y 坐标列表(与 [`ChartSeries::values`] 同字段)。
    /// - `bubble_sizes`:气泡尺寸列表(决定每个气泡的面积)。
    pub fn new_bubble(
        name: impl Into<String>,
        x_values: Vec<f64>,
        y_values: Vec<f64>,
        bubble_sizes: Vec<f64>,
    ) -> Self {
        ChartSeries {
            name: name.into(),
            values: y_values,
            x_values: Some(x_values),
            bubble_sizes: Some(bubble_sizes),
            data_labels: None,
            secondary_axis: false,
            format: None,
        }
    }

    /// Builder 方法:设置系列样式(颜色/填充/边框),返回 `self` 便于链式调用。
    ///
    /// # 参数
    /// - `format`:系列样式;`None` 清除样式(PowerPoint 自动分配主题色)。
    ///
    /// # 示例
    ///
    /// ```no_run
    /// use pptx_rs::oxml::chart::{ChartSeries, SeriesFormat};
    /// use pptx_rs::oxml::color::Color;
    /// use pptx_rs::units::RGBColor;
    ///
    /// let s = ChartSeries::new("Sales", vec![10.0, 20.0])
    ///     .with_format(SeriesFormat::solid_fill(Color::RGB(RGBColor(0xFF, 0x00, 0x00))));
    /// ```
    pub fn with_format(mut self, format: SeriesFormat) -> Self {
        self.format = Some(format);
        self
    }
}

/// 一个完整的图表,引用 chart part。
#[derive(Clone, Debug)]
pub struct Chart {
    /// 图表类型。
    pub chart_type: ChartType,
    /// 图表数据。
    pub data: ChartData,
    /// 关系 id(指向 `/ppt/charts/chartN.xml`)。
    ///
    /// 由 `Presentation::to_opc_package` 在写出 chart part 时自动分配。
    /// 高阶 API 创建时通常为空字符串,由 presentation 层填充。
    pub rid: String,
    /// 嵌入式 Excel 工作簿的关系 id(TODO-004 Excel 嵌入)。
    ///
    /// 指向 `/ppt/embeddings/Microsoft_Excel_WorksheetN.xlsx`,写出时在
    /// `</c:chart>` 之后、`</c:chartSpace>` 之前生成
    /// `<c:externalData r:id="..."><c:autoUpdate val="0"/></c:externalData>`。
    ///
    /// - `None`:不写出 `<c:externalData>`(图表数据仅靠 numCache/strCache)。
    /// - `Some(rid)`:写出 `<c:externalData r:id="{rid}"/>`,PowerPoint 打开
    ///   图表时会从 xlsx part 读取数据源("编辑数据" 启动 Excel)。
    ///
    /// 由 `Presentation::to_opc_package` 在写出 xlsx part 时自动分配;
    /// 高阶 API 创建时通常为 `None`,由 presentation 层填充。
    pub external_data_rid: Option<String>,
}

impl Chart {
    /// 新建一个图表。`rid` 默认为空字符串,由 presentation 层在写出时填充。
    pub fn new(chart_type: ChartType, data: ChartData) -> Self {
        Chart {
            chart_type,
            data,
            rid: String::new(),
            external_data_rid: None,
        }
    }

    /// 从 `<c:chartSpace>` XML 字符串解析为强类型 [`Chart`]。
    ///
    /// 详见模块级独立函数 [`parse_from_xml`](本关联函数是其委托)。
    ///
    /// # 参数
    /// - `xml`:完整 chartN.xml 内容。
    ///
    /// # 错误
    /// - `Error::Xml`:XML 解析失败。
    pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
        parse_from_xml(xml)
    }
}

impl Default for Chart {
    /// 默认图表:`ChartType::Column` + 空 `ChartData` + 空 rid。
    fn default() -> Self {
        Chart {
            chart_type: ChartType::Column,
            data: ChartData::default(),
            rid: String::new(),
            external_data_rid: None,
        }
    }
}

impl Chart {
    /// 写出 `<c:chartSpace>` 完整 XML(chartN.xml 内容)。
    ///
    /// # 元素结构(最小可用集)
    ///
    /// ```text
    /// <c:chartSpace xmlns:c=... xmlns:a=... xmlns:r=...>
    ///   <c:chart>
    ///     <c:title>...?         ← 可选
    ///     <c:plotArea>
    ///       <c:layout/>         ← 自动布局
    ///       <c:barChart|c:lineChart|c:pieChart>
    ///         <c:barDir>...     ← 仅 barChart
    ///         <c:grouping val="clustered"/>
    ///         <c:ser>           ← 一个或多个系列
    ///           <c:idx val="0"/>
    ///           <c:order val="0"/>
    ///           <c:tx><c:strRef><c:f>Sheet1!$B$1</c:f><c:strCache>...</c:strCache></c:strRef></c:tx>
    ///           <c:cat><c:strRef>...类别标签...</c:strRef></c:cat>
    ///           <c:val><c:numRef><c:numCache>...数值...</c:numCache></c:numRef></c:val>
    ///         </c:ser>
    ///         <c:axId val="..."/>  ← 1~2 个 axId
    ///       </c:barChart>
    ///       <c:catAx>...</c:catAx>     ← 类别轴(饼图无)
    ///       <c:valAx>...</c:valAx>     ← 数值轴(饼图无)
    ///     </c:plotArea>
    ///   </c:chart>
    /// </c:chartSpace>
    /// ```
    pub fn to_xml(&self) -> String {
        let mut w = XmlWriter::new();
        w.raw("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
        w.open_with(
            "c:chartSpace",
            &[
                (
                    "xmlns:c",
                    "http://schemas.openxmlformats.org/drawingml/2006/chart",
                ),
                (
                    "xmlns:a",
                    "http://schemas.openxmlformats.org/drawingml/2006/main",
                ),
                (
                    "xmlns:r",
                    "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
                ),
            ],
        );
        w.open("c:chart");
        // 标题
        if let Some(title) = &self.data.title {
            w.open("c:title");
            w.open("a:tx");
            w.open("a:rich");
            w.open("a:bodyPr");
            w.close("a:bodyPr");
            w.open("a:lstStyle");
            w.close("a:lstStyle");
            w.open("a:p");
            w.open("a:pPr");
            w.empty_with("a:defRPr", &[("sz", "1400"), ("b", "1")]);
            w.close("a:pPr");
            w.leaf("a:t", title.as_str());
            w.close("a:p");
            w.close("a:rich");
            w.close("a:tx");
            w.empty_with("c:overlay", &[("val", "0")]);
            w.close("c:title");
        }
        // plotArea
        w.open("c:plotArea");
        w.open("c:layout");
        w.close("c:layout");

        let chart_elem = self.chart_type.chart_element();
        w.open(chart_elem);
        if matches!(self.chart_type, ChartType::Column | ChartType::Bar) {
            w.empty_with("c:barDir", &[("val", self.chart_type.bar_dir())]);
            w.empty_with("c:grouping", &[("val", "clustered")]);
        }
        // 面积图 grouping(standard = 普通面积图,不堆叠)
        if matches!(self.chart_type, ChartType::Area) {
            w.empty_with("c:grouping", &[("val", "standard")]);
        }
        // 散点图 scatterStyle(lineMarker = 带标记的折线散点)
        if matches!(self.chart_type, ChartType::Scatter) {
            w.empty_with("c:scatterStyle", &[("val", "lineMarker")]);
        }
        // 雷达图 radarStyle(marker = 带数据标记的雷达图)
        if matches!(self.chart_type, ChartType::Radar) {
            w.empty_with("c:radarStyle", &[("val", "marker")]);
        }
        // 气泡图 bubbleScale(100 = 默认缩放比例)
        if matches!(self.chart_type, ChartType::Bubble) {
            w.empty_with("c:bubbleScale", &[("val", "100")]);
        }
        // varColors
        if matches!(self.chart_type, ChartType::Pie) {
            w.empty_with("c:varyColors", &[("val", "1")]);
        }

        // 图表级数据标签(<c:dLbls>,OOXML 顺序:在 varyColors 之后、<c:ser> 之前)。
        // 系列级 ChartSeries.data_labels 会覆盖此处的图表级配置。
        if let Some(dl) = &self.data.data_labels {
            if !dl.is_empty() {
                dl.write_xml(&mut w);
            }
        }

        // 系列列表(主图表元素)。
        //
        // 组合图模式(`secondary_chart_type.is_some()`):主图表元素只写出
        // `secondary_axis=false` 的系列;`secondary_axis=true` 的系列写入下方
        // 次图表元素(如 `c:lineChart`),与主图表元素共用类别轴但绑定次 valAx。
        //
        // 非组合图模式:所有系列写入主图表元素(即使 `secondary_axis=true`,
        // 仅绑定次轴 id,不创建第二个图表元素——向后兼容旧行为)。
        let has_combo = self.data.secondary_chart_type.is_some();
        for (idx, s) in self.data.series.iter().enumerate() {
            // 组合图模式下,次轴系列跳过(在次图表元素中写出)
            if has_combo && s.secondary_axis {
                continue;
            }
            write_series(&mut w, self.chart_type, s, idx, &self.data.categories);
        }

        // 轴 ID(饼图无轴;散点图/气泡图两个 valAx)。
        //
        // 次坐标轴支持(TODO-004 次坐标轴):当某系列 `secondary_axis=true` 时,
        // 该系列额外引用次轴 axId=444444444;图表级 axId 列表需额外包含次轴 id,
        // 并在下方轴定义段写出第三个轴(次 valAx,含 `<c:crosses val="max"/>`)。
        //
        // 饼图/散点图/气泡图不支持次坐标轴(OOXML 规范约束),secondary_axis 字段被忽略。
        let has_secondary = !self.chart_type.is_xy_chart()
            && !matches!(self.chart_type, ChartType::Pie)
            && self.data.series.iter().any(|s| s.secondary_axis);
        if matches!(self.chart_type, ChartType::Pie) {
            // 饼图无轴
        } else if self.chart_type.is_xy_chart() {
            // 散点图/气泡图:两个 valAx(不支持次坐标轴)
            w.empty_with("c:axId", &[("val", "111111111")]);
            w.empty_with("c:axId", &[("val", "222222222")]);
        } else {
            // 柱/条/线/面积/雷达:catAx + valAx
            w.empty_with("c:axId", &[("val", "111111111")]);
            w.empty_with("c:axId", &[("val", "222222222")]);
            // 次坐标轴:当存在 secondary_axis=true 系列时,额外引用次 valAx(axId=444444444)。
            // 次轴定义在下方轴定义段写出,含 <c:crosses val="max"/> 标记。
            if has_secondary {
                w.empty_with("c:axId", &[("val", "444444444")]);
            }
        }
        w.close(chart_elem);

        // 次图表元素(组合图模式,模块 D)。
        //
        // 当 `secondary_chart_type.is_some()` 且存在 `secondary_axis=true` 的系列时,
        // 写出第二个图表元素(如 `c:lineChart`),与主图表元素共用类别轴(catAx 111111111)
        // 但绑定次 valAx(axId=444444444)。
        //
        // OOXML 约束:`<c:plotArea>` 下的多个图表元素共享 catAx,但各自绑定不同的 valAx。
        // 主图表元素 → 主 valAx (222222222);次图表元素 → 次 valAx (444444444)。
        // 次 valAx 的定义在下方轴定义段写出(含 `<c:crosses val="max"/>`)。
        //
        // 典型场景:主轴柱形图 + 次轴折线图(双轴组合图,如"销量 vs 增长率")。
        if let Some(sec_ct) = self.data.secondary_chart_type {
            if self.data.series.iter().any(|s| s.secondary_axis) {
                let sec_elem = sec_ct.chart_element();
                w.open(sec_elem);
                // 次图表元素的 grouping/style(与主元素逻辑一致,按图表类型分支)
                if matches!(sec_ct, ChartType::Column | ChartType::Bar) {
                    w.empty_with("c:barDir", &[("val", sec_ct.bar_dir())]);
                    w.empty_with("c:grouping", &[("val", "clustered")]);
                }
                if matches!(sec_ct, ChartType::Area) {
                    w.empty_with("c:grouping", &[("val", "standard")]);
                }
                if matches!(sec_ct, ChartType::Scatter) {
                    w.empty_with("c:scatterStyle", &[("val", "lineMarker")]);
                }
                if matches!(sec_ct, ChartType::Radar) {
                    w.empty_with("c:radarStyle", &[("val", "marker")]);
                }
                if matches!(sec_ct, ChartType::Bubble) {
                    w.empty_with("c:bubbleScale", &[("val", "100")]);
                }
                // 次图表元素复用主图表级数据标签(若有)
                if let Some(dl) = &self.data.data_labels {
                    if !dl.is_empty() {
                        dl.write_xml(&mut w);
                    }
                }
                // 写出 secondary_axis=true 的系列到次图表元素
                for (idx, s) in self.data.series.iter().enumerate() {
                    if s.secondary_axis {
                        write_series(&mut w, sec_ct, s, idx, &self.data.categories);
                    }
                }
                // axId:次图表元素绑定主 catAx + 次 valAx
                w.empty_with("c:axId", &[("val", "111111111")]);
                w.empty_with("c:axId", &[("val", "444444444")]);
                w.close(sec_elem);
            }
        }

        // 轴定义(饼图跳过)
        // OOXML 子元素顺序(ECMA-376 21.2.2.16 catAx / 21.2.2.168 valAx):
        // axId → scaling → delete → axPos → majorGridlines → minorGridlines →
        // title → numFmt → ... → crossAx → ...
        if !matches!(self.chart_type, ChartType::Pie) {
            if self.chart_type.is_xy_chart() {
                // 散点图/气泡图:两个 valAx(X 轴底部 + Y 轴左侧)
                // valAx(X 轴)—— 使用 category_axis_gridlines + category_axis_title
                w.open("c:valAx");
                w.empty_with("c:axId", &[("val", "111111111")]);
                w.empty_with("c:scaling", &[("orientation", "minMax")]);
                w.empty_with("c:delete", &[("val", "0")]);
                w.empty_with("c:axPos", &[("val", "b")]);
                write_gridlines(&mut w, &self.data.category_axis_gridlines);
                if let Some(t) = &self.data.category_axis_title {
                    write_axis_title(&mut w, t.text.as_str());
                }
                w.empty_with("c:crossAx", &[("val", "222222222")]);
                w.close("c:valAx");
                // valAx(Y 轴)—— 使用 value_axis_gridlines + value_axis_title
                w.open("c:valAx");
                w.empty_with("c:axId", &[("val", "222222222")]);
                w.empty_with("c:scaling", &[("orientation", "minMax")]);
                w.empty_with("c:delete", &[("val", "0")]);
                w.empty_with("c:axPos", &[("val", "l")]);
                write_gridlines(&mut w, &self.data.value_axis_gridlines);
                if let Some(t) = &self.data.value_axis_title {
                    write_axis_title(&mut w, t.text.as_str());
                }
                w.empty_with("c:crossAx", &[("val", "111111111")]);
                w.close("c:valAx");
            } else {
                // 柱/条/线/面积:catAx + valAx
                // catAx(主类别轴,底部)
                w.open("c:catAx");
                w.empty_with("c:axId", &[("val", "111111111")]);
                w.empty_with("c:scaling", &[("orientation", "minMax")]);
                w.empty_with("c:delete", &[("val", "0")]);
                w.empty_with("c:axPos", &[("val", "b")]);
                write_gridlines(&mut w, &self.data.category_axis_gridlines);
                if let Some(t) = &self.data.category_axis_title {
                    write_axis_title(&mut w, t.text.as_str());
                }
                w.empty_with("c:crossAx", &[("val", "222222222")]);
                w.close("c:catAx");
                // valAx(主数值轴,左侧)
                w.open("c:valAx");
                w.empty_with("c:axId", &[("val", "222222222")]);
                w.empty_with("c:scaling", &[("orientation", "minMax")]);
                w.empty_with("c:delete", &[("val", "0")]);
                w.empty_with("c:axPos", &[("val", "l")]);
                write_gridlines(&mut w, &self.data.value_axis_gridlines);
                if let Some(t) = &self.data.value_axis_title {
                    write_axis_title(&mut w, t.text.as_str());
                }
                w.empty_with("c:crossAx", &[("val", "111111111")]);
                w.close("c:valAx");
                // 次数值轴(右侧,仅当 has_secondary 时写出)。
                //
                // <c:crosses val="max"/> 是 PowerPoint 识别次轴的关键元素:
                // 表示该轴在主轴的最大值处交叉(即显示在右侧/顶部)。
                // axPos="r" 表示右侧;crossAx 指向主 catAx(111111111)。
                // 注:次轴标题暂不支持(round-trip 简化),仅写出网格线。
                if has_secondary {
                    w.open("c:valAx");
                    w.empty_with("c:axId", &[("val", "444444444")]);
                    w.empty_with("c:scaling", &[("orientation", "minMax")]);
                    w.empty_with("c:delete", &[("val", "0")]);
                    w.empty_with("c:axPos", &[("val", "r")]);
                    write_gridlines(&mut w, &self.data.value_axis_gridlines);
                    w.empty_with("c:crossAx", &[("val", "111111111")]);
                    w.empty_with("c:crosses", &[("val", "max")]);
                    w.close("c:valAx");
                }
            }
        }
        w.close("c:plotArea");

        // legend —— None 时不输出(关闭图例)
        if let Some(legend) = &self.data.legend {
            w.open("c:legend");
            w.empty_with("c:legendPos", &[("val", legend.position.as_str())]);
            let overlay_val = if legend.overlay { "1" } else { "0" };
            w.empty_with("c:overlay", &[("val", overlay_val)]);
            w.close("c:legend");
        }

        w.empty_with("c:plotVisOnly", &[("val", "1")]);
        w.empty_with("c:dispBlanksAs", &[("val", "gap")]);
        w.close("c:chart");

        // 嵌入式 Excel 工作簿引用(TODO-004 Excel 嵌入)。
        //
        // OOXML 顺序约束:`<c:externalData>` 必须在 `<c:chart>` 之后、
        // `<c:chartSpace>` 关闭之前。PowerPoint 读取该元素后会从对应
        // xlsx part 加载数据源("编辑数据" 启动 Excel)。
        //
        // `<c:autoUpdate val="0"/>` 表示不自动同步(用户手动编辑后才更新),
        // 这是 PowerPoint 默认行为;val="1" 会在打开时自动同步,可能卡顿。
        if let Some(ext_rid) = &self.external_data_rid {
            w.open_with("c:externalData", &[("r:id", ext_rid.as_str())]);
            w.empty_with("c:autoUpdate", &[("val", "0")]);
            w.close("c:externalData");
        }

        w.close("c:chartSpace");
        w.into_string()
    }
}

/// 写出坐标轴的网格线(`<c:majorGridlines>` / `<c:minorGridlines>`)。
///
/// 仅当 `gridlines.major` / `gridlines.minor` 为 `true` 时输出对应元素。
/// 空元素(`<c:majorGridlines/>`)即可被 PowerPoint 识别为"显示主网格线"。
fn write_gridlines(w: &mut XmlWriter, gridlines: &Gridlines) {
    if gridlines.major {
        w.open("c:majorGridlines");
        w.close("c:majorGridlines");
    }
    if gridlines.minor {
        w.open("c:minorGridlines");
        w.close("c:minorGridlines");
    }
}

/// 写出 `<c:title>` 轴标题(含 `a:tx/a:rich/a:p/a:t` 结构)。
///
/// 用于 `c:catAx` / `c:valAx` 内的轴标题。OOXML 顺序:在 `majorGridlines` 之后、
/// `numFmt` 之前。`<c:overlay val="0"/>` 表示标题不覆盖绘图区。
fn write_axis_title(w: &mut XmlWriter, text: &str) {
    w.open("c:title");
    w.open("a:tx");
    w.open("a:rich");
    w.open("a:bodyPr");
    w.close("a:bodyPr");
    w.open("a:lstStyle");
    w.close("a:lstStyle");
    w.open("a:p");
    w.open("a:pPr");
    w.empty_with("a:defRPr", &[("sz", "1000"), ("b", "0")]);
    w.close("a:pPr");
    w.leaf("a:t", text);
    w.close("a:p");
    w.close("a:rich");
    w.close("a:tx");
    w.empty_with("c:overlay", &[("val", "0")]);
    w.close("c:title");
}

/// 写出单个 `<c:ser>` 系列元素到 XmlWriter。
///
/// 从 [`Chart::to_xml`] 的系列循环中提取,供主图表元素与次图表元素(组合图模式)复用。
///
/// # OOXML 子元素顺序(ECMA-376 21.2.2.197 `c:ser`)
///
/// `idx` → `order` → `tx` → `spPr` → `txPr` → `marker` → `invertIfNegative` →
/// `pictureOptions` → `dPt` → `dLbls` → `trendline` → `errBars` → `cat`/`xVal` →
/// `val`/`yVal` → `smooth` → ...(后续元素本库暂不写出)
///
/// 本实现实际写出顺序:`idx` → `order` → `tx` → `spPr`(可选)→
/// `cat`/`xVal` → `val`/`yVal` → `bubbleSize`(仅气泡图)→ `dLbls`(可选)。
///
/// # 参数
/// - `w`:XML 写出器。
/// - `chart_type`:当前图表类型(决定 `cat`/`xVal` + `val`/`yVal` 分支)。
///   组合图模式下,主图表元素传入主类型,次图表元素传入 [`ChartData::secondary_chart_type`]。
/// - `s`:要写出的数据系列。
/// - `idx`:系列索引(用于 `c:idx` / `c:order` 属性 + Excel 列字母计算)。
/// - `categories`:类别列表(非散点图用于 `c:cat`,散点图/气泡图忽略)。
fn write_series(
    w: &mut XmlWriter,
    chart_type: ChartType,
    s: &ChartSeries,
    idx: usize,
    categories: &[ChartCategory],
) {
    let idx_s = idx.to_string();
    w.open("c:ser");
    w.empty_with("c:idx", &[("val", idx_s.as_str())]);
    w.empty_with("c:order", &[("val", idx_s.as_str())]);
    // 系列名(c:tx)
    w.open("c:tx");
    w.open("c:strRef");
    w.leaf("c:f", &format!("Sheet1!${}$1", col_letter(idx + 1)));
    w.open("c:strCache");
    w.open_with("c:pt", &[("idx", "0")]);
    w.leaf("c:v", s.name.as_str());
    w.close("c:pt");
    w.close("c:strCache");
    w.close("c:strRef");
    w.close("c:tx");
    // 系列样式(<c:spPr>,OOXML 顺序:在 c:tx 之后、c:cat/c:xVal 之前)
    if let Some(fmt) = &s.format {
        fmt.write_xml(w);
    }
    if chart_type.is_xy_chart() {
        // 散点图/气泡图:c:xVal / c:yVal(均为数值)
        // c:xVal
        w.open("c:xVal");
        w.open("c:numRef");
        w.leaf("c:f", "Sheet1!$A$2:$A$100");
        w.open("c:numCache");
        w.empty_with("c:formatCode", &[("val", "General")]);
        if let Some(xs) = &s.x_values {
            for (i, v) in xs.iter().enumerate() {
                let i_s = i.to_string();
                let v_s = format_f64(*v);
                w.open_with("c:pt", &[("idx", i_s.as_str())]);
                w.leaf("c:v", v_s.as_str());
                w.close("c:pt");
            }
        }
        w.close("c:numCache");
        w.close("c:numRef");
        w.close("c:xVal");
        // c:yVal
        w.open("c:yVal");
        w.open("c:numRef");
        w.leaf(
            "c:f",
            &format!(
                "Sheet1!${}$2:${}$100",
                col_letter(idx + 1),
                col_letter(idx + 1)
            ),
        );
        w.open("c:numCache");
        w.empty_with("c:formatCode", &[("val", "General")]);
        for (i, v) in s.values.iter().enumerate() {
            let i_s = i.to_string();
            let v_s = format_f64(*v);
            w.open_with("c:pt", &[("idx", i_s.as_str())]);
            w.leaf("c:v", v_s.as_str());
            w.close("c:pt");
        }
        w.close("c:numCache");
        w.close("c:numRef");
        w.close("c:yVal");
        // 气泡图额外:c:bubbleSize(气泡尺寸,决定每个气泡的面积)
        if chart_type.is_bubble() {
            w.open("c:bubbleSize");
            w.open("c:numRef");
            w.leaf(
                "c:f",
                &format!(
                    "Sheet1!${}$2:${}$100",
                    col_letter(idx + 2),
                    col_letter(idx + 2)
                ),
            );
            w.open("c:numCache");
            w.empty_with("c:formatCode", &[("val", "General")]);
            if let Some(sizes) = &s.bubble_sizes {
                for (i, v) in sizes.iter().enumerate() {
                    let i_s = i.to_string();
                    let v_s = format_f64(*v);
                    w.open_with("c:pt", &[("idx", i_s.as_str())]);
                    w.leaf("c:v", v_s.as_str());
                    w.close("c:pt");
                }
            }
            w.close("c:numCache");
            w.close("c:numRef");
            w.close("c:bubbleSize");
        }
    } else {
        // 非散点图:c:cat(类别标签) + c:val(数值)
        // 类别标签(c:cat)—— 仅 bar/line/area 有
        if !matches!(chart_type, ChartType::Pie) && !categories.is_empty() {
            w.open("c:cat");
            w.open("c:strRef");
            w.leaf("c:f", "Sheet1!$A$2:$A$100");
            w.open("c:strCache");
            for (i, cat) in categories.iter().enumerate() {
                let i_s = i.to_string();
                w.open_with("c:pt", &[("idx", i_s.as_str())]);
                w.leaf("c:v", cat.name.as_str());
                w.close("c:pt");
            }
            w.close("c:strCache");
            w.close("c:strRef");
            w.close("c:cat");
        } else if matches!(chart_type, ChartType::Pie) && !categories.is_empty() {
            // 饼图:类别标签写在 c:cat 里
            w.open("c:cat");
            w.open("c:strRef");
            w.leaf("c:f", "Sheet1!$A$2:$A$100");
            w.open("c:strCache");
            for (i, cat) in categories.iter().enumerate() {
                let i_s = i.to_string();
                w.open_with("c:pt", &[("idx", i_s.as_str())]);
                w.leaf("c:v", cat.name.as_str());
                w.close("c:pt");
            }
            w.close("c:strCache");
            w.close("c:strRef");
            w.close("c:cat");
        }
        // 数值(c:val)
        w.open("c:val");
        w.open("c:numRef");
        w.leaf(
            "c:f",
            &format!(
                "Sheet1!${}$2:${}$100",
                col_letter(idx + 1),
                col_letter(idx + 1)
            ),
        );
        w.open("c:numCache");
        w.empty_with("c:formatCode", &[("val", "General")]);
        for (i, v) in s.values.iter().enumerate() {
            let i_s = i.to_string();
            let v_s = format_f64(*v);
            w.open_with("c:pt", &[("idx", i_s.as_str())]);
            w.leaf("c:v", v_s.as_str());
            w.close("c:pt");
        }
        w.close("c:numCache");
        w.close("c:numRef");
        w.close("c:val");
    }
    // 系列级数据标签(<c:dLbls>,OOXML 顺序:在 <c:val>/<c:yVal> 之后、</c:ser> 之前)。
    // 覆盖图表级 ChartData.data_labels 配置。
    if let Some(dl) = &s.data_labels {
        if !dl.is_empty() {
            dl.write_xml(w);
        }
    }
    w.close("c:ser");
}

/// 把 1-based 索引转为 Excel 列字母(1=A, 2=B, ..., 27=AA)。
fn col_letter(n: usize) -> String {
    let mut s = String::new();
    let mut n = n;
    while n > 0 {
        n -= 1;
        let ch = (b'A' + (n % 26) as u8) as char;
        s.insert(0, ch);
        n /= 26;
    }
    s
}

/// 格式化 f64 为 OOXML 兼容字符串(整数不输出小数点)。
fn format_f64(v: f64) -> String {
    if v.is_nan() || v.is_infinite() {
        return "0".to_string();
    }
    if v.fract() == 0.0 && v.abs() < 1e15 {
        format!("{}", v as i64)
    } else {
        format!("{}", v)
    }
}

/// 从 `<c:chartSpace>` XML 解析回 [`Chart`] 模型(TODO-004 读路径)。
///
/// 与 [`Chart::to_xml`] 对称的解析函数,用于 `Presentation::from_opc` 阶段
/// 读取 `/ppt/charts/chartN.xml` part 内容后还原 Chart 结构。
///
/// # 解析策略
///
/// - **chart_type**:根据 `<c:plotArea>` 下的图表元素名(`c:barChart` / `c:lineChart` /
///   `c:pieChart` / `c:scatterChart` / `c:areaChart` / `c:radarChart` / `c:bubbleChart`)
///   判断;对 `c:barChart` 额外读取 `<c:barDir val="col|bar">` 区分 Column / Bar。
/// - **title**:从 `<c:title><a:tx><a:rich>...<a:t>文本</a:t>` 提取第一个文本节点。
/// - **categories**:从**第一个** `<c:ser>` 的 `<c:cat><c:strRef><c:strCache>` 提取类别标签
///   (所有系列共享同一类别轴,取第一个即可;散点图/气泡图无 `c:cat`,类别列表为空)。
/// - **series**:遍历每个 `<c:ser>`,提取:
///   - `name`:`<c:tx><c:strRef><c:strCache><c:pt><c:v>`
///   - `values`:`<c:val><c:numRef><c:numCache><c:pt><c:v>`(Y 值)
///   - `x_values`:`<c:xVal><c:numRef><c:numCache><c:pt><c:v>`(仅散点图/气泡图)
///   - `bubble_sizes`:`<c:bubbleSize><c:numRef><c:numCache><c:pt><c:v>`(仅气泡图)
///
/// # 容错
///
/// - 缺失 `<c:plotArea>` 或其中无已知图表元素 → 返回 `Column` 占位类型(避免阻塞 round-trip);
/// - 单个系列的 numCache/strCache 解析失败 → 该系列用空数据填充,不中断整体解析;
/// - 不解析 `<c:axId>` / `<c:catAx>` / `<c:valAx>` / `<c:legend>` 等装饰元素
///   (写出时由 [`Chart::to_xml`] 按图表类型自动重建)。
///
/// # 参数
/// - `xml`:完整的 `<c:chartSpace>` XML 字符串(chartN.xml 内容)。
///
/// # 返回值
/// - 成功:返回 [`Chart`],`rid` 字段为空字符串(由调用方根据 slide rels 填充);
/// - 失败:返回 `Error::Xml`,包含解析错误上下文。
///
/// # 示例
///
/// ```no_run
/// # use pptx_rs::oxml::chart::{Chart, ChartType};
/// let xml = std::fs::read_to_string("chart1.xml").unwrap();
/// let chart = Chart::parse_from_xml(&xml).expect("parse chart");
/// println!("type: {:?}, series: {}", chart.chart_type, chart.data.series.len());
/// ```
pub fn parse_from_xml(xml: &str) -> crate::Result<Chart> {
    use quick_xml::events::Event;
    use quick_xml::reader::Reader;

    let mut chart_type = ChartType::Column;
    let mut title: Option<String> = None;
    let mut series: Vec<ChartSeries> = Vec::new();
    let mut categories: Vec<ChartCategory> = Vec::new();

    let mut rd = Reader::from_str(xml);
    rd.config_mut().trim_text(true);
    let mut buf = Vec::new();
    // 当前所在的图表元素名(如 c:barChart / c:lineChart),用于判断是否已进入 plotArea 的图表区域。
    let mut in_chart_elem: Option<&'static str> = None;
    // 是否在 <c:plotArea> 内(用于检测次坐标轴的 <c:crosses val="max"/> 标记,
    // 该标记位于 plotArea 下的 <c:valAx> 子元素内,而非主图表元素 barChart/lineChart 内)。
    let mut in_plot_area = false;
    // barChart 的 barDir 值(col/bar),用于区分 Column / Bar。
    let mut bar_dir: Option<String> = None;
    // 是否在 c:title 内的 a:t 元素中(用于精确提取标题文本,避免误取 a:defRPr 等元素的属性文本)。
    let mut in_title_text = false;
    // 当前正在解析的系列缓冲区。
    let mut cur_ser: Option<ChartSeries> = None;
    // 当前系列的字段上下文:正在读取的字段(tx/cat/val/xVal/yVal/bubbleSize)。
    let mut ser_field: Option<SerField> = None;
    // 是否在 numCache/strCache 内(决定 c:pt 的 c:v 是数值还是字符串)。
    let mut in_cache = false;
    // 嵌入式 Excel 工作簿的关系 id(从 <c:externalData r:id="..."> 提取)。
    let mut external_data_rid: Option<String> = None;
    // 临时缓冲:当前 cache 内的值列表。
    let mut cur_values: Vec<f64> = Vec::new();
    let mut cur_strings: Vec<String> = Vec::new();

    // ===== 数据标签(<c:dLbls>)解析上下文 =====
    //
    // dLbls 可出现在两个位置:
    //   1. 图表级:在 <c:barChart> 等图表元素内、<c:ser> 之前(plot 级配置);
    //   2. 系列级:在 <c:ser> 内 <c:val>/<c:yVal> 之后(覆盖图表级)。
    //
    // 通过 `dl_target` 区分当前 dLbls 归属:进入 c:ser 之前是 Chart,进入后是 Series。
    let mut chart_dl: Option<DataLabels> = None;
    let mut series_dl: Option<DataLabels> = None;
    // dLbls 解析目标:None=不在 dLbls 内, Some(Chart)=图表级, Some(Series)=系列级。
    let mut dl_target: Option<DlTarget> = None;
    // 是否检测到次坐标轴(<c:crosses val="max"/> 是 PowerPoint 识别次轴的关键标记)。
    // 若解析到次轴定义,所有非散点/非饼图系列的 secondary_axis 字段置为 true。
    let mut has_secondary_axis = false;

    // ===== 组合图(secondary_chart_type)解析上下文,模块 D =====
    //
    // OOXML 组合图在 <c:plotArea> 下写出两个图表元素(如 <c:barChart> + <c:lineChart>),
    // 各自绑定不同的 valAx。第一个图表元素是主图表,第二个是次图表。
    //
    // - `primary_chart_seen`:是否已遇到过主图表元素(用于区分主/次);
    // - `secondary_chart_type`:次图表类型(解析完成后写入 ChartData.secondary_chart_type);
    // - `in_secondary_chart`:当前是否在次图表元素内(用于标记系列 secondary_axis=true)。
    //
    // 注:`in_chart_elem` 在离开主图表元素时被置为 None,因此遇到第二个图表元素时
    // 可通过 `primary_chart_seen && in_chart_elem.is_none()` 判定为次图表。
    let mut primary_chart_seen = false;
    let mut secondary_chart_type: Option<ChartType> = None;
    let mut in_secondary_chart = false;
    // 次图表的 barDir(区分次图表的 Column/Bar),与主图表 bar_dir 独立。
    let mut secondary_bar_dir: Option<String> = None;

    // ===== 图例(<c:legend>)解析上下文 =====
    //
    // legend_position / legend_overlay 由子元素 c:legendPos / c:overlay 填充。
    // 离开 c:legend 时(End 事件)组装 Legend 并写入 legend_buf。
    // legend_buf = None 表示无 <c:legend> 元素(关闭图例,对应 ChartData.legend = None)。
    let mut legend_position: Option<LegendPosition> = None;
    let mut legend_overlay: Option<bool> = None;
    let mut in_legend = false;
    let mut legend_buf: Option<Legend> = None;

    // ===== 系列样式(<c:spPr>)解析上下文 =====
    //
    // in_sp_pr = true 时,后续 <a:srgbClr val="RRGGBB"/> 写入 sppr_color。
    // 离开 c:spPr 时(End 事件)组装 SeriesFormat 并写入 cur_ser.format。
    let mut in_sp_pr = false;
    let mut cur_series_format: Option<SeriesFormat> = None;
    let mut sppr_color: Option<Color> = None;

    /// 系列字段枚举(用于 `ser_field` 上下文)。
    #[derive(Copy, Clone, Debug, Eq, PartialEq)]
    enum SerField {
        /// `<c:tx>` 系列名。
        Name,
        /// `<c:cat>` 类别标签(仅取第一个系列的)。
        Cat,
        /// `<c:val>` 数值(Y 坐标)。
        Val,
        /// `<c:xVal>` X 坐标(散点/气泡图)。
        XVal,
        /// `<c:yVal>` Y 坐标(散点/气泡图,与 Val 同语义但元素名不同)。
        YVal,
        /// `<c:bubbleSize>` 气泡尺寸。
        BubbleSize,
    }

    /// 数据标签归属(图表级 vs 系列级)。
    #[derive(Copy, Clone, Debug, Eq, PartialEq)]
    enum DlTarget {
        /// 图表级 dLbls(写在 <c:barChart> 等图表元素内、<c:ser> 之前)。
        Chart,
        /// 系列级 dLbls(写在 <c:ser> 内 <c:val>/<c:yVal> 之后,覆盖图表级)。
        Series,
    }

    /// 从 local name 字节判断是否为已知图表元素,返回 (元素名, 对应 ChartType)。
    fn chart_elem_for(local: &[u8]) -> Option<(&'static str, ChartType)> {
        match local {
            b"barChart" => Some(("barChart", ChartType::Column)), // Column/Bar 由 barDir 后续区分
            b"lineChart" => Some(("lineChart", ChartType::Line)),
            b"pieChart" => Some(("pieChart", ChartType::Pie)),
            b"scatterChart" => Some(("scatterChart", ChartType::Scatter)),
            b"areaChart" => Some(("areaChart", ChartType::Area)),
            b"radarChart" => Some(("radarChart", ChartType::Radar)),
            b"bubbleChart" => Some(("bubbleChart", ChartType::Bubble)),
            _ => None,
        }
    }

    /// 提取元素 local name(去命名空间前缀)。
    fn local_name_quick(name: &[u8]) -> &[u8] {
        match name.iter().position(|&b| b == b':') {
            Some(i) => &name[i + 1..],
            None => name,
        }
    }

    loop {
        match rd.read_event_into(&mut buf) {
            Ok(Event::Start(e)) => {
                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
                let name = e.name();
                let local = local_name_quick(name.as_ref());
                // 提取 <c:externalData r:id="..."> 的关系 id(TODO-004 Excel 嵌入)。
                //
                // OOXML 中 externalData 通常是 Start + <c:autoUpdate/> + End 形式,
                // 这里在 Start 事件中提取 r:id 属性即可。
                if local == b"externalData" {
                    for a in e.attributes().flatten() {
                        // 兼容三种写法:
                        // - `r:id="..."`(标准 OOXML, relationships 命名空间前缀)
                        // - `ns0:id="..."` / `xxx:id="..."`(其他命名空间前缀 + id 本地名)
                        // - 裸 `id="..."`(无命名空间前缀,部分工具不严格加 r:)
                        let key = a.key.as_ref();
                        if key == b"r:id" || key == b"id" || key.ends_with(b":id") {
                            external_data_rid = Some(
                                a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
                                    .unwrap_or_default()
                                    .to_string(),
                            );
                        }
                    }
                }
                // 进入 <c:plotArea>:标记后续 crosses 检测生效
                if local == b"plotArea" {
                    in_plot_area = true;
                }
                // 进入图表元素(plotArea 下)。
                //
                // 组合图模式(模块 D):<c:plotArea> 下可能有两个图表元素,
                // 第一个是主图表(设置 chart_type),第二个是次图表(设置 secondary_chart_type)。
                // 通过 `primary_chart_seen` 标志区分主/次。
                if let Some((elem, ct)) = chart_elem_for(local) {
                    if primary_chart_seen && in_chart_elem.is_none() {
                        // 第二个图表元素 → 次图表(组合图模式)
                        secondary_chart_type = Some(ct);
                        in_secondary_chart = true;
                        secondary_bar_dir = None;
                    } else if !primary_chart_seen {
                        // 第一个图表元素 → 主图表
                        chart_type = ct;
                        primary_chart_seen = true;
                    }
                    in_chart_elem = Some(elem);
                    // barChart 需要后续 barDir 区分 Column/Bar,此处先占位 Column
                }
                // barDir 属性(区分 Column / Bar)。
                //
                // 主图表的 barDir 写入 bar_dir,次图表的写入 secondary_bar_dir,
                // 两者独立(避免次图表的 barDir 覆盖主图表的 Column/Bar 判定)。
                if local == b"barDir" && in_chart_elem.is_some() {
                    for a in e.attributes().flatten() {
                        if a.key.as_ref() == b"val" {
                            let v = a
                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
                                .unwrap_or_default()
                                .to_string();
                            if in_secondary_chart {
                                secondary_bar_dir = Some(v);
                            } else {
                                bar_dir = Some(v);
                            }
                        }
                    }
                }
                // 进入 c:title 内的 a:t 元素(精确匹配,避免误取 a:defRPr 等元素的属性文本)
                if local == b"t" {
                    in_title_text = true;
                }
                // 进入 c:ser
                if local == b"ser" && in_chart_elem.is_some() {
                    cur_ser = Some(ChartSeries::default());
                    // 重置系列级 dLbls 缓冲(每个系列独立)。
                    series_dl = None;
                }
                // 进入 c:spPr(系列级形状属性,控制颜色/填充/边框)。
                //
                // 仅在 c:ser 内的 spPr 才解析(plotArea 等其他位置的 spPr 忽略)。
                // 进入时初始化 cur_series_format 缓冲,后续 a:srgbClr 子元素填充颜色。
                if local == b"spPr" && cur_ser.is_some() {
                    in_sp_pr = true;
                    cur_series_format = Some(SeriesFormat::default());
                    sppr_color = None;
                }
                // 进入 c:dLbls:根据当前是否在 c:ser 内决定归属。
                //
                // - 在 c:ser 内 → 系列级(覆盖图表级);
                // - 不在 c:ser 内但在图表元素内 → 图表级。
                if local == b"dLbls" {
                    if cur_ser.is_some() {
                        dl_target = Some(DlTarget::Series);
                        series_dl = Some(DataLabels::default());
                    } else if in_chart_elem.is_some() {
                        dl_target = Some(DlTarget::Chart);
                        chart_dl = Some(DataLabels::default());
                    }
                }
                // 系列字段上下文
                if cur_ser.is_some() {
                    match local {
                        b"tx" => ser_field = Some(SerField::Name),
                        b"cat" => ser_field = Some(SerField::Cat),
                        b"val" => ser_field = Some(SerField::Val),
                        b"xVal" => ser_field = Some(SerField::XVal),
                        b"yVal" => ser_field = Some(SerField::YVal),
                        b"bubbleSize" => ser_field = Some(SerField::BubbleSize),
                        _ => {}
                    }
                }
                // 进入 numCache / strCache
                if matches!(local, b"numCache" | b"strCache") {
                    in_cache = true;
                    cur_values.clear();
                    cur_strings.clear();
                }
                // 检测次坐标轴的 <c:crosses val="max"/> 标记。
                //
                // PowerPoint 通过该元素识别次坐标轴(显示在右侧)。
                // 解析到该标记后,所有非散点/非饼图系列的 secondary_axis 字段在
                // 解析末尾被置为 true(写出时由 to_xml 重建次轴定义)。
                //
                // 注意:crosses 元素位于 <c:valAx> 内(plotArea 下,但不在主图表元素
                // barChart/lineChart 内),因此用 in_plot_area 而非 in_chart_elem 判断。
                if local == b"crosses" && in_plot_area {
                    for a in e.attributes().flatten() {
                        if a.key.as_ref() == b"val" {
                            let v = a
                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
                                .unwrap_or_default();
                            if v == "max" {
                                has_secondary_axis = true;
                            }
                        }
                    }
                }
                // ===== 图例(<c:legend>)解析 =====
                //
                // <c:legend> 是 <c:chart> 的子元素,位于 <c:plotArea> 之后。
                // 进入 c:legend 时设置 in_legend 标志,后续 legendPos / overlay
                // 子元素(无论 Start 还是 Empty 形式)在 in_legend 上下文内解析。
                if local == b"legend" {
                    in_legend = true;
                }
                // c:legendPos / c:overlay 的非自闭合形式(Start + End)。
                // 自闭合形式在下方 Empty 分支处理。
                if in_legend {
                    if local == b"legendPos" {
                        if let Some(v) = attr_val(&e, "val") {
                            legend_position = match v.as_str() {
                                "b" => Some(LegendPosition::Bottom),
                                "t" => Some(LegendPosition::Top),
                                "r" => Some(LegendPosition::Right),
                                "l" => Some(LegendPosition::Left),
                                _ => None,
                            };
                        }
                    }
                    if local == b"overlay" {
                        legend_overlay = parse_bool_val(&e);
                    }
                }
            }
            Ok(Event::Empty(e)) => {
                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
                let name = e.name();
                let local = local_name_quick(name.as_ref());
                // barDir 自闭合形式。
                //
                // 与 Start 分支一致:主图表的 barDir 写入 bar_dir,
                // 次图表的写入 secondary_bar_dir(组合图模式,模块 D)。
                if local == b"barDir" && in_chart_elem.is_some() {
                    for a in e.attributes().flatten() {
                        if a.key.as_ref() == b"val" {
                            let v = a
                                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
                                .unwrap_or_default()
                                .to_string();
                            if in_secondary_chart {
                                secondary_bar_dir = Some(v);
                            } else {
                                bar_dir = Some(v);
                            }
                        }
                    }
                }
                // c:pt 自闭合(无子元素,但通常 c:pt 是 Start + c:v + End)
                // c:v 自闭合形式(罕见但兼容)
                if local == b"v" && in_cache {
                    if let Some(v) = e.attributes().flatten().find(|a| a.key.as_ref() == b"val") {
                        let s = v
                            .normalized_value(quick_xml::XmlVersion::Implicit1_0)
                            .unwrap_or_default()
                            .to_string();
                        if let Ok(f) = s.parse::<f64>() {
                            cur_values.push(f);
                        }
                        cur_strings.push(s);
                    }
                }
                // ===== 数据标签子元素解析(仅在 dLbls 上下文内) =====
                //
                // 这些子元素均以自闭合形式出现(<c:showVal val="1"/>),
                // 统一在 Empty 事件中处理。归属由 `dl_target` 决定。
                if let Some(target) = dl_target.as_ref() {
                    // 取目标 dLbls 缓冲的可变引用(图表级或系列级)。
                    let dl_buf: &mut Option<DataLabels> = if *target == DlTarget::Chart {
                        &mut chart_dl
                    } else {
                        &mut series_dl
                    };
                    let dl = dl_buf.get_or_insert_with(DataLabels::default);
                    match local {
                        b"showVal" => {
                            dl.show_val = parse_bool_val(&e);
                        }
                        b"showCatName" => {
                            dl.show_cat_name = parse_bool_val(&e);
                        }
                        b"showSerName" => {
                            dl.show_ser_name = parse_bool_val(&e);
                        }
                        b"showLegendKey" => {
                            dl.show_legend_key = parse_bool_val(&e);
                        }
                        b"showPercent" => {
                            dl.show_percent = parse_bool_val(&e);
                        }
                        b"showBubbleSize" => {
                            dl.show_bubble_size = parse_bool_val(&e);
                        }
                        b"dLblPos" => {
                            if let Some(v) = attr_val(&e, "val") {
                                dl.position = LabelPosition::parse(&v);
                            }
                        }
                        b"separator" => {
                            if let Some(v) = attr_val(&e, "val") {
                                dl.separator = Some(v);
                            }
                        }
                        b"numFmt" => {
                            if let Some(fc) = attr_val(&e, "formatCode") {
                                dl.num_fmt = Some(fc);
                            }
                        }
                        _ => {}
                    }
                }
                // c:crosses 自闭合形式(部分工具输出自闭合的 <c:crosses val="max"/>)。
                // 同 Start 分支:用 in_plot_area 判断(crosses 在 <c:valAx> 内,不在主图表元素内)。
                if local == b"crosses" && in_plot_area {
                    if let Some(v) = attr_val(&e, "val") {
                        if v == "max" {
                            has_secondary_axis = true;
                        }
                    }
                }
                // ===== spPr 子元素解析(<a:srgbClr val="RRGGBB"/>) =====
                //
                // 仅在 in_sp_pr 上下文内解析(避免误取其他位置的 srgbClr)。
                // 解析到的颜色暂存 sppr_color,离开 c:spPr 时组装为 Fill::Solid。
                if in_sp_pr && local == b"srgbClr" {
                    if let Some(v) = attr_val(&e, "val") {
                        if let Some(c) = parse_hex_color(&v) {
                            sppr_color = Some(c);
                        }
                    }
                }
                // ===== 图例子元素自闭合形式解析 =====
                //
                // <c:legendPos val="b"/> / <c:overlay val="0"/> 通常以自闭合形式出现。
                // 仅在 in_legend 上下文内解析(避免误取轴标题内的 overlay等同名元素)。
                if in_legend {
                    if local == b"legendPos" {
                        if let Some(v) = attr_val(&e, "val") {
                            legend_position = match v.as_str() {
                                "b" => Some(LegendPosition::Bottom),
                                "t" => Some(LegendPosition::Top),
                                "r" => Some(LegendPosition::Right),
                                "l" => Some(LegendPosition::Left),
                                _ => None,
                            };
                        }
                    }
                    if local == b"overlay" {
                        legend_overlay = parse_bool_val(&e);
                    }
                }
            }
            Ok(Event::Text(t)) => {
                // 标题文本(c:title 内的第一个 a:t 元素的文本内容)
                if in_title_text && title.is_none() {
                    // quick-xml 0.40: BytesText::unescape() 方法已移除,
                    // 改用 quick_xml::escape::unescape 函数(接受 &str)。
                    // BytesText 的 Deref 目标是 [u8],需要先转成 &str。
                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
                    let text = quick_xml::escape::unescape(text_str)
                        .unwrap_or_default()
                        .to_string();
                    if !text.is_empty() {
                        title = Some(text);
                    }
                }
                // cache 内的文本(c:v 的文本内容)
                if in_cache {
                    let text_str = std::str::from_utf8(t.as_ref()).unwrap_or("");
                    let text = quick_xml::escape::unescape(text_str)
                        .unwrap_or_default()
                        .to_string();
                    if !text.is_empty() {
                        if let Ok(f) = text.parse::<f64>() {
                            cur_values.push(f);
                        }
                        cur_strings.push(text);
                    }
                }
            }
            Ok(Event::End(e)) => {
                // 把 e.name() 绑定到 let,避免临时 QName 在 as_ref() 后 drop
                let name = e.name();
                let local = local_name_quick(name.as_ref());
                // 离开 <c:plotArea>:清空 in_plot_area 标志
                if local == b"plotArea" {
                    in_plot_area = false;
                }
                // 离开图表元素。
                //
                // 主图表:barChart + barDir=bar → ChartType::Bar(否则保持 Column)。
                // 次图表:barChart + secondary_bar_dir=bar → secondary_chart_type = Bar。
                // 离开时清空 in_chart_elem 和 in_secondary_chart 标志。
                if let Some(elem) = in_chart_elem {
                    if local == elem.as_bytes() {
                        if !in_secondary_chart {
                            // 主图表:最终确定 chart_type
                            if elem == "barChart" {
                                if let Some(dir) = &bar_dir {
                                    if dir == "bar" {
                                        chart_type = ChartType::Bar;
                                    }
                                }
                            }
                        } else {
                            // 次图表:最终确定 secondary_chart_type
                            if elem == "barChart" {
                                if let Some(dir) = &secondary_bar_dir {
                                    if dir == "bar" {
                                        secondary_chart_type = Some(ChartType::Bar);
                                    }
                                }
                            }
                        }
                        in_chart_elem = None;
                        in_secondary_chart = false;
                    }
                }
                // 离开 a:t 元素
                if local == b"t" {
                    in_title_text = false;
                }
                // 离开 c:dLbls:清空 dl_target 上下文(缓冲已由子元素 Empty 事件填充)。
                if local == b"dLbls" {
                    dl_target = None;
                }
                // 离开 c:spPr:组装 SeriesFormat 写入当前系列。
                //
                // 若解析到 srgbClr 颜色,填充为 Fill::Solid;否则保持默认 Fill::Inherit。
                // 组装完成后写入 cur_ser.format,供 round-trip 保真。
                if local == b"spPr" && in_sp_pr {
                    if let Some(color) = sppr_color.take() {
                        if let Some(fmt) = cur_series_format.as_mut() {
                            fmt.fill = Fill::Solid(color);
                        }
                    }
                    if let Some(fmt) = cur_series_format.take() {
                        if let Some(s) = cur_ser.as_mut() {
                            s.format = Some(fmt);
                        }
                    }
                    in_sp_pr = false;
                }
                // 离开 c:legend:组装 Legend 写入 legend_buf。
                //
                // 即使未解析到 legendPos(部分工具省略该子元素),也认为是 has_legend,
                // 用默认位置(Bottom)——这与 PowerPoint 行为一致(有 <c:legend> 即显示图例)。
                if local == b"legend" {
                    let pos = legend_position.take().unwrap_or_default();
                    legend_buf = Some(Legend {
                        position: pos,
                        overlay: legend_overlay.take().unwrap_or(false),
                    });
                    in_legend = false;
                }
                // 离开 numCache/strCache:把缓冲写入当前字段
                if matches!(local, b"numCache" | b"strCache") && cur_ser.is_some() {
                    let field = ser_field.take();
                    if let Some(f) = field {
                        match f {
                            SerField::Name => {
                                // 系列名取第一个字符串
                                if let Some(s) = cur_strings.first() {
                                    if let Some(s_obj) = cur_ser.as_mut() {
                                        s_obj.name = s.clone();
                                    }
                                }
                                ser_field = None;
                            }
                            SerField::Cat => {
                                // 类别标签:仅第一个系列的 cat 写入 categories
                                if categories.is_empty() {
                                    for s in &cur_strings {
                                        categories.push(ChartCategory::new(s.clone()));
                                    }
                                }
                                ser_field = None;
                            }
                            SerField::Val | SerField::YVal => {
                                if let Some(s_obj) = cur_ser.as_mut() {
                                    s_obj.values = cur_values.clone();
                                }
                                ser_field = None;
                            }
                            SerField::XVal => {
                                if let Some(s_obj) = cur_ser.as_mut() {
                                    s_obj.x_values = Some(cur_values.clone());
                                }
                                ser_field = None;
                            }
                            SerField::BubbleSize => {
                                if let Some(s_obj) = cur_ser.as_mut() {
                                    s_obj.bubble_sizes = Some(cur_values.clone());
                                }
                                ser_field = None;
                            }
                        }
                    }
                    in_cache = false;
                    cur_values.clear();
                    cur_strings.clear();
                }
                // 离开 c:ser:把当前系列推入 series 列表,并写入系列级 dLbls。
                //
                // 组合图模式(模块 D):若当前在次图表元素内(in_secondary_chart=true),
                // 该系列标记为 secondary_axis=true,写出时绑定次 valAx。
                if local == b"ser" && cur_ser.is_some() {
                    if let Some(mut s_obj) = cur_ser.take() {
                        // 系列级数据标签:覆盖图表级配置。
                        if let Some(dl) = series_dl.take() {
                            if !dl.is_empty() {
                                s_obj.data_labels = Some(dl);
                            }
                        }
                        // 次图表内的系列标记为次轴绑定
                        if in_secondary_chart {
                            s_obj.secondary_axis = true;
                        }
                        series.push(s_obj);
                    }
                    ser_field = None;
                    // 清理系列级 dLbls 上下文,避免泄漏到下一个系列。
                    series_dl = None;
                    if dl_target == Some(DlTarget::Series) {
                        dl_target = None;
                    }
                }
            }
            Ok(Event::Eof) => break,
            Err(e) => return Err(crate::Error::Xml(format!("chart parse_from_xml: {e}"))),
            _ => {}
        }
        buf.clear();
    }

    // 次坐标轴回退策略(模块 D)。
    //
    // 优先级:
    // 1. 组合图模式(secondary_chart_type.is_some()):次图表元素内的系列已标记
    //    secondary_axis=true,无需回退处理;
    // 2. 回退策略(secondary_chart_type.is_none() && has_secondary_axis):
    //    检测到 <c:crosses val="max"/> 但无第二个图表元素(旧式次轴实现),
    //    把所有非散点/非饼图系列标记为 secondary_axis=true(旧行为)。
    //
    // 对散点图/气泡图/饼图忽略(OOXML 规范约束:这些类型不支持次坐标轴)。
    if has_secondary_axis
        && secondary_chart_type.is_none()
        && !chart_type.is_xy_chart()
        && !matches!(chart_type, ChartType::Pie)
    {
        for s in series.iter_mut() {
            s.secondary_axis = true;
        }
    }

    let data = ChartData {
        categories,
        series,
        title,
        data_labels: chart_dl.filter(|dl| !dl.is_empty()),
        legend: legend_buf,
        secondary_chart_type,
        ..Default::default()
    };
    Ok(Chart {
        chart_type,
        data,
        rid: String::new(),
        external_data_rid,
    })
}

/// 从 `<c:showXxx val="1|0|true|false"/>` 提取 bool 值。
///
/// OOXML 规范要求 `val` 属性值为 "0" 或 "1"(XML Schema boolean),
/// 但部分工具(如 LibreOffice)会输出 "true" / "false",这里一并兼容。
fn parse_bool_val(e: &quick_xml::events::BytesStart<'_>) -> Option<bool> {
    for a in e.attributes().flatten() {
        if a.key.as_ref() == b"val" {
            let v = a
                .normalized_value(quick_xml::XmlVersion::Implicit1_0)
                .unwrap_or_default();
            return match v.as_ref() {
                "1" | "true" | "True" => Some(true),
                "0" | "false" | "False" => Some(false),
                _ => None,
            };
        }
    }
    None
}

/// 从元素提取指定属性值(如 `val` / `formatCode`),未找到返回 `None`。
///
/// 兼容带命名空间前缀的写法(如 `r:val`),用 local name 后缀匹配。
fn attr_val(e: &quick_xml::events::BytesStart<'_>, key: &str) -> Option<String> {
    let key_bytes = key.as_bytes();
    // 构造 ":key" 字节序列,用于后缀匹配(如匹配 "r:val" 中的 ":val")。
    let suffix: Vec<u8> = std::iter::once(b':')
        .chain(key_bytes.iter().copied())
        .collect();
    for a in e.attributes().flatten() {
        let k = a.key.as_ref();
        if k == key_bytes || k.ends_with(&suffix) {
            return Some(
                a.normalized_value(quick_xml::XmlVersion::Implicit1_0)
                    .unwrap_or_default()
                    .to_string(),
            );
        }
    }
    None
}

/// 从 "RRGGBB" 字符串解析为 `Color::RGB(RGBColor)`。
///
/// 兼容 3 位简写("RGB" → "RRGGBB")和 6 位标准写法。无效输入返回 `None`。
///
/// # 参数
/// - `s`:十六进制颜色字符串(如 `"4F81BD"` / `"FF0000"`)。
///
/// # 返回值
/// - `Some(Color::RGB(...))`:解析成功;
/// - `None`:输入长度不为 3 或 6,或包含非法十六进制字符。
fn parse_hex_color(s: &str) -> Option<Color> {
    use crate::units::RGBColor;
    let s = s.trim();
    let (r, g, b) = if s.len() == 6 {
        (
            u8::from_str_radix(&s[0..2], 16).ok()?,
            u8::from_str_radix(&s[2..4], 16).ok()?,
            u8::from_str_radix(&s[4..6], 16).ok()?,
        )
    } else if s.len() == 3 {
        (
            u8::from_str_radix(&s[0..1].repeat(2), 16).ok()?,
            u8::from_str_radix(&s[1..2].repeat(2), 16).ok()?,
            u8::from_str_radix(&s[2..3].repeat(2), 16).ok()?,
        )
    } else {
        return None;
    };
    Some(Color::RGB(RGBColor(r, g, b)))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// 柱状图最小 XML 包含必要元素。
    #[test]
    fn column_chart_minimal_xml() {
        let data = ChartData {
            categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
            series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
            title: Some("Revenue".to_string()),
            data_labels: None,
            ..Default::default()
        };
        let chart = Chart::new(ChartType::Column, data);
        let xml = chart.to_xml();
        assert!(xml.contains("<c:chartSpace"), "xml: {}", xml);
        assert!(xml.contains("<c:barChart>"), "xml: {}", xml);
        assert!(xml.contains("barDir"), "xml: {}", xml);
        assert!(xml.contains("val=\"col\""), "xml: {}", xml);
        assert!(xml.contains("<c:ser>"), "xml: {}", xml);
        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
        assert!(xml.contains("Revenue"), "xml: {}", xml);
    }

    /// 饼图不写出 catAx/valAx。
    #[test]
    fn pie_chart_no_axes() {
        let data = ChartData {
            categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
            series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
            title: None,
            data_labels: None,
            ..Default::default()
        };
        let xml = Chart::new(ChartType::Pie, data).to_xml();
        assert!(xml.contains("<c:pieChart>"), "xml: {}", xml);
        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
        assert!(!xml.contains("<c:valAx>"), "xml: {}", xml);
        assert!(xml.contains("varyColors"), "xml: {}", xml);
    }

    /// 折线图使用 lineChart 元素。
    #[test]
    fn line_chart_uses_line_chart_element() {
        let data = ChartData {
            categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
            series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
            title: None,
            data_labels: None,
            ..Default::default()
        };
        let xml = Chart::new(ChartType::Line, data).to_xml();
        assert!(xml.contains("<c:lineChart>"), "xml: {}", xml);
        assert!(!xml.contains("<c:barChart>"), "xml: {}", xml);
        // 浮点值应正确序列化
        assert!(xml.contains("1.5"), "xml: {}", xml);
        assert!(xml.contains("2.5"), "xml: {}", xml);
    }

    /// 散点图使用 scatterChart 元素 + c:xVal/c:yVal + 两个 valAx。
    #[test]
    fn scatter_chart_uses_scatter_elements() {
        let data = ChartData {
            categories: vec![], // 散点图忽略 categories
            series: vec![ChartSeries::new_scatter(
                "Series1",
                vec![1.0, 2.0, 3.0], // X
                vec![2.0, 4.0, 6.0], // Y
            )],
            title: Some("Scatter".to_string()),
            data_labels: None,
            ..Default::default()
        };
        let xml = Chart::new(ChartType::Scatter, data).to_xml();
        assert!(xml.contains("<c:scatterChart>"), "xml: {}", xml);
        assert!(xml.contains("scatterStyle"), "xml: {}", xml);
        assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
        assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
        // 散点图不应有 catAx
        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
        // 散点图应有两个 valAx
        let val_ax_count = xml.matches("<c:valAx>").count();
        assert_eq!(
            val_ax_count, 2,
            "expected 2 valAx, got {}: {}",
            val_ax_count, xml
        );
        // 散点图不应有 c:cat
        assert!(!xml.contains("<c:cat>"), "xml: {}", xml);
        // X 坐标值应正确序列化
        assert!(xml.contains(">1<"), "xml: {}", xml);
        assert!(xml.contains(">3<"), "xml: {}", xml);
    }

    /// 面积图使用 areaChart 元素 + grouping=standard + catAx + valAx。
    #[test]
    fn area_chart_uses_area_elements() {
        let data = ChartData {
            categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
            series: vec![ChartSeries::new("S1", vec![10.0, 20.0])],
            title: None,
            data_labels: None,
            ..Default::default()
        };
        let xml = Chart::new(ChartType::Area, data).to_xml();
        assert!(xml.contains("<c:areaChart>"), "xml: {}", xml);
        assert!(xml.contains("grouping"), "xml: {}", xml);
        assert!(xml.contains("val=\"standard\""), "xml: {}", xml);
        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
        // 面积图应有 c:cat(类别标签)
        assert!(xml.contains("<c:cat>"), "xml: {}", xml);
    }

    /// 雷达图使用 radarChart 元素 + radarStyle=marker + catAx + valAx。
    #[test]
    fn radar_chart_uses_radar_elements() {
        let data = ChartData {
            categories: vec![
                ChartCategory::new("速度"),
                ChartCategory::new("力量"),
                ChartCategory::new("技巧"),
            ],
            series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
            title: Some("Radar".to_string()),
            data_labels: None,
            ..Default::default()
        };
        let xml = Chart::new(ChartType::Radar, data).to_xml();
        assert!(xml.contains("<c:radarChart>"), "xml: {}", xml);
        assert!(xml.contains("radarStyle"), "xml: {}", xml);
        assert!(xml.contains("val=\"marker\""), "xml: {}", xml);
        // 雷达图使用 catAx + valAx(与柱/线相同)
        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
        // 雷达图有 c:cat(类别标签)
        assert!(xml.contains("<c:cat>"), "xml: {}", xml);
        // 雷达图不应有散点图元素
        assert!(!xml.contains("<c:xVal>"), "xml: {}", xml);
        assert!(!xml.contains("<c:bubbleSize>"), "xml: {}", xml);
    }

    /// 气泡图使用 bubbleChart 元素 + bubbleScale + c:xVal/c:yVal/c:bubbleSize + 两个 valAx。
    #[test]
    fn bubble_chart_uses_bubble_elements() {
        let data = ChartData {
            categories: vec![],
            series: vec![ChartSeries::new_bubble(
                "S1",
                vec![1.0, 2.0, 3.0],    // x_values
                vec![10.0, 20.0, 30.0], // y_values
                vec![5.0, 15.0, 25.0],  // bubble_sizes
            )],
            title: Some("Bubble".to_string()),
            data_labels: None,
            ..Default::default()
        };
        let xml = Chart::new(ChartType::Bubble, data).to_xml();
        assert!(xml.contains("<c:bubbleChart>"), "xml: {}", xml);
        assert!(xml.contains("bubbleScale"), "xml: {}", xml);
        assert!(xml.contains("val=\"100\""), "xml: {}", xml);
        // 气泡图使用 c:xVal / c:yVal / c:bubbleSize
        assert!(xml.contains("<c:xVal>"), "xml: {}", xml);
        assert!(xml.contains("<c:yVal>"), "xml: {}", xml);
        assert!(xml.contains("<c:bubbleSize>"), "xml: {}", xml);
        // 气泡图使用两个 valAx(无 catAx)
        assert!(!xml.contains("<c:catAx>"), "xml: {}", xml);
        assert!(xml.contains("<c:valAx>"), "xml: {}", xml);
        // 验证气泡尺寸值被正确写出
        assert!(xml.contains(">5<"), "xml: {}", xml);
        assert!(xml.contains(">25<"), "xml: {}", xml);
    }

    /// col_letter 正确转换。
    #[test]
    fn col_letter_basic() {
        assert_eq!(col_letter(1), "A");
        assert_eq!(col_letter(2), "B");
        assert_eq!(col_letter(26), "Z");
        assert_eq!(col_letter(27), "AA");
        assert_eq!(col_letter(28), "AB");
    }

    /// format_f64 整数无小数点。
    #[test]
    fn format_f64_integer() {
        assert_eq!(format_f64(10.0), "10");
        assert_eq!(format_f64(1.5), "1.5");
        assert_eq!(format_f64(f64::NAN), "0");
    }

    // ===================== TODO-004 图表读路径测试 =====================

    /// 柱状图 round-trip:to_xml → parse_from_xml → 字段一致。
    ///
    /// 覆盖:Column 类型识别(barDir=col)、标题、类别、单系列数值。
    #[test]
    fn parse_column_chart_round_trip() {
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![
                    ChartCategory::new("Q1"),
                    ChartCategory::new("Q2"),
                    ChartCategory::new("Q3"),
                ],
                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0, 30.0])],
                title: Some("Revenue".to_string()),
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse column chart");

        assert_eq!(parsed.chart_type, ChartType::Column);
        assert_eq!(parsed.data.title.as_deref(), Some("Revenue"));
        assert_eq!(parsed.data.categories.len(), 3);
        assert_eq!(parsed.data.categories[0].name, "Q1");
        assert_eq!(parsed.data.categories[2].name, "Q3");
        assert_eq!(parsed.data.series.len(), 1);
        assert_eq!(parsed.data.series[0].name, "Sales");
        assert_eq!(parsed.data.series[0].values, vec![10.0, 20.0, 30.0]);
    }

    /// 条形图 round-trip:barDir=bar 区分 Bar 与 Column。
    ///
    /// 这是 parse_from_xml 的关键边界:barChart 元素本身不区分柱/条,
    /// 必须依靠 barDir 属性后续修正 chart_type。
    #[test]
    fn parse_bar_chart_distinguishes_bar_from_column() {
        let original = Chart::new(
            ChartType::Bar,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![ChartSeries::new("S1", vec![1.0, 2.0])],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse bar chart");

        // 必须识别为 Bar 而非 Column
        assert_eq!(parsed.chart_type, ChartType::Bar);
        assert_ne!(parsed.chart_type, ChartType::Column);
        assert_eq!(parsed.data.series.len(), 1);
        assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
    }

    /// 折线图 round-trip:lineChart 元素识别 + 浮点数保留。
    #[test]
    fn parse_line_chart_round_trip() {
        let original = Chart::new(
            ChartType::Line,
            ChartData {
                categories: vec![ChartCategory::new("X1"), ChartCategory::new("X2")],
                series: vec![ChartSeries::new("Y", vec![1.5, 2.5])],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse line chart");

        assert_eq!(parsed.chart_type, ChartType::Line);
        assert_eq!(parsed.data.categories.len(), 2);
        assert_eq!(parsed.data.series[0].name, "Y");
        // 浮点值必须正确解析
        assert_eq!(parsed.data.series[0].values, vec![1.5, 2.5]);
    }

    /// 饼图 round-trip:pieChart 元素识别 + categories 保留(饼图也写 c:cat)。
    #[test]
    fn parse_pie_chart_round_trip() {
        let original = Chart::new(
            ChartType::Pie,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![ChartSeries::new("X", vec![1.0, 2.0])],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse pie chart");

        assert_eq!(parsed.chart_type, ChartType::Pie);
        // 饼图 to_xml 也写 c:cat(line 654-669),所以解析后 categories 应保留
        assert_eq!(parsed.data.categories.len(), 2);
        assert_eq!(parsed.data.categories[0].name, "A");
        assert_eq!(parsed.data.categories[1].name, "B");
        assert_eq!(parsed.data.series.len(), 1);
        assert_eq!(parsed.data.series[0].values, vec![1.0, 2.0]);
    }

    /// 散点图 round-trip:scatterChart 元素 + c:xVal/c:yVal 解析。
    ///
    /// 散点图无 c:cat,使用 c:xVal / c:yVal 提供坐标。
    #[test]
    fn parse_scatter_chart_round_trip() {
        let original = Chart::new(
            ChartType::Scatter,
            ChartData {
                categories: vec![],
                series: vec![ChartSeries::new_scatter(
                    "Series1",
                    vec![1.0, 2.0, 3.0], // X
                    vec![2.0, 4.0, 6.0], // Y
                )],
                title: Some("Scatter".to_string()),
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse scatter chart");

        assert_eq!(parsed.chart_type, ChartType::Scatter);
        assert_eq!(parsed.data.title.as_deref(), Some("Scatter"));
        assert_eq!(parsed.data.series.len(), 1);
        let s = &parsed.data.series[0];
        assert_eq!(s.name, "Series1");
        // Y 坐标写入 values
        assert_eq!(s.values, vec![2.0, 4.0, 6.0]);
        // X 坐标写入 x_values
        assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
        // 散点图无 bubble_sizes
        assert!(s.bubble_sizes.is_none());
    }

    /// 气泡图 round-trip:bubbleChart 元素 + c:xVal/c:yVal/c:bubbleSize 解析。
    #[test]
    fn parse_bubble_chart_round_trip() {
        let original = Chart::new(
            ChartType::Bubble,
            ChartData {
                categories: vec![],
                series: vec![ChartSeries::new_bubble(
                    "S1",
                    vec![1.0, 2.0, 3.0],    // x_values
                    vec![10.0, 20.0, 30.0], // y_values
                    vec![5.0, 15.0, 25.0],  // bubble_sizes
                )],
                title: Some("Bubble".to_string()),
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse bubble chart");

        assert_eq!(parsed.chart_type, ChartType::Bubble);
        assert_eq!(parsed.data.series.len(), 1);
        let s = &parsed.data.series[0];
        assert_eq!(s.name, "S1");
        assert_eq!(s.values, vec![10.0, 20.0, 30.0]);
        assert_eq!(s.x_values.as_deref(), Some(&[1.0, 2.0, 3.0][..]));
        assert_eq!(s.bubble_sizes.as_deref(), Some(&[5.0, 15.0, 25.0][..]));
    }

    /// 雷达图 round-trip:radarChart 元素识别 + 类别标签保留。
    #[test]
    fn parse_radar_chart_round_trip() {
        let original = Chart::new(
            ChartType::Radar,
            ChartData {
                categories: vec![
                    ChartCategory::new("速度"),
                    ChartCategory::new("力量"),
                    ChartCategory::new("技巧"),
                ],
                series: vec![ChartSeries::new("选手A", vec![8.0, 6.0, 9.0])],
                title: Some("Radar".to_string()),
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse radar chart");

        assert_eq!(parsed.chart_type, ChartType::Radar);
        assert_eq!(parsed.data.categories.len(), 3);
        assert_eq!(parsed.data.categories[0].name, "速度");
        assert_eq!(parsed.data.series[0].values, vec![8.0, 6.0, 9.0]);
    }

    /// 多系列柱状图 round-trip:验证系列顺序与名称。
    #[test]
    fn parse_multi_series_column_chart() {
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![
                    ChartSeries::new("S1", vec![1.0, 2.0]),
                    ChartSeries::new("S2", vec![3.0, 4.0]),
                    ChartSeries::new("S3", vec![5.0, 6.0]),
                ],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse multi-series chart");

        assert_eq!(parsed.data.series.len(), 3);
        assert_eq!(parsed.data.series[0].name, "S1");
        assert_eq!(parsed.data.series[1].name, "S2");
        assert_eq!(parsed.data.series[2].name, "S3");
        assert_eq!(parsed.data.series[2].values, vec![5.0, 6.0]);
    }

    /// 无标题图表 round-trip:title 字段为 None。
    #[test]
    fn parse_chart_without_title() {
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("A")],
                series: vec![ChartSeries::new("S", vec![1.0])],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse no-title chart");

        assert!(parsed.data.title.is_none());
    }

    /// 解析空 chartSpace(无 plotArea 子元素)应返回默认 Column 类型而非 panic。
    ///
    /// 验证零 panic 设计:解析失败不阻塞 round-trip。
    #[test]
    fn parse_empty_chart_space_no_panic() {
        let xml = "<?xml version=\"1.0\"?>\
                   <c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\">\
                     <c:chart></c:chart>\
                   </c:chartSpace>";
        let parsed = Chart::parse_from_xml(xml).expect("parse empty chartSpace");
        // 无 plotArea → chart_type 保持默认 Column
        assert_eq!(parsed.chart_type, ChartType::Column);
        assert!(parsed.data.series.is_empty());
        assert!(parsed.data.categories.is_empty());
    }

    /// 解析畸形 XML 应返回 Error::Xml 而非 panic。
    #[test]
    fn parse_malformed_xml_returns_error() {
        // 注意:quick-xml 0.40 SAX 流式解析器对未闭合标签较宽容(不跟踪标签栈),
        // 需要用真正畸形的 XML 才能触发解析错误。
        // 未闭合注释(`<!--` 后必须有 `-->`)是 quick-xml 0.40 必报错的场景。
        let xml = "<c:chartSpace><!-- unclosed comment <c:chart/></c:chartSpace>";
        let result = Chart::parse_from_xml(xml);
        assert!(result.is_err(), "malformed xml should error");
    }

    /// external_data_rid 写出:to_xml 在 `</c:chart>` 之后、`</c:chartSpace>` 之前
    /// 生成 `<c:externalData r:id="..."><c:autoUpdate val="0"/></c:externalData>`(TODO-004 Excel 嵌入)。
    #[test]
    fn to_xml_writes_external_data() {
        let mut chart = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("A")],
                series: vec![ChartSeries::new("S", vec![1.0])],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        chart.external_data_rid = Some("rIdXlsx1".to_string());
        let xml = chart.to_xml();
        // 元素顺序:c:chart 关闭后紧跟 c:externalData
        assert!(
            xml.contains(r#"<c:externalData r:id="rIdXlsx1">"#),
            "应输出 c:externalData 开标签"
        );
        assert!(
            xml.contains(r#"<c:autoUpdate val="0"/>"#),
            "应输出 autoUpdate=0"
        );
        assert!(xml.contains("</c:externalData>"), "应闭合 c:externalData");
        // 验证顺序:c:chart 关闭在 c:externalData 之前
        let pos_chart_close = xml.find("</c:chart>").expect("c:chart 关闭存在");
        let pos_ext = xml.find("<c:externalData").expect("c:externalData 存在");
        assert!(
            pos_chart_close < pos_ext,
            "c:externalData 必须在 </c:chart> 之后"
        );
        let pos_chart_space_close = xml.rfind("</c:chartSpace>").expect("c:chartSpace 关闭存在");
        assert!(
            pos_ext < pos_chart_space_close,
            "c:externalData 必须在 </c:chartSpace> 之前"
        );
    }

    /// external_data_rid = None 时不输出 c:externalData(保持原有行为)。
    #[test]
    fn to_xml_omits_external_data_when_none() {
        let chart = Chart::new(
            ChartType::Line,
            ChartData {
                categories: vec![ChartCategory::new("A")],
                series: vec![ChartSeries::new("S", vec![1.0])],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = chart.to_xml();
        assert!(
            !xml.contains("c:externalData"),
            "None 时不应输出 c:externalData"
        );
    }

    /// parse_from_xml 对称还原 external_data_rid(TODO-004 Excel 嵌入读路径)。
    #[test]
    fn parse_external_data_rid_round_trip() {
        let mut original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
                title: Some("Revenue".into()),
                data_labels: None,
                ..Default::default()
            },
        );
        original.external_data_rid = Some("rIdXlsx1".into());
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse round-trip");
        assert_eq!(parsed.chart_type, ChartType::Column);
        assert_eq!(parsed.external_data_rid, Some("rIdXlsx1".to_string()));
    }

    /// parse_from_xml 兼容 r:id 简写为 id 的写法(部分工具不严格加 r: 前缀)。
    #[test]
    fn parse_external_data_rid_bare_id_form() {
        let xml = "<?xml version=\"1.0\"?>\
                   <c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\" \
                                 xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">\
                     <c:chart><c:plotArea><c:barChart><c:barDir val=\"col\"/>\
                       <c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>\
                         <c:val><c:numCache><c:formatCode>General</c:formatCode>\
                           <c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>1</c:v></c:pt>\
                         </c:numCache></c:val>\
                     </c:ser></c:barChart></c:plotArea></c:chart>\
                     <c:externalData id=\"rIdBare\"><c:autoUpdate val=\"0\"/></c:externalData>\
                   </c:chartSpace>";
        let parsed = Chart::parse_from_xml(xml).expect("parse bare id form");
        assert_eq!(parsed.external_data_rid, Some("rIdBare".to_string()));
    }

    // ===================== 数据标签(dLbls)测试 =====================

    /// `DataLabels::show_values()` 便捷构造器:仅 show_val=Some(true)。
    #[test]
    fn data_labels_show_values_constructor() {
        let dl = DataLabels::show_values();
        assert_eq!(dl.show_val, Some(true));
        assert_eq!(dl.show_cat_name, None);
        assert_eq!(dl.show_ser_name, None);
        assert!(!dl.is_empty());
    }

    /// `DataLabels::show_percent_pie()` 便捷构造器:仅 show_percent=Some(true)。
    #[test]
    fn data_labels_show_percent_pie_constructor() {
        let dl = DataLabels::show_percent_pie();
        assert_eq!(dl.show_percent, Some(true));
        assert_eq!(dl.show_val, None);
        assert!(!dl.is_empty());
    }

    /// `DataLabels::is_empty()` 对空配置返回 true。
    #[test]
    fn data_labels_is_empty_for_default() {
        let dl = DataLabels::default();
        assert!(dl.is_empty());
    }

    /// `LabelPosition` as_str / from_str 往返。
    #[test]
    fn label_position_round_trip() {
        for pos in [
            LabelPosition::BestFit,
            LabelPosition::Above,
            LabelPosition::Below,
            LabelPosition::Center,
            LabelPosition::InsideEnd,
            LabelPosition::OutsideEnd,
            LabelPosition::Left,
            LabelPosition::Right,
        ] {
            let s = pos.as_str();
            // 未知字符串返回 None(不应误识别)
            let parsed = LabelPosition::parse(s).expect("known position should parse");
            assert_eq!(parsed, pos, "position round-trip failed for {:?}", pos);
        }
        // 未知字符串返回 None
        assert!(LabelPosition::parse("unknown").is_none());
    }

    /// 图表级 `<c:dLbls>` round-trip:to_xml → parse_from_xml → 字段一致。
    ///
    /// 覆盖:show_val / show_cat_name / show_ser_name / show_legend_key / position / separator / numFmt。
    #[test]
    fn parse_chart_level_data_labels_round_trip() {
        let dl = DataLabels {
            show_val: Some(true),
            show_cat_name: Some(true),
            show_ser_name: Some(false),
            show_legend_key: Some(false),
            show_percent: None,
            show_bubble_size: None,
            position: Some(LabelPosition::OutsideEnd),
            separator: Some(", ".to_string()),
            num_fmt: Some("0.00".to_string()),
        };
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
                series: vec![ChartSeries::new("Sales", vec![10.0, 20.0])],
                title: None,
                data_labels: Some(dl.clone()),
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        // 验证 XML 包含 dLbls 子元素
        assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
        assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
        assert!(xml.contains(r#"<c:showCatName val="1"/>"#), "xml: {}", xml);
        assert!(xml.contains(r#"<c:showSerName val="0"/>"#), "xml: {}", xml);
        assert!(
            xml.contains(r#"<c:showLegendKey val="0"/>"#),
            "xml: {}",
            xml
        );
        assert!(xml.contains(r#"<c:dLblPos val="outEnd"/>"#), "xml: {}", xml);
        assert!(xml.contains(r#"<c:separator val=", "/>"#), "xml: {}", xml);
        assert!(xml.contains(r#"formatCode="0.00""#), "xml: {}", xml);

        // round-trip:解析后字段一致
        let parsed = Chart::parse_from_xml(&xml).expect("parse chart with dLbls");
        let parsed_dl = parsed
            .data
            .data_labels
            .expect("chart-level dLbls should parse");
        assert_eq!(parsed_dl.show_val, Some(true));
        assert_eq!(parsed_dl.show_cat_name, Some(true));
        assert_eq!(parsed_dl.show_ser_name, Some(false));
        assert_eq!(parsed_dl.show_legend_key, Some(false));
        assert_eq!(parsed_dl.show_percent, None);
        assert_eq!(parsed_dl.position, Some(LabelPosition::OutsideEnd));
        assert_eq!(parsed_dl.separator.as_deref(), Some(", "));
        assert_eq!(parsed_dl.num_fmt.as_deref(), Some("0.00"));
    }

    /// 系列级 `<c:dLbls>` round-trip:覆盖图表级配置。
    #[test]
    fn parse_series_level_data_labels_round_trip() {
        let series_dl = DataLabels {
            show_val: Some(true),
            show_percent: Some(true),
            position: Some(LabelPosition::InsideEnd),
            ..Default::default()
        };
        let original = Chart::new(
            ChartType::Pie,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![{
                    let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
                    s.data_labels = Some(series_dl.clone());
                    s
                }],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        // 饼图 dLbls 应在 <c:ser> 内(系列级),而非图表级
        assert!(xml.contains("<c:dLbls>"), "xml: {}", xml);
        assert!(xml.contains(r#"<c:showVal val="1"/>"#), "xml: {}", xml);
        assert!(xml.contains(r#"<c:showPercent val="1"/>"#), "xml: {}", xml);
        assert!(xml.contains(r#"<c:dLblPos val="inEnd"/>"#), "xml: {}", xml);

        let parsed = Chart::parse_from_xml(&xml).expect("parse pie with series dLbls");
        assert_eq!(parsed.chart_type, ChartType::Pie);
        // 图表级 dLbls 应为 None(pie 未设置图表级)
        assert!(parsed.data.data_labels.is_none());
        // 系列级 dLbls 应正确解析
        let s = &parsed.data.series[0];
        let parsed_dl = s.data_labels.as_ref().expect("series dLbls should parse");
        assert_eq!(parsed_dl.show_val, Some(true));
        assert_eq!(parsed_dl.show_percent, Some(true));
        assert_eq!(parsed_dl.position, Some(LabelPosition::InsideEnd));
    }

    /// 空 `<c:dLbls/>`(无子元素)不应写入 data_labels 字段(保持 None)。
    #[test]
    fn parse_empty_dlbls_is_ignored() {
        // 构造一个图表级 dLbls 但所有字段都 None(is_empty=true)
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("A")],
                series: vec![ChartSeries::new("S", vec![1.0])],
                title: None,
                data_labels: Some(DataLabels::default()), // is_empty = true
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        // 空的 dLbls 不应输出(to_xml 检查 is_empty)
        assert!(
            !xml.contains("<c:dLbls>"),
            "empty dLbls should not be written: {}",
            xml
        );
        // 解析后 data_labels 也应为 None
        let parsed = Chart::parse_from_xml(&xml).expect("parse");
        assert!(parsed.data.data_labels.is_none());
    }

    // ===================== 次坐标轴测试 =====================

    /// `to_xml` 在 series.secondary_axis=true 时输出次坐标轴定义(含 crosses=max)。
    #[test]
    fn to_xml_writes_secondary_axis() {
        let s2 = {
            let mut s = ChartSeries::new("Secondary", vec![1.0, 2.0]);
            s.secondary_axis = true;
            s
        };
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
                series: vec![s2],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        // 应包含次轴 axId=444444444
        assert!(xml.contains(r#"<c:axId val="444444444"/>"#), "xml: {}", xml);
        // 应包含次轴定义(crosses=max)
        assert!(xml.contains(r#"<c:crosses val="max"/>"#), "xml: {}", xml);
        // 应有 2 个 valAx(主 valAx + 次 valAx)+ 1 个 catAx
        let val_ax_count = xml.matches("<c:valAx>").count();
        assert_eq!(
            val_ax_count, 2,
            "expected 2 valAx (primary + secondary): {}",
            xml
        );
        assert!(xml.contains("<c:catAx>"), "xml: {}", xml);
    }

    /// 次坐标轴 round-trip:parse_from_xml 解析 crosses=max 后,
    /// 所有非散点/非饼图系列的 secondary_axis 字段置为 true。
    #[test]
    fn parse_secondary_axis_round_trip() {
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
                series: vec![{
                    let mut s = ChartSeries::new("S", vec![10.0, 20.0]);
                    s.secondary_axis = true;
                    s
                }],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        let parsed = Chart::parse_from_xml(&xml).expect("parse secondary axis chart");
        // 解析后所有非散点/非饼图系列的 secondary_axis 应为 true
        assert_eq!(parsed.data.series.len(), 1);
        assert!(
            parsed.data.series[0].secondary_axis,
            "secondary_axis should be true after round-trip"
        );
    }

    /// 饼图不支持次坐标轴:secondary_axis 字段被忽略,不输出次轴定义。
    #[test]
    fn pie_chart_ignores_secondary_axis() {
        let original = Chart::new(
            ChartType::Pie,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![{
                    let mut s = ChartSeries::new("X", vec![1.0, 2.0]);
                    s.secondary_axis = true; // 应被忽略
                    s
                }],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        // 饼图不应输出次轴 axId
        assert!(
            !xml.contains("444444444"),
            "pie should not write secondary axId: {}",
            xml
        );
        assert!(
            !xml.contains(r#"<c:crosses val="max"/>"#),
            "pie should not write crosses=max: {}",
            xml
        );
    }

    /// 散点图不支持次坐标轴:secondary_axis 字段被忽略。
    #[test]
    fn scatter_chart_ignores_secondary_axis() {
        let mut s = ChartSeries::new_scatter("S", vec![1.0, 2.0], vec![3.0, 4.0]);
        s.secondary_axis = true; // 应被忽略
        let original = Chart::new(
            ChartType::Scatter,
            ChartData {
                categories: vec![],
                series: vec![s],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        assert!(
            !xml.contains("444444444"),
            "scatter should not write secondary axId: {}",
            xml
        );
        assert!(
            !xml.contains(r#"<c:crosses val="max"/>"#),
            "scatter should not write crosses=max: {}",
            xml
        );
    }

    // ===================== 图例(<c:legend>)round-trip 测试 =====================

    /// 图例 round-trip:to_xml 写出 legend 后 parse_from_xml 能还原。
    ///
    /// 覆盖:
    /// - legend.position = Top → 写出 <c:legendPos val="t"/> → 解析回 Top
    /// - legend.overlay = false → 写出 <c:overlay val="0"/> → 解析回 false
    #[test]
    fn legend_round_trip() {
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("Q1"), ChartCategory::new("Q2")],
                series: vec![ChartSeries::new("S", vec![10.0, 20.0])],
                title: Some("Legend Test".to_string()),
                data_labels: None,
                legend: Some(Legend {
                    position: LegendPosition::Top,
                    overlay: false,
                }),
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        // 验证 XML 包含 legend 元素
        assert!(xml.contains("<c:legend>"), "xml: {}", xml);
        assert!(xml.contains(r#"<c:legendPos val="t"/>"#), "xml: {}", xml);
        assert!(xml.contains(r#"<c:overlay val="0"/>"#), "xml: {}", xml);

        let parsed = Chart::parse_from_xml(&xml).expect("parse legend chart");
        assert_eq!(
            parsed
                .data
                .legend
                .as_ref()
                .expect("legend should exist")
                .position,
            LegendPosition::Top,
            "legend position should round-trip"
        );
        assert!(
            !parsed.data.legend.as_ref().unwrap().overlay,
            "legend overlay should round-trip"
        );
    }

    /// 无图例 round-trip:ChartData.legend = None 时不写出 <c:legend>,
    /// 解析后 legend 仍为 None。
    #[test]
    fn no_legend_round_trip() {
        let original = Chart::new(
            ChartType::Line,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![ChartSeries::new("S", vec![1.0, 2.0])],
                title: None,
                data_labels: None,
                legend: None, // 显式无图例
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        assert!(
            !xml.contains("<c:legend>"),
            "should not write legend: {}",
            xml
        );

        let parsed = Chart::parse_from_xml(&xml).expect("parse no-legend chart");
        assert!(
            parsed.data.legend.is_none(),
            "legend should remain None after round-trip"
        );
    }

    /// 图例所有位置变体 round-trip:Bottom / Top / Right / Left。
    #[test]
    fn legend_all_positions_round_trip() {
        for (pos, val) in [
            (LegendPosition::Bottom, "b"),
            (LegendPosition::Top, "t"),
            (LegendPosition::Right, "r"),
            (LegendPosition::Left, "l"),
        ] {
            let original = Chart::new(
                ChartType::Column,
                ChartData {
                    categories: vec![ChartCategory::new("A")],
                    series: vec![ChartSeries::new("S", vec![1.0])],
                    title: None,
                    data_labels: None,
                    legend: Some(Legend {
                        position: pos,
                        overlay: true,
                    }),
                    ..Default::default()
                },
            );
            let xml = original.to_xml();
            assert!(
                xml.contains(&format!(r#"<c:legendPos val="{}"/>"#, val)),
                "position {:?} should write val={}: {}",
                pos,
                val,
                xml
            );
            let parsed = Chart::parse_from_xml(&xml).expect("parse legend positions");
            assert_eq!(
                parsed.data.legend.as_ref().expect("legend").position,
                pos,
                "position {:?} should round-trip",
                pos
            );
            assert!(
                parsed.data.legend.as_ref().unwrap().overlay,
                "overlay=true should round-trip for position {:?}",
                pos
            );
        }
    }

    // ===================== 系列样式(<c:spPr>)round-trip 测试 =====================

    /// 系列样式实色填充 round-trip:构造带红色填充的系列,to_xml → parse_from_xml,
    /// 断言 `parsed.data.series[0].format` 存在且 `fill == Fill::Solid(Color::RGB(...))`。
    #[test]
    fn series_format_solid_color_round_trip() {
        use crate::oxml::color::Color;
        use crate::oxml::sppr::Fill;
        use crate::units::RGBColor;
        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![{
                    let mut s = ChartSeries::new("S", vec![10.0, 20.0]);
                    s.format = Some(SeriesFormat::solid_fill(Color::RGB(RGBColor(
                        0xFF, 0x00, 0x00,
                    ))));
                    s
                }],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        // XML 应包含 <c:spPr> 和 <a:srgbClr val="FF0000"/>
        assert!(xml.contains("<c:spPr>"), "xml should contain spPr: {}", xml);
        assert!(
            xml.contains(r#"<a:srgbClr val="FF0000"/>"#),
            "xml should contain red color: {}",
            xml
        );

        let parsed = Chart::parse_from_xml(&xml).expect("parse series_format chart");
        let fmt = parsed.data.series[0]
            .format
            .as_ref()
            .expect("format should round-trip");
        // 断言 fill 为 Solid + RGB(FF, 00, 00)
        match &fmt.fill {
            Fill::Solid(Color::RGB(c)) => {
                assert_eq!(c.0, 0xFF, "red component");
                assert_eq!(c.1, 0x00, "green component");
                assert_eq!(c.2, 0x00, "blue component");
            }
            other => panic!("expected Fill::Solid(Color::RGB), got {:?}", other),
        }
    }

    /// 无系列样式时不写出 <c:spPr>,解析后 format 仍为 None。
    #[test]
    fn series_format_none_no_sp_pr() {
        let original = Chart::new(
            ChartType::Line,
            ChartData {
                categories: vec![ChartCategory::new("X")],
                series: vec![ChartSeries::new("S", vec![1.0])],
                title: None,
                data_labels: None,
                ..Default::default()
            },
        );
        let xml = original.to_xml();
        assert!(
            !xml.contains("<c:spPr>"),
            "should not write spPr when format is None: {}",
            xml
        );

        let parsed = Chart::parse_from_xml(&xml).expect("parse no-format chart");
        assert!(
            parsed.data.series[0].format.is_none(),
            "format should remain None after round-trip"
        );
    }

    /// 组合图(Column + Line 次轴)round-trip:验证 secondary_chart_type 写出与解析。
    ///
    /// 构造主轴柱形图 + 次轴折线图的双轴组合图,验证:
    /// 1. to_xml 写出两个图表元素(c:barChart + c:lineChart);
    /// 2. 主图表元素只包含 secondary_axis=false 的系列;
    /// 3. 次图表元素只包含 secondary_axis=true 的系列;
    /// 4. parse_from_xml 正确还原 secondary_chart_type + secondary_axis 标记。
    #[test]
    fn chart_combo_round_trip() {
        let mut s1 = ChartSeries::new("Sales", vec![10.0, 20.0, 15.0]);
        s1.secondary_axis = false;
        let mut s2 = ChartSeries::new("Growth", vec![5.0, 8.0, 12.0]);
        s2.secondary_axis = true;

        let original = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![
                    ChartCategory::new("Q1"),
                    ChartCategory::new("Q2"),
                    ChartCategory::new("Q3"),
                ],
                series: vec![s1, s2],
                title: Some("Combo Chart".to_string()),
                data_labels: None,
                secondary_chart_type: Some(ChartType::Line),
                ..Default::default()
            },
        );

        let xml = original.to_xml();
        // 验证写出两个图表元素
        assert!(
            xml.contains("<c:barChart>"),
            "should contain primary c:barChart: {}",
            xml
        );
        assert!(
            xml.contains("<c:lineChart>"),
            "should contain secondary c:lineChart: {}",
            xml
        );
        // 验证次轴 valAx 定义存在(含 crosses=max)
        assert!(
            xml.contains(r#"<c:crosses val="max"/>"#),
            "should contain secondary axis crosses=max: {}",
            xml
        );

        let parsed = Chart::parse_from_xml(&xml).expect("parse combo chart");
        // 验证次图表类型还原
        assert_eq!(
            parsed.data.secondary_chart_type,
            Some(ChartType::Line),
            "secondary_chart_type should round-trip"
        );
        // 验证主图表类型保持
        assert_eq!(parsed.chart_type, ChartType::Column);
        // 验证系列数量
        assert_eq!(parsed.data.series.len(), 2, "should have 2 series");
        // 验证次轴系列标记
        assert!(
            !parsed.data.series[0].secondary_axis,
            "series 0 (Sales) should be on primary axis"
        );
        assert!(
            parsed.data.series[1].secondary_axis,
            "series 1 (Growth) should be on secondary axis"
        );
    }

    /// 组合图写出验证:主图表元素只含主轴系列,次图表元素只含次轴系列。
    ///
    /// 通过检查 XML 中 c:barChart 和 c:lineChart 块的系列数量,
    /// 确保系列被正确分配到各自的图表元素。
    #[test]
    fn chart_combo_series_distribution() {
        let mut s1 = ChartSeries::new("Bar1", vec![1.0, 2.0]);
        s1.secondary_axis = false;
        let mut s2 = ChartSeries::new("Bar2", vec![3.0, 4.0]);
        s2.secondary_axis = false;
        let mut s3 = ChartSeries::new("Line1", vec![5.0, 6.0]);
        s3.secondary_axis = true;

        let chart = Chart::new(
            ChartType::Column,
            ChartData {
                categories: vec![ChartCategory::new("A"), ChartCategory::new("B")],
                series: vec![s1, s2, s3],
                title: None,
                data_labels: None,
                secondary_chart_type: Some(ChartType::Line),
                ..Default::default()
            },
        );

        let xml = chart.to_xml();
        // 统计 c:ser 出现次数(3 个系列共 3 个 c:ser)
        let ser_count = xml.matches("<c:ser>").count();
        assert_eq!(ser_count, 3, "should have 3 c:ser elements total: {}", xml);

        // 验证次轴 axId 存在(444444444)
        assert!(
            xml.contains(r#"<c:axId val="444444444"/>"#),
            "should contain secondary axId 444444444: {}",
            xml
        );
    }
}