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/lib/table.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 表格提取(design.html §4.5)
3
+ */
4
+ const { asArray, attr, child, textContent } = require('./xml-utils');
5
+ const { getGraphicXfrm } = require('./graphic');
6
+ const { boundsFromXfrm } = require('./utils/bounds');
7
+ const { resolveFillColor } = require('./utils/color');
8
+ const { emuToInch } = require('./utils/emu');
9
+
10
+ /**
11
+ * @param {object} graphicFrame
12
+ * @param {object} ctx
13
+ * @returns {import('./extractor').SlideEntity|null}
14
+ */
15
+ function extractTable(graphicFrame, ctx) {
16
+ const bounds = boundsFromXfrm(getGraphicXfrm(graphicFrame), ctx.offset);
17
+ const graphicData = child(child(graphicFrame, 'a:graphic'), 'a:graphicData');
18
+ const tbl = child(graphicData, 'a:tbl');
19
+
20
+ if (!tbl) {
21
+ return {
22
+ slideIndex: ctx.slideIndex,
23
+ slidePath: ctx.slidePath,
24
+ decision: 'SKIP',
25
+ kind: 'skip',
26
+ bounds,
27
+ skipReason: '表格数据无法解析(无内联 a:tbl)',
28
+ };
29
+ }
30
+
31
+ const rows = extractTableRows(tbl, ctx.scheme);
32
+ if (rows.length === 0) return null;
33
+
34
+ const colWidths = extractColWidths(tbl);
35
+
36
+ return {
37
+ slideIndex: ctx.slideIndex,
38
+ slidePath: ctx.slidePath,
39
+ decision: 'FULL',
40
+ kind: 'table',
41
+ bounds,
42
+ table: { rows, colWidths },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * @param {object} tbl
48
+ * @param {Record<string, string>} scheme
49
+ */
50
+ function extractTableRows(tbl, scheme) {
51
+ const rows = [];
52
+ for (const tr of asArray(tbl['a:tr'])) {
53
+ const cells = [];
54
+ for (const tc of asArray(tr['a:tc'])) {
55
+ const text = extractCellText(tc);
56
+ const tcPr = child(tc, 'a:tcPr');
57
+ const fill = tcPr ? resolveFillColor(tcPr, scheme).color : null;
58
+ const merge = {};
59
+ const rowSpan = attr(tcPr, 'rowSpan');
60
+ const gridSpan = attr(tcPr, 'gridSpan');
61
+ if (rowSpan && rowSpan !== '1') merge.rowspan = parseInt(rowSpan, 10);
62
+ if (gridSpan && gridSpan !== '1') merge.colspan = parseInt(gridSpan, 10);
63
+
64
+ const cell = { text, options: {} };
65
+ if (fill) cell.options.fill = { color: fill };
66
+ if (Object.keys(merge).length) Object.assign(cell.options, merge);
67
+
68
+ const border = extractCellBorder(tcPr, scheme);
69
+ if (border) cell.options.border = border;
70
+
71
+ cells.push(cell);
72
+ }
73
+ if (cells.length) rows.push(cells);
74
+ }
75
+ return rows;
76
+ }
77
+
78
+ /**
79
+ * @param {object|null|undefined} tcPr
80
+ * @param {Record<string, string>} scheme
81
+ */
82
+ function extractCellBorder(tcPr, scheme) {
83
+ if (!tcPr) return null;
84
+ const border = {};
85
+ const sides = {
86
+ left: 'a:lnL',
87
+ right: 'a:lnR',
88
+ top: 'a:lnT',
89
+ bottom: 'a:lnB',
90
+ };
91
+ let hasAny = false;
92
+
93
+ for (const [side, key] of Object.entries(sides)) {
94
+ const ln = child(tcPr, key);
95
+ if (!ln) continue;
96
+ const { color } = resolveFillColor(ln, scheme);
97
+ const w = parseInt(attr(ln, 'w') ?? '0', 10);
98
+ if (color || w) {
99
+ border[side] = {
100
+ ...(color && { color }),
101
+ ...(w > 0 && { pt: Math.round(w / 12700) }),
102
+ };
103
+ hasAny = true;
104
+ }
105
+ }
106
+ return hasAny ? border : null;
107
+ }
108
+
109
+ /**
110
+ * @param {object} tc
111
+ */
112
+ function extractCellText(tc) {
113
+ const txBody = child(tc, 'a:txBody');
114
+ if (!txBody) return '';
115
+ const parts = [];
116
+ for (const p of asArray(txBody['a:p'])) {
117
+ for (const r of asArray(p['a:r'])) {
118
+ parts.push(textContent(r['a:t']));
119
+ }
120
+ }
121
+ return parts.join('');
122
+ }
123
+
124
+ /**
125
+ * @param {object} tbl
126
+ * @returns {number[]|undefined}
127
+ */
128
+ function extractColWidths(tbl) {
129
+ const grid = child(tbl, 'a:tblGrid');
130
+ const cols = asArray(child(grid, 'a:gridCol'));
131
+ if (!cols.length) return undefined;
132
+ return cols.map((col) => {
133
+ const w = parseInt(attr(col, 'w') ?? '0', 10);
134
+ return Math.round(emuToInch(w) * 1000) / 1000;
135
+ });
136
+ }
137
+
138
+ module.exports = { extractTable };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ① 解压与索引
3
+ * JSZip 解压 PPTX,建立文件清单。
4
+ */
5
+ const fs = require('fs');
6
+ const JSZip = require('jszip');
7
+
8
+ /**
9
+ * @typedef {object} PptxArchive
10
+ * @property {JSZip} zip
11
+ * @property {Map<string, string>} files - 路径 → 文本内容或 __binary__
12
+ */
13
+
14
+ /**
15
+ * @param {string} filePath
16
+ * @returns {Promise<PptxArchive>}
17
+ */
18
+ async function unpack(filePath) {
19
+ const buffer = fs.readFileSync(filePath);
20
+ const zip = await JSZip.loadAsync(buffer);
21
+
22
+ /** @type {Map<string, string>} */
23
+ const files = new Map();
24
+ for (const [entryPath, entry] of Object.entries(zip.files)) {
25
+ if (entry.dir) continue;
26
+ const isText =
27
+ entryPath.endsWith('.xml') ||
28
+ entryPath.endsWith('.rels') ||
29
+ entryPath.includes('[Content_Types]');
30
+ if (isText) {
31
+ files.set(entryPath, await entry.async('string'));
32
+ } else {
33
+ files.set(entryPath, '__binary__');
34
+ }
35
+ }
36
+
37
+ return { zip, files };
38
+ }
39
+
40
+ module.exports = { unpack };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 形状坐标:a:xfrm → 英寸边界
3
+ */
4
+ const { attr, child } = require('../xml-utils');
5
+ const { emuToInch } = require('./emu');
6
+
7
+ /**
8
+ * @param {object|null|undefined} xfrm
9
+ * @param {{ x: number, y: number }} offset EMU
10
+ * @returns {{ x: number, y: number, w: number, h: number }}
11
+ */
12
+ function boundsFromXfrm(xfrm, offset = { x: 0, y: 0 }) {
13
+ const off = child(xfrm, 'a:off');
14
+ const ext = child(xfrm, 'a:ext');
15
+ const x = parseInt(attr(off, 'x') ?? '0', 10) + offset.x;
16
+ const y = parseInt(attr(off, 'y') ?? '0', 10) + offset.y;
17
+ const cx = parseInt(attr(ext, 'cx') ?? '0', 10);
18
+ const cy = parseInt(attr(ext, 'cy') ?? '0', 10);
19
+ return {
20
+ x: round3(emuToInch(x)),
21
+ y: round3(emuToInch(y)),
22
+ w: round3(emuToInch(cx)),
23
+ h: round3(emuToInch(cy)),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * @param {number} n
29
+ */
30
+ function round3(n) {
31
+ return Math.round(n * 1000) / 1000;
32
+ }
33
+
34
+ module.exports = { boundsFromXfrm };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 颜色规范化(srgbClr、schemeClr、渐变首色标)
3
+ */
4
+ const { asArray, attr, child } = require('../xml-utils');
5
+
6
+ /** @type {Record<string, string>} */
7
+ const DEFAULT_SCHEME = {
8
+ dk1: '000000',
9
+ lt1: 'FFFFFF',
10
+ dk2: '44546A',
11
+ lt2: 'E7E6E6',
12
+ accent1: '4472C4',
13
+ accent2: 'ED7D31',
14
+ accent3: 'A5A5A5',
15
+ accent4: 'FFC000',
16
+ accent5: '5B9BD5',
17
+ accent6: '70AD47',
18
+ hlink: '0563C1',
19
+ folHlink: '954F72',
20
+ };
21
+
22
+ /**
23
+ * @param {Record<string, object>|null} parsed
24
+ * @param {string|null} themePath
25
+ * @returns {Record<string, string>}
26
+ */
27
+ function loadColorScheme(parsed, themePath) {
28
+ if (!themePath || !parsed[themePath]) return { ...DEFAULT_SCHEME };
29
+
30
+ const theme = child(parsed[themePath], 'a:theme');
31
+ const clrScheme = child(child(theme, 'a:themeElements'), 'a:clrScheme');
32
+ if (!clrScheme) return { ...DEFAULT_SCHEME };
33
+
34
+ const scheme = { ...DEFAULT_SCHEME };
35
+ for (const name of Object.keys(DEFAULT_SCHEME)) {
36
+ const slot = child(clrScheme, `a:${name}`);
37
+ const rgb = extractRgbFromColorNode(slot);
38
+ if (rgb) scheme[name] = rgb;
39
+ }
40
+ return scheme;
41
+ }
42
+
43
+ /**
44
+ * @param {object|null|undefined} colorNode a:srgbClr / a:schemeClr 的父级
45
+ * @returns {string|null}
46
+ */
47
+ function extractRgbFromColorNode(colorNode) {
48
+ if (!colorNode) return null;
49
+
50
+ const srgb = child(colorNode, 'a:srgbClr');
51
+ if (srgb) {
52
+ const val = attr(srgb, 'val');
53
+ return val ? val.toUpperCase() : null;
54
+ }
55
+
56
+ const sys = child(colorNode, 'a:sysClr');
57
+ if (sys) {
58
+ const last = attr(sys, 'lastClr');
59
+ return last ? last.toUpperCase() : null;
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * @param {object|null|undefined} fillNode a:solidFill | a:gradFill 等
67
+ * @param {Record<string, string>} scheme
68
+ * @returns {{ color: string|null, degraded: boolean }}
69
+ */
70
+ function resolveFillColor(fillNode, scheme) {
71
+ if (!fillNode) return { color: null, degraded: false };
72
+
73
+ const solid = child(fillNode, 'a:solidFill');
74
+ if (solid) {
75
+ return { color: resolveColorFromContainer(solid, scheme), degraded: false };
76
+ }
77
+
78
+ const grad = child(fillNode, 'a:gradFill');
79
+ if (grad) {
80
+ const gs = asArray(child(child(grad, 'a:gsLst'), 'a:gs'))[0];
81
+ return {
82
+ color: gs ? resolveColorFromContainer(gs, scheme) : null,
83
+ degraded: true,
84
+ };
85
+ }
86
+
87
+ return { color: null, degraded: false };
88
+ }
89
+
90
+ /**
91
+ * @param {object|null|undefined} container 含 a:srgbClr / a:schemeClr
92
+ * @param {Record<string, string>} scheme
93
+ * @returns {string|null}
94
+ */
95
+ function resolveColorFromContainer(container, scheme) {
96
+ if (!container) return null;
97
+
98
+ const srgb = child(container, 'a:srgbClr');
99
+ if (srgb) {
100
+ const val = attr(srgb, 'val');
101
+ return val ? val.toUpperCase() : null;
102
+ }
103
+
104
+ const schemeClr = child(container, 'a:schemeClr');
105
+ if (schemeClr) {
106
+ const name = attr(schemeClr, 'val');
107
+ return name ? scheme[name] ?? DEFAULT_SCHEME[name] ?? null : null;
108
+ }
109
+
110
+ return extractRgbFromColorNode(container);
111
+ }
112
+
113
+ /**
114
+ * @param {Record<string, string>} scheme
115
+ * @param {object|null|undefined} clrNode
116
+ * @returns {string|null}
117
+ */
118
+ function resolveColor(scheme, clrNode) {
119
+ return resolveColorFromContainer(clrNode, scheme);
120
+ }
121
+
122
+ module.exports = {
123
+ DEFAULT_SCHEME,
124
+ loadColorScheme,
125
+ resolveFillColor,
126
+ resolveColor,
127
+ resolveColorFromContainer,
128
+ };
@@ -0,0 +1,20 @@
1
+ /** EMU(English Metric Units)与英寸换算,1 英寸 = 914400 EMU */
2
+ const EMU_PER_INCH = 914400;
3
+
4
+ /**
5
+ * @param {number} emu
6
+ * @returns {number}
7
+ */
8
+ function emuToInch(emu) {
9
+ return emu / EMU_PER_INCH;
10
+ }
11
+
12
+ /**
13
+ * @param {number} inch
14
+ * @returns {number}
15
+ */
16
+ function inchToEmu(inch) {
17
+ return inch * EMU_PER_INCH;
18
+ }
19
+
20
+ module.exports = { EMU_PER_INCH, emuToInch, inchToEmu };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 项目唯一导出的 XML 解析器实例。
3
+ * 全项目必须使用此入口,禁止在模块内自行实例化解析器。
4
+ * @see design.html §4.6
5
+ */
6
+ const xml2js = require('xml2js');
7
+
8
+ const PARSER_OPTIONS = {
9
+ explicitArray: false,
10
+ mergeAttrs: false,
11
+ explicitCharkey: false,
12
+ tagNameProcessors: [],
13
+ attrNameProcessors: [],
14
+ xmlns: false,
15
+ };
16
+
17
+ /**
18
+ * @param {string} str
19
+ * @returns {Promise<object>}
20
+ */
21
+ function parseXml(str) {
22
+ return xml2js.parseStringPromise(str, PARSER_OPTIONS);
23
+ }
24
+
25
+ module.exports = { parseXml, PARSER_OPTIONS };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * xml2js 对象树访问工具(配合 lib/xml-parser.js 统一配置)
3
+ */
4
+
5
+ /**
6
+ * @param {*} value
7
+ * @returns {Array}
8
+ */
9
+ function asArray(value) {
10
+ if (value == null) return [];
11
+ return Array.isArray(value) ? value : [value];
12
+ }
13
+
14
+ /**
15
+ * @param {object|null|undefined} node
16
+ * @returns {Record<string, string>}
17
+ */
18
+ function attrs(node) {
19
+ if (node == null) return {};
20
+ if (node.$) return node.$;
21
+ return node;
22
+ }
23
+
24
+ /**
25
+ * @param {object|null|undefined} node
26
+ * @param {string} name
27
+ * @returns {string|undefined}
28
+ */
29
+ function attr(node, name) {
30
+ return attrs(node)[name];
31
+ }
32
+
33
+ /**
34
+ * @param {object|null|undefined} node
35
+ * @param {string} key
36
+ * @returns {*}
37
+ */
38
+ function child(node, key) {
39
+ if (node == null) return undefined;
40
+ return node[key];
41
+ }
42
+
43
+ /**
44
+ * @param {object|null|undefined} node
45
+ * @param {string[]} keys
46
+ * @returns {*}
47
+ */
48
+ function firstChild(node, keys) {
49
+ for (const key of keys) {
50
+ const v = child(node, key);
51
+ if (v != null) return v;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /**
57
+ * @param {object|null|undefined} node
58
+ * @returns {string}
59
+ */
60
+ function textContent(node) {
61
+ if (node == null) return '';
62
+ if (typeof node === 'string') return node;
63
+ if (typeof node === 'number') return String(node);
64
+ if (node._ != null) return String(node._);
65
+ return '';
66
+ }
67
+
68
+ module.exports = { asArray, attrs, attr, child, firstChild, textContent };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "pptx2js",
3
+ "version": "0.4.0",
4
+ "description": "将 .pptx 文件转换为可运行的 PptxGenJS 生成脚本",
5
+ "main": "lib/index.js",
6
+ "bin": {
7
+ "pptx2js": "bin/pptx2js.js"
8
+ },
9
+ "scripts": {
10
+ "test": "jest",
11
+ "test:watch": "jest --watch"
12
+ },
13
+ "keywords": [
14
+ "pptx",
15
+ "pptxgenjs",
16
+ "converter",
17
+ "powerpoint",
18
+ "presentation",
19
+ "nodejs",
20
+ "cli"
21
+ ],
22
+ "author": "yuese12333",
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "dependencies": {
28
+ "chalk": "^4.1.2",
29
+ "commander": "^12.1.0",
30
+ "jszip": "^3.10.1",
31
+ "xml2js": "^0.6.2"
32
+ },
33
+ "devDependencies": {
34
+ "jest": "^29.7.0"
35
+ },
36
+ "files": [
37
+ "lib",
38
+ "bin",
39
+ "README.md"
40
+ ]
41
+ }