pptx2js 0.4.0
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.html +804 -0
- package/README.md +201 -0
- package/bin/pptx2js.js +75 -0
- package/lib/chart.js +245 -0
- package/lib/codegen.js +172 -0
- package/lib/convert.js +195 -0
- package/lib/extractor.js +394 -0
- package/lib/graphic.js +63 -0
- package/lib/index.js +7 -0
- package/lib/mapper.js +98 -0
- package/lib/packager.js +72 -0
- package/lib/placeholder.js +258 -0
- package/lib/presentation.js +70 -0
- package/lib/rels.js +117 -0
- package/lib/smartart.js +96 -0
- package/lib/table.js +138 -0
- package/lib/unpacker.js +40 -0
- package/lib/utils/bounds.js +34 -0
- package/lib/utils/color.js +128 -0
- package/lib/utils/emu.js +20 -0
- package/lib/xml-parser.js +25 -0
- package/lib/xml-utils.js +68 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# pptx2js
|
|
2
|
+
|
|
3
|
+
将 **PowerPoint `.pptx`** 文件转换为可直接运行的 **[PptxGenJS](https://github.com/gitbrent/PptxGenJS)** 生成脚本,并附带媒体资源与转换报告。
|
|
4
|
+
|
|
5
|
+
> 设计文档见 [`design.html`](./design.html),HTML 版说明见 [`README.html`](./README.html)。
|
|
6
|
+
|
|
7
|
+
## 核心哲学
|
|
8
|
+
|
|
9
|
+
**「PptxGenJS 能力边界即转换边界」**
|
|
10
|
+
|
|
11
|
+
| 层级 | 策略 | 示例 |
|
|
12
|
+
|------|------|------|
|
|
13
|
+
| 精确转换 | PptxGenJS 原生支持的元素一比一映射 | 文本框、图片、基本形状、线条、纯色背景 |
|
|
14
|
+
| 退化转换 | 尽力保留内容,降级为近似表示 | 渐变→纯色、未知形状→矩形 |
|
|
15
|
+
| 静默跳过 | 记录日志,不中断流程 | 不支持的 graphicFrame、ActiveX、OLE、VBA |
|
|
16
|
+
|
|
17
|
+
## 功能特性
|
|
18
|
+
|
|
19
|
+
- 输入 `.pptx`,输出 `output.js` + `media/` + `conversion.log` + 自动生成说明
|
|
20
|
+
- **CLI** 与 **编程 API** 共用同一套六模块流水线
|
|
21
|
+
- 转换报告为 JSON,含 `slideIndex`、`elementBounds`、`severity`,便于 CI 集成
|
|
22
|
+
- 仅支持 OOXML(`.pptx`),不做双向转换、不做 GUI
|
|
23
|
+
|
|
24
|
+
## 当前转换能力(v0.4.0)
|
|
25
|
+
|
|
26
|
+
| 元素 | 状态 | 说明 |
|
|
27
|
+
|------|------|------|
|
|
28
|
+
| 文本框(run 格式、段落对齐、`lvl` 缩进、段前/段后距、行距、列表、超链接) | ✅ 精确 | `addText()`;`indent`(首行缩进 EMU)暂不映射 |
|
|
29
|
+
| 图片(PNG/JPEG 等) | ✅ 精确 | `addImage()`,提取至 `media/` |
|
|
30
|
+
| 表格(内联 `a:tbl`、单元格四边边框) | ✅ 精确 | `addTable()`,含合并单元格与 `border` |
|
|
31
|
+
| 图表(BAR / LINE / PIE / AREA / DOUGHNUT / SCATTER / RADAR / BUBBLE) | ✅ 精确 | `addChart()`,从 `chartN.xml` 读数据 |
|
|
32
|
+
| 预设形状 / 线条 | ✅ 精确 | `addShape()` |
|
|
33
|
+
| 纯色背景 / 幻灯片尺寸 | ✅ 精确 | `background`、`defineLayout()` |
|
|
34
|
+
| 组合形状(`p:grpSp`) | ✅ 精确 | 递归展平 |
|
|
35
|
+
| 母版/版式占位符继承 | ✅ 精确 | `lib/placeholder.js`,按 `p:ph idx` 合并 xfrm |
|
|
36
|
+
| SmartArt | ⚡ 退化 | `lib/smartart.js`:从 `dgm:data` 提取文本列表,否则占位;缓存 PNG 因 rels 差异大暂不实现 |
|
|
37
|
+
| 渐变填充 / 未知形状 | ⚡ 退化 | 首色标→纯色;未知 `prstGeom`→矩形 |
|
|
38
|
+
| 不支持图表类型 | ⚡ 退化 | 图表部件 rels 缓存图或占位文本 |
|
|
39
|
+
| 复杂动画 | 🔜 计划中 | 见设计文档 |
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install pptx2js
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
本地开发:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
git clone https://github.com/yuese12333/pptx2js.git
|
|
51
|
+
cd pptx2js
|
|
52
|
+
npm install
|
|
53
|
+
npm test
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 使用方式
|
|
57
|
+
|
|
58
|
+
### CLI
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx pptx2js input.pptx -o ./pptx2js-output
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
常用参数:
|
|
65
|
+
|
|
66
|
+
| 参数 | 默认值 | 说明 |
|
|
67
|
+
|------|--------|------|
|
|
68
|
+
| `input.pptx` | 必填 | 源文件路径 |
|
|
69
|
+
| `-o, --output` | `./pptx2js-output` | 输出目录 |
|
|
70
|
+
| `--no-media` | — | 不提取媒体,图片引用变为占位注释 |
|
|
71
|
+
| `--strict-degrade` | `false` | 任意退化项触发非零退出码 |
|
|
72
|
+
| `--strict-skip` | `false` | `severity:error` 跳过项触发非零退出码 |
|
|
73
|
+
| `--log-level` | `info` | `minimal` / `info` / `verbose` |
|
|
74
|
+
| `--max-file-size` | 50MB | 超过阈值切换流式解析(实现中) |
|
|
75
|
+
|
|
76
|
+
> **注意:** Commander 的 `--no-media` 对应内部选项 `media`(默认 `true`)。传入 `--no-media` 后 `media` 为 `false`,才会跳过媒体提取。
|
|
77
|
+
|
|
78
|
+
### 编程 API
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
const { convert } = require('pptx2js');
|
|
82
|
+
|
|
83
|
+
const result = await convert('./input.pptx', {
|
|
84
|
+
outputDir: './pptx2js-output',
|
|
85
|
+
logLevel: 'info',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
console.log(result.scriptPath); // .../output.js
|
|
89
|
+
console.log(result.logPath); // .../conversion.log
|
|
90
|
+
console.log(result.log.statistics);
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 运行生成脚本
|
|
94
|
+
|
|
95
|
+
`output.js` 依赖 [PptxGenJS](https://github.com/gitbrent/PptxGenJS),需在输出目录单独安装:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cd pptx2js-output
|
|
99
|
+
npm init -y
|
|
100
|
+
npm install pptxgenjs
|
|
101
|
+
node output.js # 生成 output.pptx
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 输出目录结构
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
pptx2js-output/
|
|
108
|
+
├── output.js # 主生成脚本,可直接 node 运行
|
|
109
|
+
├── media/ # 提取的图片等媒体
|
|
110
|
+
├── conversion.log # JSON 格式转换报告
|
|
111
|
+
└── README.md # 自动生成的说明
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`conversion.log` 主要字段:`source`、`statistics`、`slides[]`、`degraded[]`、`omitted[]`、`warnings[]`。
|
|
115
|
+
|
|
116
|
+
## 系统架构
|
|
117
|
+
|
|
118
|
+
六模块流水线(顺序执行):
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
input.pptx
|
|
122
|
+
→ ① 解压与索引 (lib/unpacker.js)
|
|
123
|
+
→ ② XML 预解析 (lib/xml-parser.js, lib/rels.js)
|
|
124
|
+
→ ③ 实体提取器 (lib/extractor.js, lib/placeholder.js, lib/table.js, lib/chart.js, lib/smartart.js)
|
|
125
|
+
→ ④ 映射引擎 (lib/mapper.js)
|
|
126
|
+
→ ⑤ 代码生成器 (lib/codegen.js)
|
|
127
|
+
→ ⑥ 资源打包器 (lib/packager.js)
|
|
128
|
+
→ output.js + media/ + conversion.log
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
辅助模块:`lib/presentation.js`、`lib/graphic.js`、`lib/xml-utils.js`、`lib/utils/color.js`、`lib/utils/bounds.js`、`lib/smartart.js`。
|
|
132
|
+
|
|
133
|
+
## 技术栈
|
|
134
|
+
|
|
135
|
+
| 用途 | 选型 |
|
|
136
|
+
|------|------|
|
|
137
|
+
| ZIP 处理 | [JSZip](https://www.npmjs.com/package/jszip) |
|
|
138
|
+
| XML 解析 | [xml2js](https://www.npmjs.com/package/xml2js)(统一配置,见 `lib/xml-parser.js`) |
|
|
139
|
+
| CLI | [Commander.js](https://www.npmjs.com/package/commander) |
|
|
140
|
+
| 终端输出 | [chalk](https://www.npmjs.com/package/chalk) |
|
|
141
|
+
| 测试 | [Jest](https://jestjs.io/)(单元 + 集成) |
|
|
142
|
+
|
|
143
|
+
代码格式化在生成阶段自实现缩进拼接,不引入 Prettier。
|
|
144
|
+
|
|
145
|
+
## 开发
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm test # 单元测试 + 集成测试(24 用例)
|
|
149
|
+
npm run test:watch # 监听模式
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 目录结构
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
pptx2js/
|
|
156
|
+
├── bin/pptx2js.js # CLI 入口
|
|
157
|
+
├── lib/
|
|
158
|
+
│ ├── index.js # 库入口(export convert)
|
|
159
|
+
│ ├── convert.js # 流水线编排
|
|
160
|
+
│ ├── unpacker.js # ① 解压
|
|
161
|
+
│ ├── xml-parser.js # ② 统一 XML 解析
|
|
162
|
+
│ ├── rels.js # ② 关系索引
|
|
163
|
+
│ ├── presentation.js # 幻灯片列表 / 尺寸
|
|
164
|
+
│ ├── placeholder.js # 母版/版式占位符继承
|
|
165
|
+
│ ├── graphic.js # graphicFrame URI 识别
|
|
166
|
+
│ ├── table.js # 表格提取
|
|
167
|
+
│ ├── chart.js # 图表提取
|
|
168
|
+
│ ├── smartart.js # SmartArt 退化
|
|
169
|
+
│ ├── extractor.js # ③ 实体提取
|
|
170
|
+
│ ├── mapper.js # ④ IR 映射
|
|
171
|
+
│ ├── codegen.js # ⑤ 代码生成
|
|
172
|
+
│ ├── packager.js # ⑥ 资源打包
|
|
173
|
+
│ ├── xml-utils.js
|
|
174
|
+
│ └── utils/ # EMU、颜色、坐标
|
|
175
|
+
├── test/
|
|
176
|
+
│ ├── unit/
|
|
177
|
+
│ ├── integration/
|
|
178
|
+
│ └── helpers/ # 最小 PPTX 构造
|
|
179
|
+
├── design.html # 完整设计文档
|
|
180
|
+
└── README.html # 本页 HTML 版
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## 已知局限
|
|
184
|
+
|
|
185
|
+
1. **字体**依赖运行环境,不负责检测或打包嵌入字体
|
|
186
|
+
2. **母版继承**已实现 xfrm 与基础 txBody 补全;复杂段落/列表样式继承仍有限;`a:spcPct` 百分比段距/行距暂不处理
|
|
187
|
+
3. **SmartArt** 仅文本列表退化,缓存图片路径因 PPT 版本差异未实现
|
|
188
|
+
4. **动画**尚未转换(设计为退化淡入,待实现)
|
|
189
|
+
5. **不保证**往返 PPTX 二进制一致,追求视觉可接受
|
|
190
|
+
6. **不支持**密码保护或 `.ppt` 旧格式
|
|
191
|
+
7. **不支持**增量转换,每次全量重写
|
|
192
|
+
8. **大文件**(200MB+)可能内存压力较大,超过 50MB 流式解析仍在实现中
|
|
193
|
+
9. **媒体重名**不同路径同名图片可能互相覆盖(`packager` 待实现去重)
|
|
194
|
+
|
|
195
|
+
## 项目状态
|
|
196
|
+
|
|
197
|
+
当前为 **v0.4.0**:在 v0.3.0 基础上新增段落级格式(对齐、`lvl`、段距、行距)、表格单元格边框、扩展图表类型、SmartArt 文本列表退化。复杂动画、SmartArt 缓存图、外部链接表格等按 [`design.html`](./design.html) 继续推进,欢迎贡献。
|
|
198
|
+
|
|
199
|
+
## License
|
|
200
|
+
|
|
201
|
+
MIT
|
package/bin/pptx2js.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI 入口 — 对 lib/convert 的薄封装,不含独立业务逻辑。
|
|
4
|
+
*/
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const chalk = require('chalk');
|
|
7
|
+
const { Command } = require('commander');
|
|
8
|
+
const { convert, DEFAULT_OUTPUT, DEFAULT_MAX_FILE_SIZE } = require('../lib');
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('pptx2js')
|
|
14
|
+
.description('将 .pptx 文件转换为可运行的 PptxGenJS 生成脚本')
|
|
15
|
+
.version(require('../package.json').version)
|
|
16
|
+
.argument('<input>', '源 PPTX 文件路径')
|
|
17
|
+
.option('-o, --output <dir>', '输出目录', DEFAULT_OUTPUT)
|
|
18
|
+
.option('--no-media', '不提取媒体文件')
|
|
19
|
+
.option('--strict-degrade', '任意退化项触发非零退出码')
|
|
20
|
+
.option('--strict-skip', 'severity:error 级别的跳过项触发非零退出码')
|
|
21
|
+
.option(
|
|
22
|
+
'--log-level <level>',
|
|
23
|
+
'日志详细程度:minimal / info / verbose',
|
|
24
|
+
'info'
|
|
25
|
+
)
|
|
26
|
+
.option(
|
|
27
|
+
'--max-file-size <bytes>',
|
|
28
|
+
'超过此阈值时切换为逐幻灯片流式解析',
|
|
29
|
+
String(DEFAULT_MAX_FILE_SIZE)
|
|
30
|
+
)
|
|
31
|
+
.action(async (input, opts) => {
|
|
32
|
+
const logLevel = opts.logLevel;
|
|
33
|
+
const verbose = logLevel === 'verbose';
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
if (verbose) {
|
|
37
|
+
console.log(chalk.gray(`输入: ${path.resolve(input)}`));
|
|
38
|
+
console.log(chalk.gray(`输出: ${path.resolve(opts.output)}`));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await convert(input, {
|
|
42
|
+
outputDir: opts.output,
|
|
43
|
+
// Commander: --no-media → opts.media(默认 true,加 --no-media 后为 false)
|
|
44
|
+
noMedia: !opts.media,
|
|
45
|
+
strictDegrade: opts.strictDegrade,
|
|
46
|
+
strictSkip: opts.strictSkip,
|
|
47
|
+
logLevel,
|
|
48
|
+
maxFileSize: parseInt(opts.maxFileSize, 10),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
console.log(chalk.green('转换完成'));
|
|
52
|
+
console.log(chalk.gray(` 脚本: ${result.scriptPath}`));
|
|
53
|
+
console.log(chalk.gray(` 日志: ${result.logPath}`));
|
|
54
|
+
|
|
55
|
+
const { statistics, warnings } = result.log;
|
|
56
|
+
if (logLevel !== 'minimal') {
|
|
57
|
+
console.log(
|
|
58
|
+
chalk.gray(
|
|
59
|
+
` 统计: ${statistics.slides} 张幻灯片, ` +
|
|
60
|
+
`精确 ${statistics.full}, 退化 ${statistics.degraded}, 跳过 ${statistics.skipped}`
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (warnings?.length && logLevel === 'verbose') {
|
|
65
|
+
for (const w of warnings) {
|
|
66
|
+
console.log(chalk.yellow(` ⚠ ${w.message}`));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(chalk.red(err.message || String(err)));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
program.parse();
|
package/lib/chart.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表提取(design.html §4.3)
|
|
3
|
+
*/
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { asArray, attr, child, textContent } = require('./xml-utils');
|
|
6
|
+
const { getGraphicXfrm } = require('./graphic');
|
|
7
|
+
const { boundsFromXfrm } = require('./utils/bounds');
|
|
8
|
+
|
|
9
|
+
const CHART_NS_BAR = 'barChart';
|
|
10
|
+
const CHART_NS_LINE = 'lineChart';
|
|
11
|
+
const CHART_NS_PIE = 'pieChart';
|
|
12
|
+
|
|
13
|
+
const PPTX_TO_PPTGEN = {
|
|
14
|
+
barChart: 'BAR',
|
|
15
|
+
lineChart: 'LINE',
|
|
16
|
+
pieChart: 'PIE',
|
|
17
|
+
areaChart: 'AREA',
|
|
18
|
+
doughnutChart: 'DOUGHNUT',
|
|
19
|
+
scatterChart: 'SCATTER',
|
|
20
|
+
radarChart: 'RADAR',
|
|
21
|
+
bubbleChart: 'BUBBLE',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {object} graphicFrame
|
|
26
|
+
* @param {object} ctx
|
|
27
|
+
* @returns {import('./extractor').SlideEntity|null}
|
|
28
|
+
*/
|
|
29
|
+
function extractChart(graphicFrame, ctx) {
|
|
30
|
+
const bounds = boundsFromGraphicFrame(graphicFrame, ctx.offset);
|
|
31
|
+
const graphicData = child(child(graphicFrame, 'a:graphic'), 'a:graphicData');
|
|
32
|
+
const chartEl = child(graphicData, 'c:chart');
|
|
33
|
+
const relId = attr(chartEl, 'r:id');
|
|
34
|
+
|
|
35
|
+
if (!relId) {
|
|
36
|
+
return chartFallback(graphicFrame, ctx, bounds, '图表缺少关系 ID');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const chartPath = ctx.relIndex.resolve(ctx.slidePath, relId);
|
|
40
|
+
if (!chartPath || !ctx.parsed[chartPath]) {
|
|
41
|
+
return chartFallback(graphicFrame, ctx, bounds, '无法解析图表 XML');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const chartDoc = ctx.parsed[chartPath];
|
|
45
|
+
const chartSpace = child(chartDoc, 'c:chartSpace') ?? chartDoc;
|
|
46
|
+
const chartRoot = child(chartSpace, 'c:chart');
|
|
47
|
+
const plotArea = child(chartRoot, 'c:plotArea');
|
|
48
|
+
|
|
49
|
+
const parsed = parseChartPlotArea(plotArea);
|
|
50
|
+
if (parsed) {
|
|
51
|
+
return {
|
|
52
|
+
slideIndex: ctx.slideIndex,
|
|
53
|
+
slidePath: ctx.slidePath,
|
|
54
|
+
decision: 'FULL',
|
|
55
|
+
kind: 'chart',
|
|
56
|
+
bounds,
|
|
57
|
+
chart: parsed,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return chartFallback(graphicFrame, ctx, bounds, '不支持的图表类型');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object|null|undefined} plotArea
|
|
66
|
+
*/
|
|
67
|
+
function parseChartPlotArea(plotArea) {
|
|
68
|
+
if (!plotArea) return null;
|
|
69
|
+
|
|
70
|
+
for (const [xmlName, pptxType] of Object.entries(PPTX_TO_PPTGEN)) {
|
|
71
|
+
const chartNode = child(plotArea, `c:${xmlName}`);
|
|
72
|
+
if (!chartNode) continue;
|
|
73
|
+
|
|
74
|
+
const series = extractSeries(chartNode, plotArea);
|
|
75
|
+
if (!series.length) continue;
|
|
76
|
+
|
|
77
|
+
const title = extractChartTitle(plotArea);
|
|
78
|
+
return {
|
|
79
|
+
type: pptxType,
|
|
80
|
+
data: series,
|
|
81
|
+
title,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {object} chartNode c:barChart 等
|
|
89
|
+
* @param {object} plotArea
|
|
90
|
+
*/
|
|
91
|
+
function extractSeries(chartNode, plotArea) {
|
|
92
|
+
const result = [];
|
|
93
|
+
const seriesNodes = asArray(chartNode['c:ser']);
|
|
94
|
+
|
|
95
|
+
for (const ser of seriesNodes) {
|
|
96
|
+
const titleText = extractSeriesName(ser);
|
|
97
|
+
const cat = extractCategoryLabels(ser, plotArea);
|
|
98
|
+
const values = extractSeriesValues(ser, plotArea);
|
|
99
|
+
if (!values.length) continue;
|
|
100
|
+
|
|
101
|
+
result.push({
|
|
102
|
+
name: titleText || `Series ${result.length + 1}`,
|
|
103
|
+
labels: cat.length ? cat : values.map((_, i) => String(i + 1)),
|
|
104
|
+
values,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {object} ser
|
|
112
|
+
*/
|
|
113
|
+
function extractSeriesName(ser) {
|
|
114
|
+
const tx = child(child(ser, 'c:tx'), 'c:strRef');
|
|
115
|
+
const cache = child(tx, 'c:strCache');
|
|
116
|
+
const pt = asArray(child(cache, 'c:pt'))[0];
|
|
117
|
+
return pt ? textContent(child(pt, 'c:v')) : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {object} ser
|
|
122
|
+
* @param {object} plotArea
|
|
123
|
+
*/
|
|
124
|
+
function extractCategoryLabels(ser, plotArea) {
|
|
125
|
+
void plotArea;
|
|
126
|
+
const cat = child(ser, 'c:cat');
|
|
127
|
+
if (!cat) return [];
|
|
128
|
+
const strRef = child(cat, 'c:strRef');
|
|
129
|
+
const numRef = child(cat, 'c:numRef');
|
|
130
|
+
if (strRef) return extractCachePoints(strRef, 'c:strCache', 'c:v');
|
|
131
|
+
if (numRef) return extractCachePoints(numRef, 'c:numCache', 'c:v').map(String);
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {object} ser
|
|
137
|
+
* @param {object} plotArea
|
|
138
|
+
*/
|
|
139
|
+
function extractSeriesValues(ser, plotArea) {
|
|
140
|
+
void plotArea;
|
|
141
|
+
const val = child(ser, 'c:val');
|
|
142
|
+
if (!val) return [];
|
|
143
|
+
const numRef = child(val, 'c:numRef');
|
|
144
|
+
if (!numRef) return [];
|
|
145
|
+
return extractCachePoints(numRef, 'c:numCache', 'c:v').map((v) => parseFloat(v) || 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {object} refNode
|
|
150
|
+
* @param {string} cacheKey
|
|
151
|
+
* @param {string} valueKey
|
|
152
|
+
*/
|
|
153
|
+
function extractCachePoints(refNode, cacheKey, valueKey) {
|
|
154
|
+
const cache = child(refNode, cacheKey);
|
|
155
|
+
const pts = asArray(child(cache, 'c:pt'));
|
|
156
|
+
return pts
|
|
157
|
+
.sort((a, b) => parseInt(attr(a, 'idx') ?? '0', 10) - parseInt(attr(b, 'idx') ?? '0', 10))
|
|
158
|
+
.map((pt) => textContent(child(pt, valueKey)));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {object} plotArea
|
|
163
|
+
*/
|
|
164
|
+
function extractChartTitle(plotArea) {
|
|
165
|
+
const title = child(plotArea, 'c:title');
|
|
166
|
+
if (!title) return '';
|
|
167
|
+
const tx = child(title, 'c:tx');
|
|
168
|
+
const rich = child(tx, 'c:rich');
|
|
169
|
+
if (!rich) return '';
|
|
170
|
+
const parts = [];
|
|
171
|
+
for (const p of asArray(rich['a:p'])) {
|
|
172
|
+
for (const r of asArray(p['a:r'])) {
|
|
173
|
+
parts.push(textContent(r['a:t']));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return parts.join('');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 缓存图片 → 图片插入;否则占位文本
|
|
181
|
+
* @param {object} graphicFrame
|
|
182
|
+
* @param {object} ctx
|
|
183
|
+
* @param {object} bounds
|
|
184
|
+
* @param {string} reason
|
|
185
|
+
*/
|
|
186
|
+
function chartFallback(graphicFrame, ctx, bounds, reason) {
|
|
187
|
+
const cached = findChartCacheImage(graphicFrame, ctx);
|
|
188
|
+
if (cached) {
|
|
189
|
+
return {
|
|
190
|
+
slideIndex: ctx.slideIndex,
|
|
191
|
+
slidePath: ctx.slidePath,
|
|
192
|
+
decision: 'DEGRADE',
|
|
193
|
+
kind: 'image',
|
|
194
|
+
bounds,
|
|
195
|
+
image: cached,
|
|
196
|
+
degradeReason: `${reason},已使用缓存图片`,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
slideIndex: ctx.slideIndex,
|
|
202
|
+
slidePath: ctx.slidePath,
|
|
203
|
+
decision: 'DEGRADE',
|
|
204
|
+
kind: 'text',
|
|
205
|
+
bounds,
|
|
206
|
+
text: {
|
|
207
|
+
runs: [{ text: `[图表] ${reason}`, options: { fontSize: 12, color: '888888' } }],
|
|
208
|
+
},
|
|
209
|
+
degradeReason: reason,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {object} graphicFrame
|
|
215
|
+
* @param {object} ctx
|
|
216
|
+
*/
|
|
217
|
+
function findChartCacheImage(graphicFrame, ctx) {
|
|
218
|
+
const graphicData = child(child(graphicFrame, 'a:graphic'), 'a:graphicData');
|
|
219
|
+
const chartEl = child(graphicData, 'c:chart');
|
|
220
|
+
const chartRelId = attr(chartEl, 'r:id');
|
|
221
|
+
const chartPath =
|
|
222
|
+
chartRelId && ctx.relIndex.resolve(ctx.slidePath, chartRelId);
|
|
223
|
+
if (!chartPath) return null;
|
|
224
|
+
|
|
225
|
+
for (const rel of ctx.relIndex.list(chartPath)) {
|
|
226
|
+
if (!rel.target.match(/\.(png|jpe?g|gif)$/i)) continue;
|
|
227
|
+
if (rel.type && rel.type.includes('image')) {
|
|
228
|
+
return {
|
|
229
|
+
zipPath: rel.target,
|
|
230
|
+
fileName: path.posix.basename(rel.target),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @param {object} graphicFrame
|
|
239
|
+
* @param {{ x: number, y: number }} offset
|
|
240
|
+
*/
|
|
241
|
+
function boundsFromGraphicFrame(graphicFrame, offset) {
|
|
242
|
+
return boundsFromXfrm(getGraphicXfrm(graphicFrame), offset);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { extractChart, parseChartPlotArea, PPTX_TO_PPTGEN };
|
package/lib/codegen.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⑤ 代码生成器 — IR → PptxGenJS 脚本
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function generateScript(ir, options = {}) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
const indent = (n) => ' '.repeat(n);
|
|
8
|
+
|
|
9
|
+
lines.push("const path = require('path');");
|
|
10
|
+
lines.push("const PptxGenJS = require('pptxgenjs');");
|
|
11
|
+
lines.push('');
|
|
12
|
+
lines.push('async function main() {');
|
|
13
|
+
lines.push(`${indent(1)}const pptx = new PptxGenJS();`);
|
|
14
|
+
lines.push('');
|
|
15
|
+
|
|
16
|
+
const { width = 10, height = 7.5 } = ir.layout ?? {};
|
|
17
|
+
lines.push(
|
|
18
|
+
`${indent(1)}pptx.defineLayout({ name: 'PPTX_IMPORT', width: ${width}, height: ${height} });`
|
|
19
|
+
);
|
|
20
|
+
lines.push(`${indent(1)}pptx.layout = 'PPTX_IMPORT';`);
|
|
21
|
+
lines.push('');
|
|
22
|
+
|
|
23
|
+
ir.slides.forEach((slide, i) => {
|
|
24
|
+
const varName = `slide${i}`;
|
|
25
|
+
lines.push(`${indent(1)}const ${varName} = pptx.addSlide();`);
|
|
26
|
+
|
|
27
|
+
if (slide.background?.color) {
|
|
28
|
+
lines.push(
|
|
29
|
+
`${indent(1)}${varName}.background = { color: '${escapeJs(slide.background.color)}' };`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const el of slide.elements) {
|
|
34
|
+
lines.push(...emitElement(el, varName, indent, options));
|
|
35
|
+
}
|
|
36
|
+
lines.push('');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
lines.push(`${indent(1)}await pptx.writeFile({ fileName: 'output.pptx' });`);
|
|
40
|
+
lines.push('}');
|
|
41
|
+
lines.push('');
|
|
42
|
+
lines.push('main().catch((err) => {');
|
|
43
|
+
lines.push(`${indent(1)}console.error(err);`);
|
|
44
|
+
lines.push(`${indent(1)}process.exit(1);`);
|
|
45
|
+
lines.push('});');
|
|
46
|
+
lines.push('');
|
|
47
|
+
|
|
48
|
+
return lines.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function emitElement(el, slideVar, indent, options) {
|
|
52
|
+
const { x, y, w, h } = el.bounds ?? { x: 0, y: 0, w: 0, h: 0 };
|
|
53
|
+
const pos = `x: ${x}, y: ${y}, w: ${w}, h: ${h}`;
|
|
54
|
+
|
|
55
|
+
if (el.type === 'skip') {
|
|
56
|
+
return [`${indent(1)}// [skipped] ${escapeJs(el.skipReason ?? 'unsupported')}`];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (el.type === 'text') {
|
|
60
|
+
return [`${indent(1)}${slideVar}.addText(${formatTextRuns(el.runs)}, { ${pos} });`];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (el.type === 'image') {
|
|
64
|
+
if (options.noMedia) {
|
|
65
|
+
return [`${indent(1)}// [no-media] ${escapeJs(el.mediaPath ?? 'image')}`];
|
|
66
|
+
}
|
|
67
|
+
const imgPath = `path.join(__dirname, '${escapeJs(el.mediaPath)}')`;
|
|
68
|
+
return [`${indent(1)}${slideVar}.addImage({ path: ${imgPath}, ${pos} });`];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (el.type === 'table') {
|
|
72
|
+
const rowsLit = formatTableRows(el.rows);
|
|
73
|
+
const opts = [pos];
|
|
74
|
+
if (el.colWidths?.length) {
|
|
75
|
+
opts.push(`colW: [${el.colWidths.join(', ')}]`);
|
|
76
|
+
}
|
|
77
|
+
return [`${indent(1)}${slideVar}.addTable(${rowsLit}, { ${opts.join(', ')} });`];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (el.type === 'chart') {
|
|
81
|
+
const dataLit = JSON.stringify(el.data);
|
|
82
|
+
const opts = [`${pos}`];
|
|
83
|
+
if (el.title) opts.push(`title: '${escapeJs(el.title)}'`);
|
|
84
|
+
return [
|
|
85
|
+
`${indent(1)}${slideVar}.addChart(pptx.charts.${el.chartType}, ${dataLit}, { ${opts.join(', ')} });`,
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (el.type === 'shape') {
|
|
90
|
+
const shapeRef = `pptx.shapes.${el.shape}`;
|
|
91
|
+
const opts = [pos];
|
|
92
|
+
if (el.fill) opts.push(`fill: { color: '${escapeJs(el.fill)}' }`);
|
|
93
|
+
if (el.line) opts.push(`line: { color: '${escapeJs(el.line)}', width: 1 }`);
|
|
94
|
+
return [`${indent(1)}${slideVar}.addShape(${shapeRef}, { ${opts.join(', ')} });`];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatTableRows(rows) {
|
|
101
|
+
const mapped = rows.map((row) =>
|
|
102
|
+
row.map((cell) => {
|
|
103
|
+
const parts = [`text: '${escapeJs(cell.text ?? '')}'`];
|
|
104
|
+
const optKeys = Object.keys(cell.options ?? {});
|
|
105
|
+
if (optKeys.length) {
|
|
106
|
+
parts.push(`options: ${JSON.stringify(cell.options)}`);
|
|
107
|
+
}
|
|
108
|
+
return `{ ${parts.join(', ')} }`;
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
return `[\n ${mapped.map((r) => `[${r.join(', ')}]`).join(',\n ')}\n ]`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatTextRuns(runs) {
|
|
115
|
+
if (!runs?.length) return "''";
|
|
116
|
+
|
|
117
|
+
const parts = runs.map((run) => {
|
|
118
|
+
const text = escapeJs(run.text ?? '');
|
|
119
|
+
const opts = formatRunOptions(run.options ?? {});
|
|
120
|
+
if (opts === '{}') {
|
|
121
|
+
return `{ text: '${text}' }`;
|
|
122
|
+
}
|
|
123
|
+
return `{ text: '${text}', options: ${opts} }`;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (parts.length === 1 && !parts[0].includes('options:')) {
|
|
127
|
+
return `'${escapeJs(runs[0].text ?? '')}'`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `[\n ${parts.join(',\n ')}\n ]`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatRunOptions(options) {
|
|
134
|
+
const copy = { ...options };
|
|
135
|
+
delete copy._degraded;
|
|
136
|
+
const entries = [];
|
|
137
|
+
if (copy.fontSize != null) entries.push(`fontSize: ${copy.fontSize}`);
|
|
138
|
+
if (copy.bold) entries.push('bold: true');
|
|
139
|
+
if (copy.italic) entries.push('italic: true');
|
|
140
|
+
if (copy.fontFace) entries.push(`fontFace: '${escapeJs(String(copy.fontFace))}'`);
|
|
141
|
+
if (copy.color) entries.push(`color: '${escapeJs(String(copy.color))}'`);
|
|
142
|
+
if (copy.bullet === true) entries.push('bullet: true');
|
|
143
|
+
if (copy.bullet && typeof copy.bullet === 'object') {
|
|
144
|
+
entries.push(`bullet: { type: '${copy.bullet.type ?? 'number'}' }`);
|
|
145
|
+
}
|
|
146
|
+
if (copy.hyperlink?.url) {
|
|
147
|
+
entries.push(`hyperlink: { url: '${escapeJs(copy.hyperlink.url)}' }`);
|
|
148
|
+
}
|
|
149
|
+
if (copy.align) entries.push(`align: '${escapeJs(String(copy.align))}'`);
|
|
150
|
+
if (copy.indentLevel != null && copy.indentLevel > 0) {
|
|
151
|
+
entries.push(`indentLevel: ${copy.indentLevel}`);
|
|
152
|
+
}
|
|
153
|
+
if (copy.paraSpaceBefore != null) {
|
|
154
|
+
entries.push(`paraSpaceBefore: ${copy.paraSpaceBefore}`);
|
|
155
|
+
}
|
|
156
|
+
if (copy.paraSpaceAfter != null) {
|
|
157
|
+
entries.push(`paraSpaceAfter: ${copy.paraSpaceAfter}`);
|
|
158
|
+
}
|
|
159
|
+
if (copy.lineSpacing != null) entries.push(`lineSpacing: ${copy.lineSpacing}`);
|
|
160
|
+
if (entries.length === 0) return '{}';
|
|
161
|
+
return `{ ${entries.join(', ')} }`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function escapeJs(s) {
|
|
165
|
+
return String(s)
|
|
166
|
+
.replace(/\\/g, '\\\\')
|
|
167
|
+
.replace(/'/g, "\\'")
|
|
168
|
+
.replace(/\r/g, '\\r')
|
|
169
|
+
.replace(/\n/g, '\\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { generateScript, escapeJs, formatTableRows };
|