pptx2js 0.4.1 → 0.4.3

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/xml-parser.js CHANGED
@@ -1,25 +1,201 @@
1
1
  /**
2
- * 项目唯一导出的 XML 解析器实例。
3
- * 全项目必须使用此入口,禁止在模块内自行实例化解析器。
4
- * @see design.html §4.6
2
+ * OOXML 专用 XML 解析器(无外部依赖)
3
+ *
4
+ * 节点结构:
5
+ * { tag: 'a:r', attrs: { 'r:id': 'rId1', w: '25400' }, children: [...], text: '' }
6
+ *
7
+ * 特性:
8
+ * - children 保留原始 XML 子节点顺序
9
+ * - children() 始终返回数组(消除 xml2js 单/多不一致)
10
+ * - 空节点(如 <a:buNone/>)返回节点对象而非 '' / null(修复 xml2js 的 falsy 陷阱)
11
+ * - 命名空间前缀保留,与 OOXML 原文一致
5
12
  */
6
- const xml2js = require('xml2js');
7
13
 
8
- const PARSER_OPTIONS = {
9
- explicitArray: false,
10
- mergeAttrs: false,
11
- explicitCharkey: false,
12
- tagNameProcessors: [],
13
- attrNameProcessors: [],
14
- xmlns: false,
15
- };
14
+ /**
15
+ * @typedef {object} OONode
16
+ * @property {string} tag
17
+ * @property {Record<string,string>} attrs
18
+ * @property {OONode[]} children
19
+ * @property {string} text
20
+ */
16
21
 
17
22
  /**
18
23
  * @param {string} str
19
- * @returns {Promise<object>}
24
+ * @returns {OONode}
20
25
  */
