@ww_nero/mini-cli 1.0.60 → 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/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Mini CLI
2
+
3
+ 极简的 AI 命令行助手,支持工具调用、上下文压缩以及会话历史管理。
4
+
5
+ ## 快速开始
6
+ - 安装依赖:`npm install`
7
+ - 填写配置:首次运行会在 `~/.mini` 生成 `chat.json` 等文件,修改其中的 `key` 为你的接口密钥即可。
8
+ - 运行方式:`npm start` 或 `node bin/mini.js`,也可在全局安装后直接执行 `mini`。
9
+
10
+ ## 常用启动参数
11
+ - `mini -i "你好"`:启动后立即发送首条问题。
12
+ - `mini -m <model>`:指定默认模型(等同于会话内的 `/model <name>`)。
13
+ - `mini --resume`:启动时自动恢复当前工作目录最近一次对话。
14
+ - `mini --help`:查看全部参数说明。
15
+
16
+ ## 对话内快捷命令
17
+ - `/model`:查看或切换模型。
18
+ - `/clear`:清空上下文。
19
+ - `/resume [序号]`:列出或恢复历史会话。
20
+ - `/exit`:退出对话。
21
+
22
+ ## 会话历史
23
+ - 每个工作目录的历史记录保存在 `~/.mini/cli/<workspace-hash>/YYYYMMDDHHMMSS.json` 中。
24
+ - 可通过 `mini --resume` 自动载入最近一次会话,或在对话中使用 `/resume` 按序号恢复。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ww_nero/mini-cli",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
4
4
  "description": "极简的 AI 命令行助手",
