dsh-ppt-studio 1.1.2 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -18
- package/agent-presets/ppt/agent.cordis.yml +22 -15
- package/cordis.patch.yml +19 -14
- package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +1 -1
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +5 -5
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +127 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +34 -3
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +5 -3
- package/examples/fx/fx-pro/preview/04_pages_04_chart.html +1 -1
- package/examples/fx/fx-pro/preview/deck.html +1 -1
- package/examples/fx/fx-pro/preview/measured.json +1 -1
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/imported/deck.yaml +6 -6
- package/examples/smoke/imported/import-styles.json +1 -33
- package/examples/smoke/imported/loss-audit.json +99 -0
- package/examples/smoke/imported/pages/slide_03.yaml +5 -36
- package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -1
- package/examples/smoke/imported/preview-rt/deck.html +1 -1
- package/examples/smoke/imported/preview-rt/layout.json +26 -78
- package/examples/smoke/imported/reference/previews/03.png +0 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/preview/03_pages_03_chart.html +1 -1
- package/examples/smoke/preview/deck.html +1 -1
- package/lib/capabilities.js +189 -0
- package/lib/home.js +32 -0
- package/lib/index.js +278 -112
- package/lib/office-qa.js +111 -0
- package/lib/pptd/export-pptx.js +159 -10
- package/lib/pptd/import-pptx.js +12 -0
- package/lib/pptd/loss-audit.js +363 -0
- package/lib/pptd/nativeChart.js +454 -0
- package/lib/pptd/schema.js +48 -1
- package/lib/pptd/svgCharts.js +180 -37
- package/lib/pptxPy.js +96 -11
- package/lib/preset-delivery.js +107 -9
- package/lib/preview-server.js +7 -5
- package/lib/router.js +3 -2
- package/lib/state.js +8 -6
- package/lib/templates.js +4 -4
- package/lib/tools.js +81 -21
- package/package.json +2 -1
- package/scripts/audit-manual-facts.mjs +4 -2
- package/scripts/e2e-1.0.mjs +2 -2
- package/scripts/fixtures/workflow-baseline.json +20 -15
- package/scripts/install.mjs +13 -11
- package/scripts/smoke.mjs +390 -66
- package/scripts/verify-loss-audit.mjs +245 -0
- package/scripts/verify-native-charts.mjs +544 -0
- package/scripts/verify-preset-scope.mjs +593 -0
- package/skills/ppt-studio-data/SKILL.md +5 -7
- package/skills/ppt-studio-manual/SKILL.md +2 -2
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原生可编辑图表部件生成(第三轮主目标,2026-09-27)——**PowerPoint 里能"编辑数据"的真图表**。
|
|
3
|
+
*
|
|
4
|
+
* 背景(用户实测确认):旧 `chart` 是**矢量拼绘**(柱=矩形、折线=连线+圆点、饼=饼形扇区),
|
|
5
|
+
* 在 PowerPoint 里只是一个图形组合——没有"编辑数据"、不能换类型、改数字不重算、没有内嵌数据工作簿。
|
|
6
|
+
* 本模块产出真正的 DrawingML 图表部件,`render: 'vector'` 仍保留矢量降级路径(见 export-pptx.js 的 chartSp)。
|
|
7
|
+
*
|
|
8
|
+
* 部件结构(以官方 office 技能 python-pptx 产物为结构蓝本,逐件对照过):
|
|
9
|
+
* ppt/charts/chartN.xml ← c:chartSpace(barChart/lineChart/pieChart + catAx/valAx + legend + dLbls)
|
|
10
|
+
* ppt/charts/_rels/chartN.xml.rels ← rId1 → ../embeddings/Microsoft_Excel_SheetN.xlsx(type=.../package)
|
|
11
|
+
* ppt/embeddings/Microsoft_Excel_SheetN.xlsx ← 最小但**合法**的 xlsx(数据随文件走,与 deck 数据逐格一致)
|
|
12
|
+
* ppt/slides/slideN.xml ← p:graphicFrame + graphicData uri=".../chart" + c:chart r:id(见 export-pptx.js)
|
|
13
|
+
* [Content_Types].xml ← xlsx Default + 每个 chartN.xml 的 Override
|
|
14
|
+
*
|
|
15
|
+
* 内嵌工作簿布局(与 python-pptx 同构,`Sheet1!$A$2:$A$5` 这类公式引用才成立):
|
|
16
|
+
* A1 = 分类列名,B1..= 系列名;A2.. = 分类值;B2.. = 各系列数值。
|
|
17
|
+
*
|
|
18
|
+
* 自证(parity,见 export-pptx.js):`auditNativeCharts()` 从**产物**反读——
|
|
19
|
+
* ① 声明的 chart 元素数 == 包内 ppt/charts/*.xml 数;② 每个 chart 的 externalData 关系真的指向一个存在的
|
|
20
|
+
* 内嵌工作簿;③ 解 `xl/worksheets/sheet1.xml` 取值,与 deck 数据逐格比对(不一致即 parity 报红)。
|
|
21
|
+
*/
|
|
22
|
+
import { zipWrite, zipRead, decodeXml } from '../zips.js'
|
|
23
|
+
import { resolveChart } from './svgCharts.js'
|
|
24
|
+
|
|
25
|
+
const XM = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
26
|
+
const HEX = (c) => (c ?? '#000000').replace('#', '').toUpperCase().slice(0, 6).padEnd(6, '0')
|
|
27
|
+
|
|
28
|
+
const XML_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
29
|
+
const C_NS = '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"'
|
|
30
|
+
/** 图表文字(轴标签/图例/数据标签)统一字号与颜色:与预览 SVG 的 9px/#64748b 语义对齐。 */
|
|
31
|
+
const CHART_SZ = 900
|
|
32
|
+
const TICK_COLOR = '64748B'
|
|
33
|
+
const GRID_COLOR = 'E2E8F0'
|
|
34
|
+
const AXIS_COLOR = 'CBD5E1'
|
|
35
|
+
|
|
36
|
+
/** chartN 的三件套部件名(唯一命名来源:export-pptx / 自证 / 验证脚本共用)。 */
|
|
37
|
+
export function chartPartNames(chartNo) {
|
|
38
|
+
return {
|
|
39
|
+
chart: `ppt/charts/chart${chartNo}.xml`,
|
|
40
|
+
rels: `ppt/charts/_rels/chart${chartNo}.xml.rels`,
|
|
41
|
+
embed: `ppt/embeddings/Microsoft_Excel_Sheet${chartNo}.xlsx`,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 0 → A、25 → Z、26 → AA(工作表列号)。 */
|
|
46
|
+
export function colName(i) {
|
|
47
|
+
let n = Number(i) + 1
|
|
48
|
+
let s = ''
|
|
49
|
+
while (n > 0) {
|
|
50
|
+
const r = (n - 1) % 26
|
|
51
|
+
s = String.fromCharCode(65 + r) + s
|
|
52
|
+
n = Math.floor((n - 1) / 26)
|
|
53
|
+
}
|
|
54
|
+
return s
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 数值 → 工作表/缓存里的字面量(两处必须同一函数,否则内嵌数据与缓存会对不上)。 */
|
|
58
|
+
function numText(v) {
|
|
59
|
+
const n = Number(v)
|
|
60
|
+
return Number.isFinite(n) ? String(n) : '0'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* deck 图表数据 → 内嵌工作簿的二维网格(**唯一真相**:chart XML 的公式引用、工作簿内容、
|
|
65
|
+
* parity 的期望值三者都由它派生)。
|
|
66
|
+
* @returns {{header: string[], rows: Array<Array<string|number>>}}
|
|
67
|
+
*/
|
|
68
|
+
export function expectedSheetGrid(chart) {
|
|
69
|
+
const m = resolveChart(chart)
|
|
70
|
+
const header = [m.catHeader, ...m.series.map((s, i) => String(s.name ?? `系列${i + 1}`))]
|
|
71
|
+
const rows = m.categories.map((cat, ri) => [String(cat), ...m.series.map((s) => {
|
|
72
|
+
const v = Number(s.values[ri] ?? 0)
|
|
73
|
+
return Number.isFinite(v) ? v : 0
|
|
74
|
+
})])
|
|
75
|
+
return { header, rows }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 系列公式列:系列 i 落在第 i+1 列(A=分类列)。 */
|
|
79
|
+
function seriesCol(i) { return colName(i + 1) }
|
|
80
|
+
|
|
81
|
+
// ── 内嵌工作簿(最小 xlsx:inlineStr 免 sharedStrings)────────────────────────
|
|
82
|
+
function sheetXml(grid) {
|
|
83
|
+
const ncols = Math.max(1, grid.header.length)
|
|
84
|
+
const nrows = grid.rows.length + 1
|
|
85
|
+
const cellRef = (ri, ci) => `${colName(ci)}${ri + 1}`
|
|
86
|
+
const textCell = (ri, ci, v) => `<c r="${cellRef(ri, ci)}" t="inlineStr"><is><t xml:space="preserve">${XM(v)}</t></is></c>`
|
|
87
|
+
const numCell = (ri, ci, v) => `<c r="${cellRef(ri, ci)}"><v>${XM(numText(v))}</v></c>`
|
|
88
|
+
const rowXml = (ri, cells) => `<row r="${ri + 1}" spans="1:${ncols}">${cells}</row>`
|
|
89
|
+
const out = [rowXml(0, grid.header.map((h, ci) => textCell(0, ci, h)))]
|
|
90
|
+
grid.rows.forEach((r, ri) => out.push(rowXml(ri + 1, r.map((v, ci) => (ci === 0 ? textCell(ri + 1, ci, v) : numCell(ri + 1, ci, v))))))
|
|
91
|
+
const dim = `A1:${colName(ncols - 1)}${nrows}`
|
|
92
|
+
return XML_HEAD + '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
93
|
+
+ `<dimension ref="${dim}"/><sheetViews><sheetView tabSelected="1" workbookViewId="0"/></sheetViews>`
|
|
94
|
+
+ '<sheetFormatPr defaultRowHeight="15"/><sheetData>' + out.join('') + '</sheetData>'
|
|
95
|
+
+ '<pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/></worksheet>'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const XLSX_CONTENT_TYPES = XML_HEAD + '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
99
|
+
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
100
|
+
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
|
101
|
+
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
|
102
|
+
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|
103
|
+
+ '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
|
104
|
+
+ '</Types>'
|
|
105
|
+
|
|
106
|
+
const XLSX_RELS = XML_HEAD + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
107
|
+
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
|
108
|
+
+ '</Relationships>'
|
|
109
|
+
|
|
110
|
+
const XLSX_WORKBOOK = XML_HEAD + '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
111
|
+
+ '<workbookPr defaultThemeVersion="124226"/><bookViews><workbookView xWindow="240" yWindow="15" windowWidth="16095" windowHeight="9660"/></bookViews>'
|
|
112
|
+
+ '<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>'
|
|
113
|
+
+ '<calcPr calcId="124519" fullCalcOnLoad="1"/></workbook>'
|
|
114
|
+
|
|
115
|
+
const XLSX_WORKBOOK_RELS = XML_HEAD + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
116
|
+
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
|
117
|
+
+ '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
|
118
|
+
+ '</Relationships>'
|
|
119
|
+
|
|
120
|
+
/** 最小样式表。**不能省**:很多 xlsx 消费端(含 Excel/PowerPoint 的"编辑数据")假定 styles.xml 存在。 */
|
|
121
|
+
const XLSX_STYLES = XML_HEAD + '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
|
122
|
+
+ '<fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/><scheme val="minor"/></font></fonts>'
|
|
123
|
+
+ '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
|
|
124
|
+
+ '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
|
125
|
+
+ '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
|
126
|
+
+ '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>'
|
|
127
|
+
+ '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
|
128
|
+
+ '<dxfs count="0"/><tableStyles count="0" defaultTableStyle="TableStyleMedium9" defaultPivotStyle="PivotStyleLight16"/></styleSheet>'
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 内嵌数据工作簿(xlsx 字节)——图表"数据随文件走"的载体。
|
|
132
|
+
* 数据完全由 `expectedSheetGrid(chart)` 派生 ⇒ 与 chart XML 的 numCache/strCache 同源。
|
|
133
|
+
*/
|
|
134
|
+
export function embeddedWorkbook(chart) {
|
|
135
|
+
return zipWrite({
|
|
136
|
+
'[Content_Types].xml': XLSX_CONTENT_TYPES,
|
|
137
|
+
'_rels/.rels': XLSX_RELS,
|
|
138
|
+
'xl/workbook.xml': XLSX_WORKBOOK,
|
|
139
|
+
'xl/_rels/workbook.xml.rels': XLSX_WORKBOOK_RELS,
|
|
140
|
+
'xl/styles.xml': XLSX_STYLES,
|
|
141
|
+
'xl/worksheets/sheet1.xml': sheetXml(expectedSheetGrid(chart)),
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── chartN.xml ───────────────────────────────────────────────────────────
|
|
146
|
+
function catRef(colLetter, count) {
|
|
147
|
+
const n = Math.max(0, Number(count) || 0)
|
|
148
|
+
const last = n > 0 ? n + 1 : 2 // 空数据也要给出**合法**的引用($B$2:$B$1 是非法区间)
|
|
149
|
+
return `Sheet1!$${colLetter}$2:$${colLetter}$${last}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function serTx(colLetter, name) {
|
|
153
|
+
return '<c:tx><c:strRef><c:f>' + XM(`Sheet1!$${colLetter}$1`) + '</c:f><c:strCache><c:ptCount val="1"/>'
|
|
154
|
+
+ '<c:pt idx="0"><c:v>' + XM(name) + '</c:v></c:pt></c:strCache></c:strRef></c:tx>'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function catXml(m) {
|
|
158
|
+
const n = m.categories.length
|
|
159
|
+
const pts = m.categories.map((c, i) => `<c:pt idx="${i}"><c:v>${XM(c)}</c:v></c:pt>`).join('')
|
|
160
|
+
return '<c:cat><c:strRef><c:f>' + XM(catRef('A', n)) + '</c:f><c:strCache><c:ptCount val="' + n + '"/>' + pts + '</c:strCache></c:strRef></c:cat>'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function valXml(m, si) {
|
|
164
|
+
const s = m.series[si]
|
|
165
|
+
const n = s.values.length
|
|
166
|
+
const col = seriesCol(si)
|
|
167
|
+
const pts = s.values.map((v, i) => `<c:pt idx="${i}"><c:v>${XM(numText(v))}</c:v></c:pt>`).join('')
|
|
168
|
+
return '<c:val><c:numRef><c:f>' + XM(catRef(col, n)) + '</c:f><c:numCache><c:formatCode>General</c:formatCode>'
|
|
169
|
+
+ '<c:ptCount val="' + n + '"/>' + pts + '</c:numCache></c:numRef></c:val>'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function solidFill(hex) { return '<a:solidFill><a:srgbClr val="' + HEX(hex) + '"/></a:solidFill>' }
|
|
173
|
+
|
|
174
|
+
function txPrXml() {
|
|
175
|
+
return '<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="' + CHART_SZ + '" b="0">'
|
|
176
|
+
+ '<a:solidFill><a:srgbClr val="' + TICK_COLOR + '"/></a:solidFill>'
|
|
177
|
+
+ '<a:latin typeface="Calibri"/><a:ea typeface="Microsoft YaHei"/></a:defRPr></a:pPr>'
|
|
178
|
+
+ '<a:endParaRPr lang="zh-CN"/></a:p></c:txPr>'
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function gridlinesXml(on) {
|
|
182
|
+
return on ? '<c:majorGridlines><c:spPr><a:ln w="9525"><a:solidFill><a:srgbClr val="' + GRID_COLOR + '"/></a:solidFill></a:ln></c:spPr></c:majorGridlines>' : ''
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 轴(catAx + valAx)。`on=false` 用 OOXML 的"隐藏轴"表达(delete=1 + tickLblPos=none),不是删元素。 */
|
|
186
|
+
function axesXml(ax1, ax2, on) {
|
|
187
|
+
const axisLine = '<c:spPr><a:ln w="9525"><a:solidFill><a:srgbClr val="' + AXIS_COLOR + '"/></a:solidFill></a:ln></c:spPr>'
|
|
188
|
+
const ticks = on
|
|
189
|
+
? '<c:majorTickMark val="out"/><c:minorTickMark val="none"/><c:tickLblPos val="nextTo"/>'
|
|
190
|
+
: '<c:majorTickMark val="none"/><c:minorTickMark val="none"/><c:tickLblPos val="none"/>'
|
|
191
|
+
return '<c:catAx><c:axId val="' + ax1 + '"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="' + (on ? 0 : 1) + '"/><c:axPos val="b"/>'
|
|
192
|
+
+ ticks + axisLine + txPrXml()
|
|
193
|
+
+ '<c:crossAx val="' + ax2 + '"/><c:crosses val="autoZero"/><c:auto val="1"/><c:lblAlgn val="ctr"/><c:lblOffset val="100"/><c:noMultiLvlLbl val="0"/></c:catAx>'
|
|
194
|
+
+ '<c:valAx><c:axId val="' + ax2 + '"/><c:scaling><c:orientation val="minMax"/></c:scaling><c:delete val="' + (on ? 0 : 1) + '"/><c:axPos val="l"/>'
|
|
195
|
+
+ gridlinesXml(on) + ticks + axisLine + txPrXml()
|
|
196
|
+
+ '<c:crossAx val="' + ax1 + '"/><c:crosses val="autoZero"/></c:valAx>'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function dLblsXml(kind, on) {
|
|
200
|
+
if (!on) return ''
|
|
201
|
+
const show = kind === 'pie'
|
|
202
|
+
? '<c:showLegendKey val="0"/><c:showVal val="0"/><c:showCatName val="1"/><c:showSerName val="0"/><c:showPercent val="1"/><c:showBubbleSize val="0"/>'
|
|
203
|
+
: '<c:showLegendKey val="0"/><c:showVal val="1"/><c:showCatName val="0"/><c:showSerName val="0"/><c:showPercent val="0"/><c:showBubbleSize val="0"/>'
|
|
204
|
+
// CT_DLbls 子元素顺序(EG_DLblShared):numFmt → spPr → txPr → dLblPos → show*。
|
|
205
|
+
// 饼图用 outEnd:默认 bestFit 会把"甲 60%"压在彩色扇区里(实测配色浅时看不清)。
|
|
206
|
+
const pos = kind === 'pie' ? '<c:dLblPos val="outEnd"/>' : ''
|
|
207
|
+
return '<c:dLbls>' + txPrXml() + pos + show + '</c:dLbls>'
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* 生成 `ppt/charts/chartN.xml`。
|
|
212
|
+
* 不写 `<c:title>`(页面标题由 text 元素承担)+ `autoTitleDeleted=1`:显式声明"不要自动标题",
|
|
213
|
+
* 否则部分版本会自己补一个占位标题。
|
|
214
|
+
* @param {{chart: object, chartNo?: number}} args
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
export function nativeChartXml({ chart, chartNo = 1 }) {
|
|
218
|
+
const m = resolveChart(chart)
|
|
219
|
+
const kind = m.type
|
|
220
|
+
const colors = m.colors
|
|
221
|
+
const colorOf = (i) => colors[i % colors.length]
|
|
222
|
+
const ax1 = 110000000 + chartNo
|
|
223
|
+
const ax2 = 120000000 + chartNo
|
|
224
|
+
const legendPos = m.opts.legend === 'top' ? 't' : m.opts.legend === 'left' ? 'l' : m.opts.legend === 'right' ? 'r' : 'b'
|
|
225
|
+
const legend = m.opts.legend
|
|
226
|
+
? '<c:legend><c:legendPos val="' + legendPos + '"/><c:layout/><c:overlay val="0"/></c:legend>'
|
|
227
|
+
: ''
|
|
228
|
+
|
|
229
|
+
let group
|
|
230
|
+
if (kind === 'pie') {
|
|
231
|
+
const si = 0
|
|
232
|
+
const s = m.series[0] ?? { name: '值', values: [] }
|
|
233
|
+
const dpts = s.values.map((_, i) => '<c:dPt><c:idx val="' + i + '"/><c:spPr>' + solidFill(colorOf(i)) + '</c:spPr></c:dPt>').join('')
|
|
234
|
+
const ser = '<c:ser><c:idx val="0"/><c:order val="0"/>' + serTx(seriesCol(si), s.name)
|
|
235
|
+
+ dpts + catXml(m) + valXml(m, si) + '</c:ser>'
|
|
236
|
+
group = '<c:pieChart><c:varyColors val="1"/>' + ser + dLblsXml('pie', m.opts.labels) + '<c:firstSliceAng val="0"/></c:pieChart>'
|
|
237
|
+
} else if (kind === 'line') {
|
|
238
|
+
const sers = m.series.map((s, si) => '<c:ser><c:idx val="' + si + '"/><c:order val="' + si + '"/>' + serTx(seriesCol(si), s.name)
|
|
239
|
+
+ '<c:spPr><a:ln w="28575" cap="rnd"><a:solidFill><a:srgbClr val="' + HEX(colorOf(si)) + '"/></a:solidFill><a:round/></a:ln></c:spPr>'
|
|
240
|
+
+ '<c:marker><c:symbol val="circle"/><c:size val="5"/><c:spPr>' + solidFill(colorOf(si)) + '<a:ln w="9525">' + solidFill(colorOf(si)) + '</a:ln></c:spPr></c:marker>'
|
|
241
|
+
+ catXml(m) + valXml(m, si) + '<c:smooth val="0"/></c:ser>').join('')
|
|
242
|
+
group = '<c:lineChart><c:grouping val="standard"/><c:varyColors val="0"/>' + sers + dLblsXml('line', m.opts.labels)
|
|
243
|
+
+ '<c:marker val="1"/><c:smooth val="0"/>'
|
|
244
|
+
+ '<c:axId val="' + ax1 + '"/><c:axId val="' + ax2 + '"/></c:lineChart>'
|
|
245
|
+
} else {
|
|
246
|
+
const sers = m.series.map((s, si) => '<c:ser><c:idx val="' + si + '"/><c:order val="' + si + '"/>' + serTx(seriesCol(si), s.name)
|
|
247
|
+
+ '<c:spPr>' + solidFill(colorOf(si)) + '</c:spPr><c:invertIfNegative val="0"/>'
|
|
248
|
+
+ catXml(m) + valXml(m, si) + '</c:ser>').join('')
|
|
249
|
+
// gapWidth=100 + overlap=-10:与预览 SVG 的柱宽(slot/2 与 0.7slot 分组)基本对齐("预览≠成品"是最误导的缺陷)
|
|
250
|
+
group = '<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>' + sers + dLblsXml('bar', m.opts.labels)
|
|
251
|
+
+ '<c:gapWidth val="100"/><c:overlap val="-10"/>'
|
|
252
|
+
+ '<c:axId val="' + ax1 + '"/><c:axId val="' + ax2 + '"/></c:barChart>'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const axes = kind === 'pie' ? '' : axesXml(ax1, ax2, m.opts.axes !== false)
|
|
256
|
+
|
|
257
|
+
return XML_HEAD + '<c:chartSpace ' + C_NS + '>'
|
|
258
|
+
+ '<c:date1904 val="0"/>'
|
|
259
|
+
+ '<c:chart><c:autoTitleDeleted val="1"/>'
|
|
260
|
+
+ '<c:plotArea><c:layout/>' + group + axes + '</c:plotArea>'
|
|
261
|
+
+ legend + '<c:plotVisOnly val="1"/><c:dispBlanksAs val="gap"/>'
|
|
262
|
+
+ '</c:chart>'
|
|
263
|
+
+ txPrXml()
|
|
264
|
+
+ '<c:externalData r:id="rId1"><c:autoUpdate val="0"/></c:externalData>'
|
|
265
|
+
+ '</c:chartSpace>'
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** chartN.xml.rels:rId1 → 内嵌工作簿(type=.../package,与 python-pptx/PowerPoint 产物同构)。 */
|
|
269
|
+
export function nativeChartRels(chartNo) {
|
|
270
|
+
const { embed } = chartPartNames(chartNo)
|
|
271
|
+
return XML_HEAD + '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
272
|
+
+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" '
|
|
273
|
+
+ 'Target="../embeddings/' + embed.split('/').pop() + '"/>'
|
|
274
|
+
+ '</Relationships>'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* slide 里的图表帧:`p:graphicFrame` + `a:graphicData uri=".../chart"` + `c:chart r:id`。
|
|
279
|
+
* 注意(P1 事故同款):graphicFrame 的 `<p:xfrm>` **直接含** `<a:off>/<a:ext>`,不能再嵌套 `<a:xfrm>`
|
|
280
|
+
* (嵌套时 PowerPoint 静默丢弃整帧,预览却正常)。
|
|
281
|
+
* @param {object} el 元素(用 bounds)
|
|
282
|
+
* @param {number} id 形状 id
|
|
283
|
+
* @param {string} rId slide rels 里的关系 id(由调用方按"媒体之后"分配)
|
|
284
|
+
*/
|
|
285
|
+
export function chartFrameXml(el, id, rId) {
|
|
286
|
+
const b = el.bounds
|
|
287
|
+
return '<p:graphicFrame><p:nvGraphicFramePr><p:cNvPr id="' + id + '" name="' + XM(el.id) + '"/>'
|
|
288
|
+
+ '<p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr><p:nvPr/></p:nvGraphicFramePr>'
|
|
289
|
+
+ '<p:xfrm><a:off x="' + Math.round(b.x * 12700) + '" y="' + Math.round(b.y * 12700) + '"/>'
|
|
290
|
+
+ '<a:ext cx="' + Math.round(b.w * 12700) + '" cy="' + Math.round(b.h * 12700) + '"/></p:xfrm>'
|
|
291
|
+
+ '<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart">'
|
|
292
|
+
+ '<c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" '
|
|
293
|
+
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:id="' + XM(rId) + '"/>'
|
|
294
|
+
+ '</a:graphicData></a:graphic></p:graphicFrame>'
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── 内嵌工作簿反读(parity 的"数据必须与 deck 一致")──────────────────────────
|
|
298
|
+
/** 从 xlsx 字节解出 `xl/worksheets/sheet1.xml` 的单元格网格(数值 → number,inlineStr/共享串 → string)。 */
|
|
299
|
+
export function sheetGridFromXlsx(bytes) {
|
|
300
|
+
const entries = bytes instanceof Map ? bytes : zipRead(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes))
|
|
301
|
+
const sheetName = [...entries.keys()].find((k) => /^xl\/worksheets\/sheet1\.xml$/.test(k))
|
|
302
|
+
if (!sheetName) throw new Error('内嵌工作簿缺少 xl/worksheets/sheet1.xml')
|
|
303
|
+
const xml = decodeXml(entries.get(sheetName))
|
|
304
|
+
const shared = []
|
|
305
|
+
const ssName = [...entries.keys()].find((k) => /^xl\/sharedStrings\.xml$/.test(k))
|
|
306
|
+
if (ssName) {
|
|
307
|
+
const ss = decodeXml(entries.get(ssName))
|
|
308
|
+
for (const m of ss.matchAll(/<si>([\s\S]*?)<\/si>/g)) {
|
|
309
|
+
const texts = [...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => x[1])
|
|
310
|
+
shared.push(texts.join(''))
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const rows = []
|
|
314
|
+
const rowRe = /<row\b[^>]*>([\s\S]*?)<\/row>/g
|
|
315
|
+
for (const rm of xml.matchAll(rowRe)) {
|
|
316
|
+
const cells = []
|
|
317
|
+
for (const cm of rm[1].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)) {
|
|
318
|
+
const attrs = cm[1]
|
|
319
|
+
const inner = cm[2] ?? ''
|
|
320
|
+
const ref = /\br="([A-Z]+)(\d+)"/.exec(attrs)
|
|
321
|
+
const ci = ref ? colIndex(ref[1]) : cells.length
|
|
322
|
+
const t = /\bt="([^"]+)"/.exec(attrs)?.[1] ?? ''
|
|
323
|
+
let v
|
|
324
|
+
if (t === 'inlineStr') {
|
|
325
|
+
v = [...inner.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => x[1]).join('')
|
|
326
|
+
} else if (t === 's') {
|
|
327
|
+
const si = Number(/<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? -1)
|
|
328
|
+
v = shared[si] ?? ''
|
|
329
|
+
} else {
|
|
330
|
+
const raw = /<v>([\s\S]*?)<\/v>/.exec(inner)?.[1]
|
|
331
|
+
if (raw === undefined) continue
|
|
332
|
+
const num = Number(raw)
|
|
333
|
+
v = Number.isFinite(num) && raw.trim() !== '' ? num : raw
|
|
334
|
+
}
|
|
335
|
+
while (cells.length < ci) cells.push(undefined)
|
|
336
|
+
cells[ci] = typeof v === 'string' ? unescapeXml(v) : v
|
|
337
|
+
}
|
|
338
|
+
rows.push(cells)
|
|
339
|
+
}
|
|
340
|
+
return rows
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function colIndex(letters) {
|
|
344
|
+
let n = 0
|
|
345
|
+
for (const ch of letters) n = n * 26 + (ch.charCodeAt(0) - 64)
|
|
346
|
+
return n - 1
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function unescapeXml(s) {
|
|
350
|
+
return String(s).replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function pkgGet(files, key) {
|
|
354
|
+
return files instanceof Map ? files.get(key) : files[key]
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function pkgKeys(files) {
|
|
358
|
+
return files instanceof Map ? [...files.keys()] : Object.keys(files)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function pkgText(v) {
|
|
362
|
+
if (v === undefined || v === null) return null
|
|
363
|
+
if (typeof v === 'string') return v
|
|
364
|
+
return decodeXml(Buffer.isBuffer(v) ? v : Buffer.from(v))
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** 目标相对部件解析(`ppt/charts/../embeddings/x.xlsx` → `ppt/embeddings/x.xlsx`)。 */
|
|
368
|
+
export function resolvePart(fromPart, target) {
|
|
369
|
+
const out = fromPart.split('/').slice(0, -1)
|
|
370
|
+
for (const seg of String(target).split('/')) {
|
|
371
|
+
if (seg === '' || seg === '.') continue
|
|
372
|
+
if (seg === '..') out.pop()
|
|
373
|
+
else out.push(seg)
|
|
374
|
+
}
|
|
375
|
+
return out.join('/')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 图表部件自证(parity 用,**从产物反读**,不读中间层)。
|
|
380
|
+
* @param {Record<string,string|Buffer>|Map<string,Buffer>} files 包内部件表(export-pptx 的 files,或 zipRead 的 Map)
|
|
381
|
+
* @param {Array<{id: string, chart: object, chartPart?: string}>} expected 期望的原生图表(每次声明一个 chart 元素一条)
|
|
382
|
+
* @returns {{chartsExp:number, chartsOut:number, embedsOut:number, sheetsChecked:number, dataMismatch:string[], relsMissing:string[], ok:boolean}}
|
|
383
|
+
*/
|
|
384
|
+
export function auditNativeCharts(files, expected = []) {
|
|
385
|
+
const keys = pkgKeys(files)
|
|
386
|
+
const chartParts = keys.filter((k) => /^ppt\/charts\/chart\d+\.xml$/.test(k))
|
|
387
|
+
.sort((a, b) => Number(a.match(/(\d+)/)[1]) - Number(b.match(/(\d+)/)[1]))
|
|
388
|
+
const embeds = keys.filter((k) => /^ppt\/embeddings\/.+\.xlsx$/i.test(k))
|
|
389
|
+
const dataMismatch = []
|
|
390
|
+
const relsMissing = []
|
|
391
|
+
let sheetsChecked = 0
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < expected.length; i++) {
|
|
394
|
+
const e = expected[i]
|
|
395
|
+
const chartPart = e.chartPart ?? chartParts[i]
|
|
396
|
+
if (!chartPart || pkgGet(files, chartPart) === undefined) {
|
|
397
|
+
relsMissing.push(`${e.id ?? `#${i}`}: 缺图表部件 ${chartPart ?? '(未声明 chartPart)'}`)
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
const xml = pkgText(pkgGet(files, chartPart))
|
|
401
|
+
const relsPath = chartPart.replace(/\/([^/]+)$/, '/_rels/$1.rels')
|
|
402
|
+
const relsXml = pkgText(pkgGet(files, relsPath))
|
|
403
|
+
if (!relsXml) { relsMissing.push(`${e.id ?? `#${i}`}: 缺 ${relsPath}(图表→内嵌工作簿关系)`); continue }
|
|
404
|
+
const extId = /<c:externalData[^>]*\br:id="([^"]+)"/.exec(xml)?.[1]
|
|
405
|
+
if (!extId) { relsMissing.push(`${e.id ?? `#${i}`}: chart XML 没有 <c:externalData r:id>(PowerPoint"编辑数据"就断链)`); continue }
|
|
406
|
+
const rel = new RegExp(`<Relationship\\b[^>]*Id="${extId}"[^>]*>`).exec(relsXml)?.[0]
|
|
407
|
+
?? new RegExp(`<Relationship\\b[^>]*Id="${extId}"[^>]*/>`).exec(relsXml)?.[0]
|
|
408
|
+
const target = rel ? /Target="([^"]+)"/.exec(rel)?.[1] : null
|
|
409
|
+
if (!target) { relsMissing.push(`${e.id ?? `#${i}`}: ${relsPath} 里没有 Id=${extId} 的关系`); continue }
|
|
410
|
+
const resolved = resolvePart(chartPart, target)
|
|
411
|
+
const xlsx = pkgGet(files, resolved)
|
|
412
|
+
if (xlsx === undefined) { relsMissing.push(`${e.id ?? `#${i}`}: 关系目标不存在 ${resolved}`); continue }
|
|
413
|
+
let rows
|
|
414
|
+
try {
|
|
415
|
+
rows = sheetGridFromXlsx(xlsx)
|
|
416
|
+
} catch (err) {
|
|
417
|
+
dataMismatch.push(`${e.id ?? `#${i}`}: 内嵌工作簿无法解析(${err?.message ?? err})`)
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
sheetsChecked++
|
|
421
|
+
const want = expectedSheetGrid(e.chart)
|
|
422
|
+
dataMismatch.push(...diffGrid(e.id ?? `#${i}`, want, rows))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const ok = expected.length === chartParts.length && relsMissing.length === 0 && dataMismatch.length === 0
|
|
426
|
+
return { chartsExp: expected.length, chartsOut: chartParts.length, embedsOut: embeds.length, sheetsChecked, dataMismatch, relsMissing, ok }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 期望网格 vs 工作簿反读网格的逐格比对(数值带容差;文本严格相等)。 */
|
|
430
|
+
export function diffGrid(id, want, rows) {
|
|
431
|
+
const out = []
|
|
432
|
+
const at = (r, c) => (rows[r] ? rows[r][c] : undefined)
|
|
433
|
+
if (rows.length !== want.rows.length + 1) {
|
|
434
|
+
out.push(`${id}: 工作表行数不符(工作簿 ${rows.length} ≠ 期望 ${want.rows.length + 1})`)
|
|
435
|
+
}
|
|
436
|
+
want.header.forEach((h, ci) => {
|
|
437
|
+
const got = at(0, ci)
|
|
438
|
+
if (got === undefined) out.push(`${id}: 工作表缺表头 ${colName(ci)}1(期望「${h}」)`)
|
|
439
|
+
else if (String(got) !== String(h)) out.push(`${id}: 表头 ${colName(ci)}1 不符(工作簿「${got}」≠ deck「${h}」)`)
|
|
440
|
+
})
|
|
441
|
+
if (rows[0] && rows[0].length > want.header.length) out.push(`${id}: 工作表表头列数多出 ${rows[0].length - want.header.length} 列(数据与 deck 不一致)`)
|
|
442
|
+
want.rows.forEach((r, ri) => {
|
|
443
|
+
const got = at(ri + 1, 0)
|
|
444
|
+
if (String(got) !== String(r[0])) out.push(`${id}: 分类 A${ri + 2} 不符(工作簿「${got}」≠ deck「${r[0]}」)`)
|
|
445
|
+
for (let ci = 1; ci < r.length; ci++) {
|
|
446
|
+
const g = at(ri + 1, ci)
|
|
447
|
+
const w = Number(r[ci])
|
|
448
|
+
if (typeof g !== 'number' || Math.abs(g - w) > 1e-9) {
|
|
449
|
+
out.push(`${id}: 数值 ${colName(ci)}${ri + 2} 不符(工作簿「${g}」≠ deck「${w}」)`)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
return out
|
|
454
|
+
}
|
package/lib/pptd/schema.js
CHANGED
|
@@ -29,7 +29,11 @@
|
|
|
29
29
|
* # line → points: [[x,y],...] | {x1,y1,x2,y2}, arrow: bool, line:{color,width}
|
|
30
30
|
* # image → src(相对 deck 目录), fit: cover|contain|fill
|
|
31
31
|
* # table → cols: [...], rows: [[...]], header: bool
|
|
32
|
-
* # chart → chart: {type: bar|line|pie, data:{cols,rows}, series:[{name,x,y}] , colors:[...]
|
|
32
|
+
* # chart → chart: {type: bar|line|pie, data:{cols,rows}, series:[{name,x,y}] , colors:[...],
|
|
33
|
+
* # render: native|vector(默认 native = 原生可编辑图表 + 内嵌数据工作簿),
|
|
34
|
+
* # legend: bool|top|bottom|left|right(默认 auto:多系列/饼图显示,单系列不显示),
|
|
35
|
+
* # labels: bool(默认 auto:饼图显示百分比+分类名,其余不显示),
|
|
36
|
+
* # axes: bool(默认 true;饼图忽略)}
|
|
33
37
|
*/
|
|
34
38
|
import { readFile } from 'node:fs/promises'
|
|
35
39
|
import { existsSync } from 'node:fs'
|
|
@@ -400,6 +404,49 @@ function validateChart(el, path, errors) {
|
|
|
400
404
|
if (!d || !Array.isArray(d.cols) || d.cols.length === 0) errors.push(`${path}.chart.data.cols: non-empty array`)
|
|
401
405
|
if (!d || !Array.isArray(d.rows)) errors.push(`${path}.chart.data.rows: array`)
|
|
402
406
|
if (c.series && !Array.isArray(c.series)) errors.push(`${path}.chart.series: array`)
|
|
407
|
+
// 第三轮(chart 默认原生可编辑):以下字段**全可选有默认**,旧 deck(不写 render)行为 = native。
|
|
408
|
+
if (c.render !== undefined && !CHART_RENDER.includes(c.render)) {
|
|
409
|
+
errors.push(`${path}.chart.render: ${CHART_RENDER.join('|')}(默认 native = PowerPoint 里可"编辑数据"的原生图表;vector = 矢量拼绘降级)`)
|
|
410
|
+
}
|
|
411
|
+
if (c.legend !== undefined && typeof c.legend !== 'boolean' && !CHART_LEGEND_POS.includes(c.legend)) {
|
|
412
|
+
errors.push(`${path}.chart.legend: true|false|${CHART_LEGEND_POS.join('|')}(默认 auto:多系列与饼图显示图例,单系列不显示)`)
|
|
413
|
+
}
|
|
414
|
+
if (c.labels !== undefined && typeof c.labels !== 'boolean') {
|
|
415
|
+
errors.push(`${path}.chart.labels: boolean(数据标签;默认 auto:饼图显示百分比+分类名,柱/线不显示)`)
|
|
416
|
+
}
|
|
417
|
+
if (c.axes !== undefined && typeof c.axes !== 'boolean') {
|
|
418
|
+
errors.push(`${path}.chart.axes: boolean(坐标轴/网格线/刻度标签;默认 true;饼图忽略)`)
|
|
419
|
+
}
|
|
420
|
+
// 矢量降级不画图例/数据标签/坐标轴 ⇒ 显式要求这些字段时**报错**而不是静默忽略
|
|
421
|
+
// (本仓库的既有纪律:B1 修复"元素级样式被静默忽略"就是同类问题)。
|
|
422
|
+
if (c.render === 'vector') {
|
|
423
|
+
for (const k of ['legend', 'labels', 'axes']) {
|
|
424
|
+
if (c[k] !== undefined) errors.push(`${path}.chart.${k}: 仅 render=native(原生图表)生效;矢量拼绘不画图例/数据标签/坐标轴——请改用 render=native 或删掉 ${k}`)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 图表渲染通路(第三轮):native = 原生 DrawingML 图表部件(默认);vector = 旧的矢量拼绘降级。 */
|
|
430
|
+
export const CHART_RENDER = ['native', 'vector']
|
|
431
|
+
/** 图例位置(对应 OOXML legendPos t/b/l/r)。 */
|
|
432
|
+
export const CHART_LEGEND_POS = ['top', 'bottom', 'left', 'right']
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 图表可选字段的**默认值**(schema 层唯一真相;预览 svgCharts 与导出 export-pptx 都读它 ⇒ 三层同源)。
|
|
436
|
+
* `legend`/`labels` 为 'auto' 时由 `resolveChart()`(svgCharts.js)按"实际系列数/图表类型"落地:
|
|
437
|
+
* · legend auto = 多系列或饼图 → 'bottom',单系列 → false(单系列图例是噪音)
|
|
438
|
+
* · labels auto = 饼图 → true(百分比+分类名),柱/线 → false(每个数据点都标数字会糊成一团)
|
|
439
|
+
* @param {object} chart
|
|
440
|
+
* @returns {{render:'native'|'vector', legend:boolean|'top'|'bottom'|'left'|'right'|'auto', labels:boolean|'auto', axes:boolean}}
|
|
441
|
+
*/
|
|
442
|
+
export function chartOptions(chart) {
|
|
443
|
+
const c = chart ?? {}
|
|
444
|
+
return {
|
|
445
|
+
render: CHART_RENDER.includes(c.render) ? c.render : 'native',
|
|
446
|
+
legend: typeof c.legend === 'boolean' || CHART_LEGEND_POS.includes(c.legend) ? c.legend : 'auto',
|
|
447
|
+
labels: typeof c.labels === 'boolean' ? c.labels : 'auto',
|
|
448
|
+
axes: c.axes !== false,
|
|
449
|
+
}
|
|
403
450
|
}
|
|
404
451
|
|
|
405
452
|
function validBounds(b) {
|