21
26
  function parseXml(str) {
22
- return xml2js.parseStringPromise(str, PARSER_OPTIONS);
27
+ return new Parser(str).parse();
28
+ }
29
+
30
+ class Parser {
31
+ constructor(input) {
32
+ this.s = input;
33
+ this.pos = 0;
34
+ }
35
+
36
+ parse() {
37
+ this.skipProlog();
38
+ return this.parseElement();
39
+ }
40
+
41
+ skipProlog() {
42
+ while (this.pos < this.s.length) {
43
+ this.skipSpaces();
44
+ if (this.peek('<?')) {
45
+ const end = this.s.indexOf('?>', this.pos);
46
+ this.pos = end === -1 ? this.s.length : end + 2;
47
+ } else if (this.peek('<!--')) {
48
+ const end = this.s.indexOf('-->', this.pos);
49
+ this.pos = end === -1 ? this.s.length : end + 3;
50
+ } else {
51
+ break;
52
+ }
53
+ }
54
+ }
55
+
56
+ parseElement() {
57
+ this.skipSpaces();
58
+ this.eat('<');
59
+
60
+ const tag = this.readName();
61
+ const rawAttrs = this.readAttributes();
62
+ this.skipSpaces();
63
+
64
+ // 过滤掉 xmlns 声明,暴露给调用方的 attrs 只有业务属性
65
+ const attrs = {};
66
+ for (const [k, v] of Object.entries(rawAttrs)) {
67
+ if (k !== 'xmlns' && !k.startsWith('xmlns:')) {
68
+ attrs[k] = v;
69
+ }
70
+ }
71
+
72
+ /** @type {OONode} */
73
+ const node = { tag, attrs, children: [], text: '' };
74
+
75
+ if (this.s[this.pos] === '/') {
76
+ this.pos += 2; // />
77
+ return node;
78
+ }
79
+ this.eat('>');
80
+
81
+ // 收集子内容
82
+ let textBuf = '';
83
+
84
+ const flushText = () => {
85
+ if (textBuf) {
86
+ // 只在没有子元素时,或文本非纯空白时记录
87
+ // 这样格式化缩进不会污染有子节点的父元素
88
+ const decoded = decodeEntities(textBuf);
89
+ if (node.children.length === 0) {
90
+ // 叶节点:保留全部文本(包括 \n,因为 <a:t>\n</a:t> 是有意义的)
91
+ node.text += decoded;
92
+ } else {
93
+ // 有子节点的父元素:只保留非纯空白文本
94
+ if (decoded.trim()) {
95
+ node.text += decoded;
96
+ }
97
+ }
98
+ textBuf = '';
99
+ }
100
+ };
101
+
102
+ while (this.pos < this.s.length) {
103
+ if (this.peek('</')) {
104
+ flushText();
105
+ this.pos += 2;
106
+ this.readName();
107
+ this.skipSpaces();
108
+ this.eat('>');
109
+ break;
110
+ } else if (this.peek('<!--')) {
111
+ const end = this.s.indexOf('-->', this.pos);
112
+ this.pos = end === -1 ? this.s.length : end + 3;
113
+ } else if (this.peek('<![CDATA[')) {
114
+ const end = this.s.indexOf(']]>', this.pos);
115
+ if (end === -1) break;
116
+ textBuf += this.s.slice(this.pos + 9, end);
117
+ this.pos = end + 3;
118
+ } else if (this.s[this.pos] === '<') {
119
+ flushText();
120
+ node.children.push(this.parseElement());
121
+ } else {
122
+ // 文本字符
123
+ textBuf += this.s[this.pos++];
124
+ }
125
+ }
126
+
127
+ return node;
128
+ }
129
+
130
+ readName() {
131
+ const start = this.pos;
132
+ while (this.pos < this.s.length && /[\w\-\.:]/.test(this.s[this.pos])) {
133
+ this.pos++;
134
+ }
135
+ return this.s.slice(start, this.pos);
136
+ }
137
+
138
+ readAttributes() {
139
+ const attrs = {};
140
+ while (this.pos < this.s.length) {
141
+ this.skipSpaces();
142
+ const c = this.s[this.pos];
143
+ if (c === '>' || c === '/' || c === '?') break;
144
+
145
+ const name = this.readName();
146
+ if (!name) { this.pos++; continue; }
147
+
148
+ this.skipSpaces();
149
+ if (this.s[this.pos] !== '=') {
150
+ attrs[name] = '';
151
+ continue;
152
+ }
153
+ this.pos++; // =
154
+ this.skipSpaces();
155
+
156
+ const quote = this.s[this.pos];
157
+ if (quote !== '"' && quote !== "'") {
158
+ this.pos++;
159
+ const start = this.pos;
160
+ while (this.pos < this.s.length && !/[\s>]/.test(this.s[this.pos])) this.pos++;
161
+ attrs[name] = decodeEntities(this.s.slice(start, this.pos));
162
+ continue;
163
+ }
164
+ this.pos++;
165
+ const start = this.pos;
166
+ while (this.pos < this.s.length && this.s[this.pos] !== quote) this.pos++;
167
+ attrs[name] = decodeEntities(this.s.slice(start, this.pos));
168
+ this.pos++;
169
+ }
170
+ return attrs;
171
+ }
172
+
173
+ skipSpaces() {
174
+ while (this.pos < this.s.length && /[ \t\r\n]/.test(this.s[this.pos])) this.pos++;
175
+ }
176
+
177
+ peek(s) {
178
+ return this.s.startsWith(s, this.pos);
179
+ }
180
+
181
+ eat(ch) {
182
+ if (this.s[this.pos] !== ch) {
183
+ const ctx = this.s.slice(Math.max(0, this.pos - 30), this.pos + 30);
184
+ throw new Error(`XML parse error: expected '${ch}' at pos ${this.pos}, context: ...${ctx}...`);
185
+ }
186
+ this.pos++;
187
+ }
188
+ }
189
+
190
+ function decodeEntities(s) {
191
+ return s
192
+ .replace(/&amp;/g, '&')
193
+ .replace(/&lt;/g, '<')
194
+ .replace(/&gt;/g, '>')
195
+ .replace(/&quot;/g, '"')
196
+ .replace(/&apos;/g, "'")
197
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)))
198
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCharCode(parseInt(n, 16)));
23
199
  }
24
200
 
25
- module.exports = { parseXml, PARSER_OPTIONS };
201
+ module.exports = { parseXml };
package/lib/xml-utils.js CHANGED
@@ -1,68 +1,114 @@
1
1
  /**
2
- * xml2js 对象树访问工具(配合 lib/xml-parser.js 统一配置)
2
+ * OOXML 节点访问工具
3
+ * 配合自定义 xml-parser.js 使用
4
+ *
5
+ * 节点结构:{ tag, attrs, children: OONode[], text }
3
6
  */
4
7
 
5
8
  /**
6
- * @param {*} value
7
- * @returns {Array}
9
+ * 取第一个匹配 tag 的子节点
10
+ * @param {import('./xml-parser').OONode|null|undefined} node
11
+ * @param {string} tag
12
+ * @returns {import('./xml-parser').OONode|undefined}
8
13
  */
