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.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 };