diff --git a/.gitignore b/.gitignore index d5c61df8..8d51de05 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ dist/ *.egg-info __pycache__ +# Test files +tests/test_files/ + # PyInstaller *.spec *.manifest diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..3f4e9130 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "python.analysis.typeCheckingMode": "off", + "python.languageServer": "None", + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/ISSUES_AUDIT.md b/ISSUES_AUDIT.md new file mode 100644 index 00000000..55237c09 --- /dev/null +++ b/ISSUES_AUDIT.md @@ -0,0 +1,256 @@ +# python-office 代码审计报告 + +审计方式:多 agent 并行工作流(survey → 4 路并行查找 → 对抗式验证 → 用户视角缺口分析,共 42 个 agent), +对 `office/lib/**`、`office/api/**`、`office/compatibility.py`、`setup.cfg`、`tests/**` 做了逐行验证。 +所有条目均已通过独立的"对抗式验证"agent 复核(默认不信任发现者,要求验证者亲自读代码定位到具体行才能确认), +未通过验证的 6 条已剔除。以下按严重程度排列,每条给出文件、行号、具体触发场景,可直接据此实现修复。 + +--- + +## 已修复(本次会话完成) + +### `office/api/pdf.py` — `add_img_water()` 是完全的空操作(no-op) +- **文件**:`office/api/pdf.py:301-341` +- **问题**:该函数解析完弃用参数别名后,直接结束,**从未调用 `popdf.add_img_water(...)`**。对比同文件里其他所有函数(`pdf2docx`、`merge2pdf`、`del4pdf` 等)都以 `popdf.xxx(...)` 结尾,这个函数是唯一的例外。 +- **后果**:调用 `add_img_water(input_file=..., mark_file=..., output_file=...)` 静默返回 `None`,不生成任何文件,不抛异常,用户完全无法感知失败。 +- **修复**:补上委托调用,注意 `popdf.add_img_water` 的真实签名是旧式参数名 `(pdf_file_in, pdf_file_mark, pdf_file_out)`(已通过 `pip install popdf` 安装后用 `inspect.signature` 验证),不是新参数名: + ```python + popdf.add_img_water(pdf_file_in=input_file, pdf_file_mark=mark_file, pdf_file_out=output_file) + ``` +- **验证**:直接加载模块并调用,确认参数正确传递到 `popdf` 内部(`popdf` 自身另有一个独立 bug——`popdf.lib.pdf.add_watermark_service` 缺失 `pdf_add_watermark`,这是 `popdf` 包自己的问题,与本仓库无关,`popdf/__init__.py` 也已将 `add_img_water` 标记为 `__deprecated__`)。 +- **状态**:已在本地修改,未提交。 + +--- + +## 待修复:高严重度(崩溃 / 挂起 / 常规用法下出错) + +### 1. `office/lib/excel/SplitExcel.py:53` — 使用了未导入的 `tqdm` +```python +for r in tqdm(range(rows)): +``` +文件顶部只 `import os, xlrd, xlwt, openpyxl, datetime`,没有 `import tqdm`。任何调用 `process_xls`(即 `.xls` 文件走 `split_excel_by_column`)都会立刻 `NameError: name 'tqdm' is not defined`,包括文件自己 `__main__` 里的示例调用。 +**修复**:加 `from tqdm import tqdm`。 + +### 2. `office/lib/excel/SplitExcel.py:100` — 使用已被移除的 openpyxl API +```python +worksheet = workbook.get_sheet_by_name(worksheet_name) +``` +`get_sheet_by_name` 在 openpyxl 2.x 就已弃用,3.x 完全移除。只要调用 `split_excel_by_column(..., worksheet_name='Sheet2')`(函数签名明确支持的合法用法),就会 `AttributeError: 'Workbook' object has no attribute 'get_sheet_by_name'`。 +**修复**:改为 `workbook[worksheet_name]`。 + +### 3. `office/lib/image/eliminate_background.py:42-45` — 非 `.jpg`/`.png` 扩展名时 `r,g,b` 未定义 +```python +if src_img_path.endswith('.jpg'): + r, g, b = pix[...] +elif src_img_path.endswith('.png'): + r, g, b, _ = pix[...] +# 无 else 分支 +``` +没有 `bc_color` 且文件名是 `.jpeg`(常见的 JPEG 备用扩展名)或大写 `.JPG` 时(`.endswith` 大小写敏感),两个分支都不走,`r/g/b` 从未赋值,后续第 52-55 行使用时直接 `NameError`。 +**修复**:加 `else` 分支统一处理,或调用前先 `img.convert('RGBA')` 再取像素,不依赖扩展名字符串判断格式。 + +### 4. `office/lib/image/eliminate_background.py:43` — 非 RGB 模式 JPEG 导致解包错误 +```python +r, g, b = pix[int(width / 20), int(height / 20)] +``` +这行在 `img.convert("RGBA")`(第 47 行)**之前**执行,此时如果图片是灰度模式 `'L'`(单通道,合法的 JPEG),`pix[x,y]` 返回单个 int,解包报 `TypeError: cannot unpack non-iterable int object`;CMYK 模式则返回 4 元组报 `ValueError: too many values to unpack`。 +**修复**:取像素前先 `img.convert('RGB')` 规范化模式。 + +### 5. `office/lib/pdf/add_watermark_service.py:3` — import 已移除的 PyPDF2 旧 API,模块直接无法加载 +```python +from PyPDF2 import PdfFileWriter, PdfFileReader, PdfReader, PdfWriter +``` +`PdfFileWriter`/`PdfFileReader` 在 PyPDF2 3.0+ 完全移除;即使代码里只用到 `PdfReader`/`PdfWriter`,Python 的 `from X import a, b, c, d` 是原子操作,任何一个名字不存在就整行失败。**只要装的是现代 PyPDF2,这个模块连 import 都会报 `ImportError`**,导致整个模块(包括写得对的 `pdf_add_watermark`)完全不可用。 +**修复**:删掉未使用的 `PdfFileWriter, PdfFileReader`。 + +### 6. `office/lib/tools/pwd4wifi_service.py:80` 和 `:101` — 库函数里调用 `exit(0)` +```python +if interface.status() == 4: + print(f'连接成功!密码为:{pwd}') + exit(0) +``` +`wifi_password_crack()` 是普通函数,不在 `__main__` guard 内。`exit(0)` 抛 `SystemExit`(继承自 `BaseException`,外层 `except Exception` 捕不到)。如果任何 Web 服务/GUI 程序 import 这个模块并调用它,破解成功的那一刻会直接**杀死整个宿主进程**,而不是把密码返回给调用者。 +**修复**:把两处 `exit(0)` 改成 `return pwd`(并调整函数返回值设计,让调用者能拿到结果)。 + +### 7. `office/lib/tools/pwd4wifi_service.py:52` — 无超时的忙等待循环 +```python +while interface.status() == 4: + pass +``` +纯自旋等待,没有 `sleep`、没有超时。如果网卡状态因为驱动/虚拟环境等原因一直停在 4(已连接),会无限占满一个 CPU 核心且永不返回。 +**修复**:加 `time.sleep(0.1)` 和最大重试次数/超时时间。 + +### 8. `office/lib/tools/pwd4wifi_service.py:64-84` — pwd_list 全部试错后死循环 +```python +while True: + if pwd_list: + for pwd in pwd_list: + ... + print(f'{pwd_list}中,没有合适的密码') + # 没有 break,会再次回到 while True 顶部,重复同样的列表 +``` +提供的密码列表全部尝试失败后,没有 `break`/`return`,会带着同一份列表无限重试,函数永不返回。 +**修复**:for 循环结束后加 `break` 或 `return None`。 + +### 9. `office/lib/tools/weather_service.py:12-13` — 正则匹配失败时下标越界 +```python +weather = pat_weather.findall(content) +print(weather[0]) # 12 +print('更新时间:', up_time[0]) # 13 +``` +目标网页结构变化、URL 错误或被重定向时 `findall` 返回 `[]`,`[0]` 直接 `IndexError`,两处都没有任何保护。爬虫类代码这是最常见的失败模式,不是极端情况。 +**修复**:判空后再取,取不到时给出明确的错误信息而不是裸 `IndexError`。 + +### 10. `office/lib/decorator_utils/instruction_url.py:123` — 装饰器里字典漏配就整个函数报错 +```python +if func_filename in instruction_file_dict.keys() and instruction_file_dict[func_filename][func.__name__]: +``` +新增一个被 `@instruction` 装饰的函数,但忘了在对应的 `xxx_dict` 里加它的 key,调用这个新函数直接 `KeyError`,**函数体根本不会执行**(这行代码在真正调用 `func(*args, **kwargs)` 之前)。 +**修复**:改成 `instruction_file_dict.get(func_filename, {}).get(func.__name__)`。 + +### 11. `office/compatibility.py:36` + 239 — import 期无保护的文件系统写入 +```python +# 第239行,模块级别,import 时无条件执行: +compatibility_checker = check_compatibility() +``` +内部调用链最终会 `mark_file.write_text(...)`,没有任何 try/except。只读 HOME 目录(常见于部分 CI 容器、锁定的企业 Windows 账户)下 `import office` 会直接抛 `PermissionError`/`OSError`,**导致整个包无法导入**,不仅仅是兼容性提示功能失效。 +**修复**:给这段包裹 try/except,写入失败时静默跳过提示,不应该影响正常导入。 + +### 12. `office/api/pdf.py:301`(历史bug,已修复,见上方"已修复"章节) + +--- + +## 待修复:中等严重度(边界情况崩溃 / 静默错误吞掉 / 资源泄漏) + +### 13. `office/lib/excel/SplitExcel.py:42-45, 95-98` — 裸 `except:` 吞掉所有异常 +两处(`.xls` 和 `.xlsx` 分支)都用裸 `except:` 把权限错误、文件损坏、内部解析 bug 全部压成同一句 `"文件读取异常:{filepath}"`,调用者无法区分"文件不存在"和"文件被占用"和"文件损坏"。 +**修复**:改成 `except Exception as e:`,并把 `e` 的信息带进返回消息。 + +### 14. `office/lib/excel/SplitExcel.py:96` — `openpyxl.load_workbook(..., read_only=True)` 从不 `close()` +read_only 模式下 openpyxl 官方文档要求显式 `close()` 释放文件句柄;批量处理很多文件时会逐渐耗尽文件描述符,最终 `OSError: Too many open files`。 +**修复**:用 `with` 或 `try/finally` 确保 `workbook.close()`。 + +### 15. `office/lib/pdf/add_watermark_service.py:47,62` — 输入流从不关闭 +`input_stream`/`mark_stream` 用 `open()` 打开后从未 `close()`,加密文件解密失败时的 `return False`(第58行)更是直接跳过后续所有清理。批量处理场景下会泄漏文件句柄。 +**修复**:用 `with open(...)` 或 `try/finally`。 + +### 16. `office/lib/pdf/add_watermark_service.py:67` — 水印 PDF 页数为 0 时下标越界 +```python +page.merge_page(pdf_watermark.pages[0]) +``` +没有检查水印 PDF 至少有一页,如果调用者传入一个 0 页的水印文件,直接 `IndexError`,没有任何友好提示。 +**修复**:调用前检查 `len(pdf_watermark.pages) > 0`。 + +### 17. `office/lib/image/add_watermark_service.py:41` — `set_opacity()` 假设图片一定有 alpha 通道 +```python +alpha = im.split()[3] +``` +这是一个导出的公共函数,文档没写"必须是 RGBA",直接传 RGB 图片进来会 `IndexError: tuple index out of range`。当前内部唯一调用点碰巧总是传 RGBA,但作为公共 API 缺少防护。 +**修复**:函数开头 `im = im.convert('RGBA')` 或在文档里明确注明前置条件并加断言。 + +### 18. `office/lib/image/add_watermark_service.py:138` — 批量水印失败时静默吞掉所有异常 +```python +except Exception as e: + print(new_name, "保存失败。错误信息:", e) +``` +`add_mark2file` 返回类型标注是 `-> None`,任何失败(字体文件缺失、磁盘满、权限错误)都只是 print,调用者拿到的返回值和成功时完全一样(都是 `None`),批处理脚本无法判断真的成功了没有。 +**修复**:至少改为返回 `bool` 表示成功/失败,或重新抛出异常让调用者决定怎么处理。 + +### 19. `office/lib/ppt/ppt2pdf_service.py:26-31` — PowerPoint COM 对象失败时永不 `Quit()` +```python +ppt = ppt_app.Presentations.Open(filename) +ppt.SaveAs(output_filename, 32) +ppt_app.Quit() # 前面任何一步抛异常,这行就永远不会执行 +``` +没有 try/except/finally。`SaveAs` 因为路径不存在等原因失败时,`ppt_app.Quit()` 被跳过,后台留下一个隐藏的、看不见窗口的 PowerPoint.exe 进程,Python 垃圾回收也不保证真正结束这个进程。批量处理失败文件会累积多个孤儿进程。 +**修复**:整个函数体包 try/finally,finally 里调用 `ppt_app.Quit()`。 + +### 20. `office/lib/tools/pwd4wifi_service.py:14, 48` — 硬编码取第一个无线网卡,无边界检查 +```python +interface = wifi.interfaces()[0] +``` +没有无线网卡(无头服务器、网卡被禁用)时 `wifi.interfaces()` 返回空列表,`[0]` 直接 `IndexError`。 +**修复**:判空后给出"未检测到无线网卡"的明确提示。 + +### 21. `office/lib/image/eliminate_background.py:36` — `_hex_to_rgb` 返回 `None` 时解包崩溃 +```python +r, g, b = _hex_to_rgb(bc_color) +``` +`_hex_to_rgb` 对格式不对的颜色字符串(比如漏了开头的 `#`)只是 print 警告然后 `return None`,这里直接解包 `None` 会报一个和真实原因毫无关系的 `TypeError: cannot unpack non-iterable NoneType object`。 +**修复**:`_hex_to_rgb` 改成抛 `ValueError` 并带上清晰的错误信息,而不是返回 `None` 再让调用方莫名其妙崩溃。 + +### 22. `office/api/finance.py:22` — 返回类型标注与实际返回值不符 +```python +def t0(...) -> float: + ... + return stock_returns # 实际是 Decimal 对象 +``` +函数内部全程用 `Decimal` 计算,标注却是 `float`。调用者如果用 `isinstance(result, float)` 判断或 `json.dumps({'profit': t0(...)})` 序列化,会直接 `TypeError: Object of type Decimal is not JSON serializable`。 +**修复**:函数末尾 `return float(stock_returns)`,或把标注改成 `-> Decimal`(如果希望保留精度,后者更合理,但要同步更新文档)。 + +### 23. `office/__init__.py` — `office/api/web.py` 被遗漏,未在包初始化时导入 +`office/__init__.py` 第 7-19 行导入了 email/excel/file/finance/image/pdf/ppt/tools/video/wechat/word/markdown/ocr,唯独漏了 `web`。`office/api/web.py` 里的 `url2ebook()` 实际存在且 `pospider` 依赖也已在 `setup.cfg` 声明,但 `import office; office.web.url2ebook(...)` 会报 `AttributeError: module 'office' has no attribute 'web'`,跟其他模块的使用方式不一致。 +**修复**:在 `office/__init__.py` 里加一行 `from office.api import web`。 + +--- + +## 待修复:低严重度 / 测试与流程问题 + +### 24. `tests/test_code/test_tools/` 缺少 `__init__.py` +`tests/test_code/test_tools.py`(文件)和 `tests/test_code/test_tools/`(目录)同名,Python 优先解析成模块而不是包,导致目录下的 `test_trans.py` 对 `unittest discover()` 完全不可见(`python -m unittest discover` 静默报 0 个测试,退出码 5)。已用隔离环境实测复现,加上 `__init__.py` 后确认修复。pytest 默认配置不受影响(其收集机制不依赖这个)。 +**修复**:新增 `tests/test_code/test_tools/__init__.py`(空文件即可)。 + +### 25. 测试文件之间工作目录(CWD)约定不一致,没有单一 CWD 能让所有测试都通过 +`test_excel.py`/`test_file.py`/`test_image.py`/`test_ruiming.py` 等用 `'../test_files/...'`(相对 `tests/test_code/`),而 `test_pdf.py` 用 `'./tests/test_files/pdf/...'`(相对仓库根目录)。两种约定互斥:CWD 设为 `tests/test_code/` 时 `test_pdf.py` 失败,CWD 设为仓库根目录时其他测试失败。且仓库里没有 `conftest.py`/`pytest.ini` 统一配置。 +**修复**:统一所有测试文件的路径基准(建议都改成基于 `os.path.dirname(__file__)` 的相对路径,不依赖进程 CWD),或补一个 `conftest.py` 固定 CWD。 + +### 26. `tests/test_code/test_ppt.py:22-25` — 已知会失败的测试没有标记 skip +```python +# todo: 文件打开有异常 +def test_ppt2img(self): + ppt2img(...) +``` +作者已经在注释里承认这个测试有已知异常,但没有用 `@unittest.skip("原因")` 标记,导致每次跑全量测试都会看到一个"预期内"的失败,和真正的新增回归无法区分。 +**修复**:加 `@unittest.skip("文件打开有异常,待修复")` 装饰器,或修复根本问题后去掉注释。 + +--- + +## 用户视角的功能缺口(按 影响 × 实现难度 排序,均可在本仓库单个 PR 内完成,不涉及外部 po* 包) + +### 1. 非 Windows 首次 `import office` 时兼容性提示可能打印两次,且无法关闭 +`office/compatibility.py` 的 `check_compatibility()` 在模块级别(import 时)执行一次,`office/__init__.py` 又显式调用了一次,非 Windows 用户第一次 `import office` 可能看到两遍完整的 rich 表格输出。没有任何环境变量或参数可以关闭它,唯一的"开关"是 `~/.python-office/first_run_mark` 标记文件,且这个文件的写入本身还有前面提到的 bug 11(无保护写入可能直接让 import 失败)。 +**建议修复**:去重两处调用(只保留 `__init__.py` 里的),加一个 `PYTHON_OFFICE_NO_BANNER` 环境变量在 `display_warning()` 开头判断并直接 return。 +**影响**:高——所有非 Windows 新用户的第一印象都会被这段意外的 stdout 输出干扰,在 CI/notebook 场景下更是噪音污染。 +**难度**:低——只涉及 2 个文件。 + +### 2. `except_dec` 装饰器统一吞异常、只 print、从不重新抛出 +`office/lib/utils/except_utils.py` 的 `except_dec` 捕获所有异常,打印一段装饰性文字后返回 `None`,从不 `raise`,也不走 `logging`/`loguru`(`loguru` 已经是 `setup.cfg` 里声明的依赖,但完全没用上)。调用者没法区分"函数本来就该返回 None"和"函数内部出错了"。 +**建议修复**:把 `print()` 换成 `logging.getLogger('office').exception(...)` 或用已声明的 `loguru`,并把原始异常重新抛出(或提供一个可选参数控制是否吞掉)。 +**影响**:高——这是个跨多个模块使用的公共装饰器,行为改一次全局受益,但需要先排查所有调用点确认没有依赖"吞异常返回 None"这个行为。 +**难度**:中高——单文件改动,但要审计所有 `@except_dec` 使用点。 + +### 3. 没有统一的异常体系,调用者无法用一个 `except` 兜住所有 python-office 自己抛出的错误 +`word.py` 的 `_load_poword()` 会抛一个写得很好的自定义 `ModuleNotFoundError`,但没有一个 `office.exceptions.OfficeError` 基类。用户想统一捕获"python-office 抛出的任何错误"时,必须枚举 `ModuleNotFoundError`/`ImportError`/`AttributeError` 等各种底层异常类型。 +**建议修复**:新增 `office/exceptions.py`,定义 `OfficeError(Exception)` 及若干子类(如 `MissingDependencyError(OfficeError)`),让现有的 `_load_poword` 等函数改用子类抛出(因为是继承关系,现有 `except ModuleNotFoundError` 的代码不受影响)。 +**影响**:中高——纯增量修改,向后兼容。 +**难度**:低——新增一个文件加几处 import 改动。 + +### 4. 没有 `py.typed` 标记,IDE 自动补全体验差,与"一行代码"的产品定位矛盾 +`office/` 下没有 `py.typed` 文件。按 PEP 561,没有这个标记的包,类型检查器(mypy/pyright)会把整个包当作无类型对待,即使代码里已经写了一部分类型标注。这个库自我定位是"零基础一行代码搞定办公自动化",恰恰是最依赖 IDE 参数提示的用户群体,却得不到任何补全支持。 +**建议修复**:新增空文件 `office/py.typed`,在 `setup.cfg` 的 `package_data`/`include_package_data` 里注册,同时把 `office/api/*.py` 里已经部分标注类型的函数补全返回类型标注(比如很多写了 `Returns: None` 文档但函数签名没有 `-> None`)。 +**影响**:高——直接影响目标用户群体的核心体验(IDE 自动补全)。 +**难度**:低——加文件 + 打包配置一行 + 机械性补标注,不涉及外部包。 + +### 5. 没有 CLI 入口,与"办公自动化工具"的产品定位不符 +`setup.cfg` 的 `[options]` 里没有 `[options.entry_points]`/`console_scripts` 配置。尽管官网宣传"73个即用型 Skill,一行代码搞定办公自动化",非开发者用户(README 自己列的"使用场景"里包括办公室职员、学生)仍然必须先写一个 `.py` 文件才能跑任何一个转换功能,无法直接在终端/批处理脚本里一条命令调用。 +**建议修复**:新增 `office/cli.py`,用标准库 `argparse`(避免引入新的重依赖)暴露最高频的几个 skill 作为子命令(如 `pdf2docx`、`docx2pdf`、图片压缩等),在 `setup.cfg` 新增 `[options.entry_points]` 注册 `office = office.cli:main`。 +**影响**:中——显著降低非开发者用户的上手门槛,但属于增量能力,不修复现有断裂的工作流。 +**难度**:中——需要决定覆盖哪些 skill(避免一次性想覆盖全部 73 个导致范围失控),建议先选 5-10 个高频功能,单个 PR 内可完成。 + +--- + +## 附:验证方法说明 + +- 每条"已确认"的问题都经过独立的对抗式验证 agent 复核:验证者被要求默认不信任发现者的结论,必须自己读取对应文件的确切行号,找到问题代码原文引用后才能给出 `confirmed: true`。 +- 有 6 条最初被发现者提出、但未通过对抗式验证的候选问题已被剔除,不在本文档中(多为对 `setup.cfg` 平台标记的误报,验证后确认标记是正确的)。 +- 涉及外部 PyPI 包(`poexcel`/`poword`/`popdf`/`poimage` 等,均不在本仓库内)内部的 bug 不在本次审计范围内,除非该 bug 是本仓库 wrapper 层代码直接导致的(如条目 1 的 `add_img_water`,问题出在 wrapper 层缺失调用,而不是 `popdf` 内部逻辑)。 diff --git a/PR-154-review.md b/PR-154-review.md new file mode 100644 index 00000000..73c7bfa0 --- /dev/null +++ b/PR-154-review.md @@ -0,0 +1,113 @@ +# PR #154 代码审查报告 — feat(word): 支持隐藏 doc2docx 转换进度条 + +> 审查方式:基于 PR diff 静态分析 + 本地仓库 `develop` 分支(HEAD `c8f263d`)交叉核对 +> 注:`gh` CLI 未登录 GitHub,本报告未发布为 PR 评论。如需发布,请先 `gh auth login`。 + +## 一、PR 元数据 + +| 项 | 内容 | +|----|------| +| **标题** | feat(word): 支持隐藏 doc2docx 转换进度条 | +| **作者** | [@forever-ivy](https://github.com/forever-ivy) | +| **源分支** | `forever-ivy:feat/doc2docx-progress-toggle` | +| **目标分支** | `CoderWanFeng:develop` | +| **变更文件** | 3 个(`office/api/word.py`、`skills/word/doc2docx/SKILL.md`、`tests/test_code/test_word_api_parameters.py`) | +| **提交数** | 1(67 additions, 3 deletions) | +| **修复 Issue** | #139 | +| **底层依赖** | `CoderWanFeng/poword#3`(**当前状态:OPEN,未合并**) | + +## 二、提交列表 + +- `feat(word): 支持隐藏 doc2docx 转换进度条`(1 commit) + +## 三、变更概览 + +| 文件 | 类型 | 关键改动 | +|------|------|----------| +| `office/api/word.py` | 修改 | `doc2docx` 新增 `show_progress: bool = True`;`show_progress=False` 时向 `poword.doc2docx` 转发 `show_progress=False`,否则不传该键保持兼容 | +| `skills/word/doc2docx/SKILL.md` | 修改 | 示例与参数表补充 `show_progress` | +| `tests/test_code/test_word_api_parameters.py` | 新增 | mock `poword`,验证默认调用与显式隐藏两种参数转发 | + +## 四、自动化发现 + +### 🟠 BLOCKER — PR 基于过时 develop,合入将冲突并覆盖 #157 的改进 +**文件**:`office/api/word.py`(`doc2docx`) + +本地 `develop` 当前 HEAD(`c8f263d`)历史包含提交 `5e8318b Support full output path in doc2docx (#157)`,该提交已为 `doc2docx` 带来两处关键改动,**而 PR #154 的 diff 上下文里完全没有这些**: + +1. `output_name` 自动解析(#157 引入): + ```python + if output_name is None and Path(output_path).suffix.lower() == ".docx": + output_file = Path(output_path) + output_path = str(output_file.parent) + output_name = output_file.name + ``` +2. 全模块已改为 `_load_poword()` **延迟加载**(仅在调用时 `import poword`),避免 Windows 专用依赖在 `import office` 时触发 `ModuleNotFoundError`: + ```python + poword = _load_poword() + poword.doc2docx(input_path=input_path, output_path=output_path, output_name=output_name) + ``` + +PR #154 的改动却是直接把调用替换为 `poword.doc2docx(**kwargs)`(无 `_load_poword()`、无 `output_name` 解析)。 + +**后果(若直接合入)**: +- ❌ 丢失 #157 的 `output_name` 自动解析能力; +- ❌ 把延迟加载退回为直接调用 `poword`,**回归「非 Windows 环境 `import office` 因缺 poword 而崩溃」的已知风险**(这正是之前引入 `_load_poword` 要修的)。 + +**要求**:**rebase 到最新 `develop` 后再合入**,将 `show_progress` 逻辑叠加在现有 `doc2docdocx`(含 `_load_poword()` + `output_name` 解析)之上,例如: +```python +def doc2docx(input_path, output_path=r'./', output_name=None, show_progress=True): + if output_name is None and Path(output_path).suffix.lower() == ".docx": + ... + poword = _load_poword() + kwargs = {'input_path': input_path, 'output_path': output_path, 'output_name': output_name} + if not show_progress: + kwargs['show_progress'] = False + poword.doc2docx(**kwargs) +``` + +### 🟠 BLOCKER — 底层依赖 `poword#3` 未合并发布 +**依赖链**:`office.word.doc2docx(show_progress=False)` → `poword.doc2docx(show_progress=False)`(需 poword#3 提供) + +- 经核查,poword#3 页面仍为 `wants to merge ... into main` 措辞,**表明其处于 OPEN 状态、尚未合并到 `poword/main`,更未发布到 PyPI**。 +- `setup.cfg` 对 poword 的约束为 `poword;platform_system=='Windows'`,**无版本下限**。 +- **后果**:当前用户装到的 poword(旧版)`doc2docx` 大概率**不接受 `show_progress` 关键字**。本 PR 仅在 `show_progress=False` 时转发该键,因此: + - 默认调用(`show_progress=True`,不转发该键)✅ 仍兼容所有版本; + - 显式 `show_progress=False` ⚠️ 在旧版 poword 上极可能抛 `TypeError: unexpected keyword argument 'show_progress'`(除非旧版用 `**kwargs` 吞参,未验证)。 +- **要求**:至少满足其一再合入:(a) 等待 poword#3 合并并发布,且在 `setup.cfg` 提高 poword 版本下限(如 `poword>=x.y.z;platform_system=='Windows'`);或 (b) 在 `word.py` 内对 `show_progress=False` 做防御性转发(如先探测 poword 是否支持该参数,或对 `TypeError` 降级)。 + +### 🔵 实现质量(正面) +- `show_progress` 转发逻辑正确:默认不传键(兼容旧调用),仅 `False` 时传 `show_progress=False`。✅ +- 用 `kwargs` 构建参数,清晰且易维护。✅ +- `docstring` 与 `skills/word/doc2docx/SKILL.md` 同步更新(参数表 + 示例),文档一致性好。✅ + +### 📊 测试覆盖 +**新增** `tests/test_code/test_word_api_parameters.py`:`load_word_api()` 动态加载 `word.py` 并 mock `poword`,验证: +- 默认调用 → `poword.doc2docx(input_path, output_path, output_name)`(不含 `show_progress`)✅ +- `show_progress=False` → 含 `show_progress=False` ✅ + +覆盖到位,但**有缺口**: +- 测试基于 PR 改动后的 `doc2docx` 签名,未涉及 `output_name` 解析 + `show_progress` 的组合(rebase 到含 #157 的 develop 后建议补充,防止回归 #157 行为); +- 白盒加载方式依赖 `word.py` 顶部无顶层 `import poword`(当前满足),rebase 后若引入新顶层 import 需同步调整 mock。 + +### 安全与高危调用 +- 无硬编码凭据 / 密钥泄露 ✅ +- 无 `panic()` / `os._exit()` 等高危调用 ✅ +- 测试内 `print` 仅出现在 `if __name__ == "__main__":`,非生产路径 ✅ + +## 五、整体裁决 + +### 🟡 NEEDS ATTENTION(含两个 BLOCKER,合入前必须处理) + +**合入前置条件(缺一不可)**: +1. **Rebase 到最新 `develop`**:解决与 #157 的冲突,保留 `output_name` 解析与 `_load_poword()` 延迟加载,将 `show_progress` 叠加其上(否则会覆盖已有功能并回归 import 崩溃)。 +2. **落实底层依赖**:等待 `poword#3` 合并发布,并在 `setup.cfg` 设 poword 版本下限;或在 `word.py` 内做 `show_progress=False` 的防御性转发。 + +**次要建议**: +- 补充 `output_name` + `show_progress` 组合的单测; +- PR 描述已声明依赖 poword#3,建议在描述中标注其当前 OPEN 状态与预计合入节奏,便于维护者排期。 + +**优先级**:① rebase(阻断性,最高)→ ② 底层依赖落地 / 防御性转发 → ③ 测试补充。 + +--- +*生成时间:2026-08-04 | 审查人:PR 代码审查专家(本地静态分析,未发布 GitHub 评论)* diff --git a/PR-162-review.md b/PR-162-review.md new file mode 100644 index 00000000..9e510a24 --- /dev/null +++ b/PR-162-review.md @@ -0,0 +1,105 @@ +# PR #162 代码审查报告 — Fix compatibility check import side effects + +> 审查方式:基于 PR diff 静态分析(仓库本地已克隆,已核对引用关系) +> 注:`gh` CLI 未登录 GitHub,本报告未发布为 PR 评论。如需发布,请先 `gh auth login`。 + +## 一、PR 元数据 + +| 项 | 内容 | +|----|------| +| **标题** | Fix compatibility check import side effects | +| **作者** | [@echo-wee](https://github.com/echo-wee) | +| **源分支** | `echo-wee:fix-compatibility-mark-file-import` | +| **目标分支** | `CoderWanFeng:develop` | +| **变更文件** | 2 个(`office/compatibility.py`、`tests/test_code/test_optional_imports.py`) | +| **提交数** | 1 | +| **摘要** | ① 保护首次运行标记文件写入,避免 HOME 不可写时 import 崩溃;② 移除模块级兼容性检查以消除 import-time 副作用;③ 新增不可写场景的回归测试 | + +## 二、提交列表 + +- `fix compatibility check import side effects`(1 commit,含 `compatibility.py` + 测试 2 文件改动) + +## 三、变更文件概览 + +| 文件 | 类型 | 关键改动 | +|------|------|----------| +| `office/compatibility.py` | 修改 | `_check_first_run()` 包 try/except OSError;删除模块级 `compatibility_checker = check_compatibility()`;补文件结尾换行 | +| `tests/test_code/test_optional_imports.py` | 修改 | 新增 `test_compatibility_check_does_not_fail_when_mark_file_cannot_be_written` | + +## 四、自动化发现 + +### 🟡 错误处理 — `_check_first_run` 保护(有效,符合目标①) +**文件**:`office/compatibility.py`(方法 `_check_first_run`,约 24–38 行) + +改动将 `mkdir` / `write_text` 包进 `try/except OSError`: +```python +try: + self.mark_file.parent.mkdir(exist_ok=True) + if not self.mark_file.exists(): + self.mark_file.write_text(f"First run on {platform.system()} at {platform.platform()}") + return True +except OSError: + # 兼容性提示不应影响主包导入;HOME 只读或不可写时跳过首次运行提示。 + return False +return False +``` +- `PermissionError` 是 `OSError` 子类,捕获范围正确;降级返回 `False`(当作非首次运行,静默跳过警告),不会阻断 `import office`。 +- **评价**:核心修复有效。因为真实调用路径是 `office/__init__.py:5` → `check_compatibility()` → `CrossPlatformCompatibility().__init__` → `_check_first_run()`,此保护对 `import office` 同样生效。 + +### 🟠 风险 — PR 描述与实际范围有偏差(建议澄清/进一步改进) +**文件**:`office/__init__.py:5` + `office/compatibility.py:239` + +PR 摘要称 *"Remove module-level compatibility checking to avoid import-time side effects"*,但本地核对发现: +- `office/__init__.py:5` **仍然存在** `compatibility_checker = check_compatibility()`。 +- `check_compatibility()`(compatibility.py:227)会:`CrossPlatformCompatibility()`(写标记文件)+ `display_warning()`(非 Windows 首次运行时打印 rich 表格)。 + +因此本次删除的只是 `compatibility.py` 自身的重复模块级调用(即 `ISSUES_AUDIT.md:221` 记录的"双重 rich 表格输出" bug 的第二次触发),**`import office` 的 import-time 副作用(写 `~/.python-office/first_run_mark` + 显示警告)在 `__init__.py:5` 仍保留**。 + +**影响**: +- 若目标仅是"修复 import 崩溃 + 去重",本 PR 已达成。 +- 若目标是"让 `import office` 完全无副作用",则未达成——`__init__.py:5` 仍在 import 时写文件并可能打印。 + +**建议**: +1. 在 PR 描述中澄清实际范围(消除 `compatibility.py` 的重复模块级检查,而非完全移除 import 副作用);或 +2. 若确实要彻底无副作用,应同时移除 `office/__init__.py:5` 的 `compatibility_checker = check_compatibility()`,改为延迟/按需调用(例如首次调用具体 API 时再检查)。 + +### 🔵 风格 / 小建议 +- **`compatibility.py` 结尾换行**:diff 补了缺失的 `\n`(`\ No newline at end of file` → 加换行),符合 POSIX 文本规范,👍。 +- **`build/lib/` 构建产物**:仓库中存在 `build/lib/office/compatibility.py` 与 `build/lib/office/__init__.py` 旧版副本(仍含模块级调用),属 setuptools 构建遗留物,不应纳入版本库。建议加入 `.gitignore` 或清理,避免与源码混淆。 + +### 📝 TODO(仓库卫生,非阻塞) +- `ISSUES_AUDIT.md:221` 记录了"双重 rich 表格输出"bug,本 PR 已修复该重复触发,建议在该审计文档中标注"已修复(PR #162)",避免后续复测重复扣分。 + +### 📊 测试覆盖 +**新增测试**:`test_compatibility_check_does_not_fail_when_mark_file_cannot_be_written` +```python +def test_compatibility_check_does_not_fail_when_mark_file_cannot_be_written(self): + with self._optional_import_test_environment(): + compatibility = importlib.import_module("office.compatibility") + with mock.patch.object(compatibility.Path, "mkdir", side_effect=PermissionError("readonly")): + checker = compatibility.CrossPlatformCompatibility() + self.assertFalse(checker.is_first_run) +``` +- 验证了 `mkdir` 抛 `PermissionError` 时实例化不崩溃且 `is_first_run == False`,命中 `except OSError` 分支。✅ +- **覆盖缺口**:测试直接 `import office.compatibility` 并实例化 `CrossPlatformCompatibility`,未覆盖真实的 `import office` 端到端路径(即 `__init__.py:5` → `check_compatibility()` → `display_warning()`)。由于逻辑等价(都走 `_check_first_run`),风险低,但建议补充一条 `import office` 在不可写场景下的冒烟测试,以锁住回归。 + +## 五、安全与高危调用检查 +- 无硬编码凭据 / 密钥泄露 ✅ +- 无 `panic()` / `os._exit()` / `process.exit()` 等高危调用 ✅ +- `print()` 仅出现在 `if __name__ == "__main__":` 测试块(compatibility.py:244-250),非生产路径 ✅ + +## 六、整体裁决 + +### 🟡 NEEDS ATTENTION + +**理由**:改动方向正确、核心修复(`_check_first_run` 的 OSError 保护)真实有效且安全,回归测试到位;但 PR 描述与实际生效范围存在偏差——真正的 import-time 副作用入口 `office/__init__.py:5` 未被触及。建议合入前: + +1. **澄清/对齐描述**:将"remove module-level compatibility checking to avoid import-time side effects"修正为"移除 `compatibility.py` 的重复模块级检查(消除双重提示),并保护标记文件写入"; +2. **(可选)彻底去副作用**:若项目确实要求 `import office` 零副作用,进一步移除 `office/__init__.py:5` 并改为延迟调用; +3. **补充** `import office` 端到端不可写冒烟测试; +4. **仓库卫生**:清理 `build/lib/` 构建产物、更新 `ISSUES_AUDIT.md` 标注。 + +**优先级排序**:① 描述澄清(必须,避免误解)→ ② 端到端测试(建议)→ ③ 彻底去副作用 / 仓库卫生(可选,后续跟进)。 + +--- +*生成时间:2026-08-04 | 审查人:PR 代码审查专家(本地静态分析,未发布 GitHub 评论)* diff --git a/README-CN.md b/README-CN.md new file mode 100644 index 00000000..9a69b46e --- /dev/null +++ b/README-CN.md @@ -0,0 +1,422 @@ +
+ +
+
+
+
+
+ 🚀 一行代码,搞定办公自动化
+
+ 73 个开箱即用的 Skills,覆盖 PDF、Word、Excel、PPT、邮件、微信、图片、视频等全场景
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 如果这个项目对你有帮助,欢迎 ⭐ Star 支持一下! +
diff --git a/README-EN.md b/README-EN.md deleted file mode 100644 index 8231ba73..00000000 --- a/README-EN.md +++ /dev/null @@ -1,191 +0,0 @@ -
-
-
-
-
- 🍬python for office -
-- 👉 http://www.python4office.cn/ 👈 -
- - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 👉 项目官网:https://www.python-office.com/ 👈
+ 🚀 Office automation, one line of code at a time
+
+ 73 ready-to-use Skills covering PDF, Word, Excel, PPT, Email, WeChat, Images, Video and more
- 👉 本开源项目的交流群 👈
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+> **python-office** is the most popular Python office-automation library in the Chinese-speaking world.
+> One library covers 90% of office scenarios: **PDF / Word / Excel / PPT conversion, image processing, video & audio, WeChat bot, email, file management, OCR, AI tools**...
+> Zero Python knowledge required — **every feature is one line of code**.
-
+
+
+
+
+ 👉 Join the open-source community · + WeChat the author 👈 +
+ +--- + ++ If this project helps you, a ⭐ Star is the best encouragement!
diff --git a/allpackages.txt b/allpackages.txt new file mode 100644 index 00000000..e07e8f83 --- /dev/null +++ b/allpackages.txt @@ -0,0 +1,189 @@ +about-time==4.2.1 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +akshare==1.18.9 +alive-progress==3.3.0 +annotated-types==0.7.0 +anyio==4.12.1 +attrs==25.4.0 +Automat==25.4.16 +backports.tarfile==1.2.0 +beautifulsoup4==4.14.3 +cachetools==6.2.4 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +colorama==0.4.6 +comtypes==1.4.14 +constantly==23.10.4 +contourpy==1.3.3 +cryptography==46.0.3 +cssselect==1.3.0 +curl_cffi==0.14.0 +cycler==0.12.1 +decorator==5.2.1 +defusedxml==0.7.1 +Deprecated==1.3.1 +distro==1.9.0 +dnspython==2.8.0 +docutils==0.22.4 +dukpy==0.5.0 +et_xmlfile==2.0.0 +Faker==40.1.0 +filelock==3.20.3 +fire==0.7.1 +fonttools==4.61.1 +frozenlist==1.8.0 +graphemeu==0.7.2 +h11==0.16.0 +html5lib==1.1 +httpcore==1.0.9 +httpx==0.28.1 +huaweicloudsdkcore==3.1.182 +huaweicloudsdkocr==3.1.182 +hyperlink==21.0.0 +id==1.5.0 +idna==3.11 +ImageIO==2.37.2 +imageio-ffmpeg==0.6.0 +importlib_metadata==8.7.1 +Incremental==24.11.0 +iniconfig==2.3.0 +itemadapter==0.13.1 +itemloaders==1.3.2 +jaraco.classes==3.4.0 +jaraco.context==6.0.2 +jaraco.functools==4.4.0 +jieba==0.42.1 +jiter==0.12.0 +jmespath==1.0.1 +jsonpath==0.82.2 +keyring==25.7.0 +kiwisolver==1.4.9 +libretranslatepy==2.1.1 +loguru==0.7.3 +lxml==6.0.2 +markdown-it-py==4.0.0 +matplotlib==3.10.8 +mdurl==0.1.2 +mini-racer==0.14.0 +more-itertools==10.8.0 +moviepy==2.2.1 +multidict==6.7.0 +nest-asyncio==1.6.0 +nh3==0.3.2 +numpy==2.2.6 +openai==2.15.0 +opencv-python==4.12.0.88 +opencv-python-headless==4.12.0.88 +openpyxl==3.1.5 +packaging==25.0 +pandas==2.3.3 +parsel==1.10.0 +pdf2docx==0.5.8 +pillow==11.3.0 +pluggy==1.6.0 +poai==0.0.12 +pocode==0.0.3 +poemail==0.1.0 +poexcel==0.0.22 +pofile==0.1.5 +poimage==0.0.22 +pomarkdown==0.0.3 +poocr==1.0.6 +popdf @ file:///D:/workplace/code/gitcode/popdf +poppt==0.1.1 +poprogress==0.0.2 +porobot==0.0.3 +pospider==0.0.1 +potx-cloud==0.0.7 +povideo==0.0.8 +poword==0.0.17 +proglog==0.1.12 +propcache==0.4.1 +Protego==0.5.0 +psutil==7.2.1 +pyasn1==0.6.1 +pyasn1_modules==0.4.2 +pycparser==2.23 +pydantic==2.12.5 +pydantic_core==2.41.5 +PyDispatcher==2.0.7 +PyEmail==0.0.1 +Pygments==2.19.2 +PyJWT==2.8.0 +pymongo==4.16.0 +PyMuPDF==1.26.7 +PyOfficeRobot==0.1.26 +pyOpenSSL==25.3.0 +pyparsing==3.3.1 +pypdf==6.6.0 +PyPDF2==3.0.1 +pypiwin32==223 +PySide6==6.10.1 +PySide6_Addons==6.10.1 +PySide6_Essentials==6.10.1 +pytest==9.0.2 +python-dateutil==2.9.0.post0 +python-docx==1.2.0 +python-dotenv==1.2.1 +python-office @ file:///D:/workplace/code/github/python-office +python-pptx==1.0.2 +pyttsx3==2.99 +pytz==2025.2 +pywifi==1.1.12 +pywin32==311 +pywin32-ctypes==0.2.3 +pywinauto==0.6.9 +PyYAML==6.0.3 +qrcode==8.2 +queuelib==1.8.0 +readme_renderer==44.0 +requests==2.32.5 +requests-file==3.0.1 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==14.2.0 +schedule==1.2.2 +Scrapy==2.14.0 +search4file==0.1.15 +service-identity==24.2.0 +shiboken6==6.10.1 +simplejson==3.20.2 +six==1.17.0 +sniffio==1.3.1 +soupsieve==2.8.1 +speedtest-cli==2.1.3 +tabulate==0.9.0 +tencentcloud-sdk-python==3.1.29 +tencentcloud-sdk-python-common==3.1.29 +tencentcloud-sdk-python-ocr==3.1.29 +termcolor==3.3.0 +tldextract==5.3.1 +toml==0.10.2 +tqdm==4.67.1 +translate==3.8.0 +twine==6.2.0 +Twisted==25.5.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.3 +uiautomation==2.0.29 +urllib3==2.6.3 +w3lib==2.3.1 +webencodings==0.5.1 +wftools==0.0.10 +win32_setctime==1.2.0 +wordcloud==1.9.5 +wrapt==2.0.1 +xlrd==1.2.0 +xlsxwriter==3.2.9 +xlwings==0.33.19 +xlwt==1.3.0 +yarl==1.22.0 +you-get==0.4.1743 +zhipuai==2.1.5.20250825 +zipp==3.23.0 +zope.interface==8.2 diff --git a/contributors/77x2 b/contributors/77x2 new file mode 100644 index 00000000..5b9fd2b8 --- /dev/null +++ b/contributors/77x2 @@ -0,0 +1 @@ +qinandenixiangnwom diff --git a/contributors/NWTNaldo/merge_docx_fix.py b/contributors/NWTNaldo/merge_docx_fix.py new file mode 100644 index 00000000..94884739 --- /dev/null +++ b/contributors/NWTNaldo/merge_docx_fix.py @@ -0,0 +1,63 @@ +import os +import re +from docx import Document +from docxcomposer import Composer + +def merge4docx(input_path: str, output_path: str, new_word_name: str = "merged.docx") -> str: + """ + 合并指定文件夹下的所有 .docx 文件 + + :param input_path: 输入 Word 文件的文件夹路径 + :param output_path: 合并后 Word 文件的输出文件夹路径 + :param new_word_name: 合并后的新文件名(如 "111.docx") + :return: 合并后文件的完整输出路径 + """ + # 1. 自动处理目录路径与创建 + if not os.path.exists(output_path): + os.makedirs(output_path, exist_ok=True) + + # 2. 规范化文件名(防止重复拼接 .docx 后缀) + if not new_word_name.lower().endswith('.docx'): + new_word_name = f"{new_word_name}.docx" + + final_output_file = os.path.join(output_path, new_word_name) + + # 3. 过滤临时文件(以 ~$ 开头)并仅保留 .docx 格式文件 + all_files = [ + f for f in os.listdir(input_path) + if f.lower().endswith('.docx') and not f.startswith('~$') + ] + + # 4. 自然排序算法(确保 1.docx, 2.docx, 10.docx 按常规数字顺序合并) + def natural_sort_key(filename: str): + return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', filename)] + + all_files.sort(key=natural_sort_key) + + if not all_files: + raise FileNotFoundError(f"在路径 '{input_path}' 下未找到有效的 .docx 文件") + + # 5. 排除输出文件本身(防止同目录合并时递归循环) + files_to_merge = [] + for file in all_files: + full_path = os.path.abspath(os.path.join(input_path, file)) + if full_path == os.path.abspath(final_output_file): + continue + files_to_merge.append(full_path) + + if not files_to_merge: + raise ValueError("没有可用于合并的目标文件") + + # 6. 以第一个文件为主文档初始化 Composer + master_doc = Document(files_to_merge[0]) + composer = Composer(master_doc) + + # 7. 依次合并后续文件并添加分页符 + for file_path in files_to_merge[1:]: + doc_to_append = Document(file_path) + master_doc.add_page_break() # 在文档末尾追加分页 + composer.append(doc_to_append) + + # 8. 保存合并后的文档 + composer.save(final_output_file) + return final_output_file diff --git a/contributors/david3832024-coder/README.md b/contributors/david3832024-coder/README.md new file mode 100644 index 00000000..0ee921e1 --- /dev/null +++ b/contributors/david3832024-coder/README.md @@ -0,0 +1,58 @@ +# Batch Image Converter + +This contribution adds a Pillow-based helper for batch image conversion. + +## Features + +- Convert JPG, PNG, BMP, WEBP, TIFF, and GIF files in batch. +- Optionally resize images to a fixed canvas. +- Preserve RGBA transparency for formats that support it. +- Fill resized images with white or transparent background. +- Create animated GIF files with configurable frame duration. + +## Install dependency + +```bash +pip install Pillow +``` + +## Command line usage + +Convert all images in a folder to PNG: + +```bash +python batch_image_converter.py convert ./images ./output --format png +``` + +Convert to WEBP and resize to an 800x600 canvas: + +```bash +python batch_image_converter.py convert ./images ./output --format webp --size 800x600 +``` + +Create an animated GIF: + +```bash +python batch_image_converter.py gif ./images ./output/demo.gif --duration 300 +``` + +## Python usage + +```python +from batch_image_converter import convert_images, create_gif + +convert_images( + input_path="./images", + output_dir="./output", + output_format="png", + size=(800, 600), + background="transparent", +) + +create_gif( + input_path="./images", + output_file="./output/demo.gif", + duration=300, + size=(800, 600), +) +``` diff --git a/contributors/david3832024-coder/batch_image_converter.py b/contributors/david3832024-coder/batch_image_converter.py new file mode 100644 index 00000000..02d9dcb4 --- /dev/null +++ b/contributors/david3832024-coder/batch_image_converter.py @@ -0,0 +1,244 @@ +"""Batch image conversion helper based on Pillow. + +Features: +- convert images between JPG, PNG, BMP, WEBP, TIFF, and GIF +- resize images with optional aspect-ratio padding +- preserve RGBA when the target format supports transparency +- compose multiple images into an animated GIF +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Iterable, List, Optional, Sequence, Tuple + +from PIL import Image, ImageSequence + + +SUPPORTED_FORMATS = {"jpg", "jpeg", "png", "bmp", "webp", "tiff", "gif"} +TRANSPARENT_FORMATS = {"png", "webp", "tiff", "gif"} + + +def _normalize_format(image_format: str) -> str: + image_format = image_format.lower().lstrip(".") + if image_format not in SUPPORTED_FORMATS: + raise ValueError(f"Unsupported image format: {image_format}") + return "jpeg" if image_format == "jpg" else image_format + + +def _parse_size(size: Optional[str]) -> Optional[Tuple[int, int]]: + if not size: + return None + width, height = size.lower().split("x", maxsplit=1) + return int(width), int(height) + + +def _background_color(mode: str, background: str): + if background == "transparent": + return (255, 255, 255, 0) if mode == "RGBA" else 0 + return "white" + + +def _iter_image_files(input_path: Path, include_subfolders: bool = False) -> List[Path]: + if input_path.is_file(): + return [input_path] + + pattern = "**/*" if include_subfolders else "*" + files = [ + file_path + for file_path in input_path.glob(pattern) + if file_path.is_file() + and file_path.suffix.lower().lstrip(".") in SUPPORTED_FORMATS + ] + return sorted(files) + + +def _flatten_alpha(image: Image.Image, background: str = "white") -> Image.Image: + if image.mode not in ("RGBA", "LA"): + return image.convert("RGB") + + rgba_image = image.convert("RGBA") + canvas = Image.new("RGBA", rgba_image.size, _background_color("RGBA", background)) + canvas.alpha_composite(rgba_image) + return canvas.convert("RGB") + + +def _resize_with_canvas( + image: Image.Image, + size: Optional[Tuple[int, int]], + background: str = "white", + keep_aspect_ratio: bool = True, +) -> Image.Image: + if size is None: + return image.copy() + + if not keep_aspect_ratio: + return image.resize(size, Image.LANCZOS) + + resized = image.copy() + resized.thumbnail(size, Image.LANCZOS) + mode = "RGBA" if resized.mode in ("RGBA", "LA") else "RGB" + canvas = Image.new(mode, size, _background_color(mode, background)) + left = (size[0] - resized.width) // 2 + top = (size[1] - resized.height) // 2 + + if resized.mode in ("RGBA", "LA"): + canvas.paste(resized.convert("RGBA"), (left, top), resized.convert("RGBA")) + else: + canvas.paste(resized.convert(mode), (left, top)) + + return canvas + + +def _prepare_image( + image: Image.Image, + output_format: str, + size: Optional[Tuple[int, int]] = None, + background: str = "white", + keep_aspect_ratio: bool = True, +) -> Image.Image: + first_frame = next(ImageSequence.Iterator(image)).copy() + first_frame = _resize_with_canvas( + first_frame, size=size, background=background, keep_aspect_ratio=keep_aspect_ratio + ) + + if output_format not in TRANSPARENT_FORMATS: + return _flatten_alpha(first_frame, background=background) + + if first_frame.mode not in ("RGBA", "RGB"): + first_frame = first_frame.convert("RGBA") + return first_frame + + +def convert_images( + input_path: str, + output_dir: str, + output_format: str = "png", + size: Optional[Tuple[int, int]] = None, + background: str = "white", + keep_aspect_ratio: bool = True, + include_subfolders: bool = False, + overwrite: bool = True, +) -> List[Path]: + """Batch-convert images and return generated file paths.""" + source = Path(input_path) + target_dir = Path(output_dir) + output_format = _normalize_format(output_format) + target_dir.mkdir(parents=True, exist_ok=True) + + output_files: List[Path] = [] + for image_file in _iter_image_files(source, include_subfolders=include_subfolders): + output_file = target_dir / f"{image_file.stem}.{output_format}" + if output_file.exists() and not overwrite: + continue + + with Image.open(image_file) as image: + converted = _prepare_image( + image, + output_format=output_format, + size=size, + background=background, + keep_aspect_ratio=keep_aspect_ratio, + ) + converted.save(output_file, format=output_format.upper()) + + output_files.append(output_file) + + return output_files + + +def create_gif( + input_path: str, + output_file: str, + duration: int = 500, + size: Optional[Tuple[int, int]] = None, + background: str = "white", + include_subfolders: bool = False, + loop: int = 0, +) -> Path: + """Create an animated GIF from images in a file or directory.""" + image_files = _iter_image_files(Path(input_path), include_subfolders=include_subfolders) + if not image_files: + raise ValueError("No images found for GIF creation.") + + frames = [] + for image_file in image_files: + with Image.open(image_file) as image: + frame = _prepare_image( + image, + output_format="gif", + size=size, + background=background, + keep_aspect_ratio=True, + ) + frames.append(frame.convert("P", palette=Image.ADAPTIVE)) + + gif_path = Path(output_file) + gif_path.parent.mkdir(parents=True, exist_ok=True) + frames[0].save( + gif_path, + save_all=True, + append_images=frames[1:], + duration=duration, + loop=loop, + ) + return gif_path + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Batch image converter based on Pillow.") + subparsers = parser.add_subparsers(dest="command", required=True) + + convert_parser = subparsers.add_parser("convert", help="Batch convert image format.") + convert_parser.add_argument("input_path", help="Input file or directory.") + convert_parser.add_argument("output_dir", help="Directory for converted images.") + convert_parser.add_argument("--format", default="png", help="Output format.") + convert_parser.add_argument("--size", help="Resize target, for example 800x600.") + convert_parser.add_argument("--background", default="white", choices=["white", "transparent"]) + convert_parser.add_argument("--stretch", action="store_true", help="Resize without padding.") + convert_parser.add_argument("--include-subfolders", action="store_true") + convert_parser.add_argument("--skip-existing", action="store_true") + + gif_parser = subparsers.add_parser("gif", help="Create animated GIF from images.") + gif_parser.add_argument("input_path", help="Input file or directory.") + gif_parser.add_argument("output_file", help="Output GIF path.") + gif_parser.add_argument("--duration", type=int, default=500, help="Frame duration in ms.") + gif_parser.add_argument("--size", help="Resize target, for example 800x600.") + gif_parser.add_argument("--background", default="white", choices=["white", "transparent"]) + gif_parser.add_argument("--include-subfolders", action="store_true") + + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> None: + args = _build_parser().parse_args(argv) + size = _parse_size(args.size) + + if args.command == "convert": + results = convert_images( + input_path=args.input_path, + output_dir=args.output_dir, + output_format=args.format, + size=size, + background=args.background, + keep_aspect_ratio=not args.stretch, + include_subfolders=args.include_subfolders, + overwrite=not args.skip_existing, + ) + print(f"Converted {len(results)} image(s).") + return + + gif_path = create_gif( + input_path=args.input_path, + output_file=args.output_file, + duration=args.duration, + size=size, + background=args.background, + include_subfolders=args.include_subfolders, + ) + print(f"Created GIF: {gif_path}") + + +if __name__ == "__main__": + main() diff --git a/contributors/hs5057/md_office_demo.py b/contributors/hs5057/md_office_demo.py new file mode 100644 index 00000000..f7227687 --- /dev/null +++ b/contributors/hs5057/md_office_demo.py @@ -0,0 +1,31 @@ +""" +md-studio + python-office 联动极简示例 +功能流程: +1. 使用 md-studio 将 markdown 文件转为 Word +2. 使用 python-office 读取、修改生成后的 Word 文件 +""" + +# 安装依赖命令 +# pip install python-office md-studio + +import office +# 导入md-studio转换工具 +from md_studio.converter import MdToWord + +def simple_md_office_flow(): + # 1. md 转 word + converter = MdToWord() + converter.convert("demo.md", "output.docx") + + # 2. 使用 python-office 读取生成的word文档 + word = office.Word("output.docx") + content = word.read_text() + print("读取到md转换后的文档内容:", content[:100]) + + # 3. 追加一行文本到word + word.add_paragraph("本文件由 md-studio + python-office 联合处理") + word.save() + print("文档处理完成!") + +if __name__ == "__main__": + simple_md_office_flow() \ No newline at end of file diff --git a/contributors/hs5057/readme.md b/contributors/hs5057/readme.md new file mode 100644 index 00000000..bbeef661 --- /dev/null +++ b/contributors/hs5057/readme.md @@ -0,0 +1,5 @@ +# md-studio + python-office 联动示例 +配套开源项目:https://github.com/hs5057/md-studio +项目功能:Python 实现 Markdown <-> Word/PDF/HTML 双向文档转换服务 + +本示例演示如何结合 md-studio 与 python-office,完成完整办公文档流转,仅做生态互补演示,不改动项目核心代码。 \ No newline at end of file diff --git a/contributors/hxj04121-lab/test_pdf_add_img_water.py b/contributors/hxj04121-lab/test_pdf_add_img_water.py new file mode 100644 index 00000000..f12a886c --- /dev/null +++ b/contributors/hxj04121-lab/test_pdf_add_img_water.py @@ -0,0 +1,57 @@ +"""Regression tests for the PDF image-watermark API wrapper.""" + +import importlib.util +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + + +PDF_API_PATH = Path(__file__).parents[2] / "office" / "api" / "pdf.py" + + +def load_pdf_api(): + """Load the wrapper with a lightweight stand-in for the optional popdf package.""" + popdf = types.ModuleType("popdf") + popdf.add_img_water = mock.Mock() + spec = importlib.util.spec_from_file_location("pdf_api_under_test", PDF_API_PATH) + module = importlib.util.module_from_spec(spec) + + with mock.patch.dict(sys.modules, {"popdf": popdf}): + spec.loader.exec_module(module) + + return module, popdf + + +class TestAddImgWater(unittest.TestCase): + def test_forwards_current_parameters_to_popdf(self): + pdf_api, popdf = load_pdf_api() + + pdf_api.add_img_water("input.pdf", "mark.png", "output.pdf") + + popdf.add_img_water.assert_called_once_with( + pdf_file_in="input.pdf", + pdf_file_mark="mark.png", + pdf_file_out="output.pdf", + ) + + def test_forwards_deprecated_parameter_aliases(self): + pdf_api, popdf = load_pdf_api() + + with self.assertWarnsRegex(DeprecationWarning, "pdf_file_in"): + pdf_api.add_img_water( + pdf_file_in="legacy-input.pdf", + pdf_file_mark="legacy-mark.png", + pdf_file_out="legacy-output.pdf", + ) + + popdf.add_img_water.assert_called_once_with( + pdf_file_in="legacy-input.pdf", + pdf_file_mark="legacy-mark.png", + pdf_file_out="legacy-output.pdf", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/contributors/rs1973/process_img1.py b/contributors/rs1973/process_img1.py new file mode 100644 index 00000000..bfd7d970 --- /dev/null +++ b/contributors/rs1973/process_img1.py @@ -0,0 +1,139 @@ +"""" +Creator:rs1973 +E-mail:dingzheng_2023@qq.com/gunshi98@gmail.com +Description:支持输入一个目录或一张图片,修改它(或者整个目录里)的图片尺寸,色彩通道,并决定是否留下图像的 +alpha值,会自动筛选不是图片或不支持的格式 程序为多线程(多进程下使用笔记本测试时cpu过热),但gif合成 +暂时不支持多线程 + +ps:这是我的第一个项目,有很多做得不够好的地方,请多包容 +""" + +import os +import time +from concurrent.futures import ThreadPoolExecutor +from PIL import Image, ImageOps + +def get_img(srcpath=None, sinfile=None): + """筛选目录中的非图片文件,只筛选一层,不读取子目录里的文件""" + + img_lst = [] + not_img = '' + if srcpath: + for dirpath, dirname, filenames in os.walk(srcpath): + if filenames: + for img in filenames: + img = os.path.join(dirpath, img) + if os.path.isfile(img): + if os.path.splitext(img)[1].lower() in {".jpg", ".jpeg", ".png", ".bmp", ".tiff", '.webp'}: + img_lst.append(img) + else: + not_img += f'{img}\n' + else: + print(f'注意: 目录 {dirpath} 中没有图片') + return [] + + if not_img: + print(f'提示: 以下文件/目录不是图片:\n{not_img}') + return img_lst + + if sinfile: + if os.path.splitext(sinfile)[1].lower() in {".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", '.webp'}: + return [sinfile] + else: + print(f'错误: 文件 {sinfile} 不是图片') + return [] + + +def normal_img(im, input_info: tuple, outpath: str, kind: str, img: str, alpha: bool): + """图像的缩放处理,每一个格式都会用到""" + if input_info: + x, y = input_info + if round(int(x) / im.size[0], 2) == round(int(y) / im.size[1], 2): + real_tuple = (int(x), int(y)) + im = im.resize(real_tuple) + else: + if not alpha: + pad_im = ImageOps.pad(im, (int(x), int(y)), color='#FFFFFF') + else: + pad_im = ImageOps.pad(im, (int(x), int(y)), + color=(0, 0, 0, 0), centering=(0.5, 0.5)) + + im = pad_im + + base_name = os.path.basename(img) + name_only = os.path.splitext(base_name)[0] + save_path = os.path.join(outpath, name_only + kind) + im.save(save_path, format=kind[1:].upper()) + im.close() + + +def gif(filenames=None, outpath=None, duration=300, name='index'): + """合成gif""" + try: + img_lst = [Image.open(i).copy().convert('RGB') for i in filenames] + img_lst[0].save( + os.path.join(outpath, f'{name}.gif'), + append_images=img_lst[1:], + duration=duration, + loop=0, + optimize=True + ) + except ValueError as e: + print(f'错误: 序列中图片大小不一致, {e}') + + +def process_img(outpath=None, input_info=None, kind=None, alpha=None, img=None): + """修改图片中的alpha""" + if kind in ('.png', '.webp', '.bmp'): + with Image.open(img) as im: + im = im.convert('RGBA') + if not alpha: + new_im = im.copy() + new_im.convert('RGB') + alpha_pixel = im.getdata() + write_pixel = [] + for item in alpha_pixel: + r, g, b, a = item + if a == 0: + write_pixel.append((255, 255, 255)) + else: + write_pixel.append((r, g, b)) + new_im.putdata(write_pixel) + im = new_im + normal_img(im, input_info, outpath, kind, img, alpha) + else: + with Image.open(img) as im: + if im.mode == 'RGBA': + im = im.convert('RGB') + normal_img(im, input_info, outpath, kind, img, alpha) + + +def main(srcpath: str = None, outpath: str = None, sinfile: str = None, + img_size: tuple = None, kind: str = '.jpeg', alpha: bool = False, + duration: int = 300, process: int = int(os.cpu_count()//2), name='index'): + """主逻辑函数""" + print('开始处理图片……') + start = time.time() + + filenames = get_img(srcpath, sinfile) + if not filenames: + return + + if kind == '.gif': + print('注意:请确保图像列表中所有图片的大小都一样') + gif(filenames, outpath, duration, name) + else: + with ThreadPoolExecutor(process) as pool: + futures = [pool.submit(process_img, outpath, + img_size, kind, alpha, img) for img in filenames] + for fut in futures: + fut.result() + + end = time.time() + print(f'处理完成, 耗时: {end - start:.2f}s') + +# src = r"C:\Users\rollingstone\OneDrive\Desktop\001" +# out = r"C:\Users\rollingstone\OneDrive\Desktop\s\re" + +# if __name__ == '__main__': +# main(srcpath=src, outpath=out, alpha=False, kind='.gif', name='hello', duration=250) diff --git a/contributors/rs1973/readme.md b/contributors/rs1973/readme.md new file mode 100644 index 00000000..3521029e --- /dev/null +++ b/contributors/rs1973/readme.md @@ -0,0 +1,46 @@ +# 批量图片尺寸与通道处理工具 + +**Creator:** rs1973 +**E-mail:** dingzheng_2023@qq.com / gunshi98@gmail.com + +本工具支持对 **单张图片** 或 **整个目录的全部图片** 进行批量处理,包括: + +- 调整图片尺寸 +- 统一色彩通道(可选择去除或保留透明通道 alpha) +- 批量导出到指定目录 +- 支持 `.jpg / .jpeg / .png / .bmp / .tiff / .webp` 格式 +- 支持 GIF 合成(单线程) + +程序内部使用 **多线程** 加速大量图片处理,默认为5线程。 + +--- + +## 功能特点 + +| 功能 | 说明 | +|-----|----------------------------------------------------------------| +| 批量筛选图片 | 自动忽略非图片文件 | +| 多线程处理 | 提升处理速度,默认为5线程 | +| 支持保留 / 去除透明通道 | 当输出格式支持alpha,可以选择保留或丢弃,丢弃时会将原本的alpha像素替换成白色(后续会添加颜色选择选项) | +| 自适应缩放或填充模式 | 保证目标尺寸一致:当目标大小小于原图时,会缩放/拉伸原图,大于且长宽比例与目标大小相同时,则直接缩放,反之则会用白色填充图片 | +| GIF 合成 | 可根据延迟参数调节帧率,默认300ms/帧 | + +--- + +## 基本使用示例 + +```python +from contributors.rs1973.process_img1.py import main + +src = r"C:\path\to\input_dir" +out = r"C:\path\to\output_dir" +size = (1000, 1000) + +main( + srcpath=src, + outpath=out, + img_size=size, + alpha=False, # 是否保留透明通道 + kind='.png', # 输出格式 + duration=300 # GIF 合成时的帧间隔,仅在 kind='.gif' 时使用 +) \ No newline at end of file diff --git a/contributors/wangpeng/pinyin_gui.py b/contributors/wangpeng/pinyin_gui.py index eee0b991..d280f071 100644 --- a/contributors/wangpeng/pinyin_gui.py +++ b/contributors/wangpeng/pinyin_gui.py @@ -1,7 +1,7 @@ # -*- coding: UTF-8 -*- ''' @作者 :B站/抖音/微博/小红书/公众号,都叫:程序员晚枫,微信:python-office -@读者群 :http://www.python4office.cn/wechat-group/ +@读者群 :https://www.python4office.cn/wechat-group/ @学习网站 :https://www.python-office.com @代码日期 :2023/9/26 21:46 @本段代码的视频说明 : diff --git a/docs/architecture/python-office-architecture.gif b/docs/architecture/python-office-architecture.gif new file mode 100644 index 00000000..390173e8 Binary files /dev/null and b/docs/architecture/python-office-architecture.gif differ diff --git a/docs/architecture/python-office-architecture.html b/docs/architecture/python-office-architecture.html new file mode 100644 index 00000000..13708685 --- /dev/null +++ b/docs/architecture/python-office-architecture.html @@ -0,0 +1,528 @@ + + + + +🏠您可以联系网站管理员反馈:微信:wfdev7
或者
🐱🐉玩一会小恐龙快跑 (源码来自Chromium)
按空格(space)上(↑)下(↓)左(←)右(→)键试试
B站:程序员晚枫
' + document.querySelector("#message").innerHTML += '🏠您可以联系网站管理员反馈:微信:wfdev7
或者
🐱🐉玩一会小恐龙快跑 (源码来自Chromium)
按空格(space)上(↑)下(↓)左(←)右(→)键试试
B站:程序员晚枫
'