9
- function asArray(value) {
10
- if (value == null) return [];
11
- return Array.isArray(value) ? value : [value];
14
+ function child(node, tag) {
15
+ if (!node || !node.children) return undefined;
16
+ return node.children.find(c => c.tag === tag);
12
17
  }
13
18
 
14
19
  /**
15
- * @param {object|null|undefined} node
16
- * @returns {Record<string, string>}
20
+ * 取所有匹配 tag 的子节点(始终返回数组,消除 xml2js 单/多不一致)
21
+ * @param {import('./xml-parser').OONode|null|undefined} node
22
+ * @param {string} tag
23
+ * @returns {import('./xml-parser').OONode[]}
17
24
  */
18
- function attrs(node) {
19
- if (node == null) return {};
20
- if (node.$) return node.$;
21
- return node;
25
+ function children(node, tag) {
26
+ if (!node || !node.children) return [];
27
+ return node.children.filter(c => c.tag === tag);
22
28
  }
23
29
 
24
30
  /**
25
- * @param {object|null|undefined} node
31
+ * 取节点的有序子节点列表(保留所有 tag,用于顺序敏感场景如 a:p)
32
+ * @param {import('./xml-parser').OONode|null|undefined} node
33
+ * @returns {import('./xml-parser').OONode[]}
34
+ */
35
+ function childNodes(node) {
36
+ if (!node || !node.children) return [];
37
+ return node.children;
38
+ }
39
+
40
+ /**
41
+ * 取属性值
42
+ * @param {import('./xml-parser').OONode|null|undefined} node
26
43
  * @param {string} name
27
44
  * @returns {string|undefined}
28
45
  */
29
46
  function attr(node, name) {
30
- return attrs(node)[name];
47
+ if (!node || !node.attrs) return undefined;
48
+ return node.attrs[name];
49
+ }
50
+
51
+ /**
52
+ * 取文本内容
53
+ * @param {import('./xml-parser').OONode|null|undefined} node
54
+ * @returns {string}
55
+ */
56
+ function textContent(node) {
57
+ if (node == null) return '';
58
+ if (typeof node === 'string') return node;
59
+ if (typeof node === 'number') return String(node);
60
+ return node.text ?? '';
31
61
  }
32
62
 
33
63
  /**
34
- * @param {object|null|undefined} node
35
- * @param {string} key
36
- * @returns {*}
64
+ * 兼容旧版 asArray(对迁移中未改完的调用点提供临时支持)
65
+ * 新代码应直接用 children(node, tag) 代替 asArray(node[tag])
66
+ * @param {*} value
67
+ * @returns {Array}
37
68
  */
38
- function child(node, key) {
39
- if (node == null) return undefined;
40
- return node[key];
69
+ function asArray(value) {
70
+ if (value == null) return [];
71
+ return Array.isArray(value) ? value : [value];
41
72
  }
42
73
 
43
74
  /**
44
- * @param {object|null|undefined} node
45
- * @param {string[]} keys
46
- * @returns {*}
75
+ * 取第一个匹配多个 tag 之一的子节点
76
+ * @param {import('./xml-parser').OONode|null|undefined} node
77
+ * @param {string[]} tags
78
+ * @returns {import('./xml-parser').OONode|undefined}
47
79
  */
48
- function firstChild(node, keys) {
49
- for (const key of keys) {
50
- const v = child(node, key);
51
- if (v != null) return v;
80
+ function firstChild(node, tags) {
81
+ if (!node || !node.children) return undefined;
82
+ for (const tag of tags) {
83
+ const found = node.children.find(c => c.tag === tag);
84
+ if (found != null) return found;
52
85
  }
53
86
  return undefined;
54
87
  }
55
88
 
56
89
  /**
57
- * @param {object|null|undefined} node
58
- * @returns {string}
90
+ * parseXml 返回文档根 OONode;若已是目标 tag 则直接返回,否则取同名子节点
91
+ * @param {import('./xml-parser').OONode|null|undefined} doc
92
+ * @param {...string} tags
93
+ * @returns {import('./xml-parser').OONode|undefined}
59
94
  */
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 '';
95
+ function documentRoot(doc, ...tags) {
96
+ if (!doc) return undefined;
97
+ if (tags.includes(doc.tag)) return doc;
98
+ for (const tag of tags) {
99
+ const found = child(doc, tag);
100
+ if (found) return found;
101
+ }
102
+ return undefined;
66
103
  }
67
104
 
68
- module.exports = { asArray, attrs, attr, child, firstChild, textContent };
105
+ module.exports = {
106
+ child,
107
+ children,
108
+ childNodes,
109
+ attr,
110
+ textContent,
111
+ asArray,
112
+ firstChild,
113
+ documentRoot,
114
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pptx2js",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "将 .pptx 文件转换为可运行的 PptxGenJS 生成脚本",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -28,8 +28,7 @@
28
28
  "dependencies": {
29
29
  "chalk": "^4.1.2",
30
30
  "commander": "^12.1.0",
31
- "jszip": "^3.10.1",
32
- "xml2js": "^0.6.2"
31
+ "jszip": "^3.10.1"
33
32
  },
34
33
  "devDependencies": {
35
34
  "jest": "^29.7.0"
@@ -1,128 +0,0 @@
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
- };
package/lib/utils/emu.js DELETED
@@ -1,20 +0,0 @@
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 };