5
5
  "bin": {
6
6
  "mini": "bin/mini.js"
package/src/chat.js CHANGED
@@ -173,6 +173,7 @@ const setLoadingCursorState = (isLoading) => {
173
173
  const startChatSession = async ({
174
174
  initialQuestion = '',
175
175
  initialModelName,
176
+ initialResume = false,
176
177
  cliOptions = []
177
178
  } = {}) => {
178
179
  const initResult = ensureConfigFiles();
@@ -423,6 +424,38 @@ const startChatSession = async ({
423
424
  console.log(chalk.gray('-------- 历史会话结束,可继续提问 --------'));
424
425
  };
425
426
 
427
+ const resumeHistoryByIndex = (index = 0, { auto = false } = {}) => {
428
+ const entries = listHistoryFiles(workspaceRoot);
429
+
430
+ if (entries.length === 0) {
431
+ const notice = auto
432
+ ? '未找到可恢复的历史记录,将开始新的会话。'
433
+ : '暂无历史记录。';
434
+ console.log(chalk.yellow(notice));
435
+ return false;
436
+ }
437
+
438
+ const target = entries[index];
439
+ if (!target) {
440
+ console.log(chalk.yellow(`序号 ${index + 1} 超出范围,当前共有 ${entries.length} 条历史记录。`));
441
+ return false;
442
+ }
443
+
444
+ const historyMessages = readHistoryMessages(target.filePath);
445
+ if (!Array.isArray(historyMessages) || historyMessages.length === 0) {
446
+ console.log(chalk.yellow('该历史记录为空或已损坏,无法恢复。'));
447
+ return false;
448
+ }
449
+
450
+ const refreshedPath = refreshHistoryFilePath(target.filePath, workspaceRoot) || target.filePath;
451
+ loadHistoryFromFile(refreshedPath, historyMessages);
452
+
453
+ const prefix = auto ? '已自动恢复最近一条历史记录' : `已切换到历史记录 #${index + 1}`;
454
+ console.log(chalk.green(`${prefix},可继续对话。`));
455
+ replayHistoryConversation(historyMessages);
456
+ return true;
457
+ };
458
+
426
459
  const handleResumeCommand = (rawArgs = '') => {
427
460
  const entries = listHistoryFiles(workspaceRoot);
428
461
  if (entries.length === 0) {
@@ -451,22 +484,7 @@ const startChatSession = async ({
451
484
  return;
452
485
  }
453
486
 
454
- const target = entries[numeric - 1];
455
- if (!target) {
456
- console.log(chalk.yellow(`序号 ${numeric} 超出范围,当前共有 ${entries.length} 条历史记录。`));
457
- return;
458
- }
459
-
460
- const historyMessages = readHistoryMessages(target.filePath);
461
- if (!Array.isArray(historyMessages) || historyMessages.length === 0) {
462
- console.log(chalk.yellow('该历史记录为空或已损坏,无法恢复。'));
463
- return;
464
- }
465
-
466
- const refreshedPath = refreshHistoryFilePath(target.filePath, workspaceRoot) || target.filePath;
467
- loadHistoryFromFile(refreshedPath, historyMessages);
468
- console.log(chalk.green(`已切换到历史记录 #${numeric},可继续对话。`));
469
- replayHistoryConversation(historyMessages);
487
+ resumeHistoryByIndex(numeric - 1);
470
488
  };
471
489
 
472
490
  console.log(chalk.blueBright('Mini CLI 已启动,随时输入问题开始提问。'));
@@ -499,6 +517,10 @@ const startChatSession = async ({
499
517
  console.log(chalk.gray(` ${cmd.value} - ${cmd.description}`));
500
518
  });
501
519
 
520
+ if (initialResume) {
521
+ resumeHistoryByIndex(0, { auto: true });
522
+ }
523
+
502
524
  const rl = readline.createInterface({
503
525
  input: process.stdin,
504
526
  output: process.stdout,
package/src/index.js CHANGED
@@ -1,38 +1,39 @@
1
- const { Command } = require('commander');
2
- const chalk = require('chalk');
3
-
4
- const pkg = require('../package.json');
5
- const { startChatSession } = require('./chat');
6
- const { getDefaultConfigPath } = require('./config');
7
- const { CLI_OPTIONS } = require('./utils/cliOptions');
8
-
9
- const run = async () => {
10
- const program = new Command();
11
-
12
- program
13
- .name('mini')
14
- .description('Mini CLI · 极简 AI 命令行助手')
15
- .version(pkg.version, '-v, --version')
16
- .addHelpText('after', `\n退出方法:输入 /exit 或按 Ctrl+C。\n配置模板会自动生成于 ${getDefaultConfigPath()}。`)
17
- .showHelpAfterError('(使用 mini --help 查看完整帮助)')
18
- .action(async (options) => {
19
- await startChatSession({
20
- initialQuestion: options.input || '',
21
- initialModelName: options.model,
22
- cliOptions: CLI_OPTIONS
23
- });
24
- });
25
-
26
- CLI_OPTIONS.forEach((option) => {
27
- program.option(option.flags, option.description);
28
- });
29
-
30
- try {
31
- await program.parseAsync(process.argv);
32
- } catch (error) {
33
- console.error(chalk.red(`Mini CLI 运行失败: ${error.message}`));
34
- process.exitCode = 1;
35
- }
36
- };
37
-
38
- run();
1
+ const { Command } = require('commander');
2
+ const chalk = require('chalk');
3
+
4
+ const pkg = require('../package.json');
5
+ const { startChatSession } = require('./chat');
6
+ const { getDefaultConfigPath } = require('./config');
7
+ const { CLI_OPTIONS } = require('./utils/cliOptions');
8
+
9
+ const run = async () => {
10
+ const program = new Command();
11
+
12
+ program
13
+ .name('mini')
14
+ .description('Mini CLI · 极简 AI 命令行助手')
15
+ .version(pkg.version, '-v, --version')
16
+ .addHelpText('after', `\n退出方法:输入 /exit 或按 Ctrl+C。\n配置模板会自动生成于 ${getDefaultConfigPath()}。`)
17
+ .showHelpAfterError('(使用 mini --help 查看完整帮助)')
18
+ .action(async (options) => {
19
+ await startChatSession({
20
+ initialQuestion: options.input || '',
21
+ initialModelName: options.model,
22
+ initialResume: Boolean(options.resume),
23
+ cliOptions: CLI_OPTIONS
24
+ });
25
+ });
26
+
27
+ CLI_OPTIONS.forEach((option) => {
28
+ program.option(option.flags, option.description);
29
+ });
30
+
31
+ try {
32
+ await program.parseAsync(process.argv);
33
+ } catch (error) {
34
+ console.error(chalk.red(`Mini CLI 运行失败: ${error.message}`));
35
+ process.exitCode = 1;
36
+ }
37
+ };
38
+
39
+ run();
@@ -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,6 +1,7 @@
1
1
  const CLI_OPTIONS = [
2
2
  { flags: '-i, --input <text>', description: '启动后先发送一条问题' },
3
- { flags: '-m, --model <name>', description: '指定启动模型名称(等同于运行 /model <name>)' }
3
+ { flags: '-m, --model <name>', description: '指定启动模型名称(等同于运行 /model <name>)' },
4
+ { flags: '-r, --resume', description: '启动时自动恢复最近一条历史会话' }
4
5
  ];
5
6
 
6
7
  module.exports = {
@@ -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()