iflow-mcp_mcp-server-okppt 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,931 @@
1
+ import zipfile
2
+ import os
3
+ import uuid
4
+ import shutil
5
+ from lxml import etree
6
+ from reportlab.graphics import renderPM
7
+ from svglib.svglib import svg2rlg
8
+ from pptx.util import Inches, Pt, Cm, Emu
9
+ from typing import Optional, Union, Tuple, List
10
+ import traceback
11
+ import sys
12
+ from io import StringIO
13
+ import datetime
14
+
15
+ # 定义命名空间
16
+ ns = {
17
+ 'p': "http://schemas.openxmlformats.org/presentationml/2006/main",
18
+ 'a': "http://schemas.openxmlformats.org/drawingml/2006/main",
19
+ 'r': "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
20
+ 'asvg': "http://schemas.microsoft.com/office/drawing/2016/SVG/main"
21
+ }
22
+
23
+ # 路径辅助函数
24
+ def get_base_dir():
25
+ """获取基础目录(服务器目录的父目录)"""
26
+ current_dir = os.path.dirname(os.path.abspath(__file__))
27
+ return os.path.dirname(current_dir)
28
+
29
+ def get_tmp_dir():
30
+ """获取临时文件目录,如果不存在则创建"""
31
+ tmp_dir = os.path.join(get_base_dir(), "tmp")
32
+ os.makedirs(tmp_dir, exist_ok=True)
33
+ return tmp_dir
34
+
35
+ def get_output_dir():
36
+ """获取输出文件目录,如果不存在则创建"""
37
+ output_dir = os.path.join(get_base_dir(), "output")
38
+ os.makedirs(output_dir, exist_ok=True)
39
+ return output_dir
40
+
41
+ def create_temp_dir():
42
+ """创建唯一的临时目录并返回路径"""
43
+ temp_dir = os.path.join(get_tmp_dir(), f"pptx_{uuid.uuid4().hex}")
44
+ os.makedirs(temp_dir, exist_ok=True)
45
+ return temp_dir
46
+
47
+ # 添加一个路径规范化函数
48
+ def normalize_path(path: str) -> str:
49
+ """
50
+ 规范化路径格式,处理不同的路径表示方法,
51
+ 包括正斜杠、反斜杠、多重斜杠等情况。
52
+
53
+ Args:
54
+ path: 需要规范化的路径
55
+
56
+ Returns:
57
+ 规范化后的路径
58
+ """
59
+ if not path:
60
+ return path
61
+
62
+ # 标准化路径,处理多重斜杠和混合斜杠的情况
63
+ normalized = os.path.normpath(path)
64
+
65
+ # 如果路径是绝对路径,转换为绝对路径
66
+ if os.path.isabs(normalized):
67
+ return normalized
68
+ else:
69
+ # 相对路径保持不变
70
+ return normalized
71
+
72
+ # 添加一个创建SVG文件的函数
73
+ def create_svg_file(svg_path: str, width: int = 100, height: int = 100, text: str = "自动生成的SVG") -> bool:
74
+ """
75
+ 创建一个简单的SVG文件。
76
+
77
+ Args:
78
+ svg_path: 要创建的SVG文件路径
79
+ width: SVG宽度(像素)
80
+ height: SVG高度(像素)
81
+ text: 要在SVG中显示的文本
82
+
83
+ Returns:
84
+ bool: 如果成功创建则返回True,否则返回False
85
+ """
86
+ try:
87
+ # 规范化路径
88
+ svg_path = normalize_path(svg_path)
89
+
90
+ # 获取文件目录并确保存在
91
+ svg_dir = os.path.dirname(svg_path)
92
+ if svg_dir and not os.path.exists(svg_dir):
93
+ os.makedirs(svg_dir, exist_ok=True)
94
+ print(f"已创建SVG目录: {svg_dir}")
95
+
96
+ # 创建一个简单的SVG
97
+ svg_content = (
98
+ f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">\n'
99
+ f' <rect width="100%" height="100%" fill="#f0f0f0"/>\n'
100
+ f' <text x="{width/2}" y="{height/2}" font-size="14" text-anchor="middle" fill="black">{text}</text>\n'
101
+ f'</svg>'
102
+ )
103
+
104
+ with open(svg_path, "w", encoding="utf-8") as f:
105
+ f.write(svg_content)
106
+
107
+ print(f"成功创建SVG文件: {svg_path}")
108
+ return True
109
+ except Exception as e:
110
+ print(f"创建SVG文件时出错: {e}")
111
+ traceback.print_exc()
112
+ return False
113
+
114
+ def save_svg_code_to_file(
115
+ svg_code: str,
116
+ output_path: str = "",# 空字符串表示自动创建,否则使用绝对路径
117
+ create_dirs: bool = True
118
+ ) -> Tuple[bool, str, str]:
119
+ """
120
+ 将SVG代码保存为SVG文件。
121
+
122
+ Args:
123
+ svg_code: SVG代码内容
124
+ output_path: 输出文件路径,如果未指定,则生成一个带有时间戳的文件名
125
+ create_dirs: 是否创建不存在的目录
126
+
127
+ Returns:
128
+ Tuple[bool, str, str]: (成功标志, 绝对路径, 错误消息)
129
+ """
130
+ try:
131
+ # 如果未提供输出路径,则生成一个带有时间戳的文件名
132
+ if not output_path or output_path == "":
133
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
134
+ output_path = os.path.join(get_output_dir(), f"svg_{timestamp}.svg")
135
+
136
+ # 确保路径是绝对路径
137
+ if not os.path.isabs(output_path):
138
+ output_path = os.path.abspath(output_path)
139
+
140
+ # 获取文件目录并确保存在
141
+ output_dir = os.path.dirname(output_path)
142
+ if output_dir and not os.path.exists(output_dir):
143
+ if create_dirs:
144
+ os.makedirs(output_dir, exist_ok=True)
145
+ print(f"已创建目录: {output_dir}")
146
+ else:
147
+ return False, "", f"目录 {output_dir} 不存在"
148
+
149
+ # 保存SVG代码到文件
150
+ with open(output_path, "w", encoding="utf-8") as f:
151
+ f.write(svg_code)
152
+
153
+ print(f"成功保存SVG代码到文件: {output_path}")
154
+ return True, output_path, ""
155
+ except Exception as e:
156
+ error_message = f"保存SVG代码到文件时出错: {e}"
157
+ print(error_message)
158
+ traceback.print_exc()
159
+ return False, "", error_message
160
+
161
+ # EMU 单位转换辅助函数
162
+ def to_emu(value: Union[Inches, Pt, Cm, Emu, int, float]) -> str:
163
+ """将pptx单位或数值(假定为Pt)转换为EMU字符串"""
164
+ # 先处理明确的 pptx.util 类型
165
+ if isinstance(value, Inches):
166
+ emu_val = value.emu
167
+ elif isinstance(value, Cm):
168
+ emu_val = value.emu
169
+ elif isinstance(value, Pt):
170
+ emu_val = value.emu
171
+ elif isinstance(value, Emu):
172
+ emu_val = value.emu
173
+ elif isinstance(value, (int, float)):
174
+ # 如果是纯数字,假设单位是 Pt
175
+ emu_val = Pt(value).emu
176
+ else:
177
+ raise TypeError(f"Unsupported unit type for EMU conversion: {type(value)}")
178
+
179
+ return str(int(emu_val)) # 确保返回整数的字符串形式
180
+
181
+ # 创建SVG文件的辅助函数
182
+ def create_svg_file(svg_path: str, width: int = 100, height: int = 100, text: str = "自动生成的SVG") -> bool:
183
+ """
184
+ 创建一个简单的SVG文件。
185
+
186
+ Args:
187
+ svg_path: 要创建的SVG文件路径
188
+ width: SVG宽度(像素)
189
+ height: SVG高度(像素)
190
+ text: 要在SVG中显示的文本
191
+
192
+ Returns:
193
+ bool: 如果成功创建则返回True,否则返回False
194
+ """
195
+ try:
196
+ # 获取文件目录并确保存在
197
+ svg_dir = os.path.dirname(svg_path)
198
+ if svg_dir and not os.path.exists(svg_dir):
199
+ os.makedirs(svg_dir, exist_ok=True)
200
+ print(f"已创建SVG目录: {svg_dir}")
201
+
202
+ # 创建一个简单的SVG
203
+ svg_content = (
204
+ f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">\n'
205
+ f' <rect width="100%" height="100%" fill="#f0f0f0"/>\n'
206
+ f' <text x="{width/2}" y="{height/2}" font-size="14" text-anchor="middle" fill="black">{text}</text>\n'
207
+ f'</svg>'
208
+ )
209
+
210
+ with open(svg_path, "w", encoding="utf-8") as f:
211
+ f.write(svg_content)
212
+
213
+ print(f"成功创建SVG文件: {svg_path}")
214
+ return True
215
+ except Exception as e:
216
+ print(f"创建SVG文件时出错: {e}")
217
+ traceback.print_exc()
218
+ return False
219
+
220
+
221
+ def insert_svg_to_pptx(
222
+ pptx_path: str,
223
+ svg_path: str,
224
+ slide_number: int = 1,
225
+ x: Optional[Union[Inches, Pt, Cm, Emu, int]] = None,
226
+ y: Optional[Union[Inches, Pt, Cm, Emu, int]] = None,
227
+ width: Optional[Union[Inches, Pt, Cm, Emu, int]] = None,
228
+ height: Optional[Union[Inches, Pt, Cm, Emu, int]] = None,
229
+ output_path: Optional[str] = None,
230
+ create_if_not_exists: bool = True
231
+ ) -> Union[bool, Tuple[bool, str]]:
232
+ """
233
+ 将 SVG 图像插入到 PPTX 文件指定幻灯片的指定位置。
234
+ **默认行为:** 如果不提供 `x`, `y`, `width`, `height` 参数,SVG 将被插入
235
+ 为幻灯片的全屏尺寸(位置 0,0,尺寸为幻灯片定义的宽度和高度)。
236
+
237
+ **覆盖默认行为:**
238
+ - 提供 `x` 和 `y` 来指定左上角位置 (使用 pptx.util 单位, 如 Inches(1), Pt(72))。
239
+ - 提供 `width` 和/或 `height` 来指定尺寸 (使用 pptx.util 单位)。
240
+ - 如果只提供了 `width` 而未提供 `height`,函数将尝试根据 SVG 的原始
241
+ 宽高比计算高度。如果无法获取宽高比,将使用幻灯片的默认高度。
242
+
243
+ **实现方式:**
244
+ 此函数通过直接操作 PPTX 的内部 XML 文件来实现 SVG 插入。
245
+ 它会自动将 SVG 转换为 PNG 作为备用图像(用于旧版Office或不支持SVG的查看器),
246
+ 并将矢量 SVG 和 PNG 备用图都嵌入到 PPTX 文件中。
247
+
248
+ Args:
249
+ pptx_path: 原始 PPTX 文件的路径。如果文件不存在且create_if_not_exists为True,将自动创建。
250
+ svg_path: 要插入的 SVG 文件的路径。
251
+ slide_number: 要插入 SVG 的目标幻灯片编号 (从 1 开始)。
252
+ x: 图片左上角的 X 坐标 (可选, 使用 pptx.util 单位)。默认为 0。
253
+ y: 图片左上角的 Y 坐标 (可选, 使用 pptx.util 单位)。默认为 0。
254
+ width: 图片的宽度 (可选, 使用 pptx.util 单位)。默认为幻灯片宽度。
255
+ height: 图片的高度 (可选, 使用 pptx.util 单位)。默认为幻灯片高度。
256
+ 如果只提供了 width 而未提供 height,将尝试根据 SVG 原始宽高比计算。
257
+ output_path: 输出 PPTX 文件的路径。如果为 None,将覆盖原始文件。
258
+ create_if_not_exists: 如果为True且PPTX文件不存在,将自动创建一个新文件。
259
+
260
+ Returns:
261
+ Union[bool, Tuple[bool, str]]: 如果成功插入则返回 (True, ""),否则返回 (False, error_details)。
262
+ 错误细节包含所有错误消息和堆栈跟踪。
263
+
264
+ Raises:
265
+ FileNotFoundError: 如果 pptx_path 不存在且 create_if_not_exists 为 False。
266
+ FileNotFoundError: 如果 svg_path 无效。
267
+ etree.XMLSyntaxError: 如果 PPTX 内部的 XML 文件损坏或格式错误。
268
+ Exception: 其他潜在错误,如图库依赖问题或文件权限问题。
269
+
270
+ Dependencies:
271
+ - lxml: 用于 XML 处理。
272
+ - reportlab 和 svglib: 用于将 SVG 转换为 PNG。
273
+ - python-pptx: 主要用于方便的单位转换 (Inches, Pt 等)。
274
+ """
275
+ # 创建错误消息收集器
276
+ error_log = []
277
+ def log_error(message):
278
+ """记录错误消息到错误日志列表"""
279
+ error_log.append(message)
280
+ print(message) # 仍然打印到控制台
281
+
282
+ # 创建临时目录 - 移到函数开始部分
283
+ temp_dir = create_temp_dir()
284
+
285
+ # 规范化并转换为绝对路径
286
+ pptx_path = normalize_path(pptx_path)
287
+ svg_path = normalize_path(svg_path)
288
+ if output_path:
289
+ output_path = normalize_path(output_path)
290
+
291
+ # 转换为绝对路径
292
+ if not os.path.isabs(pptx_path):
293
+ pptx_path = os.path.abspath(pptx_path)
294
+
295
+ if not os.path.isabs(svg_path):
296
+ svg_path = os.path.abspath(svg_path)
297
+
298
+ if output_path and not os.path.isabs(output_path):
299
+ output_path = os.path.abspath(output_path)
300
+
301
+ # 确保PPTX父目录存在
302
+ pptx_dir = os.path.dirname(pptx_path)
303
+ if not os.path.exists(pptx_dir):
304
+ try:
305
+ os.makedirs(pptx_dir, exist_ok=True)
306
+ log_error(f"创建目录: {pptx_dir}")
307
+ except Exception as e:
308
+ error_msg = f"创建目录 {pptx_dir} 时出错: {e}"
309
+ log_error(error_msg)
310
+ log_error(traceback.format_exc())
311
+ return False, "\n".join(error_log)
312
+
313
+ # 如果有输出路径,也确保其父目录存在
314
+ if output_path:
315
+ output_dir = os.path.dirname(output_path)
316
+ if not os.path.exists(output_dir):
317
+ try:
318
+ os.makedirs(output_dir, exist_ok=True)
319
+ log_error(f"创建输出目录: {output_dir}")
320
+ except Exception as e:
321
+ error_msg = f"创建输出目录 {output_dir} 时出错: {e}"
322
+ log_error(error_msg)
323
+ log_error(traceback.format_exc())
324
+ return False, "\n".join(error_log)
325
+
326
+ # 输入验证并自动创建PPTX(如果需要)
327
+ if not os.path.exists(pptx_path):
328
+ if create_if_not_exists:
329
+ try:
330
+ from pptx import Presentation
331
+ prs = Presentation()
332
+ # 设置为16:9尺寸
333
+ prs.slide_width = Inches(16)
334
+ prs.slide_height = Inches(9)
335
+
336
+ # 直接创建足够多的幻灯片
337
+ for i in range(slide_number):
338
+ prs.slides.add_slide(prs.slide_layouts[6]) # 6是空白幻灯片
339
+
340
+ prs.save(pptx_path)
341
+ log_error(f"已重新创建PPTX文件: {pptx_path},包含{slide_number}张幻灯片")
342
+ import time
343
+ time.sleep(0.5)
344
+ # 再次尝试解压
345
+ os.makedirs(temp_dir, exist_ok=True) # 创建临时目录
346
+ with zipfile.ZipFile(pptx_path, 'r') as zip_ref:
347
+ zip_ref.extractall(temp_dir)
348
+ except Exception as e:
349
+ error_msg = f"创建PPTX文件时出错: {e}"
350
+ log_error(error_msg)
351
+ log_error(traceback.format_exc())
352
+ return False, "\n".join(error_log)
353
+ else:
354
+ error_msg = f"PPTX file not found: {pptx_path}"
355
+ log_error(error_msg)
356
+ return False, "\n".join(error_log)
357
+
358
+ # 检查并确保SVG文件的父目录存在
359
+ svg_dir = os.path.dirname(svg_path)
360
+ if not os.path.exists(svg_dir):
361
+ try:
362
+ os.makedirs(svg_dir, exist_ok=True)
363
+ log_error(f"创建SVG目录: {svg_dir}")
364
+ except Exception as e:
365
+ error_msg = f"创建SVG目录 {svg_dir} 时出错: {e}"
366
+ log_error(error_msg)
367
+ log_error(traceback.format_exc())
368
+ return False, "\n".join(error_log)
369
+
370
+ # 检查PPTX文件是否至少有一张幻灯片,如果没有则添加一张
371
+ try:
372
+ from pptx import Presentation
373
+ prs = Presentation(pptx_path)
374
+
375
+ # 检查指定的slide_number是否超出现有幻灯片数量
376
+ if slide_number > len(prs.slides):
377
+ log_error(f"幻灯片编号{slide_number}超出现有幻灯片数量{len(prs.slides)},将自动添加缺失的幻灯片")
378
+ # 获取空白幻灯片布局
379
+ blank_slide_layout = prs.slide_layouts[6] # 6是空白幻灯片
380
+
381
+ # 计算需要添加的幻灯片数量
382
+ slides_to_add = slide_number - len(prs.slides)
383
+
384
+ # 循环添加所需数量的幻灯片
385
+ for _ in range(slides_to_add):
386
+ prs.slides.add_slide(blank_slide_layout)
387
+ log_error(f"已添加新的空白幻灯片,当前幻灯片数量: {len(prs.slides)}")
388
+
389
+ # 保存文件
390
+ prs.save(pptx_path)
391
+ # 给文件写入一些时间
392
+ import time
393
+ time.sleep(0.5)
394
+ elif len(prs.slides) == 0:
395
+ log_error(f"PPTX文件 {pptx_path} 没有幻灯片,添加一张空白幻灯片")
396
+ blank_slide_layout = prs.slide_layouts[6] # 6是空白幻灯片
397
+ slide = prs.slides.add_slide(blank_slide_layout)
398
+ prs.save(pptx_path)
399
+ # 给文件写入一些时间
400
+ import time
401
+ time.sleep(0.5)
402
+ except Exception as e:
403
+ error_msg = f"检查或添加幻灯片时出错: {e}"
404
+ log_error(error_msg)
405
+ # 如果是无效的PPTX文件,可能是因为文件损坏或不是PPTX格式
406
+ if "File is not a zip file" in str(e) or "document not found" in str(e) or "Package not found" in str(e):
407
+ log_error(f"PPTX文件 {pptx_path} 似乎不是有效的PowerPoint文件,尝试重新创建")
408
+ try:
409
+ # 确保目录存在
410
+ os.makedirs(os.path.dirname(pptx_path), exist_ok=True)
411
+
412
+ # 重新创建一个新的PPTX文件
413
+ prs = Presentation()
414
+ prs.slide_width = Inches(16)
415
+ prs.slide_height = Inches(9)
416
+ blank_slide_layout = prs.slide_layouts[6]
417
+
418
+ # 直接创建足够多的幻灯片
419
+ for i in range(slide_number):
420
+ prs.slides.add_slide(blank_slide_layout)
421
+
422
+ prs.save(pptx_path)
423
+ log_error(f"已重新创建PPTX文件: {pptx_path},包含{slide_number}张幻灯片")
424
+ except Exception as e2:
425
+ error_msg = f"重新创建PPTX文件时出错: {e2}"
426
+ log_error(error_msg)
427
+ log_error(traceback.format_exc())
428
+ return False, "\n".join(error_log)
429
+ else:
430
+ # 其他类型的错误
431
+ log_error(traceback.format_exc())
432
+ return False, "\n".join(error_log)
433
+
434
+ # 确保文件存在且大小不为0
435
+ if not os.path.exists(pptx_path) or os.path.getsize(pptx_path) == 0:
436
+ error_msg = f"错误:PPTX文件 {pptx_path} 不存在或大小为0"
437
+ log_error(error_msg)
438
+ return False, "\n".join(error_log)
439
+
440
+ if not os.path.exists(svg_path):
441
+ error_msg = f"SVG file not found: {svg_path}"
442
+ log_error(error_msg)
443
+ return False, "\n".join(error_log)
444
+
445
+ # 确定输出路径和创建临时目录
446
+ output_path = output_path or pptx_path
447
+ # 临时目录已在函数开始部分创建,确保目录存在
448
+ os.makedirs(temp_dir, exist_ok=True)
449
+
450
+ default_width_emu = None
451
+ default_height_emu = None
452
+
453
+ try:
454
+ # 解压 PPTX
455
+ try:
456
+ with zipfile.ZipFile(pptx_path, 'r') as zip_ref:
457
+ zip_ref.extractall(temp_dir)
458
+ except zipfile.BadZipFile as e:
459
+ error_msg = f"解压PPTX文件时出错: {e}"
460
+ log_error(error_msg)
461
+ log_error("尝试重新创建PPTX文件...")
462
+ # 创建一个新的PPTX文件并再次尝试
463
+ try:
464
+ prs = Presentation()
465
+ prs.slide_width = Inches(16)
466
+ prs.slide_height = Inches(9)
467
+ blank_slide_layout = prs.slide_layouts[6]
468
+
469
+ # 直接创建足够多的幻灯片
470
+ for i in range(slide_number):
471
+ prs.slides.add_slide(blank_slide_layout)
472
+
473
+ prs.save(pptx_path)
474
+ log_error(f"已重新创建PPTX文件: {pptx_path},包含{slide_number}张幻灯片")
475
+ import time
476
+ time.sleep(0.5)
477
+ # 再次尝试解压
478
+ with zipfile.ZipFile(pptx_path, 'r') as zip_ref:
479
+ zip_ref.extractall(temp_dir)
480
+ except Exception as e2:
481
+ error_msg = f"重新创建和解压PPTX文件时出错: {e2}"
482
+ log_error(error_msg)
483
+ log_error(traceback.format_exc())
484
+ return False, "\n".join(error_log)
485
+
486
+ # --- 读取 presentation.xml 获取默认幻灯片尺寸 ---
487
+ pres_path = os.path.join(temp_dir, "ppt", "presentation.xml")
488
+ if os.path.exists(pres_path):
489
+ try:
490
+ pres_tree = etree.parse(pres_path)
491
+ pres_root = pres_tree.getroot()
492
+ sldSz = pres_root.find('p:sldSz', namespaces=ns)
493
+ if sldSz is not None:
494
+ default_width_emu = sldSz.get('cx')
495
+ default_height_emu = sldSz.get('cy')
496
+ if not default_width_emu or not default_height_emu:
497
+ log_error("Warning: Could not read valid cx or cy from presentation.xml. Default size might be incorrect.")
498
+ default_width_emu = default_height_emu = None # Reset if invalid
499
+ else:
500
+ log_error("Warning: <p:sldSz> element not found in presentation.xml. Cannot determine default slide size.")
501
+ except etree.XMLSyntaxError as e:
502
+ log_error(f"Warning: Could not parse presentation.xml: {e}. Cannot determine default slide size.")
503
+ else:
504
+ log_error("Warning: presentation.xml not found. Cannot determine default slide size.")
505
+
506
+ # 如果无法获取默认尺寸,提供一个备用值(例如16:9宽屏的EMU值)
507
+ if default_width_emu is None or default_height_emu is None:
508
+ default_width_emu = "12192000" # 16 inches
509
+ default_height_emu = "6858000" # 9 inches
510
+ log_error(f"Warning: Using fallback default size: width={default_width_emu}, height={default_height_emu} EMU.")
511
+
512
+ # --- 处理和转换图像 ---
513
+ base_filename = f"image_{uuid.uuid4().hex}"
514
+ media_dir = os.path.join(temp_dir, "ppt", "media")
515
+ os.makedirs(media_dir, exist_ok=True)
516
+
517
+ svg_filename = f"{base_filename}.svg"
518
+ png_filename = f"{base_filename}.png"
519
+ svg_target_path = os.path.join(media_dir, svg_filename)
520
+ png_target_path = os.path.join(media_dir, png_filename)
521
+
522
+ # 复制 SVG
523
+ shutil.copy2(svg_path, svg_target_path)
524
+
525
+ # 转换 SVG -> PNG (使用reportlab和svglib替代cairosvg)
526
+ svg_width_px = None
527
+ svg_height_px = None
528
+ try:
529
+ # 使用svglib和reportlab替代cairosvg
530
+ drawing = svg2rlg(svg_path)
531
+ renderPM.drawToFile(drawing, png_target_path, fmt="PNG")
532
+ # 获取SVG尺寸信息
533
+ svg_width_px = drawing.width
534
+ svg_height_px = drawing.height
535
+ except Exception as e:
536
+ error_msg = f"Error converting SVG to PNG using reportlab/svglib: {e}"
537
+ log_error(error_msg)
538
+ log_error(traceback.format_exc())
539
+ if os.path.exists(png_target_path): os.remove(png_target_path)
540
+ if os.path.exists(svg_target_path): os.remove(svg_target_path)
541
+ return False, "\n".join(error_log)
542
+
543
+ # --- 计算最终尺寸 (EMU) ---
544
+ # 位置
545
+ x_emu = "0" if x is None else to_emu(x)
546
+ y_emu = "0" if y is None else to_emu(y)
547
+
548
+ # 宽度
549
+ width_emu = default_width_emu if width is None else to_emu(width)
550
+
551
+ # 高度
552
+ if height is None:
553
+ if width is None: # 完全默认,使用幻灯片高度
554
+ height_emu = default_height_emu
555
+ else: # 指定了宽度,尝试计算高度
556
+ if svg_width_px and svg_height_px and svg_width_px > 0 and svg_height_px > 0:
557
+ aspect_ratio = svg_height_px / svg_width_px
558
+ height_emu_val = int(int(width_emu) * aspect_ratio)
559
+ height_emu = str(height_emu_val)
560
+ log_error(f"Info: Calculated height based on SVG aspect ratio: {height_emu} EMU")
561
+ else:
562
+ log_error(f"Warning: Could not determine SVG aspect ratio. Using default height: {default_height_emu} EMU")
563
+ height_emu = default_height_emu
564
+ else: # 用户指定了高度
565
+ height_emu = to_emu(height)
566
+
567
+ # --- 修改关系文件 (.rels) ---
568
+ rels_path = os.path.join(temp_dir, "ppt", "slides", "_rels", f"slide{slide_number}.xml.rels")
569
+ if not os.path.exists(rels_path):
570
+ error_msg = f"Error: Relationship file not found for slide {slide_number}: {rels_path}"
571
+ log_error(error_msg)
572
+ if os.path.exists(png_target_path): os.remove(png_target_path)
573
+ if os.path.exists(svg_target_path): os.remove(svg_target_path)
574
+ return False, "\n".join(error_log)
575
+
576
+ parser = etree.XMLParser(remove_blank_text=True)
577
+ rels_tree = etree.parse(rels_path, parser)
578
+ rels_root = rels_tree.getroot()
579
+
580
+ # 查找最大的现有 rId
581
+ max_rid_num = 0
582
+ for rel in rels_root.findall('Relationship', namespaces=rels_root.nsmap):
583
+ rid = rel.get('Id')
584
+ if rid and rid.startswith('rId'):
585
+ try:
586
+ num = int(rid[3:])
587
+ if num > max_rid_num:
588
+ max_rid_num = num
589
+ except ValueError:
590
+ continue
591
+
592
+ # 生成新的 rId
593
+ rid_num_svg = max_rid_num + 1
594
+ rid_num_png = max_rid_num + 2
595
+ rId_svg = f"rId{rid_num_svg}"
596
+ rId_png = f"rId{rid_num_png}"
597
+
598
+ rel_ns = rels_root.nsmap.get(None)
599
+ rel_tag = f"{{{rel_ns}}}Relationship" if rel_ns else "Relationship"
600
+
601
+ # 创建 PNG 关系
602
+ png_rel = etree.Element(
603
+ rel_tag,
604
+ Id=rId_png,
605
+ Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
606
+ Target=f"../media/{png_filename}"
607
+ )
608
+ # 创建 SVG 关系
609
+ svg_rel = etree.Element(
610
+ rel_tag,
611
+ Id=rId_svg,
612
+ Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", # 仍然使用 image 类型
613
+ Target=f"../media/{svg_filename}"
614
+ )
615
+
616
+ rels_root.append(png_rel)
617
+ rels_root.append(svg_rel)
618
+
619
+ rels_tree.write(rels_path, xml_declaration=True, encoding='UTF-8', standalone="yes")
620
+
621
+ # --- 修改幻灯片文件 (slideX.xml) ---
622
+ slide_path = os.path.join(temp_dir, "ppt", "slides", f"slide{slide_number}.xml")
623
+ if not os.path.exists(slide_path):
624
+ error_msg = f"Error: Slide file not found: {slide_path}"
625
+ log_error(error_msg)
626
+ if os.path.exists(png_target_path): os.remove(png_target_path)
627
+ if os.path.exists(svg_target_path): os.remove(svg_target_path)
628
+ return False, "\n".join(error_log)
629
+
630
+ slide_tree = etree.parse(slide_path, parser)
631
+ slide_root = slide_tree.getroot()
632
+
633
+ # 查找或创建 spTree
634
+ spTree = slide_root.find('.//p:spTree', namespaces=ns)
635
+ if spTree is None:
636
+ cSld = slide_root.find('p:cSld', namespaces=ns)
637
+ if cSld is None:
638
+ error_msg = f"Error: Could not find <p:cSld> in slide {slide_number}."
639
+ log_error(error_msg)
640
+ if os.path.exists(png_target_path): os.remove(png_target_path)
641
+ if os.path.exists(svg_target_path): os.remove(svg_target_path)
642
+ return False, "\n".join(error_log)
643
+ spTree = etree.SubElement(cSld, etree.QName(ns['p'], 'spTree'))
644
+ max_nv_id = 0
645
+ for elem in slide_root.xpath('.//p:cNvPr[@id]|.//p:cNvGrpSpPr[@id]', namespaces=ns):
646
+ try:
647
+ nv_id = int(elem.get('id'))
648
+ if nv_id > max_nv_id:
649
+ max_nv_id = nv_id
650
+ except (ValueError, TypeError):
651
+ continue
652
+ group_shape_id = max_nv_id + 1
653
+ nvGrpSpPr = etree.SubElement(spTree, etree.QName(ns['p'], 'nvGrpSpPr'))
654
+ etree.SubElement(nvGrpSpPr, etree.QName(ns['p'], 'cNvPr'), id=str(group_shape_id), name="")
655
+ etree.SubElement(nvGrpSpPr, etree.QName(ns['p'], 'cNvGrpSpPr'))
656
+ etree.SubElement(nvGrpSpPr, etree.QName(ns['p'], 'nvPr'))
657
+ grpSpPr = etree.SubElement(spTree, etree.QName(ns['p'], 'grpSpPr'))
658
+ etree.SubElement(grpSpPr, etree.QName(ns['a'], 'xfrm'))
659
+
660
+ max_shape_id = 0
661
+ for elem in slide_root.xpath('.//p:cNvPr[@id]|.//p:cNvGrpSpPr[@id]|.//p:cNvSpPr[@id]', namespaces=ns):
662
+ try:
663
+ shape_id_val = int(elem.get('id'))
664
+ if shape_id_val > max_shape_id:
665
+ max_shape_id = shape_id_val
666
+ except (ValueError, TypeError):
667
+ continue
668
+ shape_id = max(max_shape_id + 1, 2) # 确保ID至少为2
669
+
670
+ # 构建 p:pic 元素
671
+ pic = etree.Element(etree.QName(ns['p'], 'pic'))
672
+
673
+ # nvPicPr
674
+ nvPicPr = etree.SubElement(pic, etree.QName(ns['p'], 'nvPicPr'))
675
+ cNvPr = etree.SubElement(nvPicPr, etree.QName(ns['p'], 'cNvPr'), id=str(shape_id), name=f"Vector {shape_id}") # 更新名称
676
+
677
+ # 添加 a16:creationId 扩展
678
+ extLst_cNvPr = etree.SubElement(cNvPr, etree.QName(ns['a'], 'extLst'))
679
+ ext_cNvPr = etree.SubElement(extLst_cNvPr, etree.QName(ns['a'], 'ext'), uri="{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}")
680
+ a16_ns = "http://schemas.microsoft.com/office/drawing/2014/main"
681
+ etree.register_namespace('a16', a16_ns)
682
+ etree.SubElement(ext_cNvPr, etree.QName(a16_ns, 'creationId'), id="{" + str(uuid.uuid4()).upper() + "}")
683
+
684
+ cNvPicPr = etree.SubElement(nvPicPr, etree.QName(ns['p'], 'cNvPicPr'))
685
+ etree.SubElement(cNvPicPr, etree.QName(ns['a'], 'picLocks'), noChangeAspect="1")
686
+ etree.SubElement(nvPicPr, etree.QName(ns['p'], 'nvPr'))
687
+
688
+ # blipFill
689
+ blipFill = etree.SubElement(pic, etree.QName(ns['p'], 'blipFill'))
690
+ blip = etree.SubElement(blipFill, etree.QName(ns['a'], 'blip'), {etree.QName(ns['r'], 'embed'): rId_png})
691
+
692
+ # 添加包含 svgBlip 的扩展列表
693
+ extLst_blip = etree.SubElement(blip, etree.QName(ns['a'], 'extLst'))
694
+ ext_blip_svg = etree.SubElement(extLst_blip, etree.QName(ns['a'], 'ext'), uri="{96DAC541-7B7A-43D3-8B79-37D633B846F1}")
695
+ asvg_ns_uri = ns['asvg']
696
+ etree.register_namespace('asvg', asvg_ns_uri)
697
+ etree.SubElement(ext_blip_svg, etree.QName(asvg_ns_uri, 'svgBlip'), {etree.QName(ns['r'], 'embed'): rId_svg})
698
+
699
+ stretch = etree.SubElement(blipFill, etree.QName(ns['a'], 'stretch'))
700
+ etree.SubElement(stretch, etree.QName(ns['a'], 'fillRect'))
701
+
702
+ # spPr (使用最终计算的 EMU 值)
703
+ spPr = etree.SubElement(pic, etree.QName(ns['p'], 'spPr'))
704
+ xfrm = etree.SubElement(spPr, etree.QName(ns['a'], 'xfrm'))
705
+ etree.SubElement(xfrm, etree.QName(ns['a'], 'off'), x=x_emu, y=y_emu)
706
+ etree.SubElement(xfrm, etree.QName(ns['a'], 'ext'), cx=width_emu, cy=height_emu)
707
+
708
+ prstGeom = etree.SubElement(spPr, etree.QName(ns['a'], 'prstGeom'), prst="rect")
709
+ etree.SubElement(prstGeom, etree.QName(ns['a'], 'avLst'))
710
+
711
+ # 将 pic 添加到 spTree
712
+ spTree.append(pic)
713
+
714
+ # 写回 slide 文件
715
+ slide_tree.write(slide_path, xml_declaration=True, encoding='UTF-8', standalone="yes")
716
+
717
+ # --- 修改 [Content_Types].xml ---
718
+ content_types_path = os.path.join(temp_dir, "[Content_Types].xml")
719
+ if not os.path.exists(content_types_path):
720
+ error_msg = f"Error: [Content_Types].xml not found at {content_types_path}"
721
+ log_error(error_msg)
722
+ if os.path.exists(png_target_path): os.remove(png_target_path)
723
+ if os.path.exists(svg_target_path): os.remove(svg_target_path)
724
+ return False, "\n".join(error_log)
725
+
726
+ content_types_tree = etree.parse(content_types_path, parser)
727
+ content_types_root = content_types_tree.getroot()
728
+ ct_ns = content_types_root.nsmap.get(None)
729
+ ct_tag = f"{{{ct_ns}}}Default" if ct_ns else "Default"
730
+
731
+ # 检查并添加 PNG 和 SVG Content Type (如果不存在)
732
+ png_exists = any(default.get('Extension') == 'png' for default in content_types_root.findall(ct_tag, namespaces=content_types_root.nsmap))
733
+ svg_exists = any(default.get('Extension') == 'svg' for default in content_types_root.findall(ct_tag, namespaces=content_types_root.nsmap))
734
+
735
+ added_new_type = False
736
+ if not png_exists:
737
+ log_error("Info: Adding PNG Content Type to [Content_Types].xml")
738
+ png_default = etree.Element(ct_tag, Extension="png", ContentType="image/png")
739
+ content_types_root.append(png_default)
740
+ added_new_type = True
741
+ if not svg_exists:
742
+ log_error("Info: Adding SVG Content Type to [Content_Types].xml")
743
+ svg_default = etree.Element(ct_tag, Extension="svg", ContentType="image/svg+xml")
744
+ content_types_root.append(svg_default)
745
+ added_new_type = True
746
+
747
+ if added_new_type:
748
+ content_types_tree.write(content_types_path, xml_declaration=True, encoding='UTF-8', standalone="yes")
749
+
750
+ # --- 重新打包 PPTX ---
751
+ if os.path.exists(output_path):
752
+ os.remove(output_path)
753
+
754
+ with zipfile.ZipFile(output_path, 'w', compression=zipfile.ZIP_DEFLATED) as zip_out:
755
+ for root, _, files in os.walk(temp_dir):
756
+ for file in files:
757
+ file_path = os.path.join(root, file)
758
+ arcname = os.path.relpath(file_path, temp_dir)
759
+ zip_out.write(file_path, arcname)
760
+
761
+ return True, ""
762
+
763
+ except FileNotFoundError as e:
764
+ error_msg = f"File not found error: {e}"
765
+ log_error(error_msg)
766
+ log_error(traceback.format_exc())
767
+ return False, "\n".join(error_log)
768
+ except etree.XMLSyntaxError as e:
769
+ error_msg = f"XML parsing error: {e}"
770
+ log_error(error_msg)
771
+ log_error(traceback.format_exc())
772
+ return False, "\n".join(error_log)
773
+ except Exception as e:
774
+ error_msg = f"An unexpected error occurred: {e}"
775
+ log_error(error_msg)
776
+ log_error(traceback.format_exc())
777
+ return False, "\n".join(error_log)
778
+
779
+ finally:
780
+ # 清理临时目录
781
+ try:
782
+ shutil.rmtree(temp_dir, ignore_errors=True)
783
+ except Exception as e:
784
+ log_error(f"清理临时目录时出错: {e}")
785
+
786
+ def get_pptx_slide_count(pptx_path: str) -> Tuple[int, str]:
787
+ """
788
+ 获取PPTX文件中的幻灯片数量。
789
+
790
+ Args:
791
+ pptx_path: PPTX文件路径
792
+
793
+ Returns:
794
+ Tuple[int, str]: 返回(幻灯片数量, 错误信息)的元组。
795
+ 如果成功,错误信息为空字符串。
796
+ 如果失败,幻灯片数量为0,错误信息包含详细错误。
797
+ """
798
+ error_message = ""
799
+
800
+ try:
801
+ # 规范化路径
802
+ pptx_path = normalize_path(pptx_path)
803
+
804
+ # 检查文件是否存在
805
+ if not os.path.exists(pptx_path):
806
+ return 0, f"文件不存在: {pptx_path}"
807
+
808
+ # 使用python-pptx库打开文件并获取幻灯片数量
809
+ from pptx import Presentation
810
+ prs = Presentation(pptx_path)
811
+ return len(prs.slides), ""
812
+
813
+ except Exception as e:
814
+ error_trace = traceback.format_exc()
815
+ error_message = f"获取幻灯片数量时出错: {str(e)}\n{error_trace}"
816
+ return 0, error_message
817
+
818
+ # --- 测试代码 ---
819
+ if __name__ == '__main__':
820
+ import datetime
821
+
822
+ # 测试用例:使用用户提供的 svg_test.pptx 和 media 目录下的 SVG
823
+ input_pptx = "svg_test.pptx"
824
+
825
+ # 模拟pptx_path为空的情况
826
+ test_empty_path = False # 将此设为True来测试空路径处理
827
+ if test_empty_path:
828
+ print("\n--- 测试空路径处理 ---")
829
+ input_pptx = "" # 模拟空路径
830
+
831
+ # 检查pptx_path是否为空,如果为空则创建默认路径
832
+ if not input_pptx or input_pptx.strip() == "":
833
+ # 创建一个基于时间戳的默认文件名
834
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
835
+ input_pptx = f"presentation_{timestamp}.pptx"
836
+ print(f"未提供PPTX路径,将使用默认路径: {input_pptx}")
837
+
838
+ # 确保测试文件存在
839
+ if not os.path.exists(input_pptx):
840
+ print(f"Error: Test input file '{input_pptx}' not found in the workspace root.")
841
+ # 可选:如果需要,可以自动创建
842
+ from pptx import Presentation
843
+ prs = Presentation()
844
+ # 设置为16:9尺寸以便与默认备用尺寸匹配
845
+ prs.slide_width = Inches(16)
846
+ prs.slide_height = Inches(9)
847
+ # 添加一张幻灯片,确保slide1.xml和相关关系文件存在
848
+ blank_slide_layout = prs.slide_layouts[6] # 6是空白幻灯片
849
+ slide = prs.slides.add_slide(blank_slide_layout)
850
+ prs.save(input_pptx)
851
+ print(f"Created dummy file: {input_pptx} (16:9) with one slide")
852
+
853
+ svg_to_insert = "image2.svg" # 修改为当前目录下的SVG文件
854
+ if not os.path.exists(svg_to_insert):
855
+ print(f"Error: Test SVG file not found: {svg_to_insert}")
856
+ # 可以在这里添加创建虚拟 SVG 的逻辑(如果需要)
857
+ svg_content = (
858
+ '<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">\n'
859
+ ' <rect width="100%" height="100%" fill="orange"/>\n'
860
+ ' <text x="50" y="60" font-size="15" text-anchor="middle" fill="black">Test SVG</text>\n'
861
+ '</svg>'
862
+ )
863
+ try:
864
+ with open(svg_to_insert, "w") as f:
865
+ f.write(svg_content)
866
+ print(f"Created dummy SVG file for testing: {svg_to_insert}")
867
+ except Exception as e:
868
+ print(f"Could not create dummy SVG: {e}")
869
+
870
+ # --- 测试 1:指定尺寸和位置 --- (保持不变)
871
+ output_pptx_specific = "svg_test_output_specific.pptx"
872
+ print(f"\n--- Test 1: Inserting with specific size and position ---")
873
+ if os.path.exists(input_pptx) and os.path.exists(svg_to_insert):
874
+ success1, error_details1 = insert_svg_to_pptx(
875
+ pptx_path=input_pptx,
876
+ svg_path=svg_to_insert,
877
+ slide_number=3,
878
+ x=Inches(1),
879
+ y=Inches(1),
880
+ width=Inches(4),
881
+ height=Inches(3),
882
+ output_path=output_pptx_specific
883
+ )
884
+ if success1:
885
+ print(f"SVG inserted with specific size/pos successfully into '{output_pptx_specific}'")
886
+ else:
887
+ print("Failed to insert SVG with specific size/pos.")
888
+ print(error_details1)
889
+ else:
890
+ print("Skipping specific size test due to missing input files.")
891
+
892
+ # --- 测试 2:默认全屏插入 --- (新增)
893
+ output_pptx_fullscreen = "svg_test_output_fullscreen.pptx"
894
+ print(f"\n--- Test 2: Inserting with default full-screen size ---")
895
+ if os.path.exists(input_pptx) and os.path.exists(svg_to_insert):
896
+ success2, error_details2 = insert_svg_to_pptx(
897
+ pptx_path=input_pptx,
898
+ svg_path=svg_to_insert,
899
+ slide_number=1,
900
+ # x, y, width, height 使用默认值 (None)
901
+ output_path=output_pptx_fullscreen
902
+ )
903
+ if success2:
904
+ print(f"SVG inserted with default full-screen successfully into '{output_pptx_fullscreen}'")
905
+ else:
906
+ print("Failed to insert SVG with default full-screen.")
907
+ print(error_details2)
908
+ else:
909
+ print("Skipping full-screen test due to missing input files.")
910
+
911
+ # --- 测试 3:只指定宽度,高度自动计算 --- (新增)
912
+ output_pptx_autoheight = "svg_test_output_autoheight.pptx"
913
+ print(f"\n--- Test 3: Inserting with specific width, auto height ---")
914
+ if os.path.exists(input_pptx) and os.path.exists(svg_to_insert):
915
+ success3, error_details3 = insert_svg_to_pptx(
916
+ pptx_path=input_pptx,
917
+ svg_path=svg_to_insert,
918
+ slide_number=1,
919
+ x=Inches(0.5),
920
+ y=Inches(0.5),
921
+ width=Inches(5), # 指定宽度
922
+ # height 使用默认值 (None)
923
+ output_path=output_pptx_autoheight
924
+ )
925
+ if success3:
926
+ print(f"SVG inserted with auto height successfully into '{output_pptx_autoheight}'")
927
+ else:
928
+ print("Failed to insert SVG with auto height.")
929
+ print(error_details3)
930
+ else:
931
+ print("Skipping auto height test due to missing input files.")