lightpdf-aipdf-mcp 0.1.31__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,524 @@
1
+ """PDF文档编辑模块"""
2
+ import json
3
+ import os
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+ from typing import List, Optional, Dict, Any
7
+
8
+ import httpx
9
+
10
+ from .common import BaseResult, Logger, FileHandler, BaseApiClient
11
+ from .converter import InputFormat
12
+
13
+ class EditType(str, Enum):
14
+ """支持的PDF编辑操作类型"""
15
+ SPLIT = "split" # 拆分PDF
16
+ MERGE = "merge" # 合并PDF
17
+ ROTATE = "rotate" # 旋转PDF
18
+ COMPRESS = "compress" # 压缩PDF
19
+ ENCRYPT = "protect" # 加密PDF
20
+ DECRYPT = "unlock" # 解密PDF
21
+ ADD_WATERMARK = "watermark" # 添加水印
22
+
23
+ @dataclass
24
+ class EditResult(BaseResult):
25
+ """编辑结果数据类"""
26
+ pass
27
+
28
+ class Editor(BaseApiClient):
29
+ """PDF文档编辑器"""
30
+ def __init__(self, logger: Logger, file_handler: FileHandler):
31
+ super().__init__(logger, file_handler)
32
+ self.api_base_url = "https://techsz.aoscdn.com/api/tasks/document/pdfedit"
33
+
34
+ async def _validate_pdf_file(self, file_path: str) -> bool:
35
+ """验证文件是否为PDF格式
36
+
37
+ Args:
38
+ file_path: 文件路径
39
+
40
+ Returns:
41
+ bool: 如果是PDF文件则返回True,否则返回False
42
+ """
43
+ input_format = self.file_handler.get_input_format(file_path)
44
+ if input_format != InputFormat.PDF:
45
+ await self.logger.error(f"此功能仅支持PDF文件,当前文件格式为: {self.file_handler.get_file_extension(file_path)}")
46
+ return False
47
+ return True
48
+
49
+ async def _log_operation(self, operation: str, details: str = None):
50
+ """记录操作日志
51
+
52
+ Args:
53
+ operation: 操作描述
54
+ details: 详细信息(可选)
55
+ """
56
+ log_msg = f"正在{operation}"
57
+ if details:
58
+ log_msg += f"({details})"
59
+ log_msg += "..."
60
+ await self.logger.log("info", log_msg)
61
+
62
+ async def split_pdf(self, file_path: str, pages: str = "", password: Optional[str] = None, split_type: str = "page", merge_all: int = 1) -> EditResult:
63
+ """拆分PDF文件
64
+
65
+ Args:
66
+ file_path: 要拆分的PDF文件路径
67
+ pages: 拆分页面规则,例如 "1,3,5-7" 表示提取第1,3,5,6,7页,""表示所有页面,默认为""
68
+ password: 文档密码,如果文档受密码保护,则需要提供(可选)
69
+ split_type: 拆分类型,可选值: "every"=每页拆分为一个文件, "page"=指定页面规则拆分,默认为"page"
70
+ merge_all: 是否合并拆分后的文件,仅在split_type="page"时有效,0=不合并,1=合并,默认为1
71
+
72
+ Returns:
73
+ EditResult: 拆分结果
74
+ """
75
+ # 验证输入文件是否为PDF
76
+ if not await self._validate_pdf_file(file_path):
77
+ return EditResult(success=False, file_path=file_path, error_message="非PDF文件")
78
+
79
+ # 验证拆分类型
80
+ valid_split_types = {"every", "page"}
81
+ if split_type not in valid_split_types:
82
+ await self.logger.error(f"无效的拆分类型: {split_type}。有效值为: every, page")
83
+ return EditResult(success=False, file_path=file_path, error_message=f"无效的拆分类型: {split_type}。有效值为: every, page")
84
+
85
+ # 构建API参数
86
+ extra_params = {
87
+ "split_type": split_type
88
+ }
89
+
90
+ # 仅在page模式下设置pages和merge_all参数
91
+ if split_type == "page":
92
+ extra_params["pages"] = pages
93
+ extra_params["merge_all"] = merge_all
94
+
95
+ # 记录操作描述
96
+ operation_details = f"类型: {split_type}"
97
+ if split_type == "page":
98
+ operation_details += f", 页面: {pages}"
99
+ await self._log_operation("拆分PDF文件", operation_details)
100
+
101
+ # 调用edit_pdf方法处理API请求
102
+ return await self.edit_pdf(file_path, EditType.SPLIT, extra_params, password)
103
+
104
+ async def merge_pdfs(self, file_paths: List[str], password: Optional[str] = None) -> EditResult:
105
+ """合并多个PDF文件
106
+
107
+ Args:
108
+ file_paths: 要合并的PDF文件路径列表
109
+ password: 文档密码,如果文档受密码保护,则需要提供(可选)
110
+
111
+ Returns:
112
+ EditResult: 合并结果
113
+ """
114
+ if len(file_paths) < 2:
115
+ await self.logger.error("合并PDF至少需要两个文件")
116
+ return EditResult(success=False, file_path=','.join(file_paths), error_message="合并PDF至少需要两个文件")
117
+
118
+ # 验证所有文件是否都是PDF并且存在
119
+ for pdf_file in file_paths:
120
+ if not await self._validate_pdf_file(pdf_file):
121
+ return EditResult(success=False, file_path=pdf_file, error_message="非PDF文件")
122
+
123
+ if not (await self.file_handler.validate_file_exists(pdf_file))[0]:
124
+ return EditResult(success=False, file_path=pdf_file, error_message="文件不存在")
125
+
126
+ # 记录操作描述
127
+ await self._log_operation("合并PDF文件", f"{len(file_paths)} 个文件")
128
+
129
+ # 合并PDF需要特殊处理,因为涉及多个文件
130
+ async with httpx.AsyncClient(timeout=120.0) as client:
131
+ try:
132
+ # 创建合并任务
133
+ task_id = await self._create_merge_task(client, file_paths, password)
134
+
135
+ # 等待任务完成
136
+ download_url = await self._wait_for_task(client, task_id, "合并")
137
+
138
+ # 记录完成信息
139
+ await self.logger.log("info", "PDF合并完成。可通过下载链接获取结果文件。")
140
+
141
+ return EditResult(
142
+ success=True,
143
+ file_path=file_paths[0], # 使用第一个文件路径作为参考
144
+ error_message=None,
145
+ download_url=download_url
146
+ )
147
+
148
+ except Exception as e:
149
+ return EditResult(
150
+ success=False,
151
+ file_path=file_paths[0],
152
+ error_message=str(e),
153
+ download_url=None
154
+ )
155
+
156
+ async def rotate_pdf(self, file_path: str, angle: int, pages: str = "", password: Optional[str] = None) -> EditResult:
157
+ """旋转PDF文件的页面
158
+
159
+ Args:
160
+ file_path: 要旋转的PDF文件路径
161
+ angle: 旋转角度,可选值为90、180、270
162
+ pages: 指定要旋转的页面范围,例如 "1,3,5-7" 或 "" 表示所有页面
163
+ password: 文档密码,如果文档受密码保护,则需要提供(可选)
164
+
165
+ Returns:
166
+ EditResult: 旋转结果
167
+ """
168
+ # 验证输入文件是否为PDF
169
+ if not await self._validate_pdf_file(file_path):
170
+ return EditResult(success=False, file_path=file_path, error_message="非PDF文件")
171
+
172
+ # 验证旋转角度
173
+ valid_angles = {90, 180, 270}
174
+ if angle not in valid_angles:
175
+ await self.logger.error("无效的旋转角度。角度必须是: 90, 180, 270")
176
+ return EditResult(success=False, file_path=file_path, error_message="无效的旋转角度。角度必须是: 90, 180, 270")
177
+
178
+ # 构建API参数
179
+ extra_params = {
180
+ "angle": json.dumps({str(angle): pages})
181
+ }
182
+
183
+ # 记录操作描述
184
+ angle_desc = f"{angle}°" + (f": {pages}" if pages else ": 所有页面")
185
+ await self._log_operation("旋转PDF文件", f"参数: {angle_desc}")
186
+
187
+ # 调用edit_pdf方法处理API请求
188
+ return await self.edit_pdf(file_path, EditType.ROTATE, extra_params, password)
189
+
190
+ async def compress_pdf(self, file_path: str, image_quantity: int = 60, password: Optional[str] = None) -> EditResult:
191
+ """压缩PDF文件
192
+
193
+ Args:
194
+ file_path: 要压缩的PDF文件路径
195
+ image_quantity: 图片质量,范围1-100,默认为60
196
+ password: 文档密码,如果文档受密码保护,则需要提供(可选)
197
+
198
+ Returns:
199
+ EditResult: 压缩结果
200
+ """
201
+ # 验证输入文件是否为PDF
202
+ if not await self._validate_pdf_file(file_path):
203
+ return EditResult(success=False, file_path=file_path, error_message="非PDF文件")
204
+
205
+ # 验证图片质量
206
+ if not 1 <= image_quantity <= 100:
207
+ await self.logger.error(f"无效的图片质量: {image_quantity}。有效值范围为: 1-100")
208
+ return EditResult(success=False, file_path=file_path, error_message=f"无效的图片质量: {image_quantity}。有效值范围为: 1-100")
209
+
210
+ # 构建API参数
211
+ extra_params = {
212
+ "image_quantity": image_quantity
213
+ }
214
+
215
+ # 记录操作描述
216
+ await self._log_operation("压缩PDF文件", f"图片质量: {image_quantity}")
217
+
218
+ # 调用edit_pdf方法处理API请求
219
+ return await self.edit_pdf(file_path, EditType.COMPRESS, extra_params, password)
220
+
221
+ async def encrypt_pdf(self, file_path: str, password: str, original_password: Optional[str] = None) -> EditResult:
222
+ """加密PDF文件
223
+
224
+ Args:
225
+ file_path: 要加密的PDF文件路径
226
+ password: 设置的新密码(用于加密PDF)
227
+ original_password: 文档原密码,如果文档已受密码保护,则需要提供(可选)
228
+
229
+ 注意:
230
+ 根据API文档,加密操作需要通过password参数指定要设置的新密码。
231
+ 如果文档已经受密码保护,则使用original_password参数提供原密码进行解锁。
232
+
233
+ Returns:
234
+ EditResult: 加密结果
235
+ """
236
+ # 验证输入文件是否为PDF
237
+ if not await self._validate_pdf_file(file_path):
238
+ return EditResult(success=False, file_path=file_path, error_message="非PDF文件")
239
+
240
+ # 构建API参数
241
+ extra_params = {
242
+ "password": password # 设置的新密码
243
+ }
244
+
245
+ # 记录操作描述
246
+ await self._log_operation("加密PDF文件")
247
+
248
+ # 调用edit_pdf方法处理API请求
249
+ return await self.edit_pdf(file_path, EditType.ENCRYPT, extra_params, original_password)
250
+
251
+ async def decrypt_pdf(self, file_path: str, password: Optional[str] = None) -> EditResult:
252
+ """解密PDF文件
253
+
254
+ Args:
255
+ file_path: 要解密的PDF文件路径
256
+ password: 文档密码,如果文档受密码保护,则需要提供(必须提供正确的密码才能解密)
257
+
258
+ 注意:
259
+ 该方法调用API的unlock功能,需要提供正确的PDF密码才能成功解密。
260
+
261
+ Returns:
262
+ EditResult: 解密结果
263
+ """
264
+ # 验证输入文件是否为PDF
265
+ if not await self._validate_pdf_file(file_path):
266
+ return EditResult(success=False, file_path=file_path, error_message="非PDF文件")
267
+
268
+ # 记录操作描述
269
+ await self._log_operation("解密PDF文件")
270
+
271
+ # 调用edit_pdf方法处理API请求
272
+ return await self.edit_pdf(file_path, EditType.DECRYPT, {}, password)
273
+
274
+ async def add_watermark(
275
+ self,
276
+ file_path: str,
277
+ text: str,
278
+ position: str, # 必需参数:位置,如"top", "center", "diagonal"等
279
+ opacity: float = 0.5,
280
+ deg: str = "-45", # 直接使用字符串格式的角度
281
+ range: str = "", # 与API保持一致,使用range而非pages
282
+ layout: Optional[str] = None, # 可选参数: "on"/"under"
283
+ font_family: Optional[str] = None,
284
+ font_size: Optional[int] = None,
285
+ font_color: Optional[str] = None,
286
+ password: Optional[str] = None
287
+ ) -> EditResult:
288
+ """为PDF文件添加水印
289
+
290
+ Args:
291
+ file_path: 要添加水印的PDF文件路径
292
+ text: 水印文本内容
293
+ position: 水印位置,可选值:"topleft","top","topright","left","center",
294
+ "right","bottomleft","bottom","bottomright","diagonal","reverse-diagonal"
295
+ opacity: 透明度,0.0-1.0,默认为0.5
296
+ deg: 倾斜角度,字符串格式,如"-45",默认为"-45"
297
+ range: 指定页面范围,例如 "1,3,5-7" 或 "" 表示所有页面
298
+ layout: 布局位置,可选值:"on"(在内容上)/"under"(在内容下)
299
+ font_family: 字体
300
+ font_size: 字体大小
301
+ font_color: 字体颜色,如"#ff0000"表示红色
302
+ password: 文档密码,如果文档受密码保护,则需要提供(可选)
303
+
304
+ Returns:
305
+ EditResult: 添加水印结果
306
+ """
307
+ # 验证输入文件是否为PDF
308
+ if not await self._validate_pdf_file(file_path):
309
+ return EditResult(success=False, file_path=file_path, error_message="非PDF文件")
310
+
311
+ # 验证透明度
312
+ if not 0.0 <= opacity <= 1.0:
313
+ await self.logger.error(f"无效的透明度: {opacity}。有效值范围为: 0.0-1.0")
314
+ return EditResult(success=False, file_path=file_path, error_message=f"无效的透明度: {opacity}。有效值范围为: 0.0-1.0")
315
+
316
+ # 验证position参数
317
+ valid_positions = {"topleft", "top", "topright", "left", "center", "right",
318
+ "bottomleft", "bottom", "bottomright", "diagonal", "reverse-diagonal"}
319
+ if position not in valid_positions:
320
+ await self.logger.error(f"无效的位置: {position}")
321
+ return EditResult(success=False, file_path=file_path, error_message=f"无效的位置: {position}")
322
+
323
+ # 构建API参数
324
+ extra_params = {
325
+ "edit_type": "text", # 固定为文本水印
326
+ "text": text,
327
+ "position": position,
328
+ "opacity": opacity,
329
+ "deg": deg,
330
+ "range": range
331
+ }
332
+
333
+ # 添加可选参数
334
+ if layout:
335
+ extra_params["layout"] = layout
336
+ if font_family:
337
+ extra_params["font_family"] = font_family
338
+ if font_size:
339
+ extra_params["font_size"] = font_size
340
+ if font_color:
341
+ extra_params["font_color"] = font_color
342
+
343
+ # 记录操作描述
344
+ await self._log_operation("为PDF添加水印", f"文本: {text}, 位置: {position}, 透明度: {opacity}")
345
+
346
+ # 调用edit_pdf方法处理API请求
347
+ return await self.edit_pdf(file_path, EditType.ADD_WATERMARK, extra_params, password)
348
+
349
+ async def edit_pdf(self, file_path: str, edit_type: EditType, extra_params: Dict[str, Any] = None, password: Optional[str] = None) -> EditResult:
350
+ """编辑PDF文件
351
+
352
+ Args:
353
+ file_path: 要编辑的PDF文件路径
354
+ edit_type: 编辑操作类型
355
+ extra_params: 额外的API参数
356
+ password: 文档密码,如果文档受密码保护,则需要提供(可选)
357
+
358
+ 注意:
359
+ 1. 对于加密操作(protect),需要在extra_params中提供新密码
360
+ 2. 对于解密操作(unlock),需要提供正确的password参数
361
+ 3. 所有extra_params中的参数将直接传递给API
362
+
363
+ Returns:
364
+ EditResult: 编辑结果
365
+ """
366
+ if not self.api_key:
367
+ await self.logger.error("未找到API_KEY。请在客户端配置API_KEY环境变量。")
368
+ return EditResult(success=False, file_path=file_path, error_message="未找到API_KEY。请在客户端配置API_KEY环境变量。")
369
+
370
+ # 验证文件
371
+ file_exists_result = await self.file_handler.validate_file_exists(file_path)
372
+ if not file_exists_result[0]:
373
+ return EditResult(success=False, file_path=file_path, error_message="文件不存在")
374
+ is_url = file_exists_result[1]
375
+
376
+ async with httpx.AsyncClient(timeout=120.0) as client:
377
+ try:
378
+ # 初始化extra_params(如果为None)
379
+ if extra_params is None:
380
+ extra_params = {}
381
+
382
+ # 如果提供了密码,将其添加到extra_params
383
+ if password:
384
+ extra_params["password"] = password
385
+
386
+ # 创建编辑任务
387
+ task_id = await self._create_task(client, file_path, edit_type, is_url, extra_params)
388
+
389
+ # 等待任务完成
390
+ download_url = await self._wait_for_task(client, task_id, "编辑")
391
+
392
+ # 记录完成信息
393
+ await self.logger.log("info", "编辑完成。可通过下载链接获取结果文件。")
394
+
395
+ return EditResult(
396
+ success=True,
397
+ file_path=file_path,
398
+ error_message=None,
399
+ download_url=download_url
400
+ )
401
+
402
+ except Exception as e:
403
+ return EditResult(
404
+ success=False,
405
+ file_path=file_path,
406
+ error_message=str(e),
407
+ download_url=None
408
+ )
409
+
410
+ async def _create_task(self, client: httpx.AsyncClient, file_path: str, edit_type: EditType, is_url: bool, extra_params: Dict[str, Any] = None) -> str:
411
+ """创建编辑任务
412
+
413
+ Args:
414
+ client: HTTP客户端
415
+ file_path: 文件路径
416
+ edit_type: 编辑操作类型
417
+ is_url: 是否URL路径
418
+ extra_params: 额外API参数(可选)
419
+
420
+ Returns:
421
+ str: 任务ID
422
+ """
423
+ await self.logger.log("info", "正在提交PDF编辑任务...")
424
+
425
+ headers = {"X-API-KEY": self.api_key}
426
+ data = {"type": edit_type.value}
427
+
428
+ # 添加额外参数
429
+ if extra_params:
430
+ data.update(extra_params)
431
+
432
+ if is_url:
433
+ data["url"] = file_path
434
+ # 使用JSON方式时添加Content-Type
435
+ headers["Content-Type"] = "application/json"
436
+ response = await client.post(
437
+ self.api_base_url,
438
+ json=data,
439
+ headers=headers
440
+ )
441
+ else:
442
+ # 对于文件上传,使用表单方式,不需要添加Content-Type
443
+ with open(file_path, "rb") as f:
444
+ files = {"file": f}
445
+ response = await client.post(
446
+ self.api_base_url,
447
+ files=files,
448
+ data=data,
449
+ headers=headers
450
+ )
451
+
452
+ # 使用基类的方法处理API响应
453
+ return await self._handle_api_response(response, "创建任务")
454
+
455
+ async def _create_merge_task(self, client: httpx.AsyncClient, file_paths: List[str], password: Optional[str] = None) -> str:
456
+ """创建PDF合并任务
457
+
458
+ Args:
459
+ client: HTTP客户端
460
+ file_paths: 要合并的PDF文件路径列表
461
+ password: 文档密码,如果文档受密码保护,则需要提供(可选)
462
+
463
+ Returns:
464
+ str: 任务ID
465
+ """
466
+ await self.logger.log("info", "正在提交PDF合并任务...")
467
+
468
+ headers = {"X-API-KEY": self.api_key}
469
+ data = {"type": EditType.MERGE.value}
470
+
471
+ # 准备URL格式的输入
472
+ url_inputs = []
473
+
474
+ # 准备本地文件列表
475
+ local_files = []
476
+ files = {}
477
+
478
+ for i, file_path in enumerate(file_paths):
479
+ if self.file_handler.is_url(file_path):
480
+ # 对于URL,添加到inputs数组
481
+ input_item = {"url": file_path}
482
+ if password:
483
+ input_item["password"] = password
484
+ url_inputs.append(input_item)
485
+ else:
486
+ # 记录本地文件,需要使用form方式
487
+ local_files.append(file_path)
488
+
489
+ # 如果全部是URL输入,使用JSON方式
490
+ if url_inputs and not local_files:
491
+ data["inputs"] = url_inputs
492
+ # 使用JSON方式时添加Content-Type
493
+ headers["Content-Type"] = "application/json"
494
+ response = await client.post(
495
+ self.api_base_url,
496
+ json=data,
497
+ headers=headers
498
+ )
499
+ else:
500
+ # 如果有本地文件,使用form方式,不需要添加Content-Type
501
+ # 准备文件
502
+ for i, file_path in enumerate(local_files):
503
+ files[f"file{i+1}"] = open(file_path, "rb")
504
+
505
+ # 如果有URL输入,添加inputs参数
506
+ if url_inputs:
507
+ data["inputs"] = json.dumps(url_inputs)
508
+
509
+ try:
510
+ # 发送请求
511
+ response = await client.post(
512
+ self.api_base_url,
513
+ data=data,
514
+ files=files,
515
+ headers=headers
516
+ )
517
+
518
+ finally:
519
+ # 确保所有打开的文件都被关闭
520
+ for file_obj in files.values():
521
+ file_obj.close()
522
+
523
+ # 使用基类的方法处理API响应
524
+ return await self._handle_api_response(response, "创建合并任务")