@ww_nero/mini-cli 1.0.61 → 1.0.62

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ww_nero/mini-cli",
3
- "version": "1.0.61",
3
+ "version": "1.0.62",
4
4
  "description": "极简的 AI 命令行助手",
5
5
  "bin": {
6
6
  "mini": "bin/mini.js"
@@ -3,11 +3,10 @@ const read = require('./read');
3
3
  const write = require('./write');
4
4
  const replace = require('./replace');
5
5
  const todos = require('./todos');
6
- const convert = require('./convert');
7
6
  const { createMcpManager } = require('./mcp');
8
7
  const { loadSettings } = require('../config');
9
8
 
10
- const TOOL_MODULES = [bash, read, write, replace, todos, convert];
9
+ const TOOL_MODULES = [bash, read, write, replace, todos];
11
10
 
12
11
  const createToolRuntime = async (workspaceRoot, options = {}) => {
13
12
  const defaultToolNames = TOOL_MODULES.map((tool) => tool.name);
@@ -1,297 +0,0 @@
1
- const { spawn } = require('child_process');
2
- const fs = require('fs');
3
- const fsPromises = require('fs/promises');
4
- const path = require('path');
5
- const { resolveWorkspacePath } = require('../utils/helpers');
6
-
7
- const EXECUTION_TIMEOUT = 300000;
8
- const PYTHON_SCRIPT_ROOT = path.join(__dirname, 'python');
9
-
10
- const TYPE_META = {
11
- pdf_to_png: {
12
- inputType: 'file',
13
- outputType: 'directory',
14
- description: '将 PDF 每一页转成 PNG 图片'
15
- },
16
- html_to_pptx: {
17
- inputType: 'list',
18
- outputType: 'file',
19
- description: '多个 HTML 截屏后合并为单 PPTX,保持原顺序'
20
- },
21
- html_to_png: {
22
- inputType: 'file',
23
- outputType: 'file',
24
- description: '将单个 HTML 文件转换为 PNG 图片'
25
- },
26
- pptx_to_pdf: {
27
- inputType: 'file',
28
- outputType: 'file',
29
- description: '通过 LibreOffice 直接将 PPT/PPTX 导出为 PDF'
30
- }
31
- };
32
-
33
- const PYTHON_SCRIPTS = {
34
- pdf_to_png: 'pdf_to_png.py',
35
- html_to_pptx: 'html_to_pptx.py',
36
- html_to_png: 'html_to_png.py',
37
- pptx_to_pdf: 'pptx_to_pdf.py'
38
- };
39
-
40
- const TYPE_ENUM = Object.keys(TYPE_META);
41
-
42
- const shellQuote = (value = '') => {
43
- const str = String(value);
44
- const escaped = str.replace(/(["$`\\])/g, '\\$1');
45
- return `"${escaped}"`;
46
- };
47
-
48
- const normalizeListInput = (input) => {
49
- if (Array.isArray(input)) {
50
- return input;
51
- }
52
- if (typeof input === 'string' && input.trim()) {
53
- const trimmed = input.trim();
54
- if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
55
- try {
56
- const parsed = JSON.parse(trimmed);
57
- if (Array.isArray(parsed)) {
58
- return parsed;
59
- }
60
- } catch (error) {
61
- // fall through to treating as single path
62
- }
63
- }
64
- return [trimmed];
65
- }
66
- return [];
67
- };
68
-
69
- const ensureOutputLocation = async (targetPath, kind) => {
70
- if (kind === 'directory') {
71
- await fsPromises.mkdir(targetPath, { recursive: true });
72
- return;
73
- }
74
- await fsPromises.mkdir(path.dirname(targetPath), { recursive: true });
75
- };
76
-
77
- const runBashCommand = (command, cwd) => new Promise((resolve, reject) => {
78
- const child = spawn('bash', ['-lc', command], {
79
- cwd,
80
- env: process.env,
81
- stdio: ['ignore', 'pipe', 'pipe']
82
- });
83
-
84
- let stdout = '';
85
- let stderr = '';
86
- let finished = false;
87
-
88
- const finalize = (error) => {
89
- if (finished) return;
90
- finished = true;
91
- clearTimeout(timeoutId);
92
- if (error) {
93
- reject(error);
94
- } else {
95
- resolve(stdout.trim());
96
- }
97
- };
98
-
99
- child.stdout.on('data', (chunk) => {
100
- stdout += chunk.toString();
101
- });
102
-
103
- child.stderr.on('data', (chunk) => {
104
- stderr += chunk.toString();
105
- });
106
-
107
- child.on('error', (error) => {
108
- finalize(error);
109
- });
110
-
111
- child.on('close', (code) => {
112
- if (code === 0) {
113
- finalize();
114
- return;
115
- }
116
- const message = stderr.trim() || stdout.trim() || `命令执行失败,退出码 ${code}`;
117
- finalize(new Error(message));
118
- });
119
-
120
- const timeoutId = setTimeout(() => {
121
- child.kill('SIGTERM');
122
- finalize(new Error('命令执行超时 (超过 300s)'));
123
- }, EXECUTION_TIMEOUT);
124
- });
125
-
126
- const runPythonScript = async (scriptPath, scriptArgs = [], workspaceRoot) => {
127
- const quotedScript = shellQuote(scriptPath);
128
- const argsString = scriptArgs.join(' ');
129
- const appendArgs = argsString ? ` ${argsString}` : '';
130
-
131
- try {
132
- return await runBashCommand(`python3 ${quotedScript}${appendArgs}`, workspaceRoot);
133
- } catch (error) {
134
- const message = error?.message || '';
135
- if (/python3: command not found/.test(message) || /python3: not found/.test(message)) {
136
- return runBashCommand(`python ${quotedScript}${appendArgs}`, workspaceRoot);
137
- }
138
- throw error;
139
- }
140
- };
141
-
142
- const resolveInputPaths = (inputType, input, workspaceRoot, typeName) => {
143
- if (inputType === 'list') {
144
- const list = normalizeListInput(input);
145
- if (list.length === 0) {
146
- throw new Error(`${typeName} 需要至少一个输入路径`);
147
- }
148
- const resolvedList = list.map((item, index) => {
149
- if (typeof item !== 'string' || !item.trim()) {
150
- throw new Error(`第 ${index + 1} 个输入路径无效`);
151
- }
152
- const resolved = resolveWorkspacePath(workspaceRoot, item.trim());
153
- if (!fs.existsSync(resolved)) {
154
- throw new Error(`输入文件不存在: ${resolved}`);
155
- }
156
- return resolved;
157
- });
158
- return { list: resolvedList };
159
- }
160
-
161
- if (typeof input !== 'string' || !input.trim()) {
162
- throw new Error(`${typeName} 需要传入单个输入文件路径`);
163
- }
164
- const resolved = resolveWorkspacePath(workspaceRoot, input.trim());
165
- if (!fs.existsSync(resolved)) {
166
- throw new Error(`输入文件不存在: ${resolved}`);
167
- }
168
- return { file: resolved };
169
- };
170
-
171
- const formatInputPreview = (input) => {
172
- if (Array.isArray(input)) {
173
- return input.join(', ');
174
- }
175
- return typeof input === 'string' ? input : '';
176
- };
177
-
178
- const convertHandler = async ({ type, input, output } = {}, context = {}) => {
179
- const { workspaceRoot } = context;
180
- if (!workspaceRoot) {
181
- return '转换失败: 未找到工作区目录';
182
- }
183
-
184
- if (!type || typeof type !== 'string') {
185
- return '转换失败: type 参数无效';
186
- }
187
-
188
- const config = TYPE_META[type];
189
- if (!config) {
190
- return `转换失败: 不支持的 type ${type}`;
191
- }
192
-
193
- if (typeof output !== 'string' || !output.trim()) {
194
- return '转换失败: output 必须是非空字符串路径';
195
- }
196
-
197
- let outputPath;
198
- try {
199
- outputPath = resolveWorkspacePath(workspaceRoot, output.trim());
200
- } catch (error) {
201
- return `转换失败: ${error.message}`;
202
- }
203
-
204
- const pptxTypes = new Set(['html_to_pptx']);
205
- if (pptxTypes.has(type) && path.extname(outputPath).toLowerCase() !== '.pptx') {
206
- return `转换失败: ${type} 的 output 必须是 .pptx 文件`;
207
- }
208
-
209
- let inputResolution;
210
- try {
211
- inputResolution = resolveInputPaths(config.inputType, input, workspaceRoot, type);
212
- } catch (error) {
213
- return `转换失败: ${error.message}`;
214
- }
215
-
216
- const inputPreview = formatInputPreview(input);
217
-
218
- try {
219
- await ensureOutputLocation(outputPath, config.outputType);
220
-
221
- if (PYTHON_SCRIPTS[type]) {
222
- const scriptPath = path.join(PYTHON_SCRIPT_ROOT, PYTHON_SCRIPTS[type]);
223
- if (!fs.existsSync(scriptPath)) {
224
- throw new Error(`找不到 Python 脚本: ${PYTHON_SCRIPTS[type]}`);
225
- }
226
-
227
- let scriptArgs = [];
228
- if (config.inputType === 'list') {
229
- scriptArgs = [
230
- '--inputs',
231
- ...inputResolution.list.map(item => shellQuote(item)),
232
- '--output',
233
- shellQuote(outputPath)
234
- ];
235
- } else {
236
- scriptArgs = [
237
- '--input',
238
- shellQuote(inputResolution.file),
239
- config.outputType === 'directory' ? '--output-dir' : '--output',
240
- shellQuote(outputPath)
241
- ];
242
- }
243
- await runPythonScript(scriptPath, scriptArgs, workspaceRoot);
244
- } else {
245
- throw new Error('未知的转换模式');
246
- }
247
-
248
- return [
249
- '转换完成',
250
- `type: ${type}`,
251
- `input: ${inputPreview}`,
252
- `output: ${output}`
253
- ].join('\n');
254
- } catch (error) {
255
- const message = error?.message || String(error);
256
- return `转换失败: ${message}`;
257
- }
258
- };
259
-
260
- const schema = {
261
- type: 'function',
262
- function: {
263
- name: 'convert',
264
- description: [
265
- `统一文件转换工具。type 支持:${TYPE_ENUM.join('、')}。`,
266
- '所有路径都是指相对项目根目录的路径,例如:src/index.md、README.md、src/outputs等。'
267
- ].join(' '),
268
- parameters: {
269
- type: 'object',
270
- properties: {
271
- type: {
272
- type: 'string',
273
- enum: TYPE_ENUM,
274
- description: `转换类型,可选值: ${TYPE_ENUM.join(', ')}`
275
- },
276
- input: {
277
- description: '输入路径:当 inputType 为 list(如 html_to_pptx)时为路径列表,否则为单文件路径。',
278
- oneOf: [
279
- { type: 'string' },
280
- { type: 'array', items: { type: 'string' } }
281
- ]
282
- },
283
- output: {
284
- type: 'string',
285
- description: '输出路径:PDF->PNG 时为目录路径,其余类型为单文件路径。'
286
- }
287
- },
288
- required: ['type', 'input', 'output']
289
- }
290
- }
291
- };
292
-
293
- module.exports = {
294
- name: 'convert',
295
- schema,
296
- handler: convertHandler
297
- };
@@ -1,100 +0,0 @@
1
- """Convert a single HTML file to PNG via Playwright screenshot."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import sys
6
- from pathlib import Path
7
-
8
- from playwright.sync_api import sync_playwright
9
-
10
-
11
- MIN_WIDTH = 320
12
- MIN_HEIGHT = 240
13
- DEVICE_SCALE_FACTOR = 4
14
-
15
-
16
- def _find_target_element(page):
17
- body = page.query_selector('body')
18
- if body is None:
19
- raise ValueError('未找到 body 元素')
20
-
21
- top_level_elements = body.query_selector_all(':scope > *')
22
- if not top_level_elements:
23
- raise ValueError('body 中未找到可截图元素')
24
-
25
- # 单个最外层节点时直接使用该节点,否则使用整个 body
26
- return top_level_elements[0] if len(top_level_elements) == 1 else body
27
-
28
-
29
- def _measure_element(element):
30
- size = element.evaluate(
31
- """
32
- (el) => {
33
- const rect = el.getBoundingClientRect();
34
- const width = Math.max(rect.width || 0, el.scrollWidth || 0, el.offsetWidth || 0);
35
- const height = Math.max(rect.height || 0, el.scrollHeight || 0, el.offsetHeight || 0);
36
- return { width, height };
37
- }
38
- """
39
- )
40
- width = max(1, int(size.get('width') or 0))
41
- height = max(1, int(size.get('height') or 0))
42
- return width, height
43
-
44
-
45
- def convert_html_to_png(input_file: str | Path, output_file: str | Path, wait_ms: int = 1500) -> Path:
46
- """Convert a single HTML file to PNG image."""
47
- html_path = Path(input_file).expanduser().resolve()
48
- if not html_path.exists():
49
- raise FileNotFoundError(f'HTML 文件不存在: {html_path}')
50
-
51
- output_path = Path(output_file).expanduser().resolve()
52
- output_path.parent.mkdir(parents=True, exist_ok=True)
53
-
54
- with sync_playwright() as playwright:
55
- browser = playwright.chromium.launch(headless=True)
56
- context = browser.new_context(
57
- viewport={'width': MIN_WIDTH, 'height': MIN_HEIGHT},
58
- device_scale_factor=DEVICE_SCALE_FACTOR
59
- )
60
- page = context.new_page()
61
- try:
62
- file_url = html_path.as_uri()
63
- page.goto(file_url)
64
- page.wait_for_timeout(wait_ms)
65
-
66
- target = _find_target_element(page)
67
- element_width, element_height = _measure_element(target)
68
- viewport_width = max(MIN_WIDTH, element_width)
69
- viewport_height = max(MIN_HEIGHT, element_height)
70
-
71
- page.set_viewport_size({'width': viewport_width, 'height': viewport_height})
72
- page.wait_for_timeout(200)
73
- target.screenshot(path=str(output_path))
74
- finally:
75
- context.close()
76
- browser.close()
77
-
78
- return output_path
79
-
80
-
81
- def _parse_args() -> argparse.Namespace:
82
- parser = argparse.ArgumentParser(description='将 HTML 文件转换为 PNG 图片。')
83
- parser.add_argument('--input', required=True, help='HTML 文件路径')
84
- parser.add_argument('--output', required=True, help='输出 PNG 文件路径')
85
- parser.add_argument('--wait', type=int, default=1500, help='页面加载等待时间,单位毫秒,默认 1500')
86
- return parser.parse_args()
87
-
88
-
89
- def main() -> None:
90
- args = _parse_args()
91
- try:
92
- result = convert_html_to_png(args.input, args.output, wait_ms=args.wait)
93
- print(f'已生成 PNG: {result}')
94
- except Exception as error:
95
- print(f'转换失败: {error}', file=sys.stderr)
96
- sys.exit(1)
97
-
98
-
99
- if __name__ == '__main__':
100
- main()
@@ -1,163 +0,0 @@
1
- """Convert multiple HTML files into a single PPTX via Playwright screenshots."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import sys
6
- import tempfile
7
- from pathlib import Path
8
- from typing import Iterable, List
9
-
10
- from PIL import Image
11
- from playwright.sync_api import sync_playwright
12
- from pptx import Presentation
13
- from pptx.util import Inches
14
-
15
-
16
- SLIDE_SIZE_43 = (Inches(10), Inches(7.5)) # 4:3
17
- SLIDE_SIZE_169 = (Inches(13.3333), Inches(7.5)) # 16:9
18
- MIN_WIDTH = 320
19
- MIN_HEIGHT = 240
20
- DEVICE_SCALE_FACTOR = 4
21
-
22
-
23
- def _find_target_element(page):
24
- body = page.query_selector('body')
25
- if body is None:
26
- raise ValueError('未找到 body 元素')
27
-
28
- top_level_elements = body.query_selector_all(':scope > *')
29
- if not top_level_elements:
30
- raise ValueError('body 中未找到可截图元素')
31
-
32
- # 单个最外层节点时直接使用该节点,否则使用整个 body
33
- return top_level_elements[0] if len(top_level_elements) == 1 else body
34
-
35
-
36
- def _measure_element(element):
37
- size = element.evaluate(
38
- """
39
- (el) => {
40
- const rect = el.getBoundingClientRect();
41
- const width = Math.max(rect.width || 0, el.scrollWidth || 0, el.offsetWidth || 0);
42
- const height = Math.max(rect.height || 0, el.scrollHeight || 0, el.offsetHeight || 0);
43
- return { width, height };
44
- }
45
- """
46
- )
47
- width = max(1, int(size.get('width') or 0))
48
- height = max(1, int(size.get('height') or 0))
49
- return width, height
50
-
51
-
52
- def _choose_slide_size(aspect_ratios: Iterable[float]):
53
- ratios = [ratio for ratio in aspect_ratios if ratio > 0]
54
- if not ratios:
55
- return SLIDE_SIZE_43
56
- average_ratio = sum(ratios) / len(ratios)
57
- diff_43 = abs(average_ratio - (4 / 3))
58
- diff_169 = abs(average_ratio - (16 / 9))
59
- return SLIDE_SIZE_43 if diff_43 <= diff_169 else SLIDE_SIZE_169
60
-
61
-
62
- def capture_html_to_image(page, html_path: Path, target_path: Path, wait_ms: int) -> float:
63
- file_url = html_path.as_uri()
64
- page.goto(file_url)
65
- page.wait_for_timeout(wait_ms)
66
- target = _find_target_element(page)
67
- element_width, element_height = _measure_element(target)
68
- viewport_width = max(MIN_WIDTH, element_width)
69
- viewport_height = max(MIN_HEIGHT, element_height)
70
- page.set_viewport_size({'width': viewport_width, 'height': viewport_height})
71
- page.wait_for_timeout(200)
72
- target.screenshot(path=str(target_path))
73
- return element_width / element_height if element_height else 1.0
74
-
75
-
76
- def build_presentation(images: Iterable[Path], output_file: Path, slide_size) -> Path:
77
- slide_width, slide_height = slide_size
78
- presentation = Presentation()
79
- presentation.slide_width = slide_width
80
- presentation.slide_height = slide_height
81
-
82
- # Remove default slide if present
83
- while len(presentation.slides) > 0:
84
- slide_id = presentation.slides._sldIdLst[0].rId
85
- presentation.part.drop_rel(slide_id)
86
- del presentation.slides._sldIdLst[0]
87
-
88
- layout = presentation.slide_layouts[6]
89
- for image_path in images:
90
- with Image.open(image_path) as image:
91
- image_ratio = image.width / image.height if image.height else 1.0
92
- slide_ratio = slide_width / slide_height if slide_height else image_ratio
93
- if slide_ratio >= image_ratio:
94
- target_height = slide_height
95
- target_width = int(target_height * image_ratio)
96
- else:
97
- target_width = slide_width
98
- target_height = int(target_width / image_ratio)
99
- left = max(0, (slide_width - target_width) // 2)
100
- top = max(0, (slide_height - target_height) // 2)
101
- slide = presentation.slides.add_slide(layout)
102
- slide.shapes.add_picture(str(image_path), left, top, width=target_width, height=target_height)
103
-
104
- presentation.save(str(output_file))
105
- return output_file
106
-
107
-
108
- def convert_html_list_to_pptx(html_files: Iterable[str | Path], output_file: str | Path, wait_ms: int = 1500) -> Path:
109
- html_paths = [Path(item).expanduser().resolve() for item in html_files]
110
- if not html_paths:
111
- raise ValueError('请提供至少一个 HTML 文件')
112
- for path_item in html_paths:
113
- if not path_item.exists():
114
- raise FileNotFoundError(f'HTML 文件不存在: {path_item}')
115
-
116
- output_path = Path(output_file).expanduser().resolve()
117
- output_path.parent.mkdir(parents=True, exist_ok=True)
118
-
119
- screenshots: List[Path] = []
120
- aspect_ratios: List[float] = []
121
- with tempfile.TemporaryDirectory() as temp_dir:
122
- temp_root = Path(temp_dir)
123
- with sync_playwright() as playwright:
124
- browser = playwright.chromium.launch(headless=True)
125
- context = browser.new_context(
126
- viewport={'width': MIN_WIDTH, 'height': MIN_HEIGHT},
127
- device_scale_factor=DEVICE_SCALE_FACTOR
128
- )
129
- page = context.new_page()
130
- try:
131
- for index, html_path in enumerate(html_paths, start=1):
132
- target_file = temp_root / f'slide_{index:04d}.png'
133
- ratio = capture_html_to_image(page, html_path, target_file, wait_ms)
134
- screenshots.append(target_file)
135
- aspect_ratios.append(ratio)
136
- finally:
137
- context.close()
138
- browser.close()
139
- slide_size = _choose_slide_size(aspect_ratios)
140
- build_presentation(screenshots, output_path, slide_size)
141
- return output_path
142
-
143
-
144
- def _parse_args() -> argparse.Namespace:
145
- parser = argparse.ArgumentParser(description='将多个 HTML 文件合并到单个 PPTX 中。')
146
- parser.add_argument('--inputs', nargs='+', required=True, help='HTML 文件路径列表')
147
- parser.add_argument('--output', required=True, help='输出 PPTX 文件路径')
148
- parser.add_argument('--wait', type=int, default=1500, help='每个页面加载等待时间,单位毫秒,默认 1500')
149
- return parser.parse_args()
150
-
151
-
152
- def main() -> None:
153
- args = _parse_args()
154
- try:
155
- result = convert_html_list_to_pptx(args.inputs, args.output, wait_ms=args.wait)
156
- print(f'已生成 PPTX: {result}')
157
- except Exception as error:
158
- print(f'转换失败: {error}', file=sys.stderr)
159
- sys.exit(1)
160
-
161
-
162
- if __name__ == '__main__':
163
- main()
@@ -1,58 +0,0 @@
1
- """Convert PDF pages to PNG files."""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import sys
6
- from pathlib import Path
7
- from typing import List
8
-
9
- from pdf2image import convert_from_path
10
-
11
-
12
- def convert_pdf_to_png(input_file: str | Path, output_dir: str | Path, dpi: int = 300, fmt: str = 'png') -> List[Path]:
13
- """Convert a PDF into PNG images and return saved file paths."""
14
- input_path = Path(input_file).expanduser().resolve()
15
- if not input_path.exists():
16
- raise FileNotFoundError(f'PDF文件不存在: {input_path}')
17
-
18
- output_path = Path(output_dir).expanduser().resolve()
19
- output_path.mkdir(parents=True, exist_ok=True)
20
-
21
- format_lower = fmt.lower() or 'png'
22
- images = convert_from_path(str(input_path), dpi=dpi, fmt=format_lower)
23
-
24
- saved_files: List[Path] = []
25
- for index, image in enumerate(images, start=1):
26
- file_name = f'page_{index:04d}.{format_lower}'
27
- target = output_path / file_name
28
- image.save(str(target), format_lower.upper())
29
- saved_files.append(target)
30
-
31
- return saved_files
32
-
33
-
34
- def _parse_args() -> argparse.Namespace:
35
- parser = argparse.ArgumentParser(description='将 PDF 转换为 PNG 图片。')
36
- parser.add_argument('--input', required=True, help='待转换的 PDF 文件路径')
37
- parser.add_argument('--output-dir', required=True, help='保存图片的目录')
38
- parser.add_argument('--dpi', type=int, default=300, help='输出图片的 DPI,默认 300')
39
- parser.add_argument('--format', default='png', help='输出图片格式,默认 PNG')
40
- return parser.parse_args()
41
-
42
-
43
- def main() -> None:
44
- args = _parse_args()
45
- try:
46
- results = convert_pdf_to_png(args.input, args.output_dir, dpi=args.dpi, fmt=args.format)
47
- if not results:
48
- print('未生成任何图片')
49
- return
50
- print(f'成功生成 {len(results)} 张图片:')
51
- for path in results:
52
- print(path)
53
- except Exception as error:
54
- print(f'转换失败: {error}', file=sys.stderr)
55
- sys.exit(1)
56
-
57
- if __name__ == '__main__':
58
- main()
@@ -1,107 +0,0 @@
1
- """使用 LibreOffice 无头模式将 PPT/PPTX 直接转换为 PDF。"""
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import os
6
- import platform
7
- import shutil
8
- import subprocess
9
- import sys
10
- import tempfile
11
- from pathlib import Path
12
-
13
-
14
- def _detect_libreoffice() -> str:
15
- """寻找 LibreOffice 可执行文件路径。"""
16
- system = platform.system()
17
- candidates: list[str] = []
18
-
19
- if system == 'Darwin':
20
- candidates.append('/Applications/LibreOffice.app/Contents/MacOS/soffice')
21
- elif system == 'Windows':
22
- program_dirs = [
23
- os.environ.get('PROGRAMFILES', ''),
24
- os.environ.get('PROGRAMFILES(X86)', '')
25
- ]
26
- for base in program_dirs:
27
- if base:
28
- candidates.append(str(Path(base) / 'LibreOffice' / 'program' / 'soffice.exe'))
29
-
30
- # 通用命令放在末尾,优先尝试绝对路径
31
- candidates.extend(['soffice', 'libreoffice'])
32
-
33
- for candidate in candidates:
34
- candidate_path = Path(candidate)
35
- if candidate_path.exists():
36
- return str(candidate_path)
37
-
38
- resolved = shutil.which(candidate)
39
- if resolved:
40
- return resolved
41
-
42
- return ''
43
-
44
-
45
- def pptx_to_pdf(input_file: str | Path, output_file: str | Path) -> Path:
46
- pptx_path = Path(input_file).expanduser().resolve()
47
- if not pptx_path.exists():
48
- raise FileNotFoundError(f'PPT/PPTX 文件不存在: {pptx_path}')
49
- if pptx_path.is_dir():
50
- raise ValueError(f'输入路径不是文件: {pptx_path}')
51
-
52
- output_path = Path(output_file).expanduser().resolve()
53
- output_path.parent.mkdir(parents=True, exist_ok=True)
54
-
55
- libreoffice_cmd = _detect_libreoffice()
56
- if not libreoffice_cmd:
57
- raise RuntimeError('未找到 LibreOffice,请先安装(例如 apt install libreoffice 或从官网安装包)。')
58
-
59
- with tempfile.TemporaryDirectory(prefix='pptx_to_pdf_') as temp_dir:
60
- cmd = [
61
- libreoffice_cmd,
62
- '--headless',
63
- '--invisible',
64
- '--convert-to', 'pdf',
65
- '--outdir', temp_dir,
66
- str(pptx_path)
67
- ]
68
-
69
- result = subprocess.run(cmd, capture_output=True, text=True)
70
- if result.returncode != 0:
71
- message = result.stderr.strip() or result.stdout.strip() or 'LibreOffice 返回非零退出码'
72
- raise RuntimeError(f'LibreOffice 转换失败: {message}')
73
-
74
- generated = Path(temp_dir) / f'{pptx_path.stem}.pdf'
75
- if not generated.exists():
76
- pdf_candidates = list(Path(temp_dir).glob('*.pdf'))
77
- if len(pdf_candidates) == 1:
78
- generated = pdf_candidates[0]
79
- else:
80
- raise RuntimeError('未找到 LibreOffice 生成的 PDF 文件')
81
-
82
- if output_path.exists():
83
- output_path.unlink()
84
-
85
- shutil.move(str(generated), str(output_path))
86
- return output_path
87
-
88
-
89
- def _parse_args() -> argparse.Namespace:
90
- parser = argparse.ArgumentParser(description='将 PPT/PPTX 转换为 PDF(调用 LibreOffice 无头模式)。')
91
- parser.add_argument('--input', required=True, help='PPT/PPTX 文件路径')
92
- parser.add_argument('--output', required=True, help='输出 PDF 文件路径')
93
- return parser.parse_args()
94
-
95
-
96
- def main() -> None:
97
- args = _parse_args()
98
- try:
99
- result = pptx_to_pdf(args.input, args.output)
100
- print(f'已生成 PDF: {result}')
101
- except Exception as error:
102
- print(f'转换失败: {error}', file=sys.stderr)
103
- sys.exit(1)
104
-
105
-
106
- if __name__ == '__main__':
107
- main()