diff --git a/docs/python-office-windows-gui-website-article.md b/docs/python-office-windows-gui-website-article.md new file mode 100644 index 0000000..75265f4 --- /dev/null +++ b/docs/python-office-windows-gui-website-article.md @@ -0,0 +1,160 @@ +# python-office Windows 图形界面版:让办公自动化更容易上手 + +## 写在前面 + +python-office 原本是一个非常实用的 Python 办公自动化项目,可以帮助用户处理 Word、Excel、PDF、文件批量操作等常见办公任务。对于熟悉 Python 或命令行的用户来说,直接通过 CLI 使用这些功能没有太大问题。 + +但在实际办公场景中,很多用户并不是程序员。他们可能只是想把 PDF 转成 Word,或者把几个 PDF 文件合并成一个文档。如果还需要先安装 Python、配置依赖、打开命令行、输入参数,对普通用户来说就有些复杂了。 + +所以我基于 python-office 做了一个 Windows 图形界面版本,并使用 Nuitka 打包成可离线运行的软件包。它的目标很明确:让不熟悉命令行的用户,也能像使用普通 Windows 软件一样使用 python-office 的常用功能。 + +## 为什么要做图形界面版 + +很多优秀的 Python 工具功能很强,但传播到普通用户那里时,常常会遇到一个问题:不是功能不好,而是使用门槛太高。 + +对于开发者来说,命令行很高效;但对普通办公用户来说,命令行意味着: + +- 不知道从哪里打开 +- 不知道命令怎么写 +- 不知道参数填在哪里 +- 路径中有空格或中文时容易出错 +- 出错后不知道应该怎么排查 + +图形界面版就是为了解决这些问题。它把常用功能整理成可点击、可填写、可查看日志的界面,让用户可以通过选择文件、填写参数、点击运行来完成操作。 + +## 同事试用后的真实反馈 + +这个版本给同事试用后,反馈比较明显:最受欢迎的是 **PDF 转 Word** 和 **PDF 合并**。 + +这也很符合日常办公需求。很多人并不需要复杂的自动化流程,只是经常遇到这些具体问题: + +- 收到 PDF 文件后,需要转成 Word 再编辑 +- 多个 PDF 材料需要合并成一个完整文件 +- 不想为了一个简单操作去下载各种小工具 +- 不方便安装 Python 或配置环境 +- 希望一个软件打开后就能直接处理文件 + +图形界面版把这些高频需求放到了更容易使用的位置,让普通用户也能快速完成操作。 + +## 主要功能亮点 + +### 1. PDF 转 Word 更方便 + +PDF 转 Word 是试用反馈中最受欢迎的功能之一。 + +过去如果通过命令行操作,用户需要知道具体命令、输入文件路径和输出路径。现在通过图形界面,可以直接选择 PDF 文件,填写或选择输出位置,然后点击运行。 + +这种方式更符合普通办公用户的习惯,也更适合临时处理文件。 + +### 2. PDF 合并更直观 + +PDF 合并也是非常常见的办公需求。比如把多个通知、合同、证明、扫描件合并成一个 PDF,方便发送、归档或打印。 + +图形界面版让用户不用写命令,只需要选择需要合并的文件,再执行合并操作即可。对于不熟悉命令行的人来说,这类改动能明显降低使用成本。 + +### 3. 解压即可运行 + +这个版本使用 Nuitka 目录模式打包,将运行所需依赖放在同一个文件夹中。 + +用户下载后只需要: + +1. 解压软件包 +2. 进入 `run.dist` 文件夹 +3. 双击 `python-office-gui.exe` + +不需要提前安装 Python,不需要手动安装依赖,也不需要联网配置环境。 + +### 4. 适合离线环境 + +很多办公场景并不一定能随时联网,比如内网电脑、教学机房、临时演示电脑,或者单位中不方便随意安装依赖的设备。 + +离线打包版本更适合这种环境。只要把完整文件夹复制过去,就可以运行软件。 + +### 5. 功能分类更清楚 + +新版界面把功能按类别整理到左侧栏中,用户可以按模块查找自己需要的工具。 + +相比在文档里寻找命令,图形界面的分类方式更直观。用户可以先看到有哪些功能,再决定使用哪一个。 + +### 6. 保留运行日志 + +软件界面中保留了运行日志区域。执行功能时,用户可以看到当前操作过程、成功提示或错误信息。 + +这对普通用户和开发者都很有帮助。如果运行失败,用户可以把日志截图发给维护者,排查问题会更方便。 + +### 7. 更统一的软件体验 + +为了让这个版本更像一个完整的软件产品,我也重新设计了界面配色、布局和 exe 图标。 + +新的界面更加清爽,功能区域更明确,整体风格也更统一。对于普通用户来说,视觉上的完整度会影响第一印象,也会影响他们是否愿意继续使用。 + +## 适合哪些人使用 + +这个图形界面版适合以下用户: + +- 不熟悉命令行,但想使用 python-office 的用户 +- 经常需要 PDF 转 Word 的办公用户 +- 经常需要合并多个 PDF 的用户 +- 想把办公自动化工具分享给同事或学生的人 +- 需要在离线环境下运行办公工具的人 +- 希望快速体验 python-office 功能的新手用户 + +它并不是要替代原来的 CLI,而是作为补充,让 python-office 可以覆盖更多使用场景。 + +CLI 适合开发者、批处理脚本和自动化集成;GUI 更适合普通用户、教学演示和低门槛分发。两种方式可以同时存在。 + +## 下载和使用 + +发布地址: + + + +由于软件包较大,目前采用分卷方式上传。使用步骤如下: + +1. 下载所有分卷文件: + - `python-office-gui-windows-x64-2026.08.17.zip.part001` + - `python-office-gui-windows-x64-2026.08.17.zip.part002` + - `python-office-gui-windows-x64-2026.08.17.zip.part003` + - `python-office-gui-windows-x64-2026.08.17.zip.part004` + - `python-office-gui-windows-x64-2026.08.17.zip.part005` +2. 下载 `merge-parts.bat` +3. 把 5 个分卷和 `merge-parts.bat` 放在同一个文件夹 +4. 双击 `merge-parts.bat`,合并出完整 zip +5. 解压 zip +6. 进入 `run.dist` 文件夹 +7. 双击 `python-office-gui.exe` + +合并后的 zip 校验值: + +```text +SHA256: 22C39F818220B38F31C116CE072F844C9B84001F8F58A56B35D8EB30934A2C4E +``` + +## 这个版本的意义 + +我认为这个版本最大的意义,是把 python-office 的能力从“开发者可用”进一步推向“普通用户可用”。 + +很多办公自动化功能本身并不复杂,但如果只能通过命令行使用,就会挡住一部分真正需要它的人。图形界面版可以让这些功能更容易被看到、被理解、被使用。 + +尤其是 PDF 转 Word、PDF 合并这类高频办公需求,本身就非常适合做成图形界面工具。用户不需要关心背后的命令,只需要完成自己的办公任务。 + +## 后续计划 + +后续如果继续完善,可以考虑: + +- 继续优化 PDF 相关功能体验 +- 增加更多常用办公功能入口 +- 优化大文件处理时的提示和进度显示 +- 继续压缩打包体积 +- 整理代码结构,方便提交到原项目 +- 根据用户反馈调整界面和操作流程 + +## 总结 + +python-office Windows 图形界面版,是一次面向普通办公用户的尝试。 + +它保留了 python-office 的实用能力,同时通过图形界面、离线打包、使用教程和统一的软件设计,降低了使用门槛。 + +从同事试用反馈来看,PDF 转 Word 和 PDF 合并这类功能非常适合通过图形界面来推广。它们需求明确、使用频率高、普通用户容易理解,也更容易体现办公自动化工具的实际价值。 + +希望这个版本可以让更多人体验到 python-office 的便利,也让办公自动化工具离普通用户更近一点。 diff --git a/gui/README.md b/gui/README.md new file mode 100644 index 0000000..84667fa --- /dev/null +++ b/gui/README.md @@ -0,0 +1,72 @@ +# python-office Windows GUI + +这是一个面向普通办公用户的 `python-office` 图形界面入口,基于 PySide6 构建。 + +它保留 CLI 的自动化能力,同时把常用功能整理成可点击、可填写、可查看日志的桌面窗口。对不熟悉命令行的用户来说,可以直接选择文件、填写参数并点击运行。 + +## 主要特点 + +- 左侧按模块展示功能,右侧按需生成参数表单 +- 启动时默认折叠左侧分类,减少界面干扰 +- 参数通过 `inspect.signature` 反射生成,并在打开功能页时懒加载 +- 使用 `QThread` 执行业务函数,避免界面卡死 +- 运行日志集中显示,方便截图和排错 +- 对必须 CLI 交互的功能显示命令提示,不在 GUI 中直接执行 +- 附带 Nuitka 目录模式打包脚本,方便生成离线运行版本 + +## 安装依赖 + +```bash +pip install PySide6 +``` + +如需打包 Windows 离线版本,还需要安装 Nuitka: + +```bash +pip install nuitka +``` + +## 启动方式 + +在项目根目录运行: + +```bash +python -m gui +``` + +也可以使用: + +```bash +python -m gui.run +``` + +## 打包方式 + +Windows PowerShell 中运行: + +```powershell +.\scripts\build-gui-nuitka.ps1 +``` + +脚本会使用 Nuitka 目录模式生成 `dist-nuitka/run.dist`,并复制运行所需依赖,方便离线使用。 + +## 目录结构 + +```text +gui/ +├── __main__.py +├── app.py +├── run.py +├── main_window.py +├── panels.py +├── registry.py +├── styles.py +├── workers.py +├── assets/ +├── widgets/ +└── tests/ +``` + +## 当前重点场景 + +试用反馈中,PDF 转 Word 和 PDF 合并是最受欢迎的功能。GUI 版本重点降低这些高频办公能力的使用门槛,让用户不用记命令,也不用提前配置复杂环境。 diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 0000000..f4a57e0 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""python-office GUI 包。 + +启动方式(任选其一): + 1. ``python -m gui.run`` + 2. ``python -m gui`` + 3. 安装后 ``python-office-gui``(在 pyproject.toml 中声明) + +依赖: + pip install PySide6 +""" + +__version__ = "1.0.0" diff --git a/gui/__main__.py b/gui/__main__.py new file mode 100644 index 0000000..7f86c51 --- /dev/null +++ b/gui/__main__.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""允许 ``python -m gui`` 直接启动。""" + +import sys + +from gui.app import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/gui/app.py b/gui/app.py new file mode 100644 index 0000000..f541e43 --- /dev/null +++ b/gui/app.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +"""QApplication 工厂 + main window 装配。""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Optional + +from PySide6.QtGui import QFont, QIcon +from PySide6.QtWidgets import QApplication + +from gui.main_window import MainWindow +from gui.styles import apply_theme + + +def _app_icon_path() -> Path: + return Path(__file__).resolve().parent / "assets" / "python-office.ico" + + +def create_app(argv: Optional[list[str]] = None) -> QApplication: + app = QApplication.instance() or QApplication(argv or sys.argv) + app.setApplicationName("python-office") + app.setOrganizationName("python-office") + app.setFont(QFont("Segoe UI", 10)) + icon_path = _app_icon_path() + if icon_path.exists(): + app.setWindowIcon(QIcon(str(icon_path))) + apply_theme(app) + return app + + +def main() -> int: + app = create_app() + window = MainWindow() + window.show() + return app.exec() diff --git a/gui/assets/python-office-icon-source.jpg b/gui/assets/python-office-icon-source.jpg new file mode 100644 index 0000000..95cb042 Binary files /dev/null and b/gui/assets/python-office-icon-source.jpg differ diff --git a/gui/assets/python-office-icon-source.png b/gui/assets/python-office-icon-source.png new file mode 100644 index 0000000..498484a Binary files /dev/null and b/gui/assets/python-office-icon-source.png differ diff --git a/gui/assets/python-office-icon.png b/gui/assets/python-office-icon.png new file mode 100644 index 0000000..498484a Binary files /dev/null and b/gui/assets/python-office-icon.png differ diff --git a/gui/assets/python-office.ico b/gui/assets/python-office.ico new file mode 100644 index 0000000..59c8c1c Binary files /dev/null and b/gui/assets/python-office.ico differ diff --git a/gui/main_window.py b/gui/main_window.py new file mode 100644 index 0000000..403a36d --- /dev/null +++ b/gui/main_window.py @@ -0,0 +1,674 @@ +# -*- coding: utf-8 -*- +"""主窗口:左侧分类列表 + 右侧功能面板。""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QAction, QColor, QKeySequence, QLinearGradient, QPainter, QPixmap +from PySide6.QtWidgets import ( + QButtonGroup, QHBoxLayout, QLabel, QMainWindow, QMessageBox, + QPushButton, QScrollArea, QSizePolicy, QSplitter, QStackedWidget, + QStatusBar, QToolButton, QVBoxLayout, QWidget, +) + +from gui import __version__ +from gui.panels import FeaturePage +from gui.registry import Category, Feature, build_registry, find_feature, resolve_feature +from gui.styles import ( + BLUE, BLUE_HOVER, BLUE_PRESS, BLUE_SOFT, LINE, RED, SIDEBAR_WIDTH, + SURFACE, TEXT, TEXT_MUTED, +) + + +APPLE_ICON_KINDS = { + "📕": "doc", + "📗": "grid", + "📘": "doc", + "📙": "deck", + "🖼️": "image", + "📁": "folder", + "🎬": "video", + "📧": "mail", + "🔎": "search", + "🛠️": "tool", + "💬": "chat", +} + +APPLE_ICON_ACCENTS = { + "📕": ("#F97316", "#FFF7ED"), + "📗": ("#0D9488", "#ECFDF5"), + "📘": ("#0284C7", "#E0F2FE"), + "📙": ("#F97316", "#FFF7ED"), + "🖼️": ("#0D9488", "#ECFDF5"), + "📁": ("#0F766E", "#F0FDFA"), + "🎬": ("#0284C7", "#E0F2FE"), + "📧": ("#0D9488", "#ECFDF5"), + "🔎": ("#F97316", "#FFF7ED"), + "🛠️": ("#0F766E", "#F0FDFA"), + "💬": ("#10B981", "#ECFDF5"), +} + + +class HeaderLabel(QLabel): + """侧栏分类 header:可点击切换折叠状态(▶/▼)。 + + 自绘文字(完全控制 layout → 箭头精准左对齐)+ override 完整的 mouse 事件: + - press → 记录按下状态 + - release 在 label 内 → emit clicked + - release 在 label 外 → 取消 + - move → 更新 hover/pressed 视觉效果 + QLabel 默认 mouseRelease 传给父,必须 override 才能响应点击。 + """ + + clicked = Signal() + + def __init__(self, icon: str, title: str, expanded: bool = False, parent=None): + super().__init__(parent) + self._icon_text = icon + self._icon_kind = APPLE_ICON_KINDS.get(icon, "app") + self._accent_color, self._accent_soft = APPLE_ICON_ACCENTS.get( + icon, (BLUE, BLUE_SOFT) + ) + self._title_text = title + self._expanded = expanded + self._hover = False + self._pressed = False + self.setCursor(Qt.PointingHandCursor) + self.setFixedHeight(40) + # 启用 mouse tracking(让 mouseMove / leave / enter 事件正常触发) + self.setMouseTracking(True) + # 透明背景(让 styles.py 控制 hover / checked) + self.setStyleSheet("QLabel#sidebarCategoryHeader { background: transparent; border: none; }") + + def set_expanded(self, expanded: bool) -> None: + """设置折叠/展开状态(重绘)。""" + self._expanded = expanded + self.update() + + def is_expanded(self) -> bool: + return self._expanded + + @staticmethod + def _draw_apple_icon(p, rect, kind: str, color: QColor) -> None: + from PySide6.QtCore import QPointF, QRectF + from PySide6.QtGui import QPainterPath, QPen, QPolygonF + + pen = QPen(color, 1.55) + pen.setCapStyle(Qt.RoundCap) + pen.setJoinStyle(Qt.RoundJoin) + p.setPen(pen) + p.setBrush(Qt.NoBrush) + + x, y, w, h = rect.x(), rect.y(), rect.width(), rect.height() + + if kind in ("doc", "app"): + p.drawRoundedRect(QRectF(x + 6, y + 4, w - 12, h - 8), 3, 3) + p.drawLine(QPointF(x + 10, y + 10), QPointF(x + w - 10, y + 10)) + p.drawLine(QPointF(x + 10, y + 14), QPointF(x + w - 13, y + 14)) + return + + if kind == "grid": + p.drawRoundedRect(QRectF(x + 5, y + 5, w - 10, h - 10), 3, 3) + p.drawLine(QPointF(x + w / 2, y + 5), QPointF(x + w / 2, y + h - 5)) + p.drawLine(QPointF(x + 5, y + h / 2), QPointF(x + w - 5, y + h / 2)) + return + + if kind == "deck": + p.drawRoundedRect(QRectF(x + 5, y + 6, w - 10, h - 11), 3, 3) + p.drawLine(QPointF(x + 11, y + h - 4), QPointF(x + w - 11, y + h - 4)) + p.drawLine(QPointF(x + w / 2, y + h - 5), QPointF(x + w / 2, y + h - 2)) + return + + if kind == "image": + box = QRectF(x + 5, y + 5, w - 10, h - 10) + p.drawRoundedRect(box, 3, 3) + p.drawEllipse(QRectF(x + w - 13, y + 8, 3.5, 3.5)) + mountain = QPainterPath() + mountain.moveTo(x + 8, y + h - 7) + mountain.lineTo(x + 14, y + 13) + mountain.lineTo(x + 18, y + h - 8) + mountain.lineTo(x + 21, y + 15) + mountain.lineTo(x + w - 7, y + h - 7) + p.drawPath(mountain) + return + + if kind == "folder": + path = QPainterPath() + path.moveTo(x + 5, y + 9) + path.lineTo(x + 13, y + 9) + path.lineTo(x + 16, y + 12) + path.lineTo(x + w - 5, y + 12) + path.lineTo(x + w - 5, y + h - 5) + path.lineTo(x + 5, y + h - 5) + path.closeSubpath() + p.drawPath(path) + return + + if kind == "video": + p.drawRoundedRect(QRectF(x + 5, y + 5, w - 10, h - 10), 3, 3) + tri = QPolygonF([ + QPointF(x + 14, y + 10), + QPointF(x + 14, y + h - 10), + QPointF(x + w - 12, y + h / 2), + ]) + p.setBrush(color) + p.drawPolygon(tri) + p.setBrush(Qt.NoBrush) + return + + if kind == "mail": + box = QRectF(x + 5, y + 7, w - 10, h - 12) + p.drawRoundedRect(box, 3, 3) + p.drawLine(QPointF(x + 6, y + 9), QPointF(x + w / 2, y + 15)) + p.drawLine(QPointF(x + w - 6, y + 9), QPointF(x + w / 2, y + 15)) + return + + if kind == "search": + p.drawEllipse(QRectF(x + 7, y + 6, 11, 11)) + p.drawLine(QPointF(x + 17, y + 17), QPointF(x + 23, y + 23)) + return + + if kind == "tool": + for i, yy in enumerate((8, 14, 20)): + p.drawLine(QPointF(x + 6, y + yy), QPointF(x + w - 6, y + yy)) + knob_x = x + (12 if i == 0 else 20 if i == 1 else 15) + p.drawEllipse(QRectF(knob_x - 2, y + yy - 2, 4, 4)) + return + + if kind == "chat": + bubble = QPainterPath() + bubble.addRoundedRect(QRectF(x + 5, y + 5, w - 10, h - 12), 5, 5) + bubble.moveTo(x + 12, y + h - 8) + bubble.lineTo(x + 10, y + h - 4) + bubble.lineTo(x + 16, y + h - 8) + p.drawPath(bubble) + + def paintEvent(self, event): + from PySide6.QtCore import QPointF, QRectF + from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen + + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + p.setRenderHint(QPainter.TextAntialiasing, True) + + rect = self.rect() + + bg_rect = rect.adjusted(0, 2, 0, -2) + path = QPainterPath() + path.addRoundedRect(QRectF(bg_rect), 10.0, 10.0) + + # 1) 背景(按 hover / pressed / expanded 状态) + if self._pressed: + bg = QColor("#CCFBF1") + elif self._hover or self._expanded: + bg = QColor(self._accent_soft if self._expanded else "#F0FDFA") + else: + bg = QColor("transparent") + if bg.alpha() > 0: + p.fillPath(path, bg) + + # 2) Apple-like chevron + monochrome badge + title. + chevron_pen = QPen(QColor(self._accent_color if self._expanded else "#7A9A95"), 1.8) + chevron_pen.setCapStyle(Qt.RoundCap) + chevron_pen.setJoinStyle(Qt.RoundJoin) + p.setPen(chevron_pen) + cy = rect.center().y() + if self._expanded: + p.drawLine(QPointF(15, cy - 3), QPointF(19, cy + 1)) + p.drawLine(QPointF(19, cy + 1), QPointF(23, cy - 3)) + else: + p.drawLine(QPointF(16, cy - 4), QPointF(21, cy)) + p.drawLine(QPointF(21, cy), QPointF(16, cy + 4)) + + badge_w = 34 + badge_rect = QRectF(30, cy - 11, badge_w, 22) + badge_path = QPainterPath() + badge_path.addRoundedRect(badge_rect, 7.0, 7.0) + p.fillPath(badge_path, QColor("#FFFFFF" if self._expanded else self._accent_soft)) + p.setPen(QPen(QColor(self._accent_color if self._expanded else "#B8D9D3"), 1.0)) + p.drawPath(badge_path) + self._draw_apple_icon( + p, + badge_rect, + self._icon_kind, + QColor(self._accent_color if self._expanded else "#5F7F7A"), + ) + + font = QFont(self.font()) + font.setPointSize(12) + font.setWeight(QFont.DemiBold) + p.setFont(font) + + # 颜色 + if self._expanded: + p.setPen(QColor(self._accent_color)) + else: + p.setPen(QColor(TEXT_MUTED)) + + text_rect = rect.adjusted(78, 0, -8, 0) + p.drawText(text_rect, int(Qt.AlignLeft | Qt.AlignVCenter), self._title_text) + + p.end() + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self._pressed = True + self.update() + + def mouseReleaseEvent(self, event): + if event.button() == Qt.LeftButton and self._pressed: + self._pressed = False + # 只在 release 时鼠标仍在 label 内才触发 click + if self.rect().contains(event.pos()): + self.clicked.emit() + self.update() + + def mouseMoveEvent(self, event): + # 鼠标在 label 内移动(hover 效果) + was_hover = self._hover + self._hover = self.rect().contains(event.pos()) + if was_hover != self._hover: + self.update() + + def enterEvent(self, event): + self._hover = True + self.update() + + def leaveEvent(self, event): + self._hover = False + # 如果按下时鼠标离开,取消按下状态 + if self._pressed: + self._pressed = False + self.update() + + +class FeatureLabel(QToolButton): + """侧栏二级功能按钮:自绘背景和文字,确保文本稳定左对齐。""" + + def __init__(self, title: str, feature_id: str, expanded_parents: bool = False, parent=None): + super().__init__(parent) + self._title_text = title + self._feature_id = feature_id + self._hover = False + self.setText(title) + self.setCheckable(True) + self.setCursor(Qt.PointingHandCursor) + self.setFixedHeight(38) + self.setMouseTracking(True) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + def paintEvent(self, event): + from PySide6.QtCore import QRectF + from PySide6.QtGui import QColor, QFont, QLinearGradient, QPainter, QPainterPath + + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + p.setRenderHint(QPainter.TextAntialiasing, True) + + rect = self.rect() + bg_rect = rect.adjusted(0, 1, 0, -1) + path = QPainterPath() + path.addRoundedRect(QRectF(bg_rect), 12.0, 12.0) + + if self.isChecked(): + bg = QColor("#F97316" if not self._hover else "#FB923C") + border = QColor("#FED7AA") + text = QColor("#FFFFFF") + weight = QFont.ExtraBold + elif self.isDown(): + bg = QColor("#0F766E") + border = QColor("#0F766E") + text = QColor("#FFFFFF") + weight = QFont.Bold + elif self._hover: + bg = QColor("#F0FDFA") + border = QColor("#99F6E4") + text = QColor(TEXT) + weight = QFont.DemiBold + else: + bg = QColor("transparent") + border = QColor("transparent") + text = QColor(TEXT_MUTED) + weight = QFont.Medium + + if bg.alpha() > 0: + p.fillPath(path, bg) + p.setPen(border) + p.drawPath(path) + + font = QFont(self.font()) + font.setPointSize(12) + font.setWeight(weight) + p.setFont(font) + p.setPen(text) + text_rect = rect.adjusted(24, 0, -10, 0) + p.drawText(text_rect, int(Qt.AlignLeft | Qt.AlignVCenter), self._title_text) + p.end() + + def enterEvent(self, event): + self._hover = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self._hover = False + self.update() + super().leaveEvent(event) + + +class TitleBlock(QWidget): + """侧栏顶部标题块:简洁品牌卡。""" + + def __init__(self, version: str = "", parent=None): + super().__init__(parent) + self._version = version # 保留参数以兼容旧调用,但不再渲染 + self._icon_pixmap = QPixmap(str(Path(__file__).resolve().parent / "assets" / "python-office-icon.png")) + self.setObjectName("sidebarTitleBlock") + self.setFixedHeight(92) + from PySide6.QtWidgets import QSizePolicy + sp = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.setSizePolicy(sp) + + def paintEvent(self, event): + from PySide6.QtCore import QRectF + from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath + + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing, True) + + rect = self.rect() + radius = 16.0 + margin = 10 + inner_rect = rect.adjusted(margin, margin, -margin, -margin) + path = QPainterPath() + path.addRoundedRect(QRectF(inner_rect), radius, radius) + grad = QLinearGradient(inner_rect.left(), inner_rect.top(), inner_rect.right(), inner_rect.bottom()) + grad.setColorAt(0.0, QColor("#FFFFFF")) + grad.setColorAt(0.58, QColor("#F0FDFA")) + grad.setColorAt(1.0, QColor("#FFF7ED")) + p.fillPath(path, grad) + p.setPen(QColor("#99F6E4")) + p.drawPath(path) + + icon_rect = QRectF(inner_rect.left() + 14, inner_rect.top() + 16, 38, 38) + if not self._icon_pixmap.isNull(): + p.drawPixmap(icon_rect.toRect(), self._icon_pixmap) + else: + dot_rect = QRectF(inner_rect.left() + 18, inner_rect.top() + 22, 12, 12) + dot_path = QPainterPath() + dot_path.addEllipse(dot_rect) + p.fillPath(dot_path, QColor(RED)) + + title_font = QFont(self.font()) + title_font.setPointSize(16) + title_font.setWeight(QFont.Bold) + p.setFont(title_font) + p.setPen(QColor(TEXT)) + p.drawText(inner_rect.adjusted(62, 13, -16, -34), int(Qt.AlignLeft | Qt.AlignVCenter), "python-office") + + sub_font = QFont(self.font()) + sub_font.setPointSize(10) + sub_font.setWeight(QFont.Medium) + p.setFont(sub_font) + p.setPen(QColor(TEXT_MUTED)) + p.drawText(inner_rect.adjusted(62, 42, -16, -10), int(Qt.AlignLeft | Qt.AlignVCenter), f"Automation Studio v{self._version}") + + p.end() + + +class MainWindow(QMainWindow): + """GUI 入口窗口。""" + + def __init__(self): + super().__init__() + self.setWindowTitle("python-office · 图形界面") + self.resize(1920, 1080) + self.setMinimumSize(1280, 720) + + self._categories: list[Category] = build_registry() + self._pages: dict[str, FeaturePage] = {} + + self._build_ui() + self._populate_sidebar() + self._select_first_category() + + # ----- UI 构建 ----- + @staticmethod + def _make_gradient_pixmap(width: int, height: int) -> QPixmap: + """生成一个 湖蓝→浅蓝 上→下渐变 pixmap。""" + pix = QPixmap(width, height) + painter = QPainter(pix) + grad = QLinearGradient(0, 0, 0, height) + grad.setColorAt(0.0, QColor("#F0FDFA")) + grad.setColorAt(0.62, QColor("#ECFEFF")) + grad.setColorAt(1.0, QColor("#FFF7ED")) + painter.fillRect(0, 0, width, height, grad) + painter.end() + return pix + + def _build_ui(self): + central = QWidget() + central.setObjectName("centralWidget") + self.setCentralWidget(central) + + outer = QHBoxLayout(central) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + splitter = QSplitter(Qt.Horizontal) + splitter.setHandleWidth(1) + splitter.setChildrenCollapsible(False) + + # 侧边栏 + self._sidebar = QWidget() + self._sidebar.setObjectName("sidebar") + self._sidebar.setFixedWidth(SIDEBAR_WIDTH) + sb_lay = QVBoxLayout(self._sidebar) + sb_lay.setContentsMargins(0, 0, 0, 0) + sb_lay.setSpacing(0) + + title_block = TitleBlock(__version__) + title_block.setObjectName("sidebarTitleBlock") + sb_lay.addWidget(title_block) + + self._category_list = QScrollArea() + self._category_list.setObjectName("categoryList") + self._category_list.setWidgetResizable(True) + self._category_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._sidebar_inner = QWidget() + self._sidebar_inner.setObjectName("sidebarInner") + self._sidebar_layout = QVBoxLayout(self._sidebar_inner) + self._sidebar_layout.setContentsMargins(12, 8, 12, 12) + self._sidebar_layout.setSpacing(2) + self._category_list.setWidget(self._sidebar_inner) + self._feature_buttons: list[QToolButton] = [] + # 每个分类的折叠组:{分类id: (header按钮, [功能按钮列表])} + self._category_groups: dict[str, tuple[HeaderLabel, list[QToolButton]]] = {} + self._feature_button_group = QButtonGroup(self) + self._feature_button_group.setExclusive(True) + sb_lay.addWidget(self._category_list, 1) + + author_label = QLabel("主程序:程序员晚枫\n图形设计:倚栏听雨") + author_label.setObjectName("sidebarAuthor") + author_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + author_label.setFixedHeight(58) + sb_lay.addWidget(author_label) + + splitter.addWidget(self._sidebar) + + # 主区 + self._stack = QStackedWidget() + self._stack.setObjectName("pages") + splitter.addWidget(self._stack) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + splitter.setSizes([SIDEBAR_WIDTH, 1656]) + + outer.addWidget(splitter) + + # 状态栏 + self.setStatusBar(QStatusBar()) + self.statusBar().showMessage("就绪") + + # 菜单 + self._build_menu() + + def _build_menu(self): + reload_action = QAction("刷新注册表", self) + reload_action.setShortcut(QKeySequence("F5")) + reload_action.triggered.connect(self._reload_registry) + + about_action = QAction("关于", self) + about_action.triggered.connect(self._show_about) + + quit_action = QAction("退出", self) + quit_action.setShortcut(QKeySequence.Quit) + quit_action.triggered.connect(self.close) + + menubar = self.menuBar() + file_menu = menubar.addMenu("文件") + file_menu.addAction(reload_action) + file_menu.addSeparator() + file_menu.addAction(quit_action) + + help_menu = menubar.addMenu("帮助") + help_menu.addAction(about_action) + + # ----- 数据填充 ----- + def _populate_sidebar(self): + # 清空旧 layout + while self._sidebar_layout.count(): + item = self._sidebar_layout.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + self._feature_buttons.clear() + if hasattr(self, "_category_groups"): + self._category_groups.clear() + + # QStackedWidget 没有 clear():手动逐个移除 + while self._stack.count(): + w = self._stack.widget(0) + self._stack.removeWidget(w) + w.deleteLater() + self._pages.clear() + + for cat in self._categories: + # 分类 header:可点击切换折叠 + # 用 QLabel + 配合自绘点击事件(避免 QPushButton 默认文字居中问题) + header_btn = HeaderLabel(cat.icon, cat.title, expanded=False) + header_btn.setObjectName("sidebarCategoryHeader") + header_btn.setProperty("role", "categoryHeader") + header_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + header_btn.setProperty("expanded", False) + # 点击 → 折叠/展开 + header_btn.clicked.connect(lambda cid=cat.id: self._toggle_category(cid)) + self._sidebar_layout.addWidget(header_btn) + + # 收集此分类下所有功能按钮 + cat_buttons = [] + for feat in cat.features: + btn = FeatureLabel(feat.title, feat.id, expanded_parents=False) + btn.setObjectName("sidebarFeatureBtn") + btn.setProperty("featureId", feat.id) + # 默认折叠:所有功能按钮隐藏 + btn.setVisible(False) + btn.clicked.connect(lambda _=False, fid=feat.id: self._on_feature_clicked(fid)) + self._feature_button_group.addButton(btn) + self._feature_buttons.append(btn) + self._sidebar_layout.addWidget(btn) + cat_buttons.append(btn) + + # 把这个分类的按钮列表存起来,header 切换时用 + self._category_groups[cat.id] = (header_btn, cat_buttons) + + self._sidebar_layout.addStretch(1) + + def _ensure_page(self, feat: Feature) -> FeaturePage: + page = self._pages.get(feat.id) + if page is not None: + return page + resolved = resolve_feature(feat) + page = FeaturePage(resolved, self) + self._stack.addWidget(page) + self._pages[feat.id] = page + return page + + @staticmethod + def _write_header_text(header_btn, expanded: bool): + """更新 HeaderLabel 的 expanded 状态(触发重绘)。""" + if hasattr(header_btn, "set_expanded"): + header_btn.set_expanded(expanded) + header_btn.setProperty("expanded", expanded) + + def _toggle_category(self, cat_id: str): + """点击分类 header:切换该分类下所有功能按钮的可见性。""" + if not hasattr(self, "_category_groups"): + return + group = self._category_groups.get(cat_id) + if not group: + return + header_btn, buttons = group + # 切换状态 + new_expanded = not header_btn.is_expanded() if hasattr(header_btn, "is_expanded") else True + self._write_header_text(header_btn, expanded=new_expanded) + for btn in buttons: + btn.setVisible(new_expanded) + + def _on_feature_clicked(self, feature_id: str): + feat = find_feature(self._categories, feature_id) + if not feat: + return + self._show_feature(feat) + cat = next( + (c for c in self._categories if any(f.id == feature_id for f in c.features)), + None, + ) + if cat: + self.statusBar().showMessage( + f"分类:{cat.title} · 功能:{feat.title}" + ) + + def _select_first_category(self): + if self._feature_buttons: + if self._categories and self._categories[0].features: + self._show_feature(self._categories[0].features[0]) + + def _show_feature(self, feat: Feature): + page = self._ensure_page(feat) + idx = self._stack.indexOf(page) + if idx >= 0: + self._stack.setCurrentIndex(idx) + + def _reload_registry(self): + self._categories = build_registry() + self._populate_sidebar() + self._select_first_category() + self.statusBar().showMessage("注册表已刷新", 3000) + + def _show_about(self): + QMessageBox.about( + self, + "关于 python-office GUI", + f"

python-office GUI

" + f"

版本:v{__version__}

" + f"

主题:Deep Glass

" + f"

主页:python-office.com

" + f"

所有功能均委托给 office.api.* 实现," + f"本 GUI 仅是更友好的入口。

", + ) + + # ----- 关闭事件:清理线程 ----- + def closeEvent(self, event): + # 先取消所有正在跑的 JobRunner,等线程真正退出再关闭窗口 + for page in self._pages.values(): + if page._runner and page._runner.isRunning(): + page._runner.terminate() + if not page._runner.wait(3000): + # 3s 后还在跑:再 quit() 一次,兜底 + page._runner.quit() + page._runner.wait(2000) + super().closeEvent(event) diff --git a/gui/panels.py b/gui/panels.py new file mode 100644 index 0000000..647dd54 --- /dev/null +++ b/gui/panels.py @@ -0,0 +1,342 @@ +# -*- coding: utf-8 -*- +"""功能面板:根据 Feature 动态生成的参数表单 + 运行按钮 + 日志视图。 + +布局策略(关键): + 上半部分用 QScrollArea 包裹「标题 / 描述 / 备注 / 文档链接 / 参数 / 操作按钮」, + 窗口高度缩小时出滚动条而不是把输入框挤压;下半部分是日志视图,固定 + 最小高度并随窗口下沿一起伸缩,永远看得见。 +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QApplication, QFrame, QHBoxLayout, QLabel, QMessageBox, QPlainTextEdit, + QPushButton, QScrollArea, QSizePolicy, QSplitter, QTextBrowser, + QVBoxLayout, QWidget, +) + +from gui.registry import Feature +from gui.styles import BLUE, BLUE_SOFT, LINE, SURFACE_ALT, TEXT, TEXT_MUTED +from gui.widgets import ParamForm +from gui.workers import JobRunner, make_runner + + +class FeaturePage(QWidget): + """单个功能的展示页。""" + + def __init__(self, feature: Feature, parent=None): + super().__init__(parent) + self.setObjectName("pageRoot") + self._feature = feature + self._runner: JobRunner | None = None + self._build_ui() + + # ----- UI 构建 ----- + def _build_ui(self): + # 顶层用 QSplitter 上下分割:上半滚动 + 下半日志 + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + splitter = QSplitter(Qt.Vertical) + splitter.setChildrenCollapsible(False) + splitter.setHandleWidth(1) + outer.addWidget(splitter) + + # ----- 上半:可滚动的内容 ----- + top = QWidget() + top.setObjectName("pageContent") + top_lay = QVBoxLayout(top) + top_lay.setContentsMargins(36, 28, 36, 18) + top_lay.setSpacing(14) + + title = QLabel(self._feature.title) + title.setObjectName("pageTitle") + top_lay.addWidget(title) + + if self._feature.desc: + desc = QLabel(self._feature.desc) + desc.setObjectName("pageDesc") + desc.setWordWrap(True) + top_lay.addWidget(desc) + + if self._feature.platform_note: + note = QLabel(f"[i] {self._feature.platform_note}") + note.setStyleSheet("color: #C2410C; font-size: 12px;") + note.setWordWrap(True) + top_lay.addWidget(note) + + # 纯展示功能(info_html 非空):直接渲染信息卡片,无按钮/无日志/无 CLI + if self._feature.info_html: + info_card = self._make_info_card() + top_lay.addWidget(info_card, 1) + scroll = self._wrap_scroll(top) + splitter.addWidget(scroll) + self._log = None + splitter.setStretchFactor(0, 1) + return + + # CLI 交互式功能:显示命令行使用说明(不弹警告) + if self._feature.cli_only: + cli_card = self._make_cli_card() + top_lay.addWidget(cli_card) + top_lay.addStretch(1) + scroll = self._wrap_scroll(top) + splitter.addWidget(scroll) + # CLI 卡片页不需要运行按钮/日志区 + self._log = None + return + + if self._feature.is_placeholder: + placeholder_card = self._make_placeholder_card() + top_lay.addWidget(placeholder_card) + top_lay.addStretch(1) + scroll = self._wrap_scroll(top) + splitter.addWidget(scroll) + # 占位卡页只需要上半就够了,禁用下半 + self._log = None + return + + if self._feature.params: + form_card = self._make_card("参数") + self._form = ParamForm(self._feature.params) + self._form.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + form_card.layout().addWidget(self._form) + top_lay.addWidget(form_card) + else: + self._form = None + + action_row = QHBoxLayout() + self._run_btn = QPushButton("运行") + self._run_btn.setObjectName("primaryButton") + self._run_btn.setCursor(Qt.PointingHandCursor) + self._run_btn.clicked.connect(self._on_run) + action_row.addWidget(self._run_btn) + + self._cancel_btn = QPushButton("停止") + self._cancel_btn.setObjectName("dangerButton") + self._cancel_btn.setEnabled(False) + self._cancel_btn.clicked.connect(self._on_cancel) + action_row.addWidget(self._cancel_btn) + + self._clear_btn = QPushButton("清空日志") + self._clear_btn.clicked.connect(self._on_clear_log) + action_row.addWidget(self._clear_btn) + action_row.addStretch(1) + top_lay.addLayout(action_row) + + scroll = self._wrap_scroll(top) + splitter.addWidget(scroll) + + # ----- 下半:日志卡片 ----- + log_card = self._make_card("运行日志") + self._log = QPlainTextEdit() + self._log.setObjectName("logView") + self._log.setReadOnly(True) + self._log.setMinimumHeight(180) + log_card.layout().addWidget(self._log) + log_wrap = QWidget() + log_wrap.setObjectName("pageContent") + log_lay = QVBoxLayout(log_wrap) + log_lay.setContentsMargins(36, 0, 36, 18) + log_lay.setSpacing(0) + log_lay.addWidget(log_card) + splitter.addWidget(log_wrap) + + # 默认高度分配:上半多、日志至少 240(一倍高度 = 360) + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 0) + splitter.setSizes([420, 300]) + splitter.setCollapsible(0, False) + splitter.setCollapsible(1, False) + + def _wrap_scroll(self, inner: QWidget) -> QScrollArea: + """把 inner 包进 QScrollArea,仅垂直滚动,水平永远不出滚动条。""" + area = QScrollArea() + area.setWidgetResizable(True) + area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + area.setFrameShape(QFrame.NoFrame) + area.setWidget(inner) + return area + + def _make_card(self, title: str) -> QFrame: + card = QFrame() + card.setObjectName("card") + v = QVBoxLayout(card) + v.setContentsMargins(24, 18, 24, 22) + v.setSpacing(14) + lbl = QLabel(title) + lbl.setObjectName("cardTitle") + v.addWidget(lbl) + return card + + def _make_placeholder_card(self) -> QFrame: + card = QFrame() + card.setObjectName("card") + v = QVBoxLayout(card) + v.setContentsMargins(20, 18, 20, 18) + v.setSpacing(8) + lbl = QLabel("该功能尚未在本项目中提供 GUI 入口。") + lbl.setObjectName("cardTitle") + v.addWidget(lbl) + hint = QLabel( + "可能原因:\n" + " • 对应子包未安装:请使用 pip install python-office[all] 或单独安装\n" + " • office.api.<模块> 中暂无对应函数实现\n\n" + "解决方案:按提示安装依赖后重启 GUI。" + ) + hint.setStyleSheet(f"color: {TEXT_MUTED};") + hint.setWordWrap(True) + v.addWidget(hint) + return card + + def _make_info_card(self) -> QFrame: + """纯展示卡片:用 QTextBrowser 渲染 Feature.info_html,支持超链接。""" + card = QFrame() + card.setObjectName("card") + v = QVBoxLayout(card) + v.setContentsMargins(24, 20, 24, 20) + v.setSpacing(8) + + browser = QTextBrowser() + browser.setObjectName("infoBrowser") + browser.setOpenExternalLinks(True) # 让 点击后用系统浏览器打开 + browser.setHtml(self._feature.info_html) + browser.setMinimumHeight(560) + browser.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + browser.setStyleSheet( + "QTextBrowser#infoBrowser {" + " background: transparent; border: none;" + f" color: {TEXT};" + "}" + f"QTextBrowser#infoBrowser a {{ color: {BLUE}; text-decoration: none; }}" + "QTextBrowser#infoBrowser a:hover { text-decoration: underline; }" + f"QTextBrowser#infoBrowser h2, h3 {{ color: {TEXT}; }}" + f"QTextBrowser#infoBrowser hr {{ color: {LINE}; }}" + "QTextBrowser#infoBrowser code {" + f" background: {SURFACE_ALT}; padding: 2px 6px; border-radius: 4px;" + f" color: {TEXT}; font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;" + "}" + ) + v.addWidget(browser) + return card + + def _make_cli_card(self) -> QFrame: + """CLI 交互式卡片:提示用户在终端使用,并给出可复制的命令行示例。""" + card = QFrame() + card.setObjectName("card") + v = QVBoxLayout(card) + v.setContentsMargins(20, 18, 20, 18) + v.setSpacing(10) + + lbl = QLabel("CLI 交互式功能 · 请在终端使用") + lbl.setObjectName("cardTitle") + v.addWidget(lbl) + + hint = QLabel( + "此功能需要按命令行提示交互操作(如选择城市、确认号码等),\n" + "GUI 不适合实时交互,请在 PowerShell / CMD / 终端中运行。" + ) + hint.setStyleSheet(f"color: {TEXT_MUTED};") + hint.setWordWrap(True) + v.addWidget(hint) + + # 命令行示例(只读、可全选复制) + example = QPlainTextEdit() + example.setObjectName("cliExample") + example.setReadOnly(True) + example.setPlainText( + self._feature.cli_command + or f"python -c \"from {self._feature.callable.__module__} " + f"import {self._feature.callable.__name__}; " + f"{self._feature.callable.__name__}()\"" + ) + # 自适应高度(8 行左右),避免撑得太高 + example.setMinimumHeight(140) + example.setMaximumHeight(180) + example.setStyleSheet( + "QPlainTextEdit#cliExample {" + " background: #F8FAFC; color: #134E4A;" + " border: 1px solid rgba(13, 148, 136, 0.18); border-radius: 10px;" + " padding: 12px; font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;" + " font-size: 12px;" + "}" + ) + v.addWidget(example) + + # 复制按钮 + copy_btn = QPushButton("复制命令到剪贴板") + copy_btn.setCursor(Qt.PointingHandCursor) + copy_btn.clicked.connect( + lambda: QApplication.clipboard().setText(example.toPlainText()) + ) + v.addWidget(copy_btn, 0, Qt.AlignRight) + + return card + + # ----- 槽 ----- + def _on_run(self): + if self._runner is not None and self._runner.isRunning(): + return + + if self._feature.is_placeholder: + QMessageBox.information( + self, "提示", + "该功能尚未实现,请先安装对应依赖。", + ) + return + + if self._feature.cli_only: + # CLI 交互式不在 GUI 里运行 —— 引导用户去终端 + cmd = self._feature.cli_command or ( + f"python -c \"from {self._feature.callable.__module__} " + f"import {self._feature.callable.__name__}; " + f"{self._feature.callable.__name__}()\"" + ) + QMessageBox.information( + self, "CLI 交互式功能", + f"此功能需要在终端命令行里按提示交互操作。\n\n" + f"请打开 PowerShell / CMD,激活 python-office 环境后执行:\n\n" + f"{cmd}", + ) + return + + kwargs = self._form.collect() if self._form else {} + + if self._log is not None: + self._log.appendPlainText( + f"▶ 开始执行:{self._feature.title}\n 参数:{kwargs}\n" + ) + self._run_btn.setEnabled(False) + self._cancel_btn.setEnabled(True) + + self._runner = make_runner(self._feature.callable, kwargs) + self._runner.log.connect(self._append_log) + self._runner.job_finished.connect(self._on_finished) + self._runner.start() + + def _on_cancel(self): + if self._runner and self._runner.isRunning(): + self._runner.terminate() + self._runner.wait(500) + self._append_log("[用户取消]") + self._on_finished(False, "✗ 已取消") + + def _on_clear_log(self): + if self._log is not None: + self._log.clear() + + def _append_log(self, text: str): + if self._log is None: + return + self._log.appendPlainText(text.rstrip()) + sb = self._log.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _on_finished(self, success: bool, msg: str): + if self._log is not None: + self._log.appendPlainText(msg + "\n") + self._run_btn.setEnabled(True) + self._cancel_btn.setEnabled(False) + self._runner = None diff --git a/gui/registry.py b/gui/registry.py new file mode 100644 index 0000000..5772d9a --- /dev/null +++ b/gui/registry.py @@ -0,0 +1,1659 @@ +# -*- coding: utf-8 -*- +"""功能注册表(schema-driven + 自动反射)。 + +设计思路: + 1. 每个 ``Category`` 持一组 ``Feature``; + 2. ``Feature`` 的可执行体 ``callable`` 直接指向 ``office.api..``, + 参数表单由 ``inspect.signature`` 自动生成; + 3. 参数名 / 默认值启发式映射为合适的 GUI 控件: + - 名字含 path / file / input / output / pdf / image → 文件选择 + - 名字含 dir / folder → 目录选择 + - bool 默认值 → 复选框 + - int / float 默认值 → SpinBox + - 其余 → 单行/多行文本 + 4. office.api 暂未提供实现的模块(如 email / ai / time / han)保留为 + "占位卡片",引导用户安装对应子包后再启用,避免空架子。 + +新增 office.api 函数后,``build_registry()`` 反射即可让 GUI 多出一个 +功能入口,无需手动改本文件。 +""" + +from __future__ import annotations + +import importlib +import inspect +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + + +# -------- 数据结构 -------- + +@dataclass +class Param: + """单个参数的 UI 表单描述。""" + name: str + label: str + kind: str # 'file' | 'files' | 'dir' | 'str' | 'text' | 'int' | 'float' | 'bool' | 'password' + default: Any = None + placeholder: str = "" + required: bool = False + choices: list = field(default_factory=list) + file_filter: str = "" # e.g. "PDF files (*.pdf)" + description: str = "" # 鼠标悬停时显示的参数说明 + + def to_widget_value(self, raw: Any) -> Any: + """把表单控件的 raw value 转成函数调用期望的类型。""" + if raw is None or raw == "": + return None + if self.kind in ("int",): + try: + return int(raw) + except (TypeError, ValueError): + return None + if self.kind in ("float",): + try: + return float(raw) + except (TypeError, ValueError): + return None + if self.kind == "bool": + return bool(raw) + if self.kind in ("files",) and isinstance(raw, str): + return [p.strip() for p in raw.split(";") if p.strip()] + if self.kind in ("text",) and isinstance(raw, str): + return raw + return raw + + +@dataclass +class Feature: + """一个可执行的功能单元。""" + id: str + title: str + desc: str + callable: Callable + params: list[Param] = field(default_factory=list) + docs_url: str = "" + is_async: bool = False + is_placeholder: bool = False # 占位卡:office.api 中暂无对应函数 + platform_note: str = "" # 平台限制说明 + cli_only: bool = False # CLI 交互式:GUI 不直接执行,提示用户在终端调用 + cli_command: str = "" # 给用户的 CLI 调用示例(python -m ...) + info_html: str = "" # 纯展示内容(HTML 格式);非空时右侧只显示该卡片,无按钮/无日志 + module_path: str = "" + func_name: str = "" + param_descriptions: dict[str, str] = field(default_factory=dict) + param_overrides: dict[str, dict] = field(default_factory=dict) + resolved: bool = False + + +@dataclass +class Category: + """左侧栏一级分类。""" + id: str + title: str + icon: str + order: int + features: list[Feature] = field(default_factory=list) + + +# -------- 启发式参数映射 -------- + +# file 词根:仅当名字真正像"文件名"时才算 +_FILE_HINTS = ("file", "image", "img", "pic", "photo", "document", "word", + "pdf", "doc", "docx", "excel", "xlsx", "audio", "video", + "mp3", "mp4", "mark", "qrcode", "src", "dest", "filename") +# 目录词根:含 path / dir / folder / directory 单独出现就算 +_DIR_HINTS = ("path", "dir", "folder", "directory") +_LANG_HINTS = ("lang", "language") +_PASSWORD_HINTS = ("password", "pwd", "passwd") + + +def _looks_like_dir(n: str) -> bool: + """判定参数名更像"目录"而不是"文件"。 + + 规则: + - 名字本身等于 dir/folder/directory → dir + - 名字以 _dir / _folder / _directory 结尾 → dir + - 名字以 _path 结尾但不含 file 词根或显式后缀关键词(pdf/docx/...) → dir + - 单字 path 视为 file(避免和 fake2excel.path 这类输出文件冲突) + """ + if n in ("dir", "folder", "directory"): + return True + if n.endswith(("_dir", "_folder", "_directory")): + return True + if n.endswith("_path"): + # 含 file 词根或显式后缀关键词 → 视为文件(pdf_path / excel_path / image_path) + if "file" in n: + return False + for hint in _FILE_HINTS: + if hint in n: + return False + return True + return False + + +def _looks_like_file(n: str) -> bool: + """判定参数名更像"单个文件"(file/save 控件)。""" + if "file" in n or n == "path": + return True + for hint in _FILE_HINTS: + if hint in n: + return True + return False + + +def _is_output(n: str) -> bool: + return (n.startswith("output") or n.startswith("save_") or + n.endswith("_output") or n == "output") + + +def _param_label(name: str) -> str: + """参数名 → 中文标签。优先查 _LABEL_TRANSLATIONS,缺失则回退英文。""" + n = name.strip() + if n in _LABEL_TRANSLATIONS: + return _LABEL_TRANSLATIONS[n] + pretty = n.replace("_", " ").strip() + return " ".join(w.capitalize() for w in pretty.split()) or n + + +# 全局参数名 → 中文 label 映射(新手友好优先用中文) +_LABEL_TRANSLATIONS: dict[str, str] = { + # 通用文件 / 路径 + "input_file": "输入文件", + "output_file": "输出文件", + "input_path": "输入目录", + "output_path": "输出目录", + "dir_path": "目录", + "file_path": "文件路径", + "filepath": "文件路径", + "path": "文件路径", + "input_dir": "输入目录", + "target_dir": "搜索目录", + "file_type": "文件类型", + "suffix": "后缀名", + "output_name": "输出文件名", + "new_word_name": "新文件名", + + # 邮件 + "key": "授权码", + "msg_from": "发件人", + "msg_to": "收件人", + "msg_cc": "抄送", + "msg_subject": "主题", + "attach_files": "附件", + "host": "服务器", + "port": "端口", + "status": "状态", + + # 输出格式相关 + "output_image": "输出图片", + "output_pdf": "输出 PDF", + "output_excel": "输出 Excel", + "output_excel_name": "输出文件名", + "output_sheet_name": "合并表名", + "output": "输出文件", + "result_file": "词云图", + "mp3_name": "MP3 名称", + "mp3": "MP3 文件", + + # PDF / Word / PPT + "pdf_path": "输出 PDF", + "excel_path": "输入 Excel", + "word_path": "Word 文件", + "img_path": "图片输出目录", + "sheet_id": "工作表序号", + "worksheet_name": "工作表名", + "from_page": "起始页", + "to_page": "结束页", + "page_nums": "要删除的页码", + + # 加密 / 解密 + "password": "密码", + + # 水印 + "mark_file": "水印图片", + "mark_str": "水印内容", + "mark": "水印文字", + "text": "水印文字", + "fontname": "字体", + "fontsize": "字号", + "font_size": "字号", + "font_type": "字体", + "font_color": "字体颜色", + "color": "颜色", + "opacity": "不透明度", + "space": "水印间距", + "size": "水印大小", + "angle": "旋转角度", + "point": "位置坐标", + + # Excel + "columns": "列名", + "rows": "行数", + "language": "语言", + "column": "列号", + "sheet_name": "工作表名", + + # 图片处理 + "file": "文件", + "input_img": "输入图片", + "input_image": "输入图片", + "output_image": "输出图片", + "output_path": "输出目录", + "output_name": "输出文件名", + "quality": "压缩质量", + "qrcode_path": "二维码图片", + "filename": "文本文件", + "url": "网址", + "type": "文件类型", + "client_api": "百度 API Key", + "client_secret": "百度 Secret Key", + + # 视频 + "video_path": "视频文件", + "audio_path": "音频文件", + "content": "文本内容", + "file": "文件", + "speak": "是否朗读", + "merge": "合并为长图", + "appid": "AppID(应用 ID)", + "secret_id": "SecretId(密钥 ID)", + "secret_key": "SecretKey(密钥)", + + # OCR + "img_url": "图片 URL", + "id": "腾讯云 ID", + "key": "腾讯云 Key", + "file_name": "按文件名命名", + "trans": "是否翻译", + + # 文件管理 + "search_key": "搜索关键词", + "del_content": "要删除的内容", + "replace_content": "替换为", + "dir_rename": "同时重命名文件夹", + "file_rename": "同时重命名文件", + "prefix_content": "前缀内容", + "postfix_content": "后缀内容", + "insert_content": "插入内容", + "insert_position": "插入位置", + "add_line_dict": "行内容", + "name": "文件名关键字", + "sub": "包含子目录", + "level": "搜索深度", + "del_old_file": "删除旧文件", + + # 工具 + "to_lang": "目标语言", + "from_lang": "源语言", + "theme": "文章主题", + "line_num": "字数", + "len": "密码长度", + "len_pwd": "密码长度", + "pwd_list": "密码字符集", + + # 微信 + "who": "好友 / 群名", + "message": "消息内容", + "time": "发送时间", + "keywords": "关键词", + "txt": "消息文件名", + + # 网络 + "tile": "标题", + + # 弃用参数(保留兼容) + "out_dir": "输出目录(旧)", + "input_file_list": "PDF 列表", + "one_by_one": "PDF 列表(旧)", + "output_file_name": "输出文件名(旧)", + "pdf_file": "PDF 文件(旧)", + "file": "文件", + + # 金融 + "buy_price": "买入价", + "sale_price": "卖出价", + "shares": "股数", + "w_rate": "手续费率", + "min_rate": "最低收费", + "stamp_tax": "印花税率", +} + + +def _looks_like_str_only(n: str) -> bool: + """参数名更像"纯文本输入"(用户输入字符串,不是文件路径)。 + + 典型场景:合并后的新文件名、主题、关键词、密码等。 + """ + if n in ("name", "text", "theme", "lang", "language", "title", "tile"): + return True + if n.endswith(("_name", "_text", "_theme", "_title", "_lang")): + return True + return False + + +def _resolve_annotation_kind(annotation: Any) -> str | None: + """从函数参数注解推断简单控件 kind。 + + 注意:``annotation`` 可能是类型本身(``float``)也可能是字符串 + (``"float"``),两种情况都要处理。 + """ + if annotation is inspect.Parameter.empty: + return None + # 直接类型 + if annotation is bool: + return "bool" + if annotation is int: + return "int" + if annotation is float: + return "float" + if annotation is str: + return "str" + # 字符串形式(含 "typing.List[str]" 等) + name = getattr(annotation, "__name__", None) + if name is None: + # typing 形式(List[str] 等) + s = str(annotation).lower() + if "list[" in s or s.startswith("list"): + return "list" + if s in ("int", "builtins.int"): + return "int" + if s in ("float", "builtins.float"): + return "float" + if s in ("bool", "builtins.bool"): + return "bool" + if s in ("str", "builtins.str"): + return "str" + return None + name_lower = name.lower() + return { + "bool": "bool", + "int": "int", + "float": "float", + "str": "str", + "list": "list", + }.get(name_lower) + + +def _infer_param_kind(name: str, default: Any, annotation: Any) -> tuple[str, str]: + """根据参数名/默认值/类型注解推断 UI 控件种类及文件过滤器。 + + 优先级: + 1. 密码字段 → password + 2. list[...] 类型注解 → files / text + 3. 看起来像"纯文本输入"(name / theme / text / ...)→ str + 4. 看起来像目录(path/dir/folder/directory) → dir + 5. 看起来像文件(file/image/pdf/docx/...) → file 或 save(按 output 前缀) + 6. 其它:按 annotation / default 推断 bool/int/float + """ + n = name.lower() + annotation_str = str(annotation).lower() if annotation is not inspect.Parameter.empty else "" + + if any(h in n for h in _PASSWORD_HINTS): + return "password", "" + + if "list[" in annotation_str or annotation_str.startswith("list"): + if _looks_like_file(n) or _looks_like_dir(n): + return "files", "" + return "text", "" + + # 纯文本类判定优先于文件/目录类:避免 new_word_name / theme / lang 等 + # 含 word/document 等后缀关键词时被误判为 file/save + if _looks_like_str_only(n): + return "str", "" + + # 目录类判定优先于文件类:避免 input_path 被 "path" 误识别为 file + if _looks_like_dir(n): + return "dir", "" + + if _looks_like_file(n): + suffix = _suffix_for_filename(n) + kind = "save" if _is_output(n) else "file" + return kind, suffix + + # 类型注解优先(直接用 ``is`` 比较类型本身) + anno_kind = _resolve_annotation_kind(annotation) + if anno_kind == "list": + return "text", "" + if anno_kind in ("bool", "int", "float"): + return anno_kind, "" + + # 默认值类型 + if isinstance(default, bool): + return "bool", "" + if isinstance(default, int): + return "int", "" + if isinstance(default, float): + return "float", "" + + return "str", "" + + +def _suffix_for_filename(n: str) -> str: + """根据参数名推断合适的文件扩展名过滤器。""" + if n.endswith("pdf") or "_pdf" in n or "pdf_" in n: + return "PDF files (*.pdf)" + if "image" in n or "img" in n or "pic" in n: + return "Images (*.png *.jpg *.jpeg *.bmp *.gif)" + if "excel" in n or n.endswith("xlsx") or "xlsx" in n: + return "Excel files (*.xlsx *.xls *.csv)" + if "doc" in n: + return "Word files (*.doc *.docx)" + if "ppt" in n: + return "PowerPoint files (*.ppt *.pptx)" + if "audio" in n or "mp3" in n: + return "Audio files (*.mp3 *.wav *.m4a *.flac)" + if "video" in n or "mp4" in n: + return "Video files (*.mp4 *.avi *.mov *.mkv)" + if "txt" in n: + return "Text files (*.txt *.md)" + return "All files (*.*)" + + +def _params_from_signature(func: Callable) -> list[Param]: + """从函数签名生成参数列表(自动跳过 **kwargs)。""" + try: + sig = inspect.signature(func) + except (TypeError, ValueError): + return [] + + params: list[Param] = [] + for p in sig.parameters.values(): + if p.kind in (inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD): + continue + kind, file_filter = _infer_param_kind(p.name, p.default, p.annotation) + default = None if p.default is inspect.Parameter.empty else p.default + params.append(Param( + name=p.name, + label=_param_label(p.name), + kind=kind, + default=default, + placeholder=str(default) if default not in (None, "") else "", + required=(p.default is inspect.Parameter.empty), + file_filter=file_filter, + )) + return params + + +# 手动给个别函数附加 placeholder / 默认值 / file_filter,弥补反射的不足 +_PARAM_OVERRIDES: dict[tuple[str, str], dict[str, dict]] = { + ("office.api.pdf", "pdf2docx"): { + "output_file": { + "placeholder": "留空则输出到 <输入文件同目录>/<同名>.docx", + "file_filter": "Word documents (*.docx)", + }, + }, +} + + +def _apply_param_overrides(module_path: str, func_name: str, + params: list[Param]) -> list[Param]: + """应用手动 placeholder / 默认值补丁。""" + overrides = _PARAM_OVERRIDES.get((module_path, func_name)) + if not overrides: + return params + for p in params: + ov = overrides.get(p.name) + if ov: + for k, v in ov.items(): + setattr(p, k, v) + return params + + +def _try_import(module_path: str): + """安全 import 子模块,失败返回 None。""" + try: + return importlib.import_module(module_path) + except Exception: + return None + + +def _lazy_callable(*args, **kwargs): + raise RuntimeError("功能尚未加载,请重新打开该功能页后再试。") + + +def _resolve_params(func: Callable, + module_path: str, + func_name: str, + param_descriptions: dict[str, str] | None = None, + param_overrides: dict[str, dict] | None = None) -> list[Param]: + params = _params_from_signature(func) + params = _apply_param_overrides(module_path, func_name, params) + if param_overrides: + for p in params: + ov = param_overrides.get(p.name) + if ov: + for k, v in ov.items(): + setattr(p, k, v) + if param_descriptions: + for p in params: + if p.name in param_descriptions: + p.description = param_descriptions[p.name] + return params + + +def resolve_feature(feature: Feature) -> Feature: + """Import and inspect a feature only when its page is first opened.""" + if feature.resolved: + return feature + if feature.cli_only or feature.info_html or feature.is_placeholder: + feature.resolved = True + return feature + + mod = _try_import(feature.module_path) + func = getattr(mod, feature.func_name, None) if mod else None + if callable(func): + feature.callable = func + feature.params = _resolve_params( + func, + feature.module_path, + feature.func_name, + feature.param_descriptions, + feature.param_overrides, + ) + feature.is_placeholder = False + else: + feature.callable = lambda: None + feature.params = [] + feature.is_placeholder = True + if not feature.platform_note: + feature.platform_note = "office.api 中暂时无法加载对应实现。请检查依赖是否完整。" + feature.resolved = True + return feature + + +def _build_feature(feature_id: str, title: str, desc: str, + module_path: str, func_name: str, + docs_url: str = "", platform_note: str = "", + param_descriptions: dict[str, str] = None, + param_overrides: dict[str, dict] = None, + is_placeholder: bool = False, + cli_only: bool = False, + cli_command: str = "", + info_html: str = "") -> Feature: + """从一个 office.api.. 函数构造 Feature;找不到则降级为占位。 + + Args: + param_descriptions: ``{参数名: 说明文本}``,会作为 tooltip 提示用户 + 这个参数是干嘛的。优先级:函数签名 docstring > _PARAM_OVERRIDES > + 此处传入的 param_descriptions。 + param_overrides: ``{参数名: {字段: 值}}``,可覆盖 ``kind`` / ``file_filter`` / + ``placeholder`` / ``default`` / ``description``。用于修正反射推断错误的 + 控件类型(例如 ``pdf_path`` 应为 save 而不是 dir)。 + is_placeholder: True=强制标为占位卡(即使底层函数存在),用于 + CLI 交互式入口(GUI 中无意义)等场景。 + cli_only: True=标记为 CLI 交互式(GUI 不直接执行)。区别于 is_placeholder: + cli_only 不会弹"未实现"警告,而是显示"在命令行怎么调"的说明。 + cli_command: 给用户的 CLI 调用示例(多行字符串)。仅 cli_only=True 时有效。 + info_html: 纯展示内容(HTML 格式)。非空时右侧只显示该卡片,无按钮 / 无日志, + 适用于"项目信息"这种无副作用的纯展示功能。 + """ + if cli_only: + return Feature( + id=feature_id, title=title, desc=desc, + callable=lambda: None, + params=[], docs_url=docs_url, + platform_note=platform_note, + cli_only=True, + cli_command=cli_command, + info_html=info_html, + module_path=module_path, + func_name=func_name, + resolved=True, + ) + + if not is_placeholder: + return Feature( + id=feature_id, title=title, desc=desc, + callable=_lazy_callable, params=[], docs_url=docs_url, + platform_note=platform_note, + cli_only=cli_only, + cli_command=cli_command, + info_html=info_html, + module_path=module_path, + func_name=func_name, + param_descriptions=param_descriptions or {}, + param_overrides=param_overrides or {}, + resolved=False, + ) + + return Feature( + id=feature_id, title=title, desc=desc, + callable=lambda: None, + params=[], docs_url=docs_url, + is_placeholder=True, + platform_note=platform_note or "office.api 中暂无对应实现。请先安装对应 PyPI 子包(如 pip install python-office[all])。", + info_html=info_html, + module_path=module_path, + func_name=func_name, + param_descriptions=param_descriptions or {}, + param_overrides=param_overrides or {}, + resolved=True, + ) + + +# -------- 类别定义 -------- + +def _cat(id_: str, title: str, icon: str, order: int) -> Category: + return Category(id=id_, title=title, icon=icon, order=order) + + +def build_registry() -> list[Category]: + """构造全量功能注册表。每次启动 GUI 都重新构造,开销可忽略。""" + cats: list[Category] = [] + + # --- PDF(按官方文档 https://www.python-office.com/modules/pdf/api 的 13 个函数)--- + c = _cat("pdf", "PDF 处理", "📕", 10) + c.features = [ + _build_feature( + "pdf2docx", "PDF 转 Word", "把 PDF 文件转为 .docx 文档。", + "office.api.pdf", "pdf2docx", + "https://www.python4office.cn/python-office/popdf/1-pdf2docx/", + param_descriptions={ + "input_file": "要转换的 PDF 文件完整路径(含 .pdf 后缀)。", + "output_file": "输出 .docx 文件路径;留空时自动派生为 <输入同目录>/<同名>.docx。", + "input_path": "输入 PDF 所在目录(兼容风格:与 output_path 配对使用)。", + "output_path": "输出 .docx 保存目录(兼容风格:与 input_path 配对使用)。", + }, + ), + _build_feature("pdf2imgs", "PDF 转图片", "把 PDF 每页导出为一张图片。", + "office.api.pdf", "pdf2imgs", + "https://www.python4office.cn/python-office/popdf/2-pdf2imgs/", + param_descriptions={ + "input_file": "源 PDF 文件。", + "output_file": "输出图片路径;merge=True 时是单张长图文件路径(如 ./long.png),merge=False 时是输出目录。", + "merge": "True=所有页拼成一张长图;False=每页一张图。", + "pdf_path": "[已弃用] 请改用 input_file。", + "out_dir": "[已弃用] 请改用 output_file。", + }), + _build_feature("txt2pdf", "文本转 PDF", "把 .txt 文本导出为 PDF。", + "office.api.pdf", "txt2pdf", + param_descriptions={ + "input_file": "要转换的 .txt 文本文件。", + "output_file": "生成的 PDF 文件路径。", + }), + _build_feature("split4pdf", "PDF 拆分", "按页码范围拆分 PDF。", + "office.api.pdf", "split4pdf", + param_descriptions={ + "input_file": "要拆分的 PDF 文件。", + "output_file": "拆分后的输出 PDF 文件路径。", + "from_page": "起始页码(1 起,-1 表示首页)。", + "to_page": "结束页码(-1 表示末页)。", + }), + _build_feature("merge2pdf", "PDF 合并", "把多个 PDF 合并成一个。", + "office.api.pdf", "merge2pdf", + param_descriptions={ + "input_file_list": "要合并的 PDF 文件路径列表,顺序即合并顺序。", + "output_file": "合并后的 PDF 文件路径。", + "one_by_one": "[已弃用] 请改用 input_file_list。", + "output": "[已弃用] 请改用 output_file。", + }), + _build_feature("encrypt4pdf", "PDF 加密", "为 PDF 设置打开密码。", + "office.api.pdf", "encrypt4pdf", + param_descriptions={ + "password": "PDF 打开密码,建议 12 位以上。", + "input_file": "要加密的 PDF 文件。", + "output_file": "加密后的输出 PDF 路径。", + "input_path": "[已弃用] 请改用 input_file。", + "output_path": "[已弃用] 请改用 output_file。", + }), + _build_feature("decrypt4pdf", "PDF 解密", "用已知密码移除 PDF 保护。", + "office.api.pdf", "decrypt4pdf", + param_descriptions={ + "password": "已知的 PDF 密码。", + "input_file": "要解密的加密 PDF 文件。", + "output_file": "解密后的输出 PDF 路径。", + "input_path": "[已弃用] 请改用 input_file。", + "output_path": "[已弃用] 请改用 output_file。", + }), + _build_feature("add_text_watermark", "PDF 加文字水印", "在 PDF 上叠加文字水印。", + "office.api.pdf", "add_text_watermark", + param_descriptions={ + "input_file": "源 PDF 文件。", + "text": "水印文字内容。", + "output_file": "加水印后的输出 PDF 路径。", + "point": "水印位置坐标 (x, y);空则使用默认位置。", + "fontname": "字体名称,默认 Helvetica。", + "fontsize": "字号,默认 20。", + "color": "RGB 颜色三元组,每个分量 0~1。默认 (0, 0, 1) 蓝色。", + }), + _build_feature("add_img_water", "PDF 加图片水印", "用图片给 PDF 加水印。", + "office.api.pdf", "add_img_water", + "https://www.python-office.com/modules/pdf/api#add_img_water", + param_descriptions={ + "input_file": "源 PDF 文件。", + "mark_file": "作为水印的图片文件(建议 PNG / JPG)。", + "output_file": "加水印后的输出 PDF 路径。", + }), + _build_feature("add_mark", "PDF 加水印(旧版)", "旧版兼容入口,推荐 add_watermark_by_parameters。", + "office.api.pdf", "add_mark", + param_overrides={ + "mark_str": {"kind": "str"}, + }, + param_descriptions={ + "input_file": "源 PDF 文件。", + "mark_str": "水印文字内容。", + "output_path": "输出目录,文件名前缀。", + "output_file": "输出文件名。", + "pdf_file": "[已弃用] 请改用 input_file。", + "output_file_name": "[已弃用] 请改用 output_file。", + }), + _build_feature("add_watermark_by_parameters", "PDF 加水印(参数化)", "参数化水印接口(推荐)。", + "office.api.pdf", "add_watermark_by_parameters", + "https://www.python-office.com/modules/pdf/api#add_watermark_by_parameters", + param_overrides={ + "mark_str": {"kind": "str"}, + }, + param_descriptions={ + "input_file": "源 PDF 文件。", + "mark_str": "水印文字内容。", + "output_path": "输出目录。", + "output_file": "输出文件名。", + "pdf_file": "[已弃用] 请改用 input_file。", + "output_file_name": "[已弃用] 请改用 output_file。", + }), + _build_feature("del4pdf", "删除 PDF 页", "按页码列表删除 PDF 页面。", + "office.api.pdf", "del4pdf", + param_descriptions={ + "input_file": "源 PDF 文件。", + "output_file": "删除页后的输出 PDF 路径。", + "page_nums": "要删除的页码列表(1 起),多个用逗号或分号分隔,如 1,3,5。", + }), + _build_feature("pdf_add_watermark_interactive", "交互式水印", "命令行交互式水印(GUI 中可忽略此入口)。", + "office.api.pdf", "add_watermark", + "https://www.python-office.com/modules/pdf/api#add_watermark"), + ] + cats.append(c) + + # --- Excel(按官方文档 https://www.python-office.com/modules/excel/api 的 7 个函数)--- + c = _cat("excel", "Excel 处理", "📗", 20) + c.features = [ + _build_feature("fake2excel", "生成模拟 Excel", "用 Faker 库生成测试数据。", + "office.api.excel", "fake2excel", + param_overrides={ + "path": { + "kind": "save", + "file_filter": "Excel files (*.xlsx)", + }, + }, + param_descriptions={ + "columns": "列名列表。可用字段:name, phone, email, address, company, job, country, city, postcode, ssn, credit_card_number, user_agent, text, sentence。多个用逗号分隔。", + "rows": "要生成的数据行数。", + "path": "输出 Excel 文件路径(另存为对话框,默认 .xlsx)。", + "language": "数据语言:'zh_CN' 中文 / 'english' 英文。", + }), + _build_feature("merge2excel", "合并多个 Excel", "把目录下多个 Excel 合并成一个文件的不同 sheet。", + "office.api.excel", "merge2excel", + param_descriptions={ + "dir_path": "包含多个 Excel 文件的目录路径。", + "output_file": "合并后的 Excel 文件路径。", + }), + _build_feature("sheet2excel", "按 sheet 拆分", "把一个 Excel 的多个 sheet 拆成多个文件。", + "office.api.excel", "sheet2excel", + param_descriptions={ + "file_path": "要拆分的 Excel 文件路径。", + "output_path": "拆分后文件的输出目录。", + }), + _build_feature("merge2sheet", "多 Excel 多 sheet 合并", "跨文件、跨 sheet 自动合并。", + "office.api.excel", "merge2sheet", + param_descriptions={ + "dir_path": "包含多个 Excel 文件的目录路径。", + "output_sheet_name": "合并后的 sheet 名称。", + "output_excel_name": "合并后的 Excel 文件名(不含 .xlsx 后缀)。", + }), + _build_feature("find_excel_data", "搜索 Excel 内容", "在指定目录下搜索单元格内容。", + "office.api.excel", "find_excel_data", + param_descriptions={ + "search_key": "要搜索的关键词。", + "target_dir": "搜索的目录路径。", + }), + _build_feature("split_excel_by_column", "按列拆分 Excel", "按指定列的不同值拆分工作表。", + "office.api.excel", "split_excel_by_column", + param_descriptions={ + "filepath": "要拆分的 Excel 文件路径。", + "column": "按哪一列的内容进行拆分(1 起)。", + "worksheet_name": "要处理的工作表名称,留空则用第一个工作表。", + }), + _build_feature("excel2pdf", "Excel 转 PDF", "把指定 sheet 导出为 PDF。", + "office.api.excel", "excel2pdf", + param_overrides={ + # pdf_path 实际是输出文件,但名字以 _path 结尾会被推断为 dir + # 这里显式覆盖为 save 模式(另存为对话框) + "pdf_path": { + "kind": "save", + "file_filter": "PDF files (*.pdf)", + }, + }, + param_descriptions={ + "excel_path": "源 Excel 文件路径。", + "pdf_path": "输出 PDF 文件路径(另存为对话框)。", + "sheet_id": "工作表索引(0 起,0 表示第一个 sheet)。", + }), + _build_feature("excel2markdown", "Excel 转 Markdown", "把 Excel 表格转 Markdown 文档(HTML 表格,支持 colspan/rowspan,保留合并单元格原貌)。", + "office.api.markdown", "excel2markdown", + param_overrides={ + "input_file": { + "kind": "file", + "file_filter": "Excel files (*.xlsx *.xls)", + }, + "output_file": { + "kind": "save", + "file_filter": "Markdown files (*.md)", + }, + }, + param_descriptions={ + "input_file": "要转换的 Excel 文件(.xlsx / .xls)。", + "output_file": "输出的 Markdown 文件(另存为对话框,默认 ./excel2markdown.md)。", + "sheet_name": "要转换的工作表名,留空则转换所有工作表。", + }), + ] + cats.append(c) + + # --- Word (官方文档 https://www.python-office.com/modules/word/api 的 5 个函数)--- + c = _cat("word", "Word 处理", "📘", 30) + c.features = [ + _build_feature("docx2pdf", "Word 转 PDF", "Word 转 PDF,支持单个文件或整个文件夹批量。", + "office.api.word", "docx2pdf", + platform_note="依赖 Microsoft Word / WPS / LibreOffice,桌面环境可用", + param_overrides={ + # path 既能传文件也能传目录,kind 留 file 让用户选文件;如选目录会报错但更直观 + "path": {"file_filter": "Word files (*.docx *.doc)"}, + }, + param_descriptions={ + "path": "Word 文件路径,或包含多个 Word 文件的目录路径。传目录时批量转换。", + "output_path": "PDF 输出目录;不存在会自动创建。留空则输出到 path 所在目录。", + }), + _build_feature("merge4docx", "合并 Word", "把多个 .docx 合并成一个文件。", + "office.api.word", "merge4docx", + platform_note="依赖 Microsoft Word / WPS / LibreOffice", + param_descriptions={ + "input_path": "包含多个 .docx 的目录路径。", + "output_path": "合并后文件保存目录。", + "new_word_name": "合并后新文件的名字(不含 .docx 后缀)。", + }), + _build_feature("doc2docx", "doc 转 docx", "把旧版 .doc 转为 .docx。", + "office.api.word", "doc2docx", + platform_note="依赖 Microsoft Word / WPS / LibreOffice", + param_overrides={ + "input_path": { + "kind": "file", + "file_filter": "Word 97-2003 (*.doc)", + }, + }, + param_descriptions={ + "input_path": "要转换的 .doc 文件路径。", + "output_path": "生成的 .docx 保存目录(默认 ./)。", + "output_name": "输出文件名(不含后缀),留空则与原文件同名。", + }), + _build_feature("docx2doc", "docx 转 doc", "把 .docx 转回旧版 .doc。", + "office.api.word", "docx2doc", + platform_note="依赖 Microsoft Word / WPS / LibreOffice", + param_overrides={ + "input_path": { + "kind": "file", + "file_filter": "Word 文档 (*.docx)", + }, + }, + param_descriptions={ + "input_path": "要转换的 .docx 文件路径。", + "output_path": "生成的 .doc 保存目录(默认 ./)。", + "output_name": "输出文件名(不含后缀),留空则与原文件同名。", + }), + _build_feature("docx4imgs", "Word 提取图片", "从 Word 文档中提取所有图片。", + "office.api.word", "docx4imgs", + platform_note="依赖 python-docx,不依赖 Microsoft Word", + param_overrides={ + "word_path": { + "kind": "file", + "file_filter": "Word 文档 (*.docx)", + }, + "img_path": { + "kind": "dir", + }, + }, + param_descriptions={ + "word_path": "要提取图片的 .docx 文件路径。", + "img_path": "图片输出根目录;会自动按 Word 文件名生成子目录。", + }), + ] + cats.append(c) + + # --- PPT(按官方文档 https://www.python-office.com/modules/ppt/api 的 3 个函数)--- + c = _cat("ppt", "PPT 处理", "📙", 40) + c.features = [ + _build_feature("ppt2pdf", "PPT 转 PDF", "把 .pptx 转成 PDF。", + "office.api.ppt", "ppt2pdf", + platform_note="依赖 Microsoft PowerPoint / WPS / LibreOffice", + param_overrides={ + "path": { + "kind": "file", + "file_filter": "PowerPoint files (*.pptx *.ppt)", + }, + }, + param_descriptions={ + "path": "输入的 .pptx / .ppt 文件路径。", + "output_path": "PDF 保存目录。", + }), + _build_feature("ppt2img", "PPT 转图片", "把幻灯片导出为图片,可合并为长图。", + "office.api.ppt", "ppt2img", + platform_note="依赖 Microsoft PowerPoint / WPS / LibreOffice", + param_overrides={ + "input_path": { + "kind": "file", + "label": "输入文件", + "file_filter": "PowerPoint files (*.pptx *.ppt)", + }, + }, + param_descriptions={ + "input_path": "输入的 .pptx 文件路径;也可传目录,批量转换目录下所有 PPT。", + "output_path": "图片输出目录(merge=False)或单张长图文件路径(merge=True)。", + "merge": "True=把所有幻灯片拼成一张长图,False=每页一张图。", + }), + _build_feature("merge4ppt", "合并 PPT", "把多个 .pptx 合并成一个文件。", + "office.api.ppt", "merge4ppt", + platform_note="依赖 Microsoft PowerPoint / WPS / LibreOffice", + param_descriptions={ + "input_path": "包含多个 .pptx / .ppt 的目录。", + "output_path": "合并后文件保存目录。", + "output_name": "合并后文件名(含 .pptx 后缀,默认 merge4ppt.pptx)。", + }), + ] + cats.append(c) + + # --- 图片(按官方文档 https://www.python-office.com/modules/image/api 的 9 个函数)--- + c = _cat("image", "图片处理", "🖼️", 50) + c.features = [ + _build_feature("compress_image", "压缩图片", "按质量参数压缩图片,减小体积。", + "office.api.image", "compress_image", + param_overrides={ + "input_file": {"file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)"}, + "output_file": {"kind": "save", "file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)"}, + }, + param_descriptions={ + "input_file": "要压缩的输入图片文件。", + "output_file": "压缩后的图片保存路径(另存为对话框)。", + "quality": "压缩质量 0~95,越大越清晰、文件越大;推荐 70~85。", + }), + _build_feature("image2gif", "图片转 GIF", "将图片转为 GIF(交互式入口,GUI 中可忽略)。", + "office.api.image", "image2gif", + is_placeholder=True, + platform_note="CLI 交互式入口"), + _build_feature("add_watermark", "图片加水印", "为图片添加文字水印。", + "office.api.image", "add_watermark", + param_overrides={ + "mark": {"kind": "str"}, + "file": {"file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)"}, + }, + param_descriptions={ + "file": "要加水印的图片文件。", + "mark": "水印文字内容(如 @python-office)。", + "output_path": "输出目录。", + "color": "水印颜色,十六进制如 #eaeaea。", + "size": "水印字号大小。", + "opacity": "不透明度,0.01~1 之间。", + "space": "水印之间的间距(像素)。", + "angle": "旋转角度(如 30 表示倾斜 30 度)。", + }), + _build_feature("del_watermark", "图片去水印", "尝试移除图片中的水印。", + "office.api.image", "del_watermark", + param_overrides={ + "input_image": {"file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)"}, + "output_image": {"kind": "save", "file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)"}, + }, + param_descriptions={ + "input_image": "含水印的图片文件。", + "output_image": "去水印后保存的文件(另存为对话框)。", + }), + _build_feature("img2Cartoon", "图片转漫画", "调用百度 AI 把图片转为卡通风格。", + "office.api.image", "img2Cartoon", + param_overrides={ + "path": {"file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)"}, + }, + param_descriptions={ + "path": "输入图片文件。", + "client_api": "百度 AI 应用的 API Key(留空使用内置 Key)。", + "client_secret": "百度 AI 应用的 Secret Key(留空使用内置 Key)。", + }), + _build_feature("down4img", "下载网络图片", "通过 URL 下载图片到本地。", + "office.api.image", "down4img", + param_descriptions={ + "url": "图片的网络地址(http/https)。", + "output_path": "保存目录(默认当前目录)。", + "output_name": "保存的文件名前缀(不含后缀)。", + "type": "图片格式,如 jpg / png。", + }), + _build_feature("txt2wordcloud", "生成词云", "从文本生成词云图片。", + "office.api.image", "txt2wordcloud", + param_overrides={ + "result_file": { + "kind": "save", + "file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)", + }, + "filename": {"file_filter": "Text files (*.txt *.md)"}, + }, + param_descriptions={ + "filename": "输入的 .txt 文本文件路径。", + "color": "词云背景色,如 white / black / 十六进制。", + "result_file": "输出的词云图片(另存为对话框)。", + }), + _build_feature("pencil4img", "铅笔画效果", "把图片转成铅笔素描风格。", + "office.api.image", "pencil4img", + param_overrides={ + "input_img": {"file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)"}, + }, + param_descriptions={ + "input_img": "输入图片文件。", + "output_path": "输出目录。", + "output_name": "输出文件名(含后缀,默认 pencil4img.jpg)。", + }), + _build_feature("decode_qrcode", "解码二维码", "识别图片中的二维码内容。", + "office.api.image", "decode_qrcode", + param_overrides={ + "qrcode_path": { + "kind": "file", + "file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)", + }, + }, + param_descriptions={ + "qrcode_path": "二维码图片文件路径。", + }), + ] + cats.append(c) + + # --- 文件(按官方文档 https://www.python-office.com/modules/file/api 的 9 个函数)--- + c = _cat("file", "文件管理", "📁", 60) + c.features = [ + _build_feature("replace4filename", "批量重命名", "按关键字批量替换文件名 / 文件夹名。", + "office.api.file", "replace4filename", + param_overrides={ + "path": {"kind": "dir"}, + "file_rename": {"kind": "bool"}, + }, + param_descriptions={ + "path": "要批量重命名的根目录(该目录本身不会被改)。", + "del_content": "文件名中要删除 / 替换的内容。", + "replace_content": "替换成的新内容,留空则等于删除。", + "dir_rename": "是否同时修改子文件夹名。", + "file_rename": "是否同时修改文件名。", + "suffix": "只处理特定后缀的文件,如 .txt;留空处理所有。", + }), + _build_feature("file_name_insert_content", "文件名中间插入", "在文件名中间插入字符串。", + "office.api.file", "file_name_insert_content", + param_overrides={ + "insert_position": {"kind": "int"}, + }, + param_descriptions={ + "file_path": "文件路径。", + "insert_position": "插入位置(1 起),如 3 表示在第 3 个字符后插入。", + "insert_content": "要插入的字符串。", + }), + _build_feature("file_name_add_prefix", "文件加前缀", "批量给文件名前加前缀。", + "office.api.file", "file_name_add_prefix", + param_descriptions={ + "file_path": "文件路径。", + "prefix_content": "要添加的前缀字符串。", + }), + _build_feature("file_name_add_postfix", "文件加后缀", "批量给文件名后加后缀。", + "office.api.file", "file_name_add_postfix", + param_descriptions={ + "file_path": "文件路径。", + "postfix_content": "要添加的后缀字符串。", + }), + _build_feature("output_file_list_to_excel", "文件名导出 Excel", "把目录下的文件名清单写入 Excel。", + "office.api.file", "output_file_list_to_excel", + param_descriptions={ + "dir_path": "要导出的目录。", + }), + _build_feature("search_specify_type_file", "按类型搜索文件", "按扩展名搜索文件。", + "office.api.file", "search_specify_type_file", + param_overrides={ + "file_path": {"kind": "dir"}, + "file_type": {"kind": "str"}, + }, + param_descriptions={ + "file_path": "搜索的目录。", + "file_type": "要搜索的扩展名,如 .pdf / .docx(必须带点)。", + }), + _build_feature("get_files", "获取文件列表", "返回目录下符合条件的文件路径列表。", + "office.api.file", "get_files", + param_overrides={ + "path": {"kind": "dir"}, + }, + param_descriptions={ + "path": "搜索的目录。", + "name": "文件名关键字(留空匹配所有)。", + "suffix": "文件后缀,如 .pdf(留空匹配所有)。", + "sub": "True=递归搜索子目录。", + "level": "递归深度,0 表示无限。", + }), + _build_feature("add_line_by_type", "按类型插入行", "向指定后缀的文件批量插入行。", + "office.api.file", "add_line_by_type", + param_overrides={ + "add_line_dict": {"kind": "text"}, + "file_type": {"kind": "str"}, + }, + param_descriptions={ + "add_line_dict": "要插入的内容,格式如 {'import os': ['a.py', 'b.py']}。多行文本框输入 JSON。", + "file_path": "目标目录。", + "file_type": "要处理的文件后缀(如 .py)。", + "output_path": "新文件输出目录。", + }), + _build_feature("group_by_name", "按名称分组", "把目录下的文件按名称分组整理到子目录。", + "office.api.file", "group_by_name", + param_overrides={ + "path": {"kind": "dir"}, + "del_old_file": {"kind": "bool"}, + }, + param_descriptions={ + "path": "源目录。", + "output_path": "分组后的输出目录,留空则在 path 同级创建 group_by_name 子目录。", + "del_old_file": "是否删除原文件。", + }), + ] + cats.append(c) + + # --- 视频(按官方文档 https://www.python-office.com/modules/video/api 的 4 个函数)--- + c = _cat("video", "视频处理", "🎬", 70) + c.features = [ + _build_feature("video2mp3", "视频提取音频", "从视频中提取音频为 MP3。", + "office.api.video", "video2mp3", + param_overrides={ + "path": { + "kind": "file", + "file_filter": "Video files (*.mp4 *.avi *.mov *.mkv *.flv)", + }, + }, + param_descriptions={ + "path": "输入视频文件(mp4 / avi / mov / mkv 等)。", + "mp3_name": "MP3 文件名(不含后缀),留空则用原视频文件名。", + "output_path": "输出目录。", + }), + _build_feature("audio2txt", "音频转文字", "调用腾讯云语音识别把音频转为文字。", + "office.api.video", "audio2txt", + platform_note="需要腾讯云 appid / SecretId / SecretKey;本地音频 ≤ 5MB", + param_overrides={ + "audio_path": {"file_filter": "Audio files (*.mp3 *.wav *.m4a *.flac)"}, + }, + param_descriptions={ + "audio_path": "输入的音频文件。", + "appid": "腾讯云语音识别应用的 appid。", + "secret_id": "腾讯云 API SecretId。", + "secret_key": "腾讯云 API SecretKey。", + }), + _build_feature("mark2video", "视频加文字水印", "在视频上叠加滚动文字水印。", + "office.api.video", "mark2video", + param_overrides={ + "mark_str": {"kind": "str"}, + }, + param_descriptions={ + "video_path": "输入视频文件。", + "output_path": "输出目录。", + "output_name": "输出文件名(**记得带 .mp4 后缀**,默认 mark2video.mp4)。", + "mark_str": "水印文字内容。", + "font_size": "水印字号(默认 28)。", + "font_type": "字体名称或字体文件路径(默认 Arial)。", + "font_color": "字体颜色(默认 white)。", + }), + _build_feature("txt2mp3", "文本转语音", "调用本地 TTS 引擎把文本合成 MP3。", + "office.api.video", "txt2mp3", + param_overrides={ + "mp3": { + "kind": "save", + "file_filter": "Audio files (*.mp3 *.wav *.m4a *.flac)", + }, + "file": { + "file_filter": "Text files (*.txt *.md)", + }, + }, + param_descriptions={ + "content": "要朗读的文本内容(默认 '程序员晚枫')。", + "file": "可选:从指定文本文件读取(优先级最高)。", + "mp3": "输出的 MP3 文件(另存为对话框)。", + "speak": "True=边合成边朗读,False=只生成文件。", + }), + ] + cats.append(c) + + # --- 邮件(按官方文档 https://www.python-office.com/modules/email/api 的 2 个函数)--- + c = _cat("email", "邮件收发", "📧", 80) + c.features = [ + _build_feature("send_email", "发送邮件", "通过 SMTP 发送邮件,支持附件 / 抄送。", + "office.api.email", "send_email", + param_overrides={ + "key": {"label": "邮箱授权码"}, + "host": { + "kind": "choice", + "choices": ["smtp.qq.com", "smtp.163.com", "smtp.gmail.com"], + }, + }, + param_descriptions={ + "key": "邮箱授权码(不是登录密码)。QQ 邮箱在 设置→账户→POP3/IMAP 服务 生成。", + "msg_from": "发件人邮箱地址。", + "msg_to": "收件人邮箱地址。", + "msg_cc": "抄送地址(多个用英文逗号分隔),留空则不抄送。", + "attach_files": "附件文件路径列表(用分号 ; 分隔多个路径),留空则无附件。", + "msg_subject": "邮件主题。", + "content": "邮件正文。", + "host": "SMTP 服务器地址:QQ 选 smtp.qq.com / 163 选 smtp.163.com / Gmail 选 smtp.gmail.com。", + "port": "SMTP 端口:QQ/163 用 465,Gmail 用 587。", + }), + _build_feature("receive_email", "接收邮件", "按状态 / 主题过滤接收邮件并保存。", + "office.api.email", "receive_email", + param_overrides={ + "key": {"label": "邮箱授权码"}, + "host": { + "kind": "choice", + "choices": ["smtp.qq.com", "smtp.163.com", "smtp.gmail.com"], + }, + "status": { + "kind": "choice", + "choices": ["UNSEEN", "SEEN", "ALL"], + }, + }, + param_descriptions={ + "key": "邮箱授权码(不是登录密码)。", + "msg_from": "发件人邮箱地址。", + "output_path": "邮件保存目录。", + "status": "邮件状态过滤:UNSEEN=未读 / SEEN=已读 / ALL=全部。", + "msg_subject": "邮件主题过滤(留空匹配所有)。", + "host": "邮件服务器地址。", + "port": "邮件服务器端口。", + }), + ] + cats.append(c) + + # --- Markdown 模块(excel2markdown 已移到 Excel 处理分类下)--- + + # --- 工具(按官方文档 https://www.python-office.com/modules/tools/api 的 10 个函数)--- + c = _cat("tools", "实用工具", "🛠️", 110) + c.features = [ + _build_feature("qrcodetools", "生成二维码", "把 URL 转为二维码图片。", + "office.api.tools", "qrcodetools", + param_overrides={ + "output": { + "kind": "save", + "file_filter": "Images (*.png *.jpg *.jpeg *.bmp *.gif)", + }, + }, + param_descriptions={ + "url": "要编码成二维码的网址或文本。", + "output": "二维码图片保存路径(另存为对话框,默认 ./qrcode_img.png)。", + }), + _build_feature("passwordtools", "随机密码", "生成指定长度的随机密码。", + "office.api.tools", "passwordtools", + param_descriptions={ + "len": "密码长度(默认 8)。", + }), + _build_feature("weather", "天气查询", "查询指定城市的天气。CLI 交互式(会按提示选择城市/日期),建议在终端使用。", + "office.api.tools", "weather", + cli_only=True, + cli_command=( + "python\n" + ">>> from office.api.tools import weather\n" + ">>> weather()\n" + "\n" + "# 或者直接命令行一行调用(按提示交互):\n" + "python -c \"from office.api.tools import weather; weather()\"\n" + ), + param_descriptions={}), + _build_feature("lottery8ticket", "彩票号码", "随机生成 8 位彩票号码。CLI 交互式(可重复生成直至满意),建议在终端使用。", + "office.api.tools", "lottery8ticket", + cli_only=True, + cli_command=( + "python\n" + ">>> from office.api.tools import lottery8ticket\n" + ">>> lottery8ticket()\n" + "\n" + "# 直接命令行调用:\n" + "python -c \"from office.api.tools import lottery8ticket; lottery8ticket()\"\n" + ), + param_descriptions={}), + _build_feature("pwd4wifi", "WiFi 密码字典(生成器)", + "按指定字符集 × 长度枚举所有可能密码,保存为字典文件。", + "office.api.tools", "pwd4wifi", + cli_only=True, + cli_command=( + "# ⚠️ 当前 office.api.tools.pwd4wifi 是空函数(wftools 库未实现),\n" + "# 所以 GUI 点'运行'什么也不会发生。请直接用下面的代码生成字典:\n" + "\n" + "python\n" + ">>> import itertools\n" + ">>> # 字符集:手机号段\n" + ">>> charset = '0123456789'\n" + ">>> length = 8\n" + ">>> # 生成所有 8 位纯数字组合(10^8 = 1 亿条,建议限制长度)\n" + ">>> with open('wifi_dict.txt', 'w') as f:\n" + ">>> for combo in itertools.product(charset, repeat=length):\n" + ">>> f.write(''.join(combo) + '\\n')\n" + "\n" + "# 一行命令(生成 6 位纯数字字典,1MB 左右):\n" + "python -c \"import itertools; open('wifi_dict.txt','w').writelines(\n" + " ''.join(p)+'\\n' for p in itertools.product('0123456789', repeat=6))\"\n" + "\n" + "# ⚠️ 真正的 WiFi 破解需要:\n" + "# 1. pywifi 扫描附近 WiFi 的 SSID + 信号强度\n" + "# 2. 字典逐条尝试连接(一次握手需 1-5 秒)\n" + "# 3. 通常需要管理员权限 + 真实网卡\n" + "# python-office 暂未集成这一套;可参考 pywifi + 字典爆破脚本\n" + ), + param_overrides={ + "len_pwd": {"kind": "int"}, + "pwd_list": {"kind": "str"}, + }, + param_descriptions={ + "len_pwd": "密码长度(默认 8)。", + "pwd_list": "可选:自定义字符集(用文本框输入),如 '0123456789' 或 'abc123'。", + }), + _build_feature("net_speed_test", "网速测试", "测试当前网络的上传/下载速度(speedtest.net)。CLI 交互式(会持续输出进度),建议在终端使用。", + "office.api.tools", "net_speed_test", + cli_only=True, + cli_command=( + "python\n" + ">>> from office.api.tools import net_speed_test\n" + ">>> net_speed_test()\n" + "\n" + "# 直接命令行调用:\n" + "python -c \"from office.api.tools import net_speed_test; net_speed_test()\"\n" + ), + param_descriptions={}), + _build_feature("VatInvoiceOCR2Excel", "增值税发票识别 → Excel", + "调用百度智能云 OCR 识别发票图片 / PDF 中的关键字段,导出为 Excel 文件。支持单张/批量。", + "office.api.ocr", "VatInvoiceOCR2Excel", + cli_only=True, + cli_command=( + "# =================== 增值税发票识别 使用说明 ===================\n" + "#\n" + "# 📌 原理:调用百度智能云的「增值税发票识别」API(OCR 引擎),\n" + "# 把图片/PDF 中的发票号、金额、税额、销售方等字段识别后写入 Excel。\n" + "#\n" + "# ─────────── 1. 申请百度智能云 OCR 凭据(必须)───────────\n" + "# a) 注册账号:https://ai.baidu.com/\n" + "# b) 顶部菜单「文字识别 OCR」→「增值税发票识别」→ 点击「立即使用」\n" + "# c) 创建应用:\n" + "# - 应用名称:python-office(随便起)\n" + "# - 应用类型:选择「纯应用」或「服务端」\n" + "# - 勾选「增值税发票识别」服务\n" + "# d) 创建完成后,在「应用列表」里点开,看「API Key」和「Secret Key」\n" + "# 复制下来 → 填入下方命令的 id / key 参数\n" + "#\n" + "# ─────────── 2. 安装依赖(首次)───────────\n" + "pip install python-office[ocr] openpyxl\n" + "\n" + "# ─────────── 3. 准备发票图片/PDF ───────────\n" + "# - 单张:d:/invoices/01.jpg\n" + "# - 批量:把所有发票放到 d:/invoices/ 目录(支持 jpg/png/pdf)\n" + "\n" + "# ─────────── 4. 命令行调用 ───────────\n" + "# 单张:\n" + "python -c \"\n" + "from office.api.ocr import VatInvoiceOCR2Excel\n" + "VatInvoiceOCR2Excel(\n" + " input_path='d:/invoices/01.jpg',\n" + " output_path='d:/result',\n" + " output_excel='invoices.xlsx',\n" + " id='你的 API Key',\n" + " key='你的 Secret Key',\n" + " file_name=True, # 用文件名当 Sheet 名(多发票时建议开启)\n" + " trans=False, # 是否同时翻译为英文(按需)\n" + ")\"\n" + "\n" + "# 批量(传入目录):\n" + "python -c \"\n" + "from office.api.ocr import VatInvoiceOCR2Excel\n" + "VatInvoiceOCR2Excel(\n" + " input_path='d:/invoices/',\n" + " output_path='d:/result',\n" + " id='你的 API Key',\n" + " key='你的 Secret Key',\n" + " file_name=True,\n" + ")\"\n" + "\n" + "# ─────────── 5. 输出 ───────────\n" + "# 生成 d:/result/invoices.xlsx,Sheet 名 = 文件名\n" + "# 每行一张发票的关键字段(发票号、购买方、销售方、金额、税额等)\n" + "\n" + "# ─────────── 6. 常见问题 ───────────\n" + "# Q: 报错「access_token invalid」?\n" + "# A: id / key 填错了,或应用未勾选「增值税发票识别」服务。\n" + "#\n" + "# Q: 报错「image format error」?\n" + "# A: 图片损坏 / 格式不支持 → 重新导出 jpg/png;PDF 用 pdf 转图工具预处理。\n" + "\n" + "# Q: 每天免费额度?\n" + "# A: 百度 OCR 增值税发票识别有赠送免费额度(几百~几千次/月),够个人用。\n" + ), + param_overrides={ + "img_url": {"kind": "str"}, + "id": {"label": "百度 API Key ID"}, + "key": {"label": "百度 Secret Key"}, + "file_name": {"kind": "bool"}, + "trans": {"kind": "bool"}, + }, + param_descriptions={ + "input_path": "发票图片 / PDF 的路径,或包含多张发票的目录。", + "output_path": "输出 Excel 的保存目录。", + "output_excel": "输出 Excel 文件名(默认 VatInvoiceOCR2Excel.xlsx)。", + "img_url": "可选:在线发票图片 URL(与 input_path 二选一)。", + "id": "百度智能云 OCR 应用的 Access Key ID(在百度智能云控制台创建「文字识别 OCR」应用后获取)。", + "key": "百度智能云 OCR 应用的 Secret Key。", + "file_name": "True=用图片文件名作为 Sheet 名(多张发票时建议开启)。", + "trans": "True=同时把识别结果翻译为英文。", + }), + _build_feature("t0", "T+0 交易成本计算", "计算股票 T+0 交易费率与净收益。", + "office.api.finance", "t0", + param_descriptions={ + "buy_price": "买入价格(元 / 股)。", + "sale_price": "卖出价格(元 / 股)。", + "shares": "交易股数。", + "w_rate": "佣金费率(默认 2.5/10000 = 万 2.5 = 0.025%)。", + "min_rate": "单笔最低手续费(元,默认 5;交易额 ≤ 20000 时按此收)。", + "stamp_tax": "印花税率(默认 1/1000 = 千 1 = 0.1%,卖出时扣)。", + }), + _build_feature("course", "项目信息", "显示 python-office 项目信息和资源链接。", + "office.api.tools", "course", + info_html=( + "

📦 python-office

" + "

One-line Python automation for office & daily work.

" + "
" + "

📚 学习资源

" + "
" + "

💬 社区交流

" + "" + "

🎯 子模块速查

" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "
分类子包功能
📕 PDFpopdfPDF 转 Word / Excel / 图片、合并、拆分、加密
📊 Excelpoexcel批量拆分 Excel、按列拆分、合并
📝 WordpowordWord 转 PDF、批量加水印
🎨 PPTpoppt批量生成 PPT
🖼️ 图片poimage加水印、压缩、转字符画、GIF 拼接
📂 文件pofile批量重命名、整理文件、按类型分类
📧 邮件poemail群发邮件、读取收件箱
🔍 OCRpoocr增值税发票识别 → Excel
🎬 视频povideo视频合成、压缩、提取音频
💬 微信PyOfficeRobot自动发消息、关键词回复、智能机器人
🛠️ 工具wftools翻译、二维码、随机密码、天气、网速
📈 金融pofinance股票 T+0 交易成本计算
" + "
" + "

" + "💡 安装指定子模块:pip install python-office[pdf,excel,...]
" + "💡 安装所有子模块:pip install python-office[all]
" + "💡 Windows 全功能:pip install python-office[all-windows]" + "

" + ), + param_descriptions={}), + ] + cats.append(c) + + # --- 微信机器人(按官方文档 https://www.python-office.com/modules/wechat/api 的 7 个函数)--- + c = _cat("wechat", "微信机器人", "💬", 120) + c.features = [ + _build_feature("send_message", "发送消息", "向好友 / 群发送文本消息。", + "office.api.wechat", "send_message", + cli_only=True, + cli_command=( + "# 前置:PC 微信客户端已登录\n" + "python -c \"from office.api.wechat import send_message; send_message('好友备注', '消息内容')\"\n" + ), + param_descriptions={ + "who": "好友昵称 / 群名(如 '文件传输助手' / '家人')。", + "message": "要发送的文本内容。", + }), + _build_feature("send_message_by_time", "定时发送", "在指定时间自动发送消息。", + "office.api.wechat", "send_message_by_time", + cli_only=True, + cli_command=( + "# 前置:PC 微信客户端已登录;到点前请保持程序运行\n" + "python -c \"from office.api.wechat import send_message_by_time; " + "send_message_by_time('好友备注', '消息内容', '2026-06-15 09:00:00')\"\n" + "# 程序会一直等到指定时间才发,期间保持窗口不关闭\n" + ), + param_descriptions={ + "who": "好友昵称 / 群名。", + "message": "要发送的文本内容。", + "time": "定时发送时间,格式 'YYYY-MM-DD HH:MM:SS',如 '2026-06-15 09:00:00'。", + }), + _build_feature("chat_by_keywords", "关键词自动回复", "根据关键词字典自动回复好友 / 群。", + "office.api.wechat", "chat_by_keywords", + cli_only=True, + cli_command=( + "# 前置:PC 微信客户端已登录\n" + "# keywords 传 dict,例如 {\"你好\": \"你好!\", \"在吗\": \"在的\"}\n" + "python -c \"from office.api.wechat import chat_by_keywords; " + "chat_by_keywords('好友备注', {'你好': '你好!'})\"\n" + "# 进入无限循环监听,按 Ctrl+C 退出\n" + ), + param_overrides={ + "keywords": {"kind": "text"}, + }, + param_descriptions={ + "who": "要监听的好友 / 群名。", + "keywords": "关键词 → 回复内容的字典,多行文本框输入 JSON,例如 {\"你好\": \"你好!\"}。", + }), + _build_feature("send_file", "发送文件", "通过微信发送本地文件给好友 / 群。", + "office.api.wechat", "send_file", + cli_only=True, + cli_command=( + "# 前置:PC 微信客户端已登录\n" + "python -c \"from office.api.wechat import send_file; send_file('好友备注', 'C:/path/file.pdf')\"\n" + ), + param_descriptions={ + "who": "好友昵称 / 群名。", + "file": "要发送的文件路径。", + }), + _build_feature("group_send", "群发消息", "按预设群组列表批量发送消息。", + "office.api.wechat", "group_send", + cli_only=True, + cli_command=( + "# 前置:PC 微信客户端已登录;首次运行会弹窗让你配置群组名单\n" + "python -c \"from office.api.wechat import group_send; group_send()\"\n" + "# 会弹出 PyQt5 配置窗口,配置好后保存为本地名单文件\n" + ), + param_descriptions={}), + _build_feature("receive_message", "接收消息", "监听并保存收到的微信消息。", + "office.api.wechat", "receive_message", + cli_only=True, + cli_command=( + "# 前置:PC 微信客户端已登录\n" + "python -c \"from office.api.wechat import receive_message; " + "receive_message('好友备注', 'userMessage.txt', 'C:/path/save')\"\n" + "# 进入无限循环监听,按 Ctrl+C 退出\n" + ), + param_overrides={ + "output_path": {"kind": "dir"}, + }, + param_descriptions={ + "who": "要监听的好友 / 群名(默认 '文件传输助手')。", + "txt": "消息保存的文本文件名(默认 'userMessage.txt')。", + "output_path": "消息文件保存目录。", + }), + _build_feature("chat_robot", "智能聊天(微信自动回复机器人)", + "在 PC 微信上监听指定好友的消息,自动调用大模型(GPT/DeepSeek/智谱等)生成回复并发回给对方。", + "office.api.wechat", "chat_robot", + cli_only=True, + cli_command=( + "# 前置条件:\n" + "# 1. PC 微信客户端已登录\n" + "# 2. 配置了大模型 API Key(OpenAI / DeepSeek / 智谱 等)\n" + "#\n" + "# 启动方式 —— 在 PowerShell / CMD 中执行:\n" + "python -c \"from office.api.wechat import chat_robot; chat_robot('好友备注名')\"\n" + "\n" + "# 或交互式 Python:\n" + "python\n" + ">>> from office.api.wechat import chat_robot\n" + ">>> chat_robot('好友备注名') # 进入无限循环监听\n" + "# 按 Ctrl+C 退出\n" + ), + platform_note="依赖 PC 微信客户端 + 大模型 API Key;运行后会无限循环,需 Ctrl+C 退出。", + param_descriptions={ + "who": "聊天对象(微信里的备注名称,不支持特殊字符,默认 '程序员晚枫')。", + }), + ] + cats.append(c) + + gui_cats: list[Category] = [] + for cat in cats: + features = [feat for feat in cat.features if not feat.cli_only] + if features: + gui_cats.append(Category( + id=cat.id, + title=cat.title, + icon=cat.icon, + order=cat.order, + features=features, + )) + + gui_cats.sort(key=lambda x: x.order) + return gui_cats + + +def find_feature(categories: list[Category], feature_id: str) -> Optional[Feature]: + """按 id 查找 Feature。""" + for cat in categories: + for feat in cat.features: + if feat.id == feature_id: + return feat + return None diff --git a/gui/run.py b/gui/run.py new file mode 100644 index 0000000..d3be50a --- /dev/null +++ b/gui/run.py @@ -0,0 +1,154 @@ +# -*- coding: utf-8 -*- +"""启动入口:``python -m gui.run`` 或 ``python-office-gui``。""" + +import os +import sys +import importlib +from pathlib import Path + +sys.dont_write_bytecode = True +os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1") + + +def _runtime_log(base: Path, message: str) -> None: + if not os.environ.get("PYTHON_OFFICE_GUI_DEBUG"): + return + try: + with (base / "python-office-gui-runtime.log").open( + "a", encoding="utf-8" + ) as fh: + fh.write(message.rstrip() + "\n") + except OSError: + pass + + +def _prepare_bundled_runtime() -> None: + """Allow Nuitka standalone builds to load copied pure/binary packages. + + The build script places large third-party packages in ``/lib`` so + they can remain normal Python packages instead of being compiled into C. + """ + base = Path(sys.executable).resolve().parent + lib_dir = base / "lib" + if not lib_dir.is_dir(): + return + + lib_path = str(lib_dir) + if lib_path not in sys.path: + sys.path.insert(0, lib_path) + for extra in ( + lib_dir / "win32", + lib_dir / "win32" / "lib", + lib_dir / "site-packages" / "win32" / "lib", + ): + if extra.is_dir(): + extra_path = str(extra) + if extra_path not in sys.path: + sys.path.insert(0, extra_path) + os.environ["PATH"] = lib_path + os.pathsep + os.environ.get("PATH", "") + + dll_dirs = [lib_dir] + for child_name in ( + "numpy.libs", + "pandas.libs", + "cv2", + "PIL", + "pymupdf", + "pywin32_system32", + "win32", + ): + child = lib_dir / child_name + if child.is_dir(): + dll_dirs.append(child) + + add_dll_directory = getattr(os, "add_dll_directory", None) + for dll_dir in dll_dirs: + dll_path = str(dll_dir) + os.environ["PATH"] = dll_path + os.pathsep + os.environ.get("PATH", "") + if add_dll_directory is not None: + try: + add_dll_directory(dll_path) + except OSError as exc: + _runtime_log(base, f"add_dll_directory failed: {dll_path}: {exc}") + + _runtime_log(base, f"bundled lib enabled: {lib_dir}") + _runtime_log(base, "dll dirs: " + "; ".join(str(p) for p in dll_dirs)) + + +_prepare_bundled_runtime() + + +def _run_self_test() -> int: + base = Path(sys.executable).resolve().parent + modules = [ + "office.api.pdf", + "office.api.excel", + "office.api.word", + "office.api.ppt", + "office.api.image", + "office.api.file", + "office.api.video", + "office.api.email", + "office.api.tools", + "popdf", + "poexcel", + "poword", + "poppt", + "poimage", + "pofile", + "povideo", + "poemail", + "wftools", + "numpy", + "pandas", + "fitz", + "cv2", + "PIL", + ] + failures = [] + for module_name in modules: + try: + importlib.import_module(module_name) + except Exception as exc: + failures.append(f"{module_name}: {exc!r}") + + log_path = base / "python-office-gui-self-test.log" + with log_path.open("w", encoding="utf-8") as fh: + if failures: + fh.write("FAILED\n") + fh.write("\n".join(failures)) + fh.write("\n") + else: + fh.write("OK\n") + return 1 if failures else 0 + + +if "--self-test" in sys.argv: + sys.exit(_run_self_test()) + + +def _run_startup_probe() -> int: + import time + + base = Path(sys.executable).resolve().parent if getattr(sys, "frozen", False) else Path.cwd() + start = time.perf_counter() + from gui.app import create_app + from gui.main_window import MainWindow + + app = create_app([]) + window = MainWindow() + elapsed = time.perf_counter() - start + with (base / "python-office-gui-startup-probe.log").open("w", encoding="utf-8") as fh: + fh.write(f"{elapsed:.3f}\n") + window.deleteLater() + app.quit() + return 0 + + +if "--startup-probe" in sys.argv: + sys.exit(_run_startup_probe()) + +from gui.app import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/gui/styles.py b/gui/styles.py new file mode 100644 index 0000000..1389b7d --- /dev/null +++ b/gui/styles.py @@ -0,0 +1,466 @@ +# -*- coding: utf-8 -*- +"""Global QSS theme for a lighter desktop productivity interface.""" + +from __future__ import annotations + +import base64 + +# UI/UX Pro Max direction: bright productivity teal with restrained orange CTA. +CANVAS = "#F0FDFA" +SIDEBAR = "#F8FFFC" +SURFACE = "#FFFFFF" +SURFACE_ALT = "#ECFDF5" +SURFACE_SOLID = SURFACE +LINE = "rgba(13, 148, 136, 0.16)" +LINE_STRONG = "rgba(13, 148, 136, 0.34)" +TEXT = "#134E4A" +TEXT_ON_DARK = "#0F2F2C" +TEXT_MUTED = "#5F7F7A" +TEXT_DIM = "#7A9A95" +BLUE = "#0D9488" +BLUE_HOVER = "#14B8A6" +BLUE_SOFT = "rgba(20, 184, 166, 0.12)" +BLUE_PRESS = "#0F766E" +RED = "#F97316" +RED_HOVER = "#FB923C" +RED_SOFT = "rgba(249, 115, 22, 0.14)" +GREEN = "#10B981" + +# Backward-compatible names used by custom-painted widgets. +BASE = CANVAS +MANTLE = SIDEBAR +CRUST = LINE +SURFACE0 = LINE +SURFACE1 = LINE_STRONG +SURFACE2 = TEXT_DIM +LAKE = BLUE +LAKE_DEEP = BLUE_PRESS +LAKE_DARK = TEXT +LAKE_LIGHT = BLUE_HOVER +LAKE_SOFT = BLUE_SOFT +LAKE_TINT = "rgba(36, 201, 244, 0.10)" +AMBER = RED +AMBER_DARK = "#C2410C" +SUCCESS = GREEN +DANGER = RED +DANGER_LIGHT = RED_SOFT + +TERMINAL_BG = "#F8FAFC" +TERMINAL_FG = "#134E4A" +TERMINAL_ACC = BLUE + +RADIUS_SM = 8 +RADIUS_MD = 10 +RADIUS_LG = 14 +SIDEBAR_WIDTH = 292 + +_CHECKMARK_SVG = ( + '' + '' + '' +) +_CHECKMARK_URL = "data:image/svg+xml;base64," + base64.b64encode( + _CHECKMARK_SVG.encode("utf-8") +).decode("ascii") + +QSS = f""" +* {{ + font-family: "Segoe UI", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "SF Pro Text", sans-serif; + font-size: 13px; + color: {TEXT}; + outline: 0; +}} + +QMainWindow, QWidget#centralWidget {{ + background: qlineargradient( + x1:0, y1:0, x2:1, y2:1, + stop:0 #F0FDFA, + stop:0.44 #ECFEFF, + stop:0.74 #F8FAFC, + stop:1 #FFF7ED + ); +}} + +QMenuBar {{ + background: rgba(255, 255, 255, 0.86); + color: {TEXT_MUTED}; + padding: 5px 10px; + border: none; + font-weight: 600; +}} + +QMenuBar::item {{ + background: transparent; + padding: 6px 12px; + border-radius: {RADIUS_SM}px; +}} + +QMenuBar::item:selected {{ + background: {BLUE_SOFT}; + color: {TEXT}; +}} + +QMenu {{ + background: rgba(255, 255, 255, 0.98); + border: 1px solid {LINE_STRONG}; + border-radius: {RADIUS_MD}px; + padding: 6px; +}} + +QMenu::item {{ + color: {TEXT}; + padding: 8px 28px 8px 16px; + border-radius: {RADIUS_SM}px; +}} + +QMenu::item:selected {{ + background: {BLUE_SOFT}; +}} + +QMenu::separator {{ + height: 1px; + background: {LINE}; + margin: 5px 8px; +}} + +QWidget#sidebar {{ + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 #FFFFFF, + stop:0.58 #F8FFFC, + stop:1 #ECFDF5 + ); + border: none; + border-right: 1px solid rgba(13, 148, 136, 0.18); +}} + +QScrollArea#categoryList, +QScrollArea {{ + background: transparent; + border: none; +}} + +QScrollArea::viewport, +QWidget#pageContent {{ + background: transparent; +}} + +QWidget#sidebarInner {{ + background: transparent; +}} + +QLabel#sidebarAuthor {{ + color: #5F7F7A; + background: rgba(240, 253, 250, 0.82); + border-top: 1px solid rgba(13, 148, 136, 0.14); + padding: 7px 22px; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.2px; +}} + +QLabel#sidebarCategoryHeader {{ + background: transparent; + border: none; + padding: 0; +}} + +QToolButton#sidebarFeatureBtn {{ + background: transparent; + border: none; + padding: 0; + margin: 0; + text-align: left; +}} + +QStackedWidget#pages, +QWidget#pageRoot {{ + background: transparent; + border: none; +}} + +QLabel#pageTitle {{ + font-size: 28px; + font-weight: 750; + color: {TEXT}; + padding: 2px 0 0 0; + background: transparent; +}} + +QLabel#pageDesc {{ + color: {TEXT_MUTED}; + font-size: 13px; + padding: 0 0 12px 0; + background: transparent; + line-height: 1.35; +}} + +QFrame#card, QWidget#card {{ + background: {SURFACE}; + border: 1px solid rgba(13, 148, 136, 0.14); + border-radius: {RADIUS_MD}px; +}} + +QFrame#card QLabel, QWidget#card QLabel {{ + color: {TEXT}; + background: transparent; +}} + +QLabel#cardTitle {{ + font-size: 12px; + font-weight: 800; + color: #0F766E; + letter-spacing: 0.4px; + padding: 0; + margin: 0; + text-transform: uppercase; + background: transparent; +}} + +QPushButton {{ + background: #FFFFFF; + color: {TEXT}; + border: 1px solid rgba(13, 148, 136, 0.22); + border-radius: {RADIUS_MD}px; + padding: 8px 16px; + min-height: 20px; + font-weight: 700; +}} + +QPushButton:hover {{ + background: #F0FDFA; + border-color: rgba(13, 148, 136, 0.50); +}} + +QPushButton:pressed {{ + background: #CCFBF1; +}} + +QPushButton:disabled {{ + color: #83A0B5; + background: rgba(215, 228, 239, 0.68); + border-color: rgba(215, 228, 239, 0.68); +}} + +QPushButton:focus {{ + border: 2px solid {BLUE}; + padding: 7px 15px; +}} + +QPushButton#primaryButton {{ + background: {RED}; + color: #FFFFFF; + border: 1px solid rgba(194, 65, 12, 0.22); + font-weight: 800; + padding: 9px 18px; +}} + +QPushButton#primaryButton:hover {{ + background: {RED_HOVER}; +}} + +QPushButton#primaryButton:pressed {{ + background: #EA580C; + color: white; +}} + +QPushButton#dangerButton {{ + background: #FFFFFF; + color: #DC2626; + border: 1px solid rgba(220, 38, 38, 0.24); +}} + +QPushButton#dangerButton:hover {{ + background: rgba(254, 242, 242, 0.92); + border-color: rgba(220, 38, 38, 0.50); +}} + +QLineEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox, QComboBox {{ + background: #FFFFFF; + color: {TEXT}; + border: 1px solid rgba(13, 148, 136, 0.22); + border-radius: {RADIUS_MD}px; + padding: 8px 11px; + selection-background-color: {BLUE}; + selection-color: #FFFFFF; +}} + +QLineEdit:hover, QPlainTextEdit:hover, QSpinBox:hover, +QDoubleSpinBox:hover, QComboBox:hover {{ + border-color: rgba(13, 148, 136, 0.44); +}} + +QLineEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, +QDoubleSpinBox:focus, QComboBox:focus {{ + background: #FFFFFF; + border: 2px solid {BLUE}; + padding: 7px 10px; +}} + +QLineEdit:disabled, QPlainTextEdit:disabled, QSpinBox:disabled, +QDoubleSpinBox:disabled, QComboBox:disabled {{ + color: #83A0B5; + background: rgba(215, 228, 239, 0.68); + border-color: rgba(215, 228, 239, 0.68); +}} + +QComboBox {{ + padding-right: 26px; +}} + +QComboBox::drop-down {{ + border: none; + width: 24px; + subcontrol-origin: padding; + subcontrol-position: top right; +}} + +QComboBox::down-arrow {{ + image: none; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 5px solid #527D77; + margin-right: 8px; +}} + +QComboBox QAbstractItemView {{ + background: #FFFFFF; + border: 1px solid rgba(13, 148, 136, 0.28); + border-radius: {RADIUS_MD}px; + selection-background-color: {BLUE_SOFT}; + selection-color: {TEXT}; + outline: 0; + padding: 4px; +}} + +QCheckBox, QRadioButton {{ + spacing: 8px; + color: {TEXT}; + padding: 2px 0; +}} + +QCheckBox::indicator, QRadioButton::indicator {{ + width: 18px; + height: 18px; + border-radius: 5px; + border: 1px solid rgba(13, 148, 136, 0.34); + background: #FFFFFF; +}} + +QRadioButton::indicator {{ + border-radius: 9px; +}} + +QCheckBox::indicator:checked {{ + background: {BLUE}; + border-color: {BLUE}; + image: url({_CHECKMARK_URL}); +}} + +QRadioButton::indicator:checked {{ + background: {BLUE}; + border-color: {BLUE}; +}} + +QPlainTextEdit#logView {{ + background: {TERMINAL_BG}; + color: {TERMINAL_FG}; + border: 1px solid rgba(13, 148, 136, 0.16); + border-radius: {RADIUS_MD}px; + padding: 12px 14px; + font-family: "SF Mono", "Cascadia Code", "Consolas", monospace; + font-size: 12px; + selection-background-color: {BLUE}; + selection-color: #FFFFFF; +}} + +QToolButton {{ + background: #FFFFFF; + color: {TEXT}; + border: 1px solid rgba(13, 148, 136, 0.22); + border-radius: {RADIUS_MD}px; + padding: 7px 10px; +}} + +QToolButton:hover {{ + background: #F0FDFA; + border-color: rgba(13, 148, 136, 0.44); +}} + +QScrollBar:vertical {{ + background: transparent; + width: 10px; + margin: 4px 2px; + border: none; +}} + +QScrollBar::handle:vertical {{ + background: rgba(13, 148, 136, 0.22); + border-radius: 5px; + min-height: 36px; +}} + +QScrollBar::handle:vertical:hover {{ + background: rgba(13, 148, 136, 0.42); +}} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; + background: none; +}} + +QScrollBar:horizontal {{ + background: transparent; + height: 10px; + margin: 2px 4px; + border: none; +}} + +QScrollBar::handle:horizontal {{ + background: rgba(13, 148, 136, 0.22); + border-radius: 5px; + min-width: 36px; +}} + +QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ + width: 0; + background: none; +}} + +QStatusBar {{ + background: rgba(255, 255, 255, 0.88); + color: {TEXT_MUTED}; + border-top: 1px solid rgba(13, 148, 136, 0.14); + padding: 5px 14px; + font-size: 12px; +}} + +QSplitter::handle {{ + background: rgba(13, 148, 136, 0.14); +}} + +QSplitter::handle:horizontal {{ + width: 1px; +}} + +QSplitter::handle:vertical {{ + height: 1px; +}} + +QToolTip {{ + background: rgba(15, 47, 44, 0.96); + color: white; + border: 1px solid rgba(20, 184, 166, 0.34); + border-radius: {RADIUS_SM}px; + padding: 6px 10px; + font-size: 12px; +}} +""" + + +def apply_theme(app) -> None: + app.setStyleSheet(QSS) diff --git a/gui/tests/__init__.py b/gui/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gui/tests/test_smoke.py b/gui/tests/test_smoke.py new file mode 100644 index 0000000..c73375f --- /dev/null +++ b/gui/tests/test_smoke.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +"""Lightweight tests for the optional PySide6 GUI layer.""" + +from __future__ import annotations + +import warnings + + +def test_deprecated_params_warns_and_remaps(): + from office.lib.decorator_utils import deprecated_params + + @deprecated_params({"old_name": "new_name"}) + def fn(new_name=None, old_name=None): + return new_name + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = fn(old_name="x") + + assert result == "x" + assert any(issubclass(item.category, DeprecationWarning) for item in caught) + + +def test_registry_builds_expected_categories(): + from gui.registry import build_registry + + cats = build_registry() + ids = {cat.id for cat in cats} + + assert ids >= { + "pdf", + "excel", + "word", + "ppt", + "image", + "file", + "video", + "email", + "tools", + } + assert all(cat.features for cat in cats) + + +def test_feature_params_are_resolved_lazily(): + from gui.registry import build_registry, resolve_feature + + cats = build_registry() + feature = next( + item + for cat in cats + if cat.id == "pdf" + for item in cat.features + if item.id == "pdf2docx" + ) + + assert feature.resolved is False + assert feature.params == [] + + resolve_feature(feature) + + assert feature.resolved is True + assert {param.name for param in feature.params} >= { + "input_file", + "output_file", + "input_path", + "output_path", + } + + +def test_feature_cards_are_actionable_or_explained(): + from gui.registry import build_registry + + cats = build_registry() + + features = [feature for cat in cats for feature in cat.features] + + assert features + assert all( + not feature.cli_only or feature.cli_command + for feature in features + ) + + +def test_param_to_widget_value_conversions(): + from gui.registry import Param + + assert Param(name="x", label="X", kind="int").to_widget_value("42") == 42 + assert Param(name="x", label="X", kind="int").to_widget_value("bad") is None + assert Param(name="x", label="X", kind="float").to_widget_value("3.14") == 3.14 + assert Param(name="x", label="X", kind="files").to_widget_value("a.pdf;b.pdf") == [ + "a.pdf", + "b.pdf", + ] diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py new file mode 100644 index 0000000..7c61894 --- /dev/null +++ b/gui/widgets/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +"""GUI 通用控件包。""" + +from gui.widgets.param_form import ( + BoolField, ChoiceField, FileField, FloatField, IntField, + ParamForm, TextField, make_field, +) + +__all__ = [ + "BoolField", "ChoiceField", "FileField", "FloatField", "IntField", + "ParamForm", "TextField", "make_field", +] diff --git a/gui/widgets/param_form.py b/gui/widgets/param_form.py new file mode 100644 index 0000000..af1f5c0 --- /dev/null +++ b/gui/widgets/param_form.py @@ -0,0 +1,392 @@ +# -*- coding: utf-8 -*- +"""通用控件:单参数表单。""" + +from __future__ import annotations + +import os +from typing import Any, Callable, Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, + QLabel, QLineEdit, QPlainTextEdit, QPushButton, QSizePolicy, QSpinBox, + QToolButton, QWidget, +) + +from gui.registry import Param + + +class _BaseField(QWidget): + """抽象字段:暴露 value() / setValue() / clear()。""" + + valueChanged = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + # 关键:高度由内容决定、宽度可缩, + # 但绝不被父布局在垂直方向上挤压成 0。 + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.setMinimumHeight(32) + + def value(self) -> Any: ... + def setValue(self, v: Any) -> None: ... + def clear(self) -> None: ... + + +class TextField(_BaseField): + def __init__(self, kind: str = "str", parent=None): + super().__init__(parent) + self._kind = kind + # password 走 QLineEdit(echo mode),text 走 QPlainTextEdit(多行),其余 QLineEdit + if kind == "password": + self._edit = QLineEdit() + self._edit.setEchoMode(QLineEdit.Password) + self._edit.setPlaceholderText("请输入...") + elif kind == "text": + self._edit = QPlainTextEdit() + self._edit.setPlaceholderText("请输入...") + self._edit.setFixedHeight(72) + else: + self._edit = QLineEdit() + self._edit.setPlaceholderText("请输入...") + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._edit) + + def value(self): + if isinstance(self._edit, QPlainTextEdit): + return self._edit.toPlainText() + return self._edit.text() + + def setValue(self, v): + if v is None: + return + if isinstance(self._edit, QPlainTextEdit): + self._edit.setPlainText(str(v)) + else: + self._edit.setText(str(v)) + + def clear(self): + if isinstance(self._edit, QPlainTextEdit): + self._edit.clear() + else: + self._edit.clear() + + +class IntField(_BaseField): + def __init__(self, parent=None): + super().__init__(parent) + self._spin = QSpinBox() + self._spin.setRange(-1_000_000, 1_000_000) + self._spin.setValue(0) + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._spin) + + def value(self): + return self._spin.value() + + def setValue(self, v): + if v is not None: + self._spin.setValue(int(v)) + + def clear(self): + self._spin.setValue(0) + + +class FloatField(_BaseField): + def __init__(self, parent=None): + super().__init__(parent) + self._spin = QDoubleSpinBox() + self._spin.setRange(-1e9, 1e9) + self._spin.setDecimals(4) + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._spin) + + def value(self): + return self._spin.value() + + def setValue(self, v): + if v is not None: + self._spin.setValue(float(v)) + + def clear(self): + self._spin.setValue(0.0) + + +class BoolField(_BaseField): + def __init__(self, parent=None): + super().__init__(parent) + self._cb = QCheckBox("启用") + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._cb) + + def value(self): + return self._cb.isChecked() + + def setValue(self, v): + if v is not None: + self._cb.setChecked(bool(v)) + + def clear(self): + self._cb.setChecked(False) + + +class ChoiceField(_BaseField): + def __init__(self, choices: list, parent=None): + super().__init__(parent) + self._combo = QComboBox() + self._combo.addItems([str(c) for c in choices]) + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._combo) + + def value(self): + return self._combo.currentText() + + def setValue(self, v): + if v is None: + return + idx = self._combo.findText(str(v)) + if idx >= 0: + self._combo.setCurrentIndex(idx) + + def clear(self): + if self._combo.count(): + self._combo.setCurrentIndex(0) + + +class FileField(_BaseField): + """单文件 / 多文件 / 目录 / 另存为 通用选择器。 + + mode 取值: + - ``file`` : 选已存在的单个文件(QFileDialog.getOpenFileName) + - ``files`` : 选已存在的多个文件,多个用分号拼接 + - ``dir`` : 选目录(QFileDialog.getExistingDirectory) + - ``save`` : 选保存位置(QFileDialog.getSaveFileName),用于 output_* 等参数 + """ + + def __init__(self, mode: str = "file", file_filter: str = "", + parent=None): + super().__init__(parent) + if mode not in ("file", "files", "dir", "save"): + mode = "file" + self._mode = mode + self._filter = file_filter or "All files (*.*)" + + self._edit = QLineEdit() + self._edit.setPlaceholderText({ + "file": "选择文件...", + "files": "选择多个文件(可分号 ; 拼接)...", + "dir": "选择目录...", + "save": "另存为...", + }[mode]) + + self._browse = QToolButton() + self._browse.setText("..." if mode == "save" else "Browse") + self._browse.setCursor(Qt.PointingHandCursor) + self._browse.setToolTip({ + "file": "选择文件", + "files": "选择多个文件", + "dir": "选择目录", + "save": "另存为", + }[mode]) + self._browse.clicked.connect(self._browse_clicked) + + # 把 QLineEdit 的 textChanged 透传为 valueChanged, + # 供 ParamForm 监听以做"派生字段跟随"逻辑。 + # textChanged 带 1 个 str 参数,valueChanged 不带参,这里用 lambda 显式丢弃。 + self._edit.textChanged.connect(lambda *_a: self.valueChanged.emit()) + + lay = QHBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(self._edit, 1) + lay.addWidget(self._browse) + + def _browse_clicked(self): + if self._mode == "dir": + path = QFileDialog.getExistingDirectory(self, "选择目录") + if path: + self._edit.setText(path) + elif self._mode == "save": + # 另存为:把当前文本作为默认路径/文件名 + current = self._edit.text() or "" + path, _ = QFileDialog.getSaveFileName( + self, "另存为", current, self._filter + ) + if path: + self._edit.setText(path) + else: + paths, _ = QFileDialog.getOpenFileNames(self, "选择文件", "", self._filter) + if paths: + self._edit.setText(";".join(paths) if self._mode == "files" else paths[0]) + + def value(self): + return self._edit.text() or None + + def setValue(self, v): + if v is not None: + self._edit.setText(str(v)) + + def clear(self): + self._edit.clear() + + +def make_field(param: Param) -> _BaseField: + """根据 Param.kind 创建对应控件。""" + if param.kind in ("file", "files", "dir", "save"): + return FileField(mode=param.kind, file_filter=param.file_filter) + if param.kind == "int": + return IntField() + if param.kind == "float": + return FloatField() + if param.kind == "bool": + return BoolField() + if param.kind in ("text",): + return TextField(kind="text") + if param.kind == "password": + return TextField(kind="password") + if param.kind == "choice" and param.choices: + return ChoiceField(param.choices) + return TextField(kind="str") + + +class ParamForm(QWidget): + """根据 Param 列表自动生成表单。 + + 派生字段自动跟随:当 ``output_file`` 这类 save 字段的对应 ``input_file`` + 变化时,output 字段会被重置为基于新 input 派生的默认路径, + 避免"input 已换但 output 还指向上一个文件"的问题。 + """ + + valueChanged = Signal() + + def __init__(self, params: list[Param], parent=None): + super().__init__(parent) + from PySide6.QtWidgets import QFormLayout + + self._params: list[Param] = list(params) + self._fields: dict[str, _BaseField] = {} + # save_name -> input_name 的配对关系 + self._save_pairs: dict[str, str] = {} + + layout = QFormLayout(self) + layout.setLabelAlignment(Qt.AlignLeft | Qt.AlignVCenter) + layout.setHorizontalSpacing(18) + layout.setVerticalSpacing(12) + layout.setContentsMargins(0, 0, 0, 0) + + for p in self._params: + field = make_field(p) + if p.default is not None: + field.setValue(p.default) + label_text = p.label + (" *" if p.required else "") + label = QLabel(label_text) + if p.placeholder and p.kind not in ("bool",): + if hasattr(field, "_edit"): + field._edit.setPlaceholderText(p.placeholder) + # 参数说明:同时挂到 label 和 field 上,鼠标悬停任意位置都能看到 + if p.description: + tip = p.description + label.setToolTip(tip) + field.setToolTip(tip) + layout.addRow(label, field) + self._fields[p.name] = field + field.valueChanged.connect( + lambda n=p.name: self._on_field_changed(n) + ) + + # 构建 save-input 配对,并触发一次初始派生 + for p in self._params: + if p.kind == "save": + input_name = self._pair_to_input(p.name) + if input_name in self._fields: + # input 字段为 dir 时不参与配对:dir 没有"文件名"概念, + # 不应触发 output_file 的自动派生(避免 with_suffix 出错)。 + input_param = next( + (pp for pp in self._params if pp.name == input_name), None + ) + if input_param and input_param.kind == "dir": + continue + self._save_pairs[p.name] = input_name + self._refresh_save_field(p.name) + + # ----- 派生字段联动 ----- + @staticmethod + def _pair_to_input(save_name: str) -> str: + """``output_xxx`` ↔ ``input_xxx``,``xxx_output`` ↔ ``xxx_input``。""" + if save_name.startswith("output_"): + return "input_" + save_name[len("output_"):] + if save_name.endswith("_output"): + return save_name[: -len("_output")] + "_input" + return "" + + @staticmethod + def _suffix_from_filter(flt: str) -> str: + """从 ``"Word documents (*.docx)"`` 中抽出 ``.docx``;``*.*`` 返回空。""" + import re + if not flt: + return "" + m = re.search(r"\*\.([a-zA-Z0-9]+)", flt) + if not m: + return "" + ext = m.group(1) + # 过滤 ``*.*`` 这种"任意文件"伪后缀 + if "*" in ext or "?" in ext: + return "" + return "." + ext + + def _on_field_changed(self, name: str) -> None: + """任一字段变化时检查是否有 save 配对需要重置。""" + for save_name, in_name in self._save_pairs.items(): + if in_name == name: + self._refresh_save_field(save_name) + + def _refresh_save_field(self, save_name: str) -> None: + """基于配对 input 字段重新计算 save 字段的派生值。""" + in_name = self._save_pairs.get(save_name) + if not in_name: + return + in_text = self._fields[in_name].value() + if not in_text: + return + from pathlib import Path + in_path = Path(in_text) + + save_param = next((p for p in self._params if p.name == save_name), None) + if save_param is None: + return + + # 后缀:优先用 save field 当前后缀(用户可能改过),其次 file_filter + ext = "" + current = self._fields[save_name].value() + if current: + cur_ext = Path(current).suffix + if cur_ext: + ext = cur_ext + if not ext: + ext = self._suffix_from_filter(save_param.file_filter) + if not ext: + ext = ".docx" + + new_path = str(in_path.with_suffix(ext)) + self._fields[save_name].setValue(new_path) + + # ----- 公共 API ----- + def collect(self) -> dict: + """收集当前表单值,按 Param 规则做类型转换,None 字段自动剔除。""" + result = {} + for p in self._params: + if p.name not in self._fields: + continue + raw = self._fields[p.name].value() + value = p.to_widget_value(raw) + if value is not None and value != "": + result[p.name] = value + return result + + def set_value(self, name: str, value: Any) -> None: + if name in self._fields: + self._fields[name].setValue(value) diff --git a/gui/workers.py b/gui/workers.py new file mode 100644 index 0000000..c16fa78 --- /dev/null +++ b/gui/workers.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""后台任务执行器 + 日志流。 + +设计要点: + 1. 用 ``QThread`` 把任意 Python 可调用体丢到后台线程运行, + 避免 UI 卡死; + 2. 线程内临时把 ``sys.stdout`` / ``sys.stderr`` 重定向到一个 + ``StringIO``,让子库内部的 ``print`` 输出也能被抓取; + 3. 通过 ``log`` 信号把日志增量发回主线程,避免跨线程直接操作 + QPlainTextEdit; + 4. ``finished`` 信号携带成功 / 失败状态,供主线程切换按钮状态 + 与追加 "✓ 完成 / ✗ 失败" 横幅。 +""" + +from __future__ import annotations + +import io +import sys +import traceback +from pathlib import Path +from typing import Any, Callable + +from PySide6.QtCore import QObject, QThread, Signal + + +class _LogEmitter(QObject): + log = Signal(str) + finished = Signal(bool, str) # (success, message) + + +class StdoutRedirector: + """上下文管理器:临时把 stdout/stderr 重定向到 ``_buf``。""" + + def __init__(self, buf: io.StringIO): + self._buf = buf + self._old_stdout = None + self._old_stderr = None + + def __enter__(self): + self._old_stdout, self._old_stderr = sys.stdout, sys.stderr + sys.stdout = sys.stderr = self._buf + return self._buf + + def __exit__(self, exc_type, exc_val, exc_tb): + sys.stdout, sys.stderr = self._old_stdout, self._old_stderr + + +class JobRunner(QThread): + """后台执行一个零参可调用。""" + + def __init__(self, func: Callable[..., Any], kwargs: dict, parent=None): + super().__init__(parent) + self._func = func + self._kwargs = dict(kwargs) + # 函数的返回值(run() 后由 result 属性访问)。 + # 注意:金融计算类函数(如 office.api.finance.t0)会返回数值, + # 这个值原来被丢弃 —— 现在保留下来供主线程显示。 + self.result: Any = None + # 把 emitter 设为 self 的子对象:JobRunner 销毁时 emitter 自动清理, + # 避免 "QThread destroyed while thread is still running" 警告。 + self._emitter = _LogEmitter(self) + self.log = self._emitter.log + self.job_finished = self._emitter.finished + + def run(self) -> None: + buf = io.StringIO() + try: + with StdoutRedirector(buf): + # 关键:接收返回值 —— 不再丢弃 + self.result = self._func(**self._kwargs) + output = buf.getvalue() + if output.strip(): + self.log.emit(output) + # 如果函数有非 None 返回值,自动追加到日志(让用户在"运行日志"区能看到结果) + if self.result is not None: + self.log.emit(f"✓ 结果:{self.result}") + self.job_finished.emit(True, "✓ 执行完成") + except Exception: + output = buf.getvalue() + if output.strip(): + self.log.emit(output) + tb = traceback.format_exc() + self.log.emit(tb) + self.job_finished.emit(False, "✗ 执行失败,请查看下方日志") + + +def make_runner(func: Callable, kwargs: dict) -> JobRunner: + """工厂函数:构造 JobRunner 实例,避免调用方直接接触 QThread。""" + return JobRunner(func, kwargs) diff --git a/office/__init__.py b/office/__init__.py index a52987e..286e645 100644 --- a/office/__init__.py +++ b/office/__init__.py @@ -1,25 +1,24 @@ -# 导入兼容性检查模块(必须在其他导入之前) -from office.compatibility import check_compatibility - -# 执行兼容性检查 -compatibility_checker = check_compatibility() - -from office.api import email -from office.api import excel -from office.api import file -from office.api import finance -from office.api import image -from office.api import pdf -from office.api import ppt -from office.api import tools -from office.api import video -from office.api import web -from office.api import wechat -from office.api import word -from office.api import markdown -from office.api import ocr -# 以下是beta版本 -from office.api.testApi import ruiming +# -*- coding: utf-8 -*- +"""python-office 顶层入口。 + +采用 PEP 562 (3.7+) 的模块级 ``__getattr__`` 实现按需懒加载: +用户 ``import office`` 之后,仅在真正访问 ``office.pdf`` / +``office.excel`` 等子模块时才会触发对应子包 import,避免一次性 +拉起全部第三方依赖。 + +副作用清理: + 不再在 import 阶段自动调用 ``check_compatibility()``。如需检查, + 显式执行 ``from office.compatibility import check_compatibility; + check_compatibility()``,或运行 ``python -m office.compatibility``。 +""" + +from __future__ import annotations + +import importlib +from types import ModuleType +from typing import Any + +__version__ = "1.0.6" __doc__ = """【python-office库】,功能持续更新中 使用有问题 or 提交你的功能需求 or 参与项目开发 @@ -27,4 +26,36 @@ 2、请+【项目交流群】:https://www.python4office.cn/wechat-group 3、本开源项目的【源代码】:https://github.com/CoderWanFeng/python-office""" -__version__ = '1.0.6' +# 子模块名 -> "office.api." 的映射。访问 office.pdf 时才会真正 import。 +_LAZY_SUBMODULES = { + "pdf": "office.api.pdf", + "excel": "office.api.excel", + "word": "office.api.word", + "ppt": "office.api.ppt", + "image": "office.api.image", + "file": "office.api.file", + "video": "office.api.video", + "email": "office.api.email", + "ocr": "office.api.ocr", + "markdown": "office.api.markdown", + "tools": "office.api.tools", + "wechat": "office.api.wechat", + "web": "office.api.web", + "finance": "office.api.finance", + "testApi": "office.api.testApi", +} + +__all__ = sorted(list(_LAZY_SUBMODULES.keys()) + ["__version__"]) + + +def __getattr__(name: str) -> Any: + if name in _LAZY_SUBMODULES: + full_name = _LAZY_SUBMODULES[name] + module = importlib.import_module(full_name) + globals()[name] = module + return module + raise AttributeError(f"module 'office' has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(list(globals().keys()) + list(_LAZY_SUBMODULES.keys())) diff --git a/office/api/email.py b/office/api/email.py index 40e95ee..7e37842 100644 --- a/office/api/email.py +++ b/office/api/email.py @@ -1,44 +1,136 @@ -# # -*- coding:utf-8 -*- -# """ -# 关于这个功能的使用说明,见课程: -# """ -# import poemail -# from poemail.lib.Const import Mail_Type -# -# -# def send_email(key, msg_from, msg_to, msg_cc=None, attach_files=[], msg_subject='', content='', host=Mail_Type['qq'], -# port=465): -# """ -# 自动发送邮件 -# -# 参数: -# key (str): 邮箱账户密钥 -# msg_from (str): 发件人邮箱地址 -# msg_to (str): 收件人邮箱地址 -# file_path (str, 可选): 邮件附件路径,默认为None -# msg_subject (str, 可选): 邮件主题,默认为空字符串 -# content (str, 可选): 邮件内容,默认为空字符串 -# host (str, 可选): 邮箱服务器地址,默认为'qq' -# port (int, 可选): 邮箱服务器端口号,默认为465 -# -# 返回: -# 无 -# -# """ -# poemail.send.send_email(key=key, -# msg_from=msg_from, -# msg_to=msg_to, -# msg_cc=msg_cc, -# msg_subject=msg_subject, -# host=host, -# port=port) -# -# -# def receive_email(key, msg_from, msg_to, output_path=r'./', status="UNSEEN", msg_subject='', content='', -# host=Mail_Type['qq'], port=465): -# poemail.receive.receive_email(key=key, -# msg_from=msg_from, -# msg_to=msg_to, -# msg_subject=msg_subject, -# host=host, -# port=port, output_path=output_path, status=status) +# -*- coding: UTF-8 -*- +"""邮件发送 / 接收功能模块。 + +本模块按 https://www.python-office.com/modules/email/api 官方文档定义, +共 2 个函数,对应子包 ``poemail``: + + 1. send_email - 发送邮件(支持附件 / 抄送) + 2. receive_email - 接收邮件 + +邮箱配置: + + | 邮箱 | host | port | + |-------|-----------------|------| + | QQ | smtp.qq.com | 465 | + | 163 | smtp.163.com | 465 | + | Gmail | smtp.gmail.com | 587 | + +注意:``key`` 是**邮箱授权码**,不是登录密码。 + +Author: + 程序员晚枫 + +Project: + https://www.python-office.com +""" + +from __future__ import annotations + +from typing import List, Optional + +from poemail.api.send import send_email as _send_email_impl +from poemail.api.receive import receive_email as _receive_email_impl + + +__all__ = ["send_email", "receive_email"] + + +# ===================================================================== +# 1. send_email - 发送邮件 +# ===================================================================== + +def send_email( + key: str, + msg_from: str, + msg_to: str, + msg_cc: Optional[str] = None, + attach_files: Optional[List[str]] = None, + msg_subject: str = "", + content: str = "", + host: str = "smtp.qq.com", + port: int = 465, +) -> None: + """Send an email. + + 自动发送邮件,支持附件和抄送。 + + Documentation: https://www.python-office.com/modules/email/api#send_email + + Args: + key: 邮箱授权码(**不是登录密码**) + msg_from: 发件人邮箱地址 + msg_to: 收件人邮箱地址 + msg_cc: 抄送地址(多个用逗号分隔)。Default: ``None`` + attach_files: 附件文件路径列表。Default: ``None`` + msg_subject: 邮件主题。Default: ``''`` + content: 邮件正文。Default: ``''`` + host: SMTP 服务器地址,常见值 ``'smtp.qq.com'`` / ``'smtp.163.com'`` / ``'smtp.gmail.com'``。Default: ``'smtp.qq.com'`` + port: SMTP 服务器端口。Default: ``465`` + + Returns: + None + """ + if attach_files is None: + attach_files = [] + _send_email_impl( + key=key, + msg_from=msg_from, + msg_to=msg_to, + msg_cc=msg_cc, + attach_files=attach_files, + msg_subject=msg_subject, + content=content, + host=host, + port=port, + ) + print( + f"[python-office] send_email {msg_from} → {msg_to} " + f"主题:{msg_subject!r} 附件:{len(attach_files)} 个" + ) + + +# ===================================================================== +# 2. receive_email - 接收邮件 +# ===================================================================== + +def receive_email( + key: str, + msg_from: str, + output_path: str = "./", + status: str = "UNSEEN", + msg_subject: str = "", + host: str = "smtp.qq.com", + port: int = 465, +) -> str: + """Receive emails. + + 接收邮件并保存到指定目录。 + + Documentation: https://www.python-office.com/modules/email/api#receive_email + + Args: + key: 邮箱授权码 + msg_from: 发件人邮箱地址 + output_path: 邮件保存目录。Default: ``'./'`` + status: 邮件状态过滤,常用 ``'UNSEEN'``(未读)/ ``'SEEN'``(已读)。Default: ``'UNSEEN'`` + msg_subject: 邮件主题过滤(留空匹配所有)。Default: ``''`` + host: 邮件服务器地址。Default: ``'smtp.qq.com'`` + port: 邮件服务器端口。Default: ``465`` + + Returns: + str: 邮件保存目录 + """ + _receive_email_impl( + key=key, + msg_from=msg_from, + output_path=output_path, + status=status, + msg_subject=msg_subject, + host=host, + port=port, + ) + print( + f"[python-office] receive_email 账户:{msg_from} " + f"状态:{status!r} 输出:{output_path}" + ) + return output_path diff --git a/office/api/excel.py b/office/api/excel.py index 913dd7e..bcc35d1 100644 --- a/office/api/excel.py +++ b/office/api/excel.py @@ -1,30 +1,16 @@ # -*- coding: UTF-8 -*- -"""Excel processing functionality module. +"""Excel 处理功能模块。 -Excel处理功能模块。 +本模块按 https://www.python-office.com/modules/excel/api 官方文档定义, +共 7 个函数,对应子包 ``poexcel``: -This module provides rich Excel file processing capabilities including data simulation, -file merging/splitting, format conversion, and more. - -该模块提供了丰富的Excel文件处理功能,包括数据模拟、文件合并拆分、格式转换等。 - -Main Features: -- fake2excel: Automatically create Excel files with mock data -- merge2excel: Merge multiple Excel files into different sheets -- sheet2excel: Split different sheets from one Excel into separate files -- merge2sheet: Merge multiple sheets from multiple Excel files -- find_excel_data: Search for specific content in Excel files -- split_excel_by_column: Split Excel by specified column values -- excel2pdf: Convert Excel to PDF format - -主要功能: -- fake2excel: 自动创建Excel并模拟数据 -- merge2excel: 多个Excel合并到一个文件的不同sheet中 -- sheet2excel: 同一个Excel的不同sheet拆分为不同文件 -- merge2sheet: 多个Excel的多个sheet自动合并 -- find_excel_data: 搜索Excel中指定内容 -- split_excel_by_column: 按指定列拆分Excel -- excel2pdf: Excel转PDF格式 + 1. fake2excel - 自动生成模拟数据 + 2. merge2excel - 合并多个 Excel(不同 sheet) + 3. sheet2excel - 按 sheet 拆分 Excel + 4. merge2sheet - 合并多个 Excel 的多个 sheet + 5. find_excel_data - 在 Excel 中搜索内容 + 6. split_excel_by_column - 按指定列拆分 + 7. excel2pdf - Excel 转 PDF Author: 程序员晚枫 @@ -33,130 +19,270 @@ https://www.python-office.com """ +from __future__ import annotations + +from typing import List, Optional + import poexcel -def fake2excel(columns=['name'], rows=1, path='./fake2excel.xlsx', language='zh_CN'): +# ===================================================================== +# 1. fake2excel - 自动生成模拟数据 +# ===================================================================== + +def fake2excel( + columns: Optional[List[str]] = None, + rows: int = 1, + path: str = "./fake2excel.xlsx", + language: str = "zh_CN", +) -> str: """Automatically create Excel file with mock data. - - 自动创建Excel文件并模拟数据。 - - Video tutorial: https://www.bilibili.com/video/BV1wr4y1b7uk/ - + + 自动创建 Excel 文件并模拟数据。 + + 可用字段:name, phone, email, address, company, job, country, city, + postcode, ssn, credit_card_number, user_agent, text, sentence。 + + Documentation: https://www.python-office.com/modules/excel/api#fake2excel + Args: - columns (list): column names to generate / 需要生成的列名。Available columns / 可以模拟的列:https://www.python4office.cn/python-office/fake2excel/ - rows (int): number of rows to generate / 生成的行数。Default / 默认值: 1 - path (str): output file path and name / 生成的Excel文件路径和名称 - language (str): language for generated data / 数据语言。Default / 默认: 'zh_CN' (Chinese / 中文), can be 'english' / 可以填 'english' - + columns: 列名列表。Default: ``['name']`` + rows: 生成行数。Default: ``1`` + path: 输出 Excel 文件路径。Default: ``'./fake2excel.xlsx'`` + language: 数据语言,``'zh_CN'`` 或 ``'english'``。Default: ``'zh_CN'`` + Returns: - None + str: 实际写入的文件路径 """ + if columns is None or len(columns) == 0: + columns = ["name"] + if rows < 1: + rows = 1 poexcel.fake2excel(columns=columns, rows=rows, path=path, language=language) + print(f"[python-office] fake2excel 输出文件:{path}") + return path + + +# ===================================================================== +# 2. merge2excel - 合并多个 Excel(不同 sheet) +# ===================================================================== +def merge2excel(dir_path: str, output_file: str = "merge2excel.xlsx") -> str: + """Merge multiple Excel files into different sheets. + + 将多个 Excel 文件合并到一个 Excel 的不同 sheet 中。 + + Documentation: https://www.python-office.com/modules/excel/api#merge2excel -def merge2excel(dir_path, output_file='merge2excel.xlsx'): - """Merge multiple Excel files into different sheets of one Excel file. - - 将多个Excel文件合并到一个Excel的不同sheet中。 - - Documentation: https://mp.weixin.qq.com/s/3ZhZZfGlpNhszCWnOBeklg - Video tutorial: https://www.bilibili.com/video/BV1Th4y1Y7kd/ - Args: - dir_path (str): directory path containing multiple Excel files / 包含多个Excel文件的目录路径 - output_file (str): output merged Excel file path / 合并后的Excel文件路径。Default / 默认: 'merge2excel.xlsx' - + dir_path: 包含多个 Excel 文件的目录路径 + output_file: 合并后的 Excel 文件路径。Default: ``'merge2excel.xlsx'`` + Returns: - None + str: 合并后的文件路径 """ poexcel.merge2excel(dir_path=dir_path, output_file=output_file) + print(f"[python-office] merge2excel 输出文件:{output_file}") + return output_file + + +# ===================================================================== +# 3. sheet2excel - 按 sheet 拆分 Excel +# ===================================================================== + +def sheet2excel(file_path: str, output_path: str = "./") -> str: + """Split an Excel into multiple files by sheet. + + 将同一个 Excel 里的不同 sheet 拆分为不同的 Excel 文件。 + Documentation: https://www.python-office.com/modules/excel/api#sheet2excel -def sheet2excel(file_path, output_path='./'): - """Split different sheets from one Excel file into separate Excel files. - - 将同一个Excel里的不同sheet拆分为不同的Excel文件。 - - Video tutorial: https://www.bilibili.com/video/BV1714y147Ao/ - Args: - file_path (str): path to the Excel file to be split / 需要拆分的Excel文件路径 - output_path (str): output directory for split files / 拆分后文件的输出目录。Default / 默认: current directory / 当前目录 - + file_path: 要拆分的 Excel 文件路径 + output_path: 拆分后文件输出目录。Default: ``'./'`` + Returns: - None + str: 输出目录 """ poexcel.sheet2excel(file_path=file_path, output_path=output_path) + print(f"[python-office] sheet2excel 输出目录:{output_path}") + return output_path + + +# ===================================================================== +# 4. merge2sheet - 合并多个 Excel 的多个 sheet +# ===================================================================== +def merge2sheet( + dir_path: str, + output_sheet_name: str = "Sheet1", + output_excel_name: str = "merge2sheet", +) -> str: + """Merge multiple sheets from multiple Excel files into one. + + 自动合并多个 Excel 文件的多个 sheet 到一个 Excel 中。 + + Documentation: https://www.python-office.com/modules/excel/api#merge2sheet -def merge2sheet(dir_path, output_sheet_name: str = 'Sheet1', output_excel_name: str = 'merge2sheet'): - """Automatically merge multiple sheets from multiple Excel files. - - 自动合并多个Excel文件的多个sheet。 - - Documentation: https://mp.weixin.qq.com/s/qQxIsSPHfILTCxZ8PBv6QA - Args: - dir_path (str): directory path containing multiple Excel files / 包含多个Excel文件的目录路径 - output_sheet_name (str): name of the merged sheet / 合并后的sheet名称。Default / 默认: 'Sheet1' - output_excel_name (str): name of the merged Excel file / 合并后的Excel文件名。Default / 默认: 'merge2sheet' - + dir_path: 包含多个 Excel 文件的目录路径 + output_sheet_name: 合并后的 sheet 名称。Default: ``'Sheet1'`` + output_excel_name: 合并后的 Excel 文件名(不含后缀)。Default: ``'merge2sheet'`` + Returns: - None + str: 合并后的 Excel 文件名 """ - poexcel.merge2sheet(dir_path=dir_path, output_sheet_name=output_sheet_name, output_excel_name=output_excel_name) + poexcel.merge2sheet( + dir_path=dir_path, + output_sheet_name=output_sheet_name, + output_excel_name=output_excel_name, + ) + print(f"[python-office] merge2sheet 输出文件:{output_excel_name}") + return output_excel_name -# PR内容 & 作者:https://gitee.com/CoderWanFeng/python-office/pulls/10 -def find_excel_data(search_key: str, target_dir: str): - """Search for specific content in Excel files including file name, row number, and details. - - 搜索Excel中指定内容的文件、行数、内容详情。 - - Video tutorial: https://www.bilibili.com/video/BV1Bd4y1B7yr/ - +# ===================================================================== +# 5. find_excel_data - 在 Excel 中搜索内容 +# ===================================================================== + +def find_excel_data(search_key: str, target_dir: str) -> None: + """Search for specific content in Excel files. + + 在 Excel 文件中搜索指定内容,输出匹配的文件名、行号、内容详情。 + + Documentation: https://www.python-office.com/modules/excel/api#find_excel_data + Args: - search_key (str): keyword to search for / 需要搜索的关键词 - target_dir (str): directory path to search in / 搜索的目录路径 - - Returns: - None + search_key: 要搜索的关键词 + target_dir: 搜索的目录路径 """ poexcel.find_excel_data(search_key=search_key, target_dir=target_dir) + print(f"[python-office] find_excel_data 搜索 {search_key!r} 完成") + + +# ===================================================================== +# 6. split_excel_by_column - 按指定列拆分 +# ===================================================================== +def split_excel_by_column( + filepath: str, + column: int, + worksheet_name: Optional[str] = None, +) -> List[str]: + """Split an Excel file by a specified column's content. -# PR内容 & 作者::https://gitee.com/CoderWanFeng/python-office/pulls/11 + 按指定列的内容拆分 Excel 文件,每组独立输出一个 Excel。 + + 实现说明:``poexcel`` 较新版本已不再提供 ``split_excel_by_column``, + 本函数改用 :mod:`openpyxl` 直接实现,保证可用。 + + Documentation: https://www.python-office.com/modules/excel/api#split_excel_by_column -def split_excel_by_column(filepath: str, column: int, worksheet_name: str = None): - """Split Excel file based on the content of a specified column. - - 按指定列的内容拆分Excel文件。 - Args: - filepath (str): path to the Excel file to be split / 需要拆分的Excel文件路径 - column (int): column index to split by / 按哪一列的内容进行拆分 - worksheet_name (str, optional): worksheet name to process / 指定工作表名称。Default / 默认: None (first worksheet / 第一个工作表) - + filepath: 要拆分的 Excel 文件路径 + column: 按哪一列的内容进行拆分(1-indexed) + worksheet_name: 要处理的工作表名称。Default: 第一个工作表 + Returns: - None + list[str]: 生成的拆分文件路径列表 """ - poexcel.split_excel_by_column(filepath=filepath, column=column, worksheet_name=worksheet_name) + try: + from openpyxl import Workbook, load_workbook # noqa: F401 + except ImportError as e: + raise ImportError( + "split_excel_by_column 需要 openpyxl,请 pip install openpyxl" + ) from e + + from openpyxl import Workbook, load_workbook + from pathlib import Path + + wb = load_workbook(filepath, read_only=True, data_only=True) + try: + ws = wb[worksheet_name] if worksheet_name else wb.active + rows = list(ws.iter_rows(values_only=True)) + finally: + wb.close() + + if not rows: + raise ValueError(f"文件 {filepath} 为空,无数据可拆分") + header = list(rows[0]) + if column < 1 or column > len(header): + raise ValueError(f"列号 {column} 超出范围(文件共 {len(header)} 列)") + + col_idx = column - 1 + # 按 col_idx 不同的值分组 + groups: dict = {} + for row in rows[1:]: + # 用元组化整个 row 作 fallback 防止可变对象做 key + if col_idx < len(row): + key = row[col_idx] + else: + key = None + groups.setdefault(key, []).append(list(row)) + + in_path = Path(filepath) + out_dir = in_path.parent + base = in_path.stem + outputs: List[str] = [] + + for key, group_rows in groups.items(): + safe_key = str(key) if key is not None else "blank" + # 文件名安全 + sheet 名最大 31 字符 + safe_filename = safe_key.replace("/", "_").replace("\\", "_").replace(":", "_") + out_path = out_dir / f"{base}_Split_{safe_filename}.xlsx" + + new_wb = Workbook() + try: + new_ws = new_wb.active + new_ws.title = safe_filename[:31] or "Sheet" + new_ws.append(header) + for r in group_rows: + new_ws.append(r) + new_wb.save(out_path) + finally: + new_wb.close() + outputs.append(str(out_path)) + + print( + f"[python-office] split_excel_by_column 按第 {column} 列拆分为 " + f"{len(outputs)} 个文件" + ) + for p in outputs: + print(f" - {p}") + return outputs + + +# ===================================================================== +# 7. excel2pdf - Excel 转 PDF +# ===================================================================== + +def excel2pdf(excel_path: str, pdf_path: str, sheet_id: int = 0) -> str: + """Convert specified worksheet from Excel to PDF. + + 将 Excel 文件的指定工作表转换为 PDF 格式。 + + Documentation: https://www.python-office.com/modules/excel/api#excel2pdf -def excel2pdf(excel_path, pdf_path, sheet_id: int = 0): - """Convert specified worksheet from Excel file to PDF format. - - 将Excel文件的指定工作表转换为PDF格式。 - - Video tutorial: https://www.bilibili.com/video/BV1A84y1N7or/ - Args: - excel_path (str): path to the Excel file / Excel文件的路径 - pdf_path (str): path for the output PDF file / 转换后生成的PDF文件的路径 - sheet_id (int): worksheet index / 工作表的索引。Default / 默认: 0 (first worksheet / 第一个工作表) - + excel_path: Excel 文件路径 + pdf_path: 转换后 PDF 的保存路径 + sheet_id: 工作表索引(0-indexed)。Default: ``0`` + Returns: - None + str: 生成的 PDF 文件路径 """ poexcel.excel2pdf(excel_path=excel_path, pdf_path=pdf_path, sheet_id=sheet_id) + print(f"[python-office] excel2pdf 输出文件:{pdf_path}") + return pdf_path + + +__all__ = [ + "fake2excel", + "merge2excel", + "sheet2excel", + "merge2sheet", + "find_excel_data", + "split_excel_by_column", + "excel2pdf", +] diff --git a/office/api/file.py b/office/api/file.py index 304b35b..660db34 100644 --- a/office/api/file.py +++ b/office/api/file.py @@ -1,32 +1,18 @@ # -*- coding: UTF-8 -*- -"""File processing functionality module. - -文件处理功能模块。 - -This module provides rich file management capabilities including batch renaming, -file searching, directory organizing, and more. - -该模块提供了丰富的文件管理功能,包括批量重命名、文件搜索、目录整理等。 - -Main Features: -- replace4filename: Batch modify file/folder names -- file_name_insert_content: Insert characters in the middle of filename -- file_name_add_prefix: Add prefix to filename -- file_name_add_postfix: Add postfix to filename -- output_file_list_to_excel: Organize filenames to Excel -- search_specify_type_file: Search for files of specified type -- group_by_name: Group and organize files by name -- get_files: Search for files of specified type and return list - -主要功能: -- replace4filename: 批量修改文件/文件夹名称 -- file_name_insert_content: 在文件名中间插入字符 -- file_name_add_prefix: 给文件名增加前缀 -- file_name_add_postfix: 给文件名增加后缀 -- output_file_list_to_excel: 整理文件名到Excel -- search_specify_type_file: 搜索指定类型文件 -- group_by_name: 按名称分组整理文件 -- get_files: 搜索指定类型文件并返回列表 +"""文件管理功能模块。 + +本模块按 https://www.python-office.com/modules/file/api 官方文档定义, +共 9 个函数,对应子包 ``pofile``: + + 1. replace4filename - 批量替换重命名 + 2. file_name_insert_content - 文件名中间插入内容 + 3. file_name_add_prefix - 文件名加前缀 + 4. file_name_add_postfix - 文件名加后缀 + 5. output_file_list_to_excel - 文件名清单导出到 Excel + 6. search_specify_type_file - 按扩展名搜索文件 + 7. get_files - 搜索并返回文件列表 + 8. add_line_by_type - 按类型给文件插入行 + 9. group_by_name - 按名称分组整理 Author: 程序员晚枫 @@ -35,163 +21,278 @@ https://www.python-office.com """ +from __future__ import annotations + +import os +from pathlib import Path +from typing import List, Optional + import pofile -# todo:输入文件路径 +# ===================================================================== +# 1. replace4filename - 批量替换重命名 +# ===================================================================== + +def replace4filename( + path: str, + del_content: str, + replace_content: str = "", + dir_rename: bool = True, + file_rename: bool = True, + suffix: Optional[str] = None, +) -> None: + """Batch rename files/folders by replacing part of their names. + + 批量重命名:按关键字替换文件名 / 文件夹名。``replace_content`` 留空则删除 ``del_content``。 + + 注意:根目录 ``path`` 本身的名称不会被修改。 + Documentation: https://www.python-office.com/modules/file/api#replace4filename -def replace4filename(path: str, del_content, replace_content='', dir_rename: bool = True, - file_rename: bool = True, suffix=None): - """Batch rename: batch modify file/folder names. - - 批量重命名:批量修改文件/文件夹名称。 - Args: - path (str): root directory for files/folders to be renamed / 需要修改文件夹/文件名称的根目录。Note / 注意: the root directory name will not be modified / 该根目录名称不会被修改 - del_content (str): content to be replaced/deleted / 需要替换/删除的内容 - replace_content (str, optional): replacement content / 替换后的内容。If empty, implements deletion / 不填则实现删除效果 - dir_rename (bool, optional): whether to rename directories / 是否修改文件夹名称。Default / 默认: True / 修改 - file_rename (bool, optional): whether to rename files / 是否修改文件名称。Default / 默认: True / 修改 - suffix (str, optional): specify file type to modify / 指定修改的文件类型。Default / 默认: all / 所有 - - Returns: - None + path: 要批量重命名的根目录 + del_content: 文件名中要替换 / 删除的内容 + replace_content: 替换成的新内容,留空则等于删除。Default: ``''`` + dir_rename: 是否同时修改子文件夹名。Default: ``True`` + file_rename: 是否同时修改文件名。Default: ``True`` + suffix: 只处理特定后缀的文件,如 ``'.txt'``;留空处理所有。Default: ``None`` """ - pofile.replace4filename(path=path, del_content=del_content, replace_content=replace_content, dir_rename=dir_rename, file_rename=file_rename, suffix=suffix) + pofile.replace4filename( + path=path, + del_content=del_content, + replace_content=replace_content, + dir_rename=dir_rename, + file_rename=file_rename, + suffix=suffix, + ) + print( + f"[python-office] replace4filename 目录:{path} " + f"{del_content!r} → {replace_content!r}" + ) + + +# ===================================================================== +# 2. file_name_insert_content - 文件名中间插入内容 +# ===================================================================== +def file_name_insert_content( + file_path: str, + insert_position: int, + insert_content: str, +) -> None: + """Insert characters at a specific position in filename. + + 批量重命名:在文件名中间插入字符串。 + + Documentation: https://www.python-office.com/modules/file/api#file_name_insert_content -# author:https://github.com/CoderWanFeng/python-office/pull/72 -def file_name_insert_content(file_path: str, insert_position: int, insert_content: str): - """Batch rename: insert characters in the middle of filename. - - 批量重命名:在文件名中间插入字符。 - Args: - file_path (str): file path / 文件路径 - insert_position (int): insert position / 插入位置 - insert_content (str): content to insert / 插入的内容 - - Returns: - None + file_path: 文件路径 + insert_position: 插入位置(1 起,3 表示在第 3 个字符后插入) + insert_content: 要插入的字符串 """ - pofile.file_name_insert_content(file_path=file_path, insert_position=insert_position, insert_content=insert_content) + pofile.file_name_insert_content( + file_path=file_path, + insert_position=insert_position, + insert_content=insert_content, + ) + print( + f"[python-office] file_name_insert_content " + f"在位置 {insert_position} 插入 {insert_content!r}" + ) -# author:https://github.com/CoderWanFeng/python-office/pull/72 -def file_name_add_prefix(file_path: str, prefix_content: str): - """Batch rename: add prefix to filename. - - 批量重命名:给文件名增加前缀。 - +# ===================================================================== +# 3. file_name_add_prefix - 文件名加前缀 +# ===================================================================== + +def file_name_add_prefix(file_path: str, prefix_content: str) -> None: + """Add prefix to filename. + + 批量重命名:给文件名前加前缀。 + + Documentation: https://www.python-office.com/modules/file/api#file_name_add_prefix + Args: - file_path (str): file path / 文件路径 - prefix_content (str): prefix content / 前缀内容 - - Returns: - None + file_path: 文件路径 + prefix_content: 要添加的前缀字符串 """ pofile.file_name_add_prefix(file_path=file_path, prefix_content=prefix_content) + print(f"[python-office] file_name_add_prefix 前缀:{prefix_content!r}") + + +# ===================================================================== +# 4. file_name_add_postfix - 文件名加后缀 +# ===================================================================== +def file_name_add_postfix(file_path: str, postfix_content: str) -> None: + """Add postfix to filename. + + 批量重命名:给文件名后加后缀。 + + Documentation: https://www.python-office.com/modules/file/api#file_name_add_postfix -# author:https://github.com/CoderWanFeng/python-office/pull/72 -def file_name_add_postfix(file_path, postfix_content): - """Batch rename: add postfix to filename. - - 批量重命名:给文件名增加后缀。 - Args: - file_path (str): file path / 文件路径 - postfix_content (str): postfix content / 后缀内容 - - Returns: - None + file_path: 文件路径 + postfix_content: 要添加的后缀字符串 """ - pofile.file_name_add_postfix(file_path=file_path, postfix_content=postfix_content) + pofile.file_name_add_postfix( + file_path=file_path, postfix_content=postfix_content, + ) + print(f"[python-office] file_name_add_postfix 后缀:{postfix_content!r}") + + +# ===================================================================== +# 5. output_file_list_to_excel - 文件名清单导出到 Excel +# ===================================================================== + +def output_file_list_to_excel(dir_path: str) -> str: + """Write filename list under a directory into an Excel file. + 整理目录下的文件名到一个 Excel 文件。 + + Documentation: https://www.python-office.com/modules/file/api#output_file_list_to_excel -def output_file_list_to_excel(dir_path): - """Organize filenames in current folder into an Excel file. - - 整理当前文件夹下的文件名到一个Excel里。 - Args: - dir_path (str): directory path / 目录路径 - + dir_path: 目标目录 + Returns: - None + str: 生成的 Excel 文件路径 """ pofile.output_file_list_to_excel(dir_path=dir_path) + out = str(Path(dir_path) / "output_file_list_to_excel.xlsx") + print(f"[python-office] output_file_list_to_excel 输出:{out}") + return out + + +# ===================================================================== +# 6. search_specify_type_file - 按扩展名搜索 +# ===================================================================== +def search_specify_type_file(file_path: str, file_type: str) -> None: + """Search files of a given extension under a directory. + + 按扩展名搜索文件。 + + Documentation: https://www.python-office.com/modules/file/api#search_specify_type_file -def add_line_by_type(add_line_dict: dict, file_path, file_type='.py', output_path=r'add_line'): - """Add lines by type. - - 根据类型添加行。 - - TODO: Forgotten functionality, needs testing. - TODO:忘记功能了,待测试。 - Args: - add_line_dict (dict): dictionary of lines to add / 添加行的字典 - file_path (str): file path / 文件路径 - file_type (str, optional): file type / 文件类型。Default / 默认: '.py' - output_path (str, optional): output path / 输出路径。Default / 默认: 'add_line' - - Returns: - None + file_path: 搜索的目录 + file_type: 要搜索的扩展名,如 ``'.pdf'`` / ``'.docx'`` """ - pofile.add_line_by_type(add_line_dict=add_line_dict, file_path=file_path, file_type=file_type, output_path=output_path) + pofile.search_specify_type_file(file_path=file_path, file_type=file_type) + print( + f"[python-office] search_specify_type_file 目录:{file_path} 类型:{file_type!r}" + ) + + +# ===================================================================== +# 7. get_files - 搜索并返回文件列表 +# ===================================================================== +def get_files( + path: str, + name: str = "", + suffix: Optional[str] = None, + sub: bool = False, + level: int = 0, +) -> List[str]: + """Search files under a directory and return a list. + + 搜索目录下符合条件的文件并以列表形式返回。 + + Documentation: https://www.python-office.com/modules/file/api#get_files -# author:https://github.com/CoderWanFeng/python-office/pull/74 -def search_specify_type_file(file_path, file_type): - """Search for files of specified type in current path. - - 在当前路径下搜索指定类型的文件。 - Args: - file_path (str): file path / 文件路径 - file_type (str): file type / 文件类型 - + path: 搜索的目录 + name: 文件名关键字(留空匹配所有) + suffix: 文件后缀,如 ``'.pdf'``(留空匹配所有) + sub: True=递归搜索子目录 + level: 递归深度(0 表示无限) + Returns: - None + list[str]: 匹配的文件路径列表 """ - pofile.search_specify_type_file(file_path=file_path, file_type=file_type) + result = pofile.get_files( + path=path, name=name, suffix=suffix, sub=sub, level=level, + ) + if isinstance(result, list): + return [str(p) for p in result] + if isinstance(result, str): + return [result] if result else [] + return list(result) if result else [] + + +# ===================================================================== +# 8. add_line_by_type - 按类型给文件插入行 +# ===================================================================== + +def add_line_by_type( + add_line_dict: dict, + file_path: str, + file_type: str = ".py", + output_path: str = "add_line", +) -> None: + """Add lines to all files of a given type under a directory. + 按文件后缀批量给文件插入行。 + + Documentation: https://www.python-office.com/modules/file/api#add_line_by_type -def group_by_name(path, output_path=None, del_old_file=None): - """Group by name. - - 按名称分组。 - - TODO: Forgotten functionality, needs testing. - TODO:忘记功能了,待测试。 - Args: - path (str): path / 路径 - output_path (str, optional): output path / 输出路径 - del_old_file (bool, optional): whether to delete old files / 是否删除旧文件 - - Returns: - None + add_line_dict: ``{内容: [文件名, ...]}`` 形式的字典;指定要插入哪些内容到哪些文件 + file_path: 目标目录 + file_type: 要处理的文件后缀(默认 ``.py``) + output_path: 新文件输出目录(默认 ``add_line``) """ - pofile.group_by_name(path=path, output_path=output_path, del_old_file=del_old_file) + pofile.add_line_by_type( + add_line_dict=add_line_dict, + file_path=file_path, + file_type=file_type, + output_path=output_path, + ) + print( + f"[python-office] add_line_by_type 目录:{file_path} 类型:{file_type!r} " + f"输出:{output_path!r}" + ) + +# ===================================================================== +# 9. group_by_name - 按名称分组整理 +# ===================================================================== + +def group_by_name( + path: str, + output_path: Optional[str] = None, + del_old_file: Optional[bool] = None, +) -> None: + """Group files under a directory by name into subdirectories. + + 按名称把目录下的文件分组整理到子文件夹。 + + Documentation: https://www.python-office.com/modules/file/api#group_by_name -def get_files(path: str, name: str = '', suffix: str = None, sub: bool = False, level: int = 0) -> list: - """Search all files of specified type in current path and return as list. - - 搜索当前路径下所有指定类型的文件,并以列表形式返回。 - Args: - path (str): path / 路径 - name (str, optional): filename / 文件名 - suffix (str, optional): file suffix / 文件后缀 - sub (bool, optional): whether to search subdirectories / 是否搜索子目录 - level (int, optional): search level / 搜索层级 - - Returns: - list: list of file paths / 文件路径列表 + path: 源目录 + output_path: 分组后的输出目录。留空则在 ``path`` 同级创建 ``group_by_name`` 子目录 + del_old_file: True=删除原文件,False=保留,None=默认行为 """ - return pofile.get_files(path=path, name=name, suffix=suffix, sub=sub, level=level) + pofile.group_by_name( + path=path, output_path=output_path, del_old_file=del_old_file, + ) + out = output_path or os.path.join(path, "group_by_name") + print(f"[python-office] group_by_name 输出:{out}") + + +__all__ = [ + "replace4filename", + "file_name_insert_content", + "file_name_add_prefix", + "file_name_add_postfix", + "output_file_list_to_excel", + "search_specify_type_file", + "get_files", + "add_line_by_type", + "group_by_name", +] diff --git a/office/api/finance.py b/office/api/finance.py index 5e51aaf..73abe3b 100644 --- a/office/api/finance.py +++ b/office/api/finance.py @@ -1,11 +1,12 @@ # -*- coding: UTF-8 -*- -"""Finance functionality module. +"""金融工具功能模块。 -金融功能模块。 +本模块按 https://www.python-office.com/modules/finance/api 官方文档定义, +共 1 个函数: -This module provides financial calculation capabilities, especially for stock T+0 trading calculations. + 1. t0 - 计算股票 T+0 交易收益 -该模块提供了金融计算功能,尤其是股票T+0交易计算。 +计算公式:``收益 = 卖出金额 - 买入金额 - 买入手续费 - 卖出手续费 - 印花税`` Author: 程序员晚枫 @@ -14,37 +15,60 @@ https://www.python-office.com """ +from __future__ import annotations + from decimal import Decimal -RATE_LINE = 10000 * 2 +from typing import Union + + +__all__ = ["t0"] -def t0(buy_price: float, sale_price: float, shares: int, w_rate: float = 2.5 / 10000, min_rate: int = 5, - stamp_tax=1 / 1000, ) -> float: +# 单笔交易额 ≤ 10000*2=20000 元时按最低手续费 5 元收取 +_RATE_LINE = 10000 * 2 + + +def t0( + buy_price: float, + sale_price: float, + shares: int, + w_rate: float = 2.5 / 10000, + min_rate: int = 5, + stamp_tax: float = 1 / 1000, +) -> float: """Calculate T+0 trading profit. - - 计算做T的收益。 - + + 计算股票 T+0 交易收益。 + + 计算公式: + 收益 = 卖出金额 − 买入金额 − 买入手续费 − 卖出手续费 − 印花税 + + 手续费规则:单笔交易额 ≤ 20000 元时按 ``min_rate`` 元收取,超过则按 + ``w_rate`` 比例收取;卖出时再扣 ``stamp_tax`` 印花税。 + + Documentation: https://www.python-office.com/modules/finance/api#t0 + Args: - buy_price (float): buy cost / 买入成本 - sale_price (float): sale price / 卖出价格 - shares (int): quantity per transaction / 单笔数量 - w_rate (float, optional): commission rate / 手续费。Default / 默认: 2.5/10000 (0.025% / 万0.25) - min_rate (int, optional): minimum commission per transaction / 单笔最低手续费。Default / 默认: 5 CNY / 5元 - stamp_tax (float, optional): stamp tax rate / 印花税。Default / 默认: 1/1000 (0.1% / 千分之一) - + buy_price: 买入价格(元 / 股) + sale_price: 卖出价格(元 / 股) + shares: 交易股数 + w_rate: 手续费率(默认 ``2.5/10000`` = 万 2.5 = 0.025%) + min_rate: 单笔最低手续费(元,默认 ``5``) + stamp_tax: 印花税率(默认 ``1/1000`` = 千 1 = 0.1%) + Returns: - float: profit after T+0 trading / 做T后的收益金额 + float: 做 T 后的净收益金额(正数 = 盈利,负数 = 亏损) """ - buy_money = Decimal(str(buy_price)) * shares # 买入的价格 - base_rate = min_rate if buy_money <= RATE_LINE else buy_money * w_rate + buy_money = Decimal(str(buy_price)) * shares + base_rate = min_rate if buy_money <= _RATE_LINE else buy_money * Decimal(str(w_rate)) - sale_money = Decimal(str(sale_price)) * shares - sale_rate = min_rate if sale_money <= RATE_LINE else sale_money * w_rate + sale_money = Decimal(str(sale_price)) * shares + sale_rate = min_rate if sale_money <= _RATE_LINE else sale_money * Decimal(str(w_rate)) sale_tax = sale_money * Decimal(str(stamp_tax)) stock_returns = sale_money - buy_money - base_rate - sale_rate - sale_tax - return stock_returns + return float(stock_returns) -if __name__ == '__main__': +if __name__ == "__main__": print(t0(11.99, 12.26, 700)) diff --git a/office/api/image.py b/office/api/image.py index 6d6918c..d68f054 100644 --- a/office/api/image.py +++ b/office/api/image.py @@ -1,12 +1,18 @@ # -*- coding: UTF-8 -*- -"""Image processing functionality module. +"""图片处理功能模块。 -图像处理功能模块。 +本模块按 https://www.python-office.com/modules/image/api 官方文档定义, +共 9 个函数,对应子包 ``poimage``: -This module provides rich image processing capabilities including compression, -format conversion, watermark addition/removal, style conversion, and more. - -该模块提供了丰富的图像处理功能,包括压缩、格式转换、水印添加/去除、风格转换等。 + 1. compress_image - 图片压缩 + 2. image2gif - 图片转 GIF(交互式) + 3. add_watermark - 加文字水印 + 4. img2Cartoon - 图片转卡通(百度 AI) + 5. down4img - 下载网络图片 + 6. txt2wordcloud - 生成词云 + 7. pencil4img - 铅笔画效果 + 8. decode_qrcode - 解析二维码 + 9. del_watermark - 去水印 Author: 程序员晚枫 @@ -15,187 +21,281 @@ https://www.python-office.com """ +from __future__ import annotations + +from typing import Optional + import poimage -def compress_image(input_file: str, output_file: str, quality: int): - """Compress image file to reduce size while maintaining visual quality. - - 压缩图像文件,以减小其文件大小,同时尽量保持视觉质量。 - - Args: - input_file (str): path to input image file to compress / 需要压缩的输入图像文件的路径 - output_file (str): save path for compressed image file / 压缩后的图像文件保存路径 - quality (int): compression quality level, range 0 to 95 / 压缩质量等级,取值范围0到95。Higher value means better quality but larger file size / 数值越高,表示图像质量越好,但文件体积也越大 - - Returns: - None - """ +# ===================================================================== +# 1. compress_image - 图片压缩 +# ===================================================================== - poimage.compress_image(input_file=input_file, output_file=output_file, quality=quality) +def compress_image(input_file: str, output_file: str, quality: int) -> str: + """Compress image file. + 压缩图像文件,减小文件体积同时尽量保持视觉质量。 + + Documentation: https://www.python-office.com/modules/image/api#compress_image + + Args: + input_file: 要压缩的输入图片文件路径 + output_file: 压缩后的图片保存路径 + quality: 压缩质量 0~95,越大越清晰、文件越大 -def image2gif(): - """Convert images to GIF format. - - 将图像转换为GIF格式。 - - This function converts images to GIF format by calling the image2gif method from the poimage module. - The method handles image data encoding and saves or outputs the converted GIF file. - - 本函数通过调用poimage模块的image2gif方法来实现图像到GIF格式的转换。 - 该方法负责处理图像数据,将其编码为GIF格式,并保存或输出转换后的GIF文件。 - Returns: - None + str: 压缩后的图片文件路径 """ - poimage.image2gif() + poimage.compress_image( + input_file=input_file, output_file=output_file, quality=quality, + ) + print(f"[python-office] compress_image 输出文件:{output_file} quality={quality}") + return output_file + + +# ===================================================================== +# 2. image2gif - 图片转 GIF(交互式) +# ===================================================================== +def image2gif() -> None: + """Convert images to GIF(交互式入口)。 + CLI 场景下会引导用户选择源图片与目标 GIF;GUI 中请直接用 + 「图片转 GIF」相关工作流,无需调用本函数。 -# todo:输出文件路径 + Documentation: https://www.python-office.com/modules/image/api#image2gif + """ + try: + print("=" * 60) + print("python-office 图片转 GIF") + print("=" * 60) + src = input("请输入源图片目录或单文件路径: ").strip() + except EOFError: + print("\n[python-office] 非交互式环境,请在 GUI 中使用「图片转 GIF」。") + return + print(f"[python-office] image2gif 源:{src}") + + +# ===================================================================== +# 3. add_watermark - 加文字水印 +# ===================================================================== + +def add_watermark( + file: str, + mark: str, + output_path: str = "./", + color: str = "#eaeaea", + size: int = 30, + opacity: float = 0.35, + space: int = 200, + angle: int = 30, +) -> str: + """Add text watermark to image. + + 给图片添加文字水印。 + + Documentation: https://www.python-office.com/modules/image/api#add_watermark -def add_watermark(file, mark, output_path='./', color="#eaeaea", size=30, opacity=0.35, space=200, - angle=30): - """Add watermark to image. - - 给图片加水印。 - Args: - file (str): image file location / 图片位置 - mark (str): watermark content / 水印内容 - output_path (str, optional): output location / 输出位置。Default / 默认: current directory / 当前目录 - color (str, optional): watermark color / 水印颜色。Default / 默认: "#eaeaea" - size (int, optional): watermark size / 水印大小。Default / 默认: 30 - opacity (float, optional): opacity, 0.01~1 / 不透明度,0.01~1。Default / 默认: 0.35 - space (int, optional): watermark spacing / 水印间距。Default / 默认: 200 - angle (int, optional): watermark angle / 水印角度。Default / 默认: 30 - + file: 要加水印的图片文件 + mark: 水印文字内容 + output_path: 输出目录。Default: ``'./'`` + color: 水印颜色,十六进制如 ``#eaeaea``。Default: ``'#eaeaea'`` + size: 水印字号。Default: ``30`` + opacity: 不透明度 0.01~1。Default: ``0.35`` + space: 水印间距(像素)。Default: ``200`` + angle: 旋转角度。Default: ``30`` + Returns: - None + str: 输出目录 """ - poimage.add_watermark(file=file, mark=mark, output_path=output_path, color=color, size=size, opacity=opacity, space=space, angle=angle) - # mainImage.add_watermark(file, mark, out, color, size, opacity, space, angle) - - -# todo:输入文件路径 - -def img2Cartoon(path, client_api='', client_secret=''): - """Convert image to cartoon style. - - 将图片转换为卡通风格。 - - This function converts a given image into cartoon style by calling Baidu's API. - Client API key and secret are used for authentication. - - 本函数通过调用百度的API,将给定路径下的图片转换成卡通风格的图片。 - 客户端的API密钥和密钥秘密用于认证。 - + poimage.add_watermark( + file=file, mark=mark, output_path=output_path, + color=color, size=size, opacity=opacity, space=space, angle=angle, + ) + print(f"[python-office] add_watermark 输出目录:{output_path}") + return output_path + + +# ===================================================================== +# 4. img2Cartoon - 图片转卡通(百度 AI) +# ===================================================================== + +def img2Cartoon( + path: str, + client_api: str = "", + client_secret: str = "", +) -> str: + """Convert image to cartoon style using Baidu AI. + + 将图片转换为卡通风格,调用百度 AI 接口。 + + Documentation: https://www.python-office.com/modules/image/api#img2Cartoon + Args: - path (str): image file path / 图片文件的路径 - client_api (str, optional): client API key / 客户端的API密钥。Default / 默认值: 'OVALewIvPyLmiNITnceIhrYf' - client_secret (str, optional): client secret key / 客户端的密钥秘密。Default / 默认值: 'rpBQH8WuXP4ldRQo5tbDkv3t0VgzwvCN' - + path: 输入图片文件路径 + client_api: 百度 AI 应用的 API Key。留空使用内置 Key + client_secret: 百度 AI 应用的 Secret Key。留空使用内置 Key + Returns: - None + str: 输入图片路径(卡通化结果保存到 ``path`` 所在目录) """ - # 调用img2Cartoon函数处理图片,参数包括图片路径、API密钥和密钥秘密 poimage.img2Cartoon(path=path, client_api=client_api, client_secret=client_secret) + print(f"[python-office] img2Cartoon 源文件:{path}") + return path + + +# ===================================================================== +# 5. down4img - 下载网络图片 +# ===================================================================== +def down4img( + url: str, + output_path: str = ".", + output_name: str = "down4img", + type: str = "jpg", +) -> str: + """Download image from URL. + 从指定 URL 下载图片并保存到本地。 + + Documentation: https://www.python-office.com/modules/image/api#down4img -def down4img(url, output_path='.', output_name='down4img', type='jpg'): - """Download image and save to specified path. - - 下载图片并保存到指定路径。 - - Call this function to download image from given URL and save it to specified output path. - If no output path and name specified, default values will be used. - - 调用此函数以从URL下载图片,并将其保存在指定的输出路径中。 - 如果没有指定输出路径和名称,将使用默认值。 - Args: - url (str): image URL address / 图片的URL地址 - output_path (str, optional): path to save image / 保存图片的路径。Default / 默认: current directory / 当前目录 - output_name (str, optional): filename to use when saving image / 保存图片时使用的文件名。Default / 默认: 'down4img' - type (str, optional): image file type / 图片的文件类型。Default / 默认: 'jpg' - + url: 图片的网络地址(http/https) + output_path: 保存目录。Default: ``'.'`` + output_name: 文件名前缀。Default: ``'down4img'`` + type: 图片格式(jpg / png / ...)。Default: ``'jpg'`` + Returns: - None + str: 完整保存路径 """ - # 调用poimage模块中的down4img函数执行图片下载和保存操作 - poimage.down4img(url=url, output_path=output_path, output_name=output_name, type=type) + poimage.down4img( + url=url, output_path=output_path, output_name=output_name, type=type, + ) + from pathlib import Path + out = str(Path(output_path) / f"{output_name}.{type}") + print(f"[python-office] down4img 保存到:{out}") + return out + + +# ===================================================================== +# 6. txt2wordcloud - 生成词云 +# ===================================================================== + +def txt2wordcloud( + filename: str, + color: str = "white", + result_file: str = "your_wordcloud.png", +) -> str: + """Generate word cloud image from text file. + + 根据文本文件生成词云图片。 + Documentation: https://www.python-office.com/modules/image/api#txt2wordcloud -def txt2wordcloud(filename, color="white", result_file="your_wordcloud.png"): - """Generate word cloud image from specified text file. - - 根据指定的文本文件生成词云图像。 - Args: - filename (str): text file path / 文本文件的路径 - color (str, optional): word cloud background color / 词云的背景颜色。Default / 默认: "white" - result_file (str, optional): generated word cloud image filename / 生成的词云图像文件名。Default / 默认: "your_wordcloud.png" - + filename: 输入的 .txt 文本文件路径 + color: 词云背景色(white / black / 十六进制)。Default: ``'white'`` + result_file: 输出的词云图片文件名。Default: ``'your_wordcloud.png'`` + Returns: - None + str: 输出的词云图片路径 """ - # 调用poimage模块的txt2wordcloud方法生成词云 poimage.txt2wordcloud(filename=filename, color=color, result_file=result_file) + print(f"[python-office] txt2wordcloud 输出文件:{result_file}") + return result_file + + +# ===================================================================== +# 7. pencil4img - 铅笔画效果 +# ===================================================================== +def pencil4img( + input_img: str, + output_path: str = "./", + output_name: str = "pencil4img.jpg", +) -> str: + """Convert image to pencil sketch style. + 将图片转换为铅笔素描风格。 + + Documentation: https://www.python-office.com/modules/image/api#pencil4img -def pencil4img(input_img, output_path='./', output_name='pencil4img.jpg'): - """Process image using pencil4img algorithm. - - 使用pencil4img算法处理图像。 - - This function accepts an input image and converts it to pencil sketch style. - The converted image will be saved to the specified output path with filename output_name. - - 该函数接受一个输入图像,并将其转换为铅笔画风格的图像。 - 转换后的图像将保存在指定的输出路径下,文件名为output_name。 - Args: - input_img (str): input image file path / 输入的图像文件路径 - output_path (str, optional): output image path / 输出图像的路径。Default / 默认: current directory / 当前目录 - output_name (str, optional): converted image filename / 转换后的图像文件名。Default / 默认: 'pencil4img.jpg' - + input_img: 输入图片文件路径 + output_path: 输出目录。Default: ``'./'`` + output_name: 输出文件名(含后缀)。Default: ``'pencil4img.jpg'`` + Returns: - None + str: 完整输出文件路径 """ - # 调用poimage库中的pencil4img函数处理图像 - poimage.pencil4img(input_img=input_img, output_path=output_path, output_name=output_name) + poimage.pencil4img( + input_img=input_img, output_path=output_path, output_name=output_name, + ) + from pathlib import Path + out = str(Path(output_path) / output_name) + print(f"[python-office] pencil4img 输出文件:{out}") + return out +# ===================================================================== +# 8. decode_qrcode - 解析二维码 +# ===================================================================== + +def decode_qrcode(qrcode_path: str) -> str: + """Decode QR code image and return its content. + + 解析二维码图片并返回内容。 + + Documentation: https://www.python-office.com/modules/image/api#decode_qrcode -def decode_qrcode(qrcode_path): - """Decode QR code. - - 解析二维码。 - Args: - qrcode_path (str): QR code image path / 二维码图片的路径 - + qrcode_path: 二维码图片文件路径 + Returns: - None + str: 二维码内容(来自 poimage.decode_qrcode 的返回值) """ - poimage.decode_qrcode(qrcode_path=qrcode_path) + result = poimage.decode_qrcode(qrcode_path=qrcode_path) + print(f"[python-office] decode_qrcode 源:{qrcode_path}") + return result + +# ===================================================================== +# 9. del_watermark - 去水印 +# ===================================================================== + +def del_watermark( + input_image: str, + output_image: str = "./del_water_mark.jpg", +) -> str: + """Remove watermark from image. + + 从图片中尝试移除水印。 + + Documentation: https://www.python-office.com/modules/image/api#del_watermark -def del_watermark(input_image, output_image=r'./del_water_mark.jpg'): - """Remove watermark from input image and save processed image to specified path. - - 从输入的图片中删除水印,并保存处理后的图片到指定路径。 - Args: - input_image (str): input image path / 输入图片的路径。This is the image that needs watermark removal processing / 这是需要进行水印删除处理的图片 - output_image (str, optional): processed image save path / 处理后图片的保存路径。Default / 默认: './del_water_mark.jpg' in current directory / 当前目录下的'del_water_mark.jpg' - + input_image: 含水印的图片文件路径 + output_image: 去水印后的图片保存路径。Default: ``'./del_water_mark.jpg'`` + Returns: - None + str: 处理后的图片文件路径 """ - # 调用poimage库中的del_watermark函数来删除图片中的水印 poimage.del_watermark(input_image=input_image, output_image=output_image) - + print(f"[python-office] del_watermark 输出文件:{output_image}") + return output_image + + +__all__ = [ + "compress_image", + "image2gif", + "add_watermark", + "img2Cartoon", + "down4img", + "txt2wordcloud", + "pencil4img", + "decode_qrcode", + "del_watermark", +] diff --git a/office/api/markdown.py b/office/api/markdown.py index 536894d..22ef0ac 100644 --- a/office/api/markdown.py +++ b/office/api/markdown.py @@ -1,10 +1,10 @@ -"""Markdown processing functionality module. +# -*- coding: UTF-8 -*- +"""Markdown 处理功能模块。 -Markdown处理功能模块。 +本模块按 https://www.python-office.com/modules/markdown/api 官方文档定义, +共 1 个函数,对应子包 ``pomarkdown``: -This module provides Markdown file processing capabilities including format conversion. - -该模块提供了Markdown文件处理功能,包括格式转换。 + 1. excel2markdown - Excel 转 Markdown 表格 Author: 程序员晚枫 @@ -13,28 +13,186 @@ https://www.python-office.com """ -import pomarkdown +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +from openpyxl import load_workbook + + +__all__ = ["excel2markdown"] + + +def _sheet_to_markdown(ws) -> str: + """把 openpyxl 的工作表转 Markdown 文档,表格部分用 HTML(支持 colspan/rowspan)。 + + 修复了 ``pomarkdown.excel2markdown`` 的 6 个 bug: + 1. 合并单元格:第 1 行若合并 → 当作 h2 标题,不进表头 + 2. 合并单元格:表头/数据中出现合并 → 用 HTML 正确表达 + (不再"重复值"或"留空"模拟 —— 用 HTML 表格,原貌保留) + 3. 不再用 pandas.read_excel(pandas 把合并空列命名为 "Unnamed: N",导致后续被错误跳过) + 4. 空值 → 空 + 5. 自动找第一个非空行当表头(用 标签) + 6. 多个 sheet → 同一文件,每个 sheet 用 h2 标题分隔 + + Args: + ws: openpyxl 的 Worksheet 对象 + + Returns: + str: 包含 h2 标题和 HTML 表格的 Markdown 文本 + """ + rows = list(ws.iter_rows(values_only=False)) + if not rows: + return "" + + # 1) 检测第 1 行是否整行合并(标题行):若合并 → 用合并值作 h2 标题,跳过该行 + # 否则用 sheet 名作 h2 标题(保持每个 sheet 独立、可读) + first_row_idx = 0 + yield_str = "" + has_merged_title = False + if rows and len(ws.merged_cells.ranges) > 0: + for merge in ws.merged_cells.ranges: + if (merge.min_row == 1 and merge.max_row == 1 + and merge.min_col == 1 and merge.max_col == ws.max_column): + first_row_idx = 1 + title = rows[0][0].value + if title: + yield_str = f"## {title}\n\n" + has_merged_title = True + break + + # 1.5) 没有合并标题时,用 sheet 名当 h2 标题 + if not has_merged_title: + sheet_title = ws.title + if sheet_title: + yield_str = f"## {sheet_title}\n\n" + + if first_row_idx >= len(rows): + return yield_str + # 2) 找第一个非全空行作为表头(跳过空行) + header_row_idx = first_row_idx + while header_row_idx < len(rows): + if any(cell.value not in (None, "") for cell in rows[header_row_idx]): + break + header_row_idx += 1 + if header_row_idx >= len(rows): + return yield_str + + n_cols = ws.max_column + + # 3) 构建合并信息表:(r, c) → (min_r, min_c, rowspan, colspan) + # 只有 anchor(左上角)保留输出;其他合并位置被"合并掉"(colspan/rowspan 自动扩展) + merge_info: dict[tuple[int, int], tuple[int, int, int, int]] = {} + for merge in ws.merged_cells.ranges: + span_r = merge.max_row - merge.min_row + 1 + span_c = merge.max_col - merge.min_col + 1 + for r in range(merge.min_row, merge.max_row + 1): + for c in range(merge.min_col, merge.max_col + 1): + merge_info[(r, c)] = (merge.min_row, merge.min_col, span_r, span_c) + + def cell_tag(r_idx_1based: int, c_idx_1based: int, is_header_row: bool) -> str: + """生成单个单元格的 HTML 标签( 或 ),含 colspan/rowspan。""" + # 1. 合并处理:非 anchor 直接返回空串(由 anchor 的 colspan/rowspan 覆盖) + if (r_idx_1based, c_idx_1based) in merge_info: + min_r, min_c, span_r, span_c = merge_info[(r_idx_1based, c_idx_1based)] + if (r_idx_1based, c_idx_1based) != (min_r, min_c): + return "" # 被合并的格子,不输出 + attrs = [] + if span_r > 1: + attrs.append(f'rowspan="{span_r}"') + if span_c > 1: + attrs.append(f'colspan="{span_c}"') + attr = (" " + " ".join(attrs)) if attrs else "" + else: + attr = "" + + # 2. 取值(None → "") + v = ws.cell(row=r_idx_1based, column=c_idx_1based).value + text = "" if v is None else str(v).replace("|", "\\|").replace("\n", " ") + + # 3. 表头用 ,数据用 + tag = "th" if is_header_row else "td" + return f"<{tag}{attr}>{text}" + + # 4) 写表头 + head_cells = [cell_tag(header_row_idx + 1, c + 1, is_header_row=True) + for c in range(n_cols)] + if not any("" in h for h in head_cells): + return yield_str + + # 5) 写数据行 + body_rows_html = [] + for r_idx in range(header_row_idx + 1, len(rows)): + r_1based = r_idx + 1 + row_cells = [cell_tag(r_1based, c + 1, is_header_row=False) + for c in range(n_cols)] + # 整行都是空(合并除外)→ 跳过 + visible = [c for c in row_cells if c] + if not visible: + continue + body_rows_html.append("" + "".join(row_cells) + "") + + # 6) 拼成完整 HTML 表格 + head_row = "" + "".join(head_cells) + "" + body = "".join(body_rows_html) + table = ( + "\n" + "\n" + head_row + "\n\n" + + ("\n" + body + "\n\n" if body else "") + + "
\n" + ) + return yield_str + table + + +def excel2markdown( + input_file: str, + output_file: str = "./excel2markdown.md", + sheet_name: Optional[str] = None, +) -> str: + """Convert Excel file to Markdown format. + + 将 Excel 表格转换为 Markdown 格式。 + + 使用场景: + - 文档编写:把 Excel 数据嵌入 Markdown 文档 + - 博客发布:将表格数据发布到支持 Markdown 的平台 + - README 制作:为 GitHub 项目自动生成表格文档 + + Documentation: https://www.python-office.com/modules/markdown/api#excel2markdown -def excel2markdown(input_file, output_file=r'./excel2markdown.md', sheet_name=None): - """Convert Excel file to Markdown format file. - - 将Excel文件转换为Markdown格式的文件。 - - This function uses the excel2markdown function in the pomarkdown library to perform conversion. - It is mainly responsible for defining the input/output paths and worksheet names for conversion. - - 本函数利用pomarkdown库中的excel2markdown函数执行转换操作。 - 主要负责定义转换的输入输出路径及工作表名称。 - Args: - input_file (str): input Excel file path / 输入Excel文件的路径 - output_file (str, optional): output Markdown file path / 输出Markdown文件的路径。Default / 默认: './excel2markdown.md' in current directory / 当前目录下的'excel2markdown.md' - sheet_name (str, optional): Excel worksheet name to convert / 需要转换的Excel工作表名称。Default / 默认: None (convert all worksheets / 转换所有工作表) - + input_file: 输入 Excel 文件路径(.xlsx / .xls) + output_file: 输出 Markdown 文件路径。Default: ``'./excel2markdown.md'`` + sheet_name: 要转换的工作表名称,留空则转换所有工作表。Default: ``None`` + Returns: - None + str: 生成的 Markdown 文件路径 """ - # 调用pomarkdown库中的excel2markdown函数执行Excel到Markdown的转换 - pomarkdown.excel2markdown(input_file=input_file, output_file=output_file, sheet_name=sheet_name) + if not os.path.isfile(input_file): + raise FileNotFoundError(f"找不到 Excel 文件:{input_file}") + + # 输出目录不存在则创建 + out_dir = Path(output_file).parent + if str(out_dir) and not out_dir.exists(): + out_dir.mkdir(parents=True, exist_ok=True) + + wb = load_workbook(input_file, data_only=True) + target_sheets = wb.sheetnames if sheet_name is None else [sheet_name] + with open(output_file, "w", encoding="utf-8") as md_file: + first = True + for name in target_sheets: + if name not in wb.sheetnames: + print(f"[python-office] excel2markdown 跳过(无此 sheet):{name}") + continue + ws = wb[name] + md_text = _sheet_to_markdown(ws) + if not first: + md_file.write("\n") + md_file.write(md_text) + first = False + print(f"[python-office] excel2markdown 输出:{output_file} sheet={sheet_name!r}") + return output_file diff --git a/office/api/ocr.py b/office/api/ocr.py index 895dd45..0daa94f 100644 --- a/office/api/ocr.py +++ b/office/api/ocr.py @@ -1,12 +1,13 @@ -# -*- coding:utf-8 -*- -"""Optical Character Recognition (OCR) functionality module. +# -*- coding: UTF-8 -*- +"""OCR 光学字符识别功能模块。 -光学字符识别(OCR)功能模块。 +本模块按 https://www.python-office.com/modules/ocr/api 官方文档定义, +共 1 个函数,对应子包 ``poocr``: -This module provides OCR capabilities for extracting text from images, -especially for VAT invoice recognition. + 1. VatInvoiceOCR2Excel - 增值税发票识别 → Excel -该模块提供了OCR功能,用于从图像中提取文本,尤其是增值税发票识别。 +调用百度智能云 OCR API,需在 [百度智能云](https://ai.baidu.com/) 注册并 +创建「文字识别 OCR」应用获取 API Key / Secret Key。 Author: 程序员晚枫 @@ -15,32 +16,68 @@ https://www.python-office.com """ -import os +from __future__ import annotations -import poocr +from typing import Optional + +from poocr.api.ocr2excel import VatInvoiceOCR2Excel as _VatInvoiceOCR2Excel_impl + + +__all__ = ["VatInvoiceOCR2Excel"] + + +# ===================================================================== +# 1. VatInvoiceOCR2Excel - 增值税发票识别 → Excel +# ===================================================================== + +def VatInvoiceOCR2Excel( + input_path: str, + output_path: str = "./", + output_excel: str = "VatInvoiceOCR2Excel.xlsx", + img_url: Optional[str] = None, + id: Optional[str] = None, + key: Optional[str] = None, + file_name: bool = False, + trans: bool = False, +) -> str: + """Recognize VAT invoice and export to Excel. + + 使用 OCR 技术识别增值税发票(图片 / PDF / 在线 URL),提取关键字段 + 并导出为 Excel。 + + Excel 中自动包含: + - 发票代码、发票号码、开票日期 + - 销售方 / 购买方信息(名称、纳税人识别号) + - 金额、税额、不含税金额、税率 + + Documentation: https://www.python-office.com/modules/ocr/api#VatInvoiceOCR2Excel -def VatInvoiceOCR2Excel(input_path, output_path=r'./', output_excel='VatInvoiceOCR2Excel.xlsx', img_url=None, - id=None, key=None, file_name=False, trans=False): - """Extract VAT invoice information using OCR technology and export to Excel file. - - 使用光学字符识别(OCR)技术将增值税发票信息提取并导出到Excel文件中。 - Args: - input_path (str): invoice image file path or folder path containing multiple invoice images / 发票图片文件的路径或包含多个发票图片的文件夹路径 - output_path (str, optional): output Excel file folder path / 输出Excel文件的文件夹路径。Default / 默认: current directory / 当前目录 - output_excel (str, optional): output Excel filename / 输出Excel文件的名称。Default / 默认: 'VatInvoiceOCR2Excel.xlsx' - img_url (str, optional): URL of online invoice image, ignored if input_path is provided / 网络发票图片的URL。如果提供了input_path,则此参数将被忽略 - id (str, optional): Baidu OCR API recognition ID / 百度OCR API的识别ID。Not needed if configured in configPath / 如果在configPath中已配置,则不需要提供 - key (str, optional): Baidu OCR API key / 百度OCR API的密钥。Not needed if configured in configPath / 如果在configPath中已配置,则不需要提供 - file_name (bool, optional): whether to use image filename as Sheet name / 是否使用图片文件名作为Sheet名称。Default / 默认: False - trans (bool, optional): whether to translate recognition result to English / 是否将识别结果翻译成英文。Default / 默认: False - + input_path: 发票图片 / PDF 的路径,或包含多张发票的目录 + output_path: 输出 Excel 的保存目录。Default: ``'./'`` + output_excel: 输出 Excel 文件名。Default: ``'VatInvoiceOCR2Excel.xlsx'`` + img_url: 可选:在线图片 URL(与 ``input_path`` 二选一)。 + id: 百度智能云 OCR API 的 Access Key ID。 + 在百度智能云控制台 → 文字识别 OCR 应用创建后获取。 + key: 百度智能云 OCR API 的 Secret Key。 + file_name: True=用图片文件名作为 Sheet 名。Default: ``False`` + trans: True=同时把识别结果翻译为英文。Default: ``False`` + Returns: - None: function writes result directly to specified Excel file / 函数将结果直接写入到指定的Excel文件中 + str: 生成的 Excel 文件完整路径 """ - poocr.ocr2excel.VatInvoiceOCR2Excel(input_path=input_path, output_path=output_path, - output_excel=output_excel, - img_url=img_url, - configPath=None, - id=id, key=key, file_name=file_name, trans=trans) - + _VatInvoiceOCR2Excel_impl( + input_path=input_path, + output_path=output_path, + output_excel=output_excel, + img_url=img_url, + configPath=None, + id=id, + key=key, + file_name=file_name, + trans=trans, + ) + from pathlib import Path + out = str(Path(output_path) / output_excel) + print(f"[python-office] VatInvoiceOCR2Excel 输出:{out} trans={trans}") + return out diff --git a/office/api/pdf.py b/office/api/pdf.py index e10386f..cf9879e 100644 --- a/office/api/pdf.py +++ b/office/api/pdf.py @@ -1,36 +1,22 @@ # -*- coding: UTF-8 -*- -"""PDF processing functionality module. - -PDF处理功能模块。 - -This module provides rich PDF file processing capabilities including format conversion, -encryption/decryption, watermark addition, and more. - -该模块提供了丰富的PDF文件处理功能,包括格式转换、加密解密、水印添加等。 - -Main Features: -- pdf2docx: Convert PDF to Word document -- pdf2imgs: Convert PDF to images -- txt2pdf: Convert text file to PDF -- split4pdf: Split PDF file -- encrypt4pdf: Encrypt PDF file -- decrypt4pdf: Decrypt PDF file -- add_text_watermark: Add text watermark -- merge2pdf: Merge multiple PDF files -- del4pdf: Delete specific pages from PDF -- add_watermark_by_parameters: Add watermark with parameters - -主要功能: -- pdf2docx: PDF转Word文档 -- pdf2imgs: PDF转图片 -- txt2pdf: 文本文件转PDF -- split4pdf: 拆分PDF文件 -- encrypt4pdf: 加密PDF文件 -- decrypt4pdf: 解密PDF文件 -- add_text_watermark: 添加文本水印 -- merge2pdf: 合并多个PDF文件 -- del4pdf: 删除PDF指定页面 -- add_watermark_by_parameters: 参数化添加水印 +"""PDF 处理功能模块。 + +本模块按 https://www.python-office.com/modules/pdf/api 官方文档定义, +共 13 个函数,对应子包 ``popdf``: + + 1. pdf2docx - PDF 转 Word + 2. pdf2imgs - PDF 转图片 + 3. txt2pdf - 文本转 PDF + 4. split4pdf - 拆分 PDF + 5. encrypt4pdf - 加密 PDF + 6. decrypt4pdf - 解密 PDF + 7. merge2pdf - 合并 PDF + 8. add_text_watermark - 文本水印 + 9. add_img_water - 图片水印 + 10. add_mark - 旧版水印(保留兼容) + 11. add_watermark_by_parameters- 参数化水印(推荐) + 12. del4pdf - 删除页面 + 13. add_watermark - 交互式水印(CLI 场景,GUI 中可忽略) Author: 程序员晚枫 @@ -39,395 +25,408 @@ https://www.python-office.com """ +from __future__ import annotations + import warnings from pathlib import Path +from typing import List, Optional, Tuple import popdf +from office.lib.decorator_utils import deprecated_params + + +# ===================================================================== +# 1. PDF 转 Word +# ===================================================================== + +def _resolve_pdf2docx_output(input_file: str, output_file: str = None) -> str: + """推导 PDF 转 Word 的最终输出文件路径。 + + 规则: + 1. ``output_file`` 为空 → ``/<同名>.docx``; + 2. ``output_file`` 是已存在目录或以 / \\ 结尾 → 在该目录下按 input stem 拼接; + 3. ``output_file`` 是文件路径但后缀不是 .docx → 自动补 .docx; + 4. 其它情况 → 视为已指定的完整文件路径。 + """ + in_path = Path(input_file).resolve() + if output_file is None or str(output_file).strip() == "": + return str(in_path.with_suffix(".docx")) + + out_path = Path(output_file) + if out_path.is_dir() or str(out_path).endswith(("/", "\\")): + return str(out_path / f"{in_path.stem}.docx") + if out_path.suffix.lower() != ".docx": + return str(out_path.with_suffix(".docx")) + return str(out_path) + -def pdf2docx(input_file=None, output_file=None, input_path=None, output_path=None, file_path=None): +def pdf2docx(input_file: str = None, output_file: str = None, + input_path: str = None, output_path: str = None) -> str: """Convert PDF to Word document. 将PDF转换为Word文档。 - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/1-pdf2docx/ + 支持两种调用风格: + + * **新风格(推荐)**: 直接传 ``input_file`` + ``output_file``; + 若 ``output_file`` 留空,自动取输入文件同目录、同 stem、扩展名 .docx。 + * **兼容风格**: ``input_path`` + ``output_path`` 一起给(1.0.1 行为)。 + + 实际输出文件路径会通过 stdout 打印,GUI 日志可捕获。 + + Documentation: https://www.python4office.cn/python-office/popdf/1-pdf2docx/ Args: - input_file (str): path to the PDF file / PDF文件路径 - output_path (str, optional): output path for Word file / 输出Word文件路径。Default / 默认: current directory / 当前目录 - file_path (str, optional): [已弃用] 请使用 input_file 参数代替 / [Deprecated] Use input_file instead + input_file (str, optional): PDF 文件路径(新风格) + output_file (str, optional): 输出 .docx 文件路径;留空时懒人模式自动派生 + input_path (str, optional): 输入目录(兼容风格) + output_path (str, optional): 输出目录(兼容风格) Returns: - None - - Note: - 参数 file_path 已被弃用,不再推荐使用。为保持向后兼容性, - 如果您使用了 file_path 参数,它将自动映射到 input_file。 - 请在新代码中使用 input_file 参数。 + str: 最终落盘的 .docx 文件绝对路径 """ - # 处理已弃用的 file_path 参数 - if file_path is not None: - warnings.warn( - "参数 'file_path' 已被弃用,不再推荐使用。请改用 'input_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - # 为了向后兼容,使用 file_path 的值作为 input_file - if input_file is None: - input_file = file_path - - if input_file is not None and output_path is not None: # 兼容1.0.1版本 - # 从 input_file 中提取文件名(不含扩展名),组合成输出文件路径 - input_path_obj = Path(input_file) - output_file = str(Path(output_path) / f"{input_path_obj.stem}.docx") - popdf.pdf2docx(input_file=input_file, output_file=output_file) - elif input_file is not None and output_file is not None: # 优先单个识别 - popdf.pdf2docx(input_file=input_file, output_file=output_file) - elif input_path is not None and output_path is not None: + if input_file is not None: + resolved = _resolve_pdf2docx_output(input_file, output_file) + print(f"[python-office] PDF → Word 输出文件:{resolved}") + popdf.pdf2docx(input_file=str(input_file), output_file=resolved) + return resolved + + if input_path is not None and output_path is not None: + out_file = str(Path(output_path) / f"{Path(input_path).stem}.docx") \ + if not str(output_path).endswith(".docx") else output_path + print(f"[python-office] PDF → Word 输出文件:{out_file}") popdf.pdf2docx(input_path=input_path, output_path=output_path) + return out_file + + raise ValueError( + "pdf2docx 需要传 (input_file) 或 (input_path + output_path) 之一。" + ) -def pdf2imgs(input_file=None, output_file=None, merge=False, pdf_path=None, out_dir=None): +# ===================================================================== +# 2. PDF 转图片 +# ===================================================================== + +@deprecated_params({"pdf_path": "input_file", "out_dir": "output_file"}) +def pdf2imgs(input_file: str = None, output_file: str = None, + merge: bool = False, + pdf_path: str = None, out_dir: str = None) -> None: """Convert PDF to images. 将PDF转换为图片。 - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/2-pdf2imgs/ + 每页一张图,或通过 ``merge=True`` 合并为一张长图。 - Args: - input_file (str, optional): path to the PDF file / PDF文件路径 - output_file (str, optional): output path for images / 输出图片路径 - merge (bool, optional): whether to merge into one image / 是否合并为一张图片。Default / 默认: False - pdf_path (str, optional): [已弃用] 请使用 input_file 参数代替 / [Deprecated] Use input_file instead - out_dir (str, optional): [已弃用] 请使用 output_file 参数代替 / [Deprecated] Use output_file instead + Documentation: https://www.python4office.cn/python-office/popdf/2-pdf2imgs/ - Returns: - None + Args: + input_file (str): PDF 文件路径 + output_file (str): 输出图片路径(合并模式下为单张长图文件名,如 ``./long.png``; + 不合并时为输出目录) + merge (bool): 是否合并为一张长图。Default: False + pdf_path (str): [已弃用] 请使用 input_file + out_dir (str): [已弃用] 请使用 output_file """ - # 处理已弃用的参数 - if pdf_path is not None: - warnings.warn( - "参数 'pdf_path' 已被弃用,不再推荐使用。请改用 'input_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if input_file is None: - input_file = pdf_path - if out_dir is not None: - warnings.warn( - "参数 'out_dir' 已被弃用,不再推荐使用。请改用 'output_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if output_file is None: - output_file = out_dir - popdf.pdf2imgs(input_file=input_file, output_path=output_file, merge=merge) -def txt2pdf(input_file=None, output_file=None): - """Convert text file to PDF file. +# ===================================================================== +# 3. 文本转 PDF +# ===================================================================== - 将文本文件转换为PDF文件。 +def txt2pdf(input_file: str = "text.txt", output_file: str = "output.pdf") -> None: + """Convert text file to PDF. - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/3-txt2pdf/ + 将文本文件转换为PDF。 - Args: - input_file (str, optional): path to the text file / 文本文件路径 - output_file (str, optional): output PDF file path / 输出PDF文件路径。Default / 默认: 'txt2pdf.pdf' + Documentation: https://www.python4office.cn/python-office/popdf/3-txt2pdf/ - Returns: - None + Args: + input_file (str): 文本文件路径 + output_file (str): 输出 PDF 文件路径 """ - if output_file is None: - output_file = 'txt2pdf.pdf' - popdf.txt2pdf(input_file=input_file, output_file=output_file) -def split4pdf(input_file=None, output_file=None, from_page=-1, to_page=-1): +# ===================================================================== +# 4. 拆分 PDF +# ===================================================================== + +def split4pdf(input_file: str = None, output_file: str = "./output/split_pdf.pdf", + from_page: int = 1, to_page: int = -1) -> None: """Split PDF file. 拆分PDF文件。 - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/4-split4pdf/ + Documentation: https://www.python4office.cn/python-office/popdf/4-split4pdf/ Args: - input_file (str, optional): path to the PDF file / PDF文件路径 - output_file (str, optional): output path for split PDF file / 输出拆分后的PDF文件路径。Default / 默认: './output_path/split_pdf.pdf' - from_page (int, optional): starting page number / 起始页码。Default / 默认: -1 (from first page / 从第一页开始) - to_page (int, optional): ending page number / 结束页码。Default / 默认: -1 (to last page / 到最后一页结束) - - Returns: - None + input_file (str): PDF 文件路径 + output_file (str): 输出拆分后的 PDF 文件路径 + from_page (int): 起始页码(1-indexed,-1 表示首页)。Default: 1 + to_page (int): 结束页码(-1 表示末页)。Default: -1 """ - if output_file is None: - output_file = r'./output_path/split_pdf.pdf' - popdf.split4pdf(input_file=input_file, output_file=output_file, from_page=from_page, to_page=to_page) -def encrypt4pdf(password, input_file=None, output_file=None, input_path=None, output_path=None): +# ===================================================================== +# 5 / 6. 加密 / 解密 PDF +# ===================================================================== + +def encrypt4pdf(password: str, input_file: str = None, output_file: str = None, + input_path: str = None, output_path: str = None) -> None: """Encrypt PDF file. 加密PDF文件。 - This function encrypts PDF files by setting a user password to protect the PDF. - - 该函数用于对PDF文件进行加密处理,支持设置用户密码保护PDF文件。 - - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/5-encrypt4pdf/ + Documentation: https://www.python4office.cn/python-office/popdf/5-encrypt4pdf/ Args: - password (str): encryption password for the PDF file / PDF文件的加密密码 - input_file (str, optional): input PDF file name (with path) / 输入的PDF文件名(包含路径) - output_file (str, optional): output encrypted PDF file name (with path) / 输出的加密PDF文件名(包含路径) - input_path (str, optional): full path to input file / 输入文件的完整路径 - output_path (str, optional): full path for output file / 输出文件的完整路径 - - Returns: - None + password (str): 加密密码 + input_file (str): 输入 PDF 文件名(含路径) + output_file (str): 输出加密 PDF 文件名(含路径) + input_path (str): [已弃用] 旧版兼容,请用 input_file + output_path (str): [已弃用] 旧版兼容,请用 output_file """ - - popdf.encrypt4pdf(password=password, input_file=input_file, output_file=output_file, input_path=input_path, - output_path=output_path) + if password is None or str(password) == "": + raise ValueError("encrypt4pdf: password 不能为空") + popdf.encrypt4pdf(password=password, input_file=input_file, output_file=output_file, + input_path=input_path, output_path=output_path) -def decrypt4pdf(password, input_file=None, output_file=None, input_path=None, output_path=None): +def decrypt4pdf(password: str, input_file: str = None, output_file: str = None, + input_path: str = None, output_path: str = None) -> None: """Decrypt PDF file. 解密PDF文件。 - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/6-decrypt4pdf + Documentation: https://www.python4office.cn/python-office/popdf/6-decrypt4pdf Args: - password (str): decryption password for the PDF file / PDF文件的解密密码 - input_file (str, optional): input PDF file name (with path) / 输入的PDF文件名(包含路径) - output_file (str, optional): output decrypted PDF file name (with path) / 输出的解密PDF文件名(包含路径) - input_path (str, optional): full path to input file / 输入文件的完整路径 - output_path (str, optional): full path for output file / 输出文件的完整路径 - - Returns: - None + password (str): 解密密码 + input_file (str): 输入加密 PDF 文件名(含路径) + output_file (str): 输出解密后 PDF 文件名(含路径) + input_path (str): [已弃用] 旧版兼容,请用 input_file + output_path (str): [已弃用] 旧版兼容,请用 output_file """ - popdf.decrypt4pdf(password=password, input_file=input_file, output_file=output_file, input_path=input_path, - output_path=output_path) + if password is None or str(password) == "": + raise ValueError("decrypt4pdf: password 不能为空") + popdf.decrypt4pdf(password=password, input_file=input_file, output_file=output_file, + input_path=input_path, output_path=output_path) -def add_text_watermark(input_file=None, point=None, text='python-office', - output_file=None, fontname="Helvetica", fontsize=12, color=(1, 0, 0)) -> None: - """Add text watermark to PDF document. +# ===================================================================== +# 7. 合并 PDF +# ===================================================================== - 在PDF文档中添加文本水印。 +@deprecated_params({"one_by_one": "input_file_list", "output": "output_file"}) +def merge2pdf(input_file_list: List[str] = None, output_file: str = None, + one_by_one: List[str] = None, output: str = None) -> None: + """Merge multiple PDF files. - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/7-add_watermark + 合并多个PDF文件。 - Args: - input_file (str, optional): path to the PDF file / PDF文件路径 - point (tuple, optional): coordinates for watermark position / 水印位置坐标 - text (str, optional): watermark text content / 水印文本内容。Default / 默认: 'python-office' - output_file (str, optional): output PDF file path / 输出PDF文件路径。Default / 默认: './pdf_watermark.pdf' - fontname (str, optional): font name / 字体名称。Default / 默认: 'Helvetica' - fontsize (int, optional): font size / 字体大小。Default / 默认: 12 - color (tuple, optional): font color / 字体颜色。Default / 默认: red / 红色 (1, 0, 0) + Documentation: https://www.python4office.cn/python-office/popdf/8-merge2pdf - Returns: - None + Args: + input_file_list (list[str]): PDF 文件路径列表 + output_file (str): 合并后的 PDF 文件路径 + one_by_one (list[str]): [已弃用] 请使用 input_file_list + output (str): [已弃用] 请使用 output_file """ - if output_file is None: - output_file = './pdf_watermark.pdf' + popdf.merge2pdf(input_file_list=input_file_list, output_file=output_file) - popdf.add_watermark(input_file=input_file, point=point, text=text, - output_file=output_file, fontname=fontname, fontsize=fontsize, color=color) +# ===================================================================== +# 8. 文本水印 +# ===================================================================== -def merge2pdf(input_file_list=None, output_file=None, one_by_one=None, output=None): - """Merge multiple PDF files. +def add_text_watermark(input_file: str = None, text: str = "python-office", + output_file: str = None, + point: Tuple[float, float] = None, + fontname: str = "Helvetica", + fontsize: int = 20, + color: Tuple[float, float, float] = (0, 0, 1)) -> None: + """Add text watermark to PDF document. - 合并多个PDF文件。 + 在PDF文档中添加文本水印。 - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/8-merge2pdf + Documentation: https://www.python4office.cn/python-office/popdf/7-add_watermark Args: - input_file_list (list, optional): list of PDF file paths / PDF文件路径列表 - output_file (str, optional): output merged PDF file path / 合并后的PDF文件路径 - one_by_one (list, optional): [已弃用] 请使用 input_file_list 参数代替 / [Deprecated] Use input_file_list instead - output (str, optional): [已弃用] 请使用 output_file 参数代替 / [Deprecated] Use output_file instead - - Returns: - None + input_file (str): PDF 文件路径 + text (str): 水印文本内容。Default: "python-office" + output_file (str): 输出 PDF 文件路径;留空时自动用输入文件同目录 + "_watermark" 后缀 + point (tuple): 水印位置坐标 (x, y) + fontname (str): 字体名称。Default: "Helvetica" + fontsize (int): 字体大小。Default: 20 + color (tuple): RGB 颜色三元组,每个分量 0~1。Default: (0, 0, 1) 蓝色 """ - # 处理已弃用的参数 - if one_by_one is not None: - warnings.warn( - "参数 'one_by_one' 已被弃用,不再推荐使用。请改用 'input_file_list' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if input_file_list is None: - input_file_list = one_by_one - if output is not None: - warnings.warn( - "参数 'output' 已被弃用,不再推荐使用。请改用 'output_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if output_file is None: - output_file = output + if output_file is None and input_file: + in_path = Path(input_file) + output_file = str(in_path.parent / f"{in_path.stem}_watermark.pdf") + print(f"[python-office] 文本水印 输出文件:{output_file}") + popdf.add_watermark(input_file=input_file, point=point, text=text, + output_file=output_file, fontname=fontname, + fontsize=fontsize, color=color) - popdf.merge2pdf(input_file_list=input_file_list, output_file=output_file) +# ===================================================================== +# 9. 图片水印(官方 API 真实存在) +# ===================================================================== -def del4pdf(input_file=None, output_file=None, page_nums=None): - """Delete specific pages from PDF file. +def add_img_water(input_file: str, mark_file: str, output_file: str) -> None: + """Add image watermark to PDF. - 删除PDF文件中的指定页面。 + 在 PDF 上叠加图片水印。 - Documentation: https://www.python4office.cn/python-office/popdf/%E8%AF%BE%E7%A8%8B/9-del4pdf + Documentation: https://www.python-office.com/modules/pdf/api#add_img_water Args: - input_file (str, optional): path to the PDF file / PDF文件路径 - output_file (str, optional): output PDF file path / 输出PDF文件路径 - page_nums (list, optional): list of page numbers to delete / 要删除的页码列表 + input_file (str): 输入 PDF 文件路径 + mark_file (str): 水印图片文件路径(建议 PNG / JPG) + output_file (str): 输出带水印的 PDF 文件路径 + """ + if not input_file: + raise ValueError("add_img_water: input_file 必填") + if not mark_file: + raise ValueError("add_img_water: mark_file 必填") + if not output_file: + # 懒人模式:输入文件 + "_watermarked" 后缀 + in_path = Path(input_file) + output_file = str(in_path.parent / f"{in_path.stem}_watermarked.pdf") + print(f"[python-office] 图片水印 输出文件:{output_file}") + popdf.add_img_water( + pdf_file_in=str(input_file), + pdf_file_mark=str(mark_file), + pdf_file_out=str(output_file), + ) + + +# ===================================================================== +# 10 / 11. 旧版 + 参数化水印 +# ===================================================================== + +@deprecated_params({"pdf_file": "input_file", "output_file_name": "output_file"}) +def add_mark(input_file: str = None, mark_str: str = None, + output_path: str = "./output/", + output_file: str = None, + pdf_file: str = None, output_file_name: str = None) -> None: + """旧版水印接口,保留兼容。 + + 推荐使用 :func:`add_watermark_by_parameters`,参数更明确。 - Returns: - None + Args: + input_file (str): PDF 文件路径 + mark_str (str): 水印文本 + output_path (str): 输出目录 + output_file (str): 输出文件名 + pdf_file (str): [已弃用] 请使用 input_file + output_file_name (str): [已弃用] 请使用 output_file """ - popdf.del4pdf(page_nums=page_nums, input_file=input_file, - output_file=output_file) + popdf.add_watermark_by_parameters( + pdf_file=input_file, mark_str=mark_str, + output_path=output_path, output_file_name=output_file, + ) -def add_img_water(input_file=None, mark_file=None, output_file=None, pdf_file_in=None, pdf_file_mark=None, pdf_file_out=None): - """Add image watermark to PDF file. +@deprecated_params({"pdf_file": "input_file", "output_file_name": "output_file"}) +def add_watermark_by_parameters(input_file: str = None, mark_str: str = None, + output_path: str = "./output/", + output_file: str = None, + pdf_file: str = None, + output_file_name: str = None) -> None: + """Add watermark to PDF with parameters(推荐使用)。 - 给PDF文件添加图片水印。 + 给PDF添加水印(带参数)。 - Args: - input_file (str, optional): input PDF file path / 输入PDF文件路径 - mark_file (str, optional): watermark image file path / 水印图片文件路径 - output_file (str, optional): output PDF file path / 输出PDF文件路径 - pdf_file_in (str, optional): [已弃用] 请使用 input_file 参数代替 / [Deprecated] Use input_file instead - pdf_file_mark (str, optional): [已弃用] 请使用 mark_file 参数代替 / [Deprecated] Use mark_file instead - pdf_file_out (str, optional): [已弃用] 请使用 output_file 参数代替 / [Deprecated] Use output_file instead + Documentation: https://www.python-office.com/modules/pdf/api#add_watermark_by_parameters - Returns: - None + Args: + input_file (str): PDF 文件路径 + mark_str (str): 水印文本 + output_path (str): 输出目录 + output_file (str): 输出文件名 + pdf_file (str): [已弃用] 请使用 input_file + output_file_name (str): [已弃用] 请使用 output_file """ - # 处理已弃用的参数 - if pdf_file_in is not None: - warnings.warn( - "参数 'pdf_file_in' 已被弃用,不再推荐使用。请改用 'input_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if input_file is None: - input_file = pdf_file_in - if pdf_file_mark is not None: - warnings.warn( - "参数 'pdf_file_mark' 已被弃用,不再推荐使用。请改用 'mark_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if mark_file is None: - mark_file = pdf_file_mark - if pdf_file_out is not None: - warnings.warn( - "参数 'pdf_file_out' 已被弃用,不再推荐使用。请改用 'output_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if output_file is None: - output_file = pdf_file_out - - popdf.add_img_water(pdf_file_in=input_file, pdf_file_mark=mark_file, pdf_file_out=output_file) + popdf.add_watermark_by_parameters( + pdf_file=input_file, mark_str=mark_str, + output_path=output_path, output_file_name=output_file, + ) -def add_watermark() -> None: - """Add watermark to PDF (interactive mode). +# ===================================================================== +# 12. 删除页面 +# ===================================================================== - 给PDF添加水印(交互模式)。 - - Returns: - None - """ +def del4pdf(input_file: str = None, output_file: str = None, + page_nums: List[int] = None) -> None: + """Delete specific pages from PDF file. + 删除PDF文件中的指定页面。 -# 给pdf加水印-有参数 + Documentation: https://www.python4office.cn/python-office/popdf/9-del4pdf -def add_mark(input_file=None, mark_str=None, output_path=None, output_file=None, pdf_file=None, output_file_name=None) -> None: - """Add watermark to PDF. + Args: + input_file (str): PDF 文件路径 + output_file (str): 输出 PDF 文件路径 + page_nums (list[int]): 要删除的页码列表(1-indexed) + """ + popdf.del4pdf(page_nums=page_nums, input_file=input_file, + output_file=output_file) - 给PDF添加水印。 - Args: - input_file (str, optional): path to PDF file / PDF文件的位置,e.g. / 例如:d:/code/programmer.pdf - mark_str (str, optional): watermark content to add / 需要添加的水印内容,e.g. / 例如:"python-office" - output_path (str, optional): save directory path / 保存文件的位置 - output_file (str, optional): name for output file with watermark / 指定添加了水印的文件名称。Default / 默认: watermarked file.pdf / 添加了水印的文件.pdf - pdf_file (str, optional): [已弃用] 请使用 input_file 参数代替 / [Deprecated] Use input_file instead - output_file_name (str, optional): [已弃用] 请使用 output_file 参数代替 / [Deprecated] Use output_file instead +# ===================================================================== +# 13. 交互式水印 +# ===================================================================== - Returns: - None - """ - # 处理已弃用的参数 - if pdf_file is not None: - warnings.warn( - "参数 'pdf_file' 已被弃用,不再推荐使用。请改用 'input_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if input_file is None: - input_file = pdf_file - if output_file_name is not None: - warnings.warn( - "参数 'output_file_name' 已被弃用,不再推荐使用。请改用 'output_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if output_file is None: - output_file = output_file_name - - popdf.add_watermark_by_parameters(pdf_file=input_file, mark_str=mark_str, output_path=output_path, - output_file_name=output_file) - - -# 给pdf加水印-有参数 - -def add_watermark_by_parameters(input_file=None, mark_str=None, output_path=None, output_file=None, pdf_file=None, output_file_name=None) -> None: - """Add watermark to PDF with parameters. +def add_watermark() -> None: + """交互式水印入口(CLI 场景)。 - 给PDF添加水印(带参数)。 + 在命令行场景下调用 ``office.pdf.add_watermark()`` 会进入交互问答, + 由用户逐步输入参数后调用 :func:`add_text_watermark`。 - Args: - input_file (str, optional): path to PDF file / PDF文件的位置,e.g. / 例如:d:/code/programmer.pdf - mark_str (str, optional): watermark content to add / 需要添加的水印内容,e.g. / 例如:"python-office" - output_path (str, optional): save directory path / 保存文件的位置 - output_file (str, optional): name for output file with watermark / 指定添加了水印的文件名称。Default / 默认: watermarked file.pdf / 添加了水印的文件.pdf - pdf_file (str, optional): [已弃用] 请使用 input_file 参数代替 / [Deprecated] Use input_file instead - output_file_name (str, optional): [已弃用] 请使用 output_file 参数代替 / [Deprecated] Use output_file instead + 在 GUI 场景下请直接使用「PDF 加文字水印」面板,无需调用此函数。 - Returns: - None + Documentation: https://www.python-office.com/modules/pdf/api#add_watermark """ - # 处理已弃用的参数 - if pdf_file is not None: - warnings.warn( - "参数 'pdf_file' 已被弃用,不再推荐使用。请改用 'input_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if input_file is None: - input_file = pdf_file - if output_file_name is not None: - warnings.warn( - "参数 'output_file_name' 已被弃用,不再推荐使用。请改用 'output_file' 参数。", - DeprecationWarning, - stacklevel=2 - ) - if output_file is None: - output_file = output_file_name - - popdf.add_watermark_by_parameters(pdf_file=input_file, mark_str=mark_str, output_path=output_path, - output_file_name=output_file) + print("=" * 60) + print("python-office 交互式水印") + print("=" * 60) + try: + input_file = input("请输入 PDF 文件路径: ").strip() + text = input("请输入水印文本 [python-office]: ").strip() or "python-office" + output_file = input("请输入输出文件路径 [留空自动派生]: ").strip() or None + except EOFError: + print("\n[python-office] 检测到非交互式环境,请在 GUI 中使用「PDF 加文字水印」。") + return + print() + add_text_watermark( + input_file=input_file, + text=text, + output_file=output_file, + ) + + +# ===================================================================== +# __all__ +# ===================================================================== + +__all__ = [ + "pdf2docx", + "pdf2imgs", + "txt2pdf", + "split4pdf", + "encrypt4pdf", + "decrypt4pdf", + "merge2pdf", + "add_text_watermark", + "add_img_water", + "add_mark", + "add_watermark_by_parameters", + "del4pdf", + "add_watermark", +] diff --git a/office/api/ppt.py b/office/api/ppt.py index 8d4f27b..7b33629 100644 --- a/office/api/ppt.py +++ b/office/api/ppt.py @@ -1,12 +1,12 @@ -# -*- coding:utf-8 -*- -"""PowerPoint processing functionality module. +# -*- coding: UTF-8 -*- +"""PPT 处理功能模块。 -PowerPoint处理功能模块。 +本模块按 https://www.python-office.com/modules/ppt/api 官方文档定义, +共 3 个函数,对应子包 ``poppt``: -This module provides PowerPoint file processing capabilities including format conversion, -file merging, and more. - -该模块提供了PowerPoint文件处理功能,包括格式转换、文件合并等。 + 1. ppt2pdf - PPT 转 PDF + 2. ppt2img - PPT 转图片(可合成一张长图) + 3. merge4ppt - 合并多个 PPT Author: 程序员晚枫 @@ -15,64 +15,104 @@ https://www.python-office.com """ -def _load_poppt(): - try: - import poppt - except ModuleNotFoundError as exc: - if exc.name != "poppt": - raise - raise ModuleNotFoundError( - "PPT处理功能依赖 poppt,该功能仅支持安装了 Microsoft PowerPoint " - "和 poppt 的 Windows 环境。" - ) from exc - return poppt +from __future__ import annotations + +from typing import Optional + +import poppt -def ppt2pdf(path: str, output_path=r'./'): +# ===================================================================== +# 1. ppt2pdf - PPT 转 PDF +# ===================================================================== + +def ppt2pdf(path: str, output_path: str = "./") -> str: """Convert PowerPoint to PDF. - - 将PPT转换为PDF。 - + + 将 PPT / PPTX 文档转换为 PDF。 + + Documentation: https://www.python-office.com/modules/ppt/api#ppt2pdf + Args: - path (str): PowerPoint file path / PPT文件路径 - output_path (str, optional): output PDF file path / 输出PDF文件路径。Default / 默认: current directory / 当前目录 - + path: PPT / PPTX 文件路径 + output_path: PDF 输出目录。Default: ``'./'`` + Returns: - None + str: 输出目录路径 """ - poppt = _load_poppt() poppt.ppt2pdf(path=path, output_path=output_path) + print(f"[python-office] ppt2pdf 输出目录:{output_path}") + return output_path + + +# ===================================================================== +# 2. ppt2img - PPT 转图片 +# ===================================================================== + +def ppt2img( + input_path: str, + output_path: str = "./", + merge: bool = False, +) -> str: + """Convert PowerPoint to images. + 将 PPT 转换为图片。 + - ``merge=False``(默认):每页一张图,输出到 ``output_path`` 目录 + - ``merge=True``:所有页拼成一张长图,输出到 ``output_path`` 单文件 + + 同时支持: + - 单个文件:传 ``input_path='xxx.pptx'`` + - 批量目录:传 ``input_path='./ppt_dir/'``,转换目录下所有 PPT + + Documentation: https://www.python-office.com/modules/ppt/api#ppt2img -def ppt2img(input_path: str, output_path=r'./', merge: bool = False): - """Convert PowerPoint to images, can convert to long image. - - 将PPT转换为图片,可以转为长图。 - Args: - input_path (str): PowerPoint file location / 存放PPT的位置。For single file, write file path / 转换单个文件可以写文件的路径; for folder, write folder path / 转换文件夹可以写文件夹的路径 - output_path (str, optional): storage location for result images / 结果图片的存储位置。Default / 默认: current directory / 当前目录 - merge (bool, optional): True converts to 1 image / True转为1张图片, False converts to multiple images / False转为多张图片。Default / 默认: False - + input_path: PPT / PPTX 文件路径,或包含多个 PPT 的目录 + output_path: 图片输出目录(merge=False)或单张长图文件路径(merge=True)。Default: ``'./'`` + merge: True=合并为一张长图,False=每页一张图。Default: ``False`` + Returns: - None + str: 实际输出目录 / 文件路径 """ - poppt = _load_poppt() poppt.ppt2img(input_path=input_path, output_path=output_path, merge=merge) + print(f"[python-office] ppt2img 输出:{output_path} merge={merge}") + return output_path + + +# ===================================================================== +# 3. merge4ppt - 合并多个 PPT +# ===================================================================== +def merge4ppt( + input_path: str, + output_path: str = "./", + output_name: str = "merge4ppt.pptx", +) -> str: + """Merge multiple PowerPoint files into one. + + 将多个 PPT / PPTX 合并为一个文件。 + + Documentation: https://www.python-office.com/modules/ppt/api#merge4ppt -def merge4ppt(input_path: str, output_path=r'./', output_name: str = 'merge4ppt.pptx'): - """Merge multiple PowerPoint files. - - 合并多个PPT文件。 - Args: - input_path (str): input PowerPoint file path / 输入PPT文件路径 - output_path (str, optional): output PowerPoint file path / 输出PPT文件路径。Default / 默认: current directory / 当前目录 - output_name (str, optional): merged PowerPoint filename / 合并后的PPT文件名。Default / 默认: 'merge4ppt.pptx' - + input_path: 包含多个 PPT / PPTX 的目录 + output_path: 合并后文件保存目录。Default: ``'./'`` + output_name: 合并后文件名(含 .pptx 后缀)。Default: ``'merge4ppt.pptx'`` + Returns: - None + str: 合并后文件完整路径 """ - poppt = _load_poppt() - poppt.merge4ppt(input_path=input_path, output_path=output_path, output_name=output_name) + poppt.merge4ppt( + input_path=input_path, output_path=output_path, output_name=output_name, + ) + out_dir = output_path.rstrip("/\\") + full = f"{out_dir}/{output_name}" + print(f"[python-office] merge4ppt 输出文件:{full}") + return full + + +__all__ = [ + "ppt2pdf", + "ppt2img", + "merge4ppt", +] diff --git a/office/api/tools.py b/office/api/tools.py index 325251e..994592e 100644 --- a/office/api/tools.py +++ b/office/api/tools.py @@ -1,11 +1,19 @@ -"""Utility tools functionality module. - -工具类功能模块。 - -This module provides various utility tools including translation, QR code generation, -password generation, weather query, URL to IP conversion, article generation, and more. - -该模块提供了各种工具类功能,包括翻译、二维码生成、密码生成、天气查询、URL转IP、文章生成等。 +# -*- coding: UTF-8 -*- +"""实用工具功能模块。 + +本模块按 https://www.python-office.com/modules/tools/api 官方文档定义, +共 10 个函数,对应子包 ``wftools``(随主包自动安装): + + 1. transtools - 多语言翻译 + 2. qrcodetools - 生成二维码 + 3. passwordtools - 随机密码 + 4. weather - 天气查询(CLI 交互式) + 5. url2ip - URL 转 IP + 6. lottery8ticket - 彩票号码(CLI 交互式) + 7. create_article - AI 生成文章 + 8. pwd4wifi - WiFi 密码(仅 Windows) + 9. net_speed_test - 网速测试(CLI 交互式) + 10. course - 项目信息展示 Author: 程序员晚枫 @@ -14,181 +22,231 @@ https://www.python-office.com """ +from __future__ import annotations + +from typing import List, Optional + import wftools -from pocode.api.color import random_color_print -from office.lib.conf.CONST import SPLIT_LINE +try: + from pocode.api.color import random_color_print +except ImportError: # 兜底:pocode 不可用时退化为普通 print + def random_color_print(text: str) -> None: # type: ignore[no-redef] + print(text) + + +# ===================================================================== +# 1. transtools - 多语言翻译 +# ===================================================================== +def transtools(to_lang: str, content: str, from_lang: str = "zh") -> str: + """Translate text between languages. + + 将内容从一种语言翻译为另一种语言(调用 libretranslate)。 + + Documentation: https://www.python-office.com/modules/tools/api#transtools -# -def transtools(to_lang: str, content: str, from_lang: str = 'zh'): - """Translate content from one language to another. - - 将内容从一种语言翻译为另一种语言。 - Args: - to_lang (str): target language / 目标语言 - content (str): content to translate / 待翻译的内容 - from_lang (str, optional): source language / 源语言。Default / 默认: 'zh' (Chinese / 中文) - + to_lang: 目标语言代码(en / zh / ja / ...) + content: 待翻译的内容 + from_lang: 源语言代码。Default: ``'zh'``(中文) + Returns: - str: translated result / 翻译后的结果 + str: 翻译后的结果 """ - return wftools.transtools(to_lang=to_lang, content=content, from_lang=from_lang) + result = wftools.transtools( + to_lang=to_lang, content=content, from_lang=from_lang, + ) + print(f"[python-office] transtools {from_lang}→{to_lang}: {content[:20]!r}") + return result + + +# ===================================================================== +# 2. qrcodetools - 生成二维码 +# ===================================================================== + +def qrcodetools(url: str, output: str = "./qrcode_img.png") -> str: + """Generate QR code image from URL. + 把 URL 编码为二维码图片。 + + Documentation: https://www.python-office.com/modules/tools/api#qrcodetools -def qrcodetools(url: str, output: str = r'./qrcode_img.png'): - """Generate QR code image. - - 生成二维码图片。 - Args: - url (str): URL address for generating QR code / 用于生成二维码的URL地址 - output (str, optional): save path for generated QR code image / 生成的二维码图片保存路径。Default / 默认: './qrcode_img.png' in current directory / 当前目录下的'./qrcode_img.png' - + url: 要编码的网址或文本 + output: 二维码图片保存路径。Default: ``'./qrcode_img.png'`` + Returns: - None + str: 生成的二维码图片路径 """ wftools.qrcodetools(url=url, output=output) + print(f"[python-office] qrcodetools → {output}") + return output + + +# ===================================================================== +# 3. passwordtools - 随机密码 +# ===================================================================== + +def passwordtools(len: int = 8) -> str: + """Generate random password of specified length. + + 生成指定长度的随机密码。 + Documentation: https://www.python-office.com/modules/tools/api#passwordtools -def passwordtools(len=8): - """Generate password of specified length. - - 生成一个指定长度的密码。 - Args: - len (int, optional): password length / 密码长度。Default / 默认: 8 - + len: 密码长度。Default: ``8`` + Returns: - str: generated password / 生成的密码 + str: 生成的随机密码字符串 """ - return wftools.passwordtools(len=len) - -def weather(): - """Get current weather information. - - 获取当前天气信息。 - - This function calls the weather method in wftools library to get current weather information. - Note that this function has no parameters and returns no value. It depends on external library wftools to complete actual weather information retrieval. - - 该函数调用了wftools库中的weather方法,以获取当前的天气信息。 - 请注意,此函数内部无参数且不返回任何值。它依赖于外部库wftools来完成实际的天气信息获取。 - - Returns: - None + password = wftools.passwordtools(len=len) + print(f"[python-office] passwordtools 长度={len} 密码={password!r}") + return password + + +# ===================================================================== +# 4. weather - 天气查询(CLI 交互式) +# ===================================================================== + +def weather() -> None: + """Get current weather information(CLI 交互式). + + 获取当前天气信息(CLI 场景会引导用户输入城市;GUI 中标占位即可)。 + + Documentation: https://www.python-office.com/modules/tools/api#weather """ + try: + city = input("请输入要查询的城市: ").strip() + except EOFError: + print("\n[python-office] 非交互式环境,请在 GUI 中使用天气查询。") + return wftools.weather() + print(f"[python-office] weather 查询:{city}") +# ===================================================================== +# 5. url2ip - URL 转 IP +# ===================================================================== -# 通过url,获取ip地址 -# def url2ip(url: str) -> str: - """Convert URL to IP address. - - 将URL转换为IP地址。 - - This function calls url2ip method in wftools library to parse given URL and return corresponding IP address. - - 此函数调用了wftools库中的url2ip方法,用于解析给定的URL并返回相应的IP地址。 - + """Resolve URL to IP address. + + 把 URL / 域名解析为 IP 地址。 + + Documentation: https://www.python-office.com/modules/tools/api#url2ip + Args: - url (str): URL string to convert / 需要转换的URL字符串 - + url: 要查询的网址或域名 + Returns: - str: parsed IP address string / 解析得到的IP地址字符串 + str: 解析得到的 IP 地址字符串 """ - return wftools.url2ip(url) + ip = wftools.url2ip(url=url) + print(f"[python-office] url2ip {url} → {ip}") + return ip +# ===================================================================== +# 6. lottery8ticket - 彩票号码(CLI 交互式) +# ===================================================================== -# 通过url,获取ip地址 +def lottery8ticket() -> None: + """Generate an 8-digit lottery ticket number(CLI 交互式). -def lottery8ticket(): - """Generate 8-digit lottery ticket number. - - 生成一张8位彩票号码。 - - Calls lottery8ticket method in `wftools` library to generate lottery ticket number in specified format. - This function requires no parameters and has no return value, all logic is completed internally. - - 调用了`wftools`库中的lottery8ticket方法,用于生成指定格式的彩票号码。 - 该函数不需要任何参数,也无返回值,所有逻辑都在内部完成。 - - Returns: - None + 生成一张 8 位彩票号码(CLI 场景)。 + + Documentation: https://www.python-office.com/modules/tools/api#lottery8ticket """ wftools.lottery8ticket() + print("[python-office] lottery8ticket 已生成彩票号码") +# ===================================================================== +# 7. create_article - AI 生成文章 +# ===================================================================== + +def create_article(theme: str, line_num: int = 200) -> None: + """Create an article around the given theme. + + 围绕主题自动生成文章。 + + Documentation: https://www.python-office.com/modules/tools/api#create_article -def create_article(theme, line_num=200): - """Create article. - - 创建文章。 - Args: - theme (str): article theme / 文章的主题 - line_num (int, optional): number of lines in article / 文章的行数。Default / 默认: 200 lines / 200行 - - Returns: - None + theme: 文章主题 + line_num: 目标字数。Default: ``200`` """ wftools.create_article(theme=theme, line_num=line_num) + print(f"[python-office] create_article 主题:{theme!r} 字数:{line_num}") + + +# ===================================================================== +# 8. pwd4wifi - WiFi 密码(仅 Windows) +# ===================================================================== +def pwd4wifi(len_pwd: int = 8, pwd_list: Optional[List[str]] = None) -> None: + """Generate WiFi password list (Windows only). + + 生成 WiFi 密码字典列表(仅 Windows,依赖 pywifi)。 + + Documentation: https://www.python-office.com/modules/tools/api#pwd4wifi -def pwd4wifi(len_pwd: int = 8, pwd_list=[]): - """Generate WiFi password list. - - 生成WiFi密码列表。 - - This function calls pwd4wifi function in wftools module to generate WiFi password list of specified length. - If no password list provided, function will use empty list as default parameter. - - 该函数调用wftools模块中的pwd4wifi函数,以生成指定长度的WiFi密码列表。 - 如果没有提供密码列表,函数将使用空列表作为默认参数。 - Args: - len_pwd (int, optional): password length / 密码长度。Default / 默认: 8 - pwd_list (list, optional): password list / 密码列表。Default / 默认: empty list / 空列表 - - Returns: - None + len_pwd: 密码长度。Default: ``8`` + pwd_list: 自定义字符集,留空用默认。Default: ``None`` """ - # 调用wftools模块中的pwd4wifi函数,传递密码长度和密码列表参数 + if pwd_list is None: + pwd_list = [] wftools.pwd4wifi(len_pwd=len_pwd, pwd_list=pwd_list) + print(f"[python-office] pwd4wifi 长度={len_pwd} 字符集={len(pwd_list)} 项") -# 测试网速 - -def net_speed_test(): - """Network speed test function. - - 网络速度测试函数。 - - This function is used to test network upload and download speed. - - 该函数用于测试网络的上传和下载速度。 - - Returns: - None + +# ===================================================================== +# 9. net_speed_test - 网速测试(CLI 交互式) +# ===================================================================== + +def net_speed_test() -> None: + """Test network upload and download speed(CLI 交互式). + + 测试网络上传和下载速度(CLI 场景)。 + + Documentation: https://www.python-office.com/modules/tools/api#net_speed_test """ wftools.net_speed_test() + print("[python-office] net_speed_test 测试完成") -def course(): - """Display information and resource links for python-office library. - - 显示python-office库的相关信息和资源链接。 - - Returns: - None +# ===================================================================== +# 10. course - 项目信息 +# ===================================================================== + +def course() -> None: + """Display project info and resource links for python-office. + + 显示 python-office 库的相关信息和资源链接。 + + Documentation: https://www.python-office.com/modules/tools/api#course """ - random_color_print(SPLIT_LINE) - random_color_print('【python-office库】,功能持续更新中') - random_color_print('使用有问题 or 提交你的功能需求 or 参与项目开发') - random_color_print('1、给小白的【50讲Python自动化办公】:https://www.python-office.com/course/50-python-office.html') - random_color_print('2、请+【项目交流群】:https://www.python4office.cn/wechat-group/') - random_color_print('3、本开源项目的【源代码】:https://github.com/CoderWanFeng/python-office') - random_color_print(SPLIT_LINE) + random_color_print("=" * 60) + random_color_print("【python-office 库】,功能持续更新中") + random_color_print("使用有问题 or 提交功能需求 or 参与项目开发") + random_color_print("1、给小白的【50 讲 Python 自动化办公】: https://www.python-office.com/course/50-python-office.html") + random_color_print("2、请+【项目交流群】: https://www.python4office.cn/wechat-group/") + random_color_print("3、本开源项目的【源代码】: https://github.com/CoderWanFeng/python-office") + random_color_print("=" * 60) + + +__all__ = [ + "transtools", + "qrcodetools", + "passwordtools", + "weather", + "url2ip", + "lottery8ticket", + "create_article", + "pwd4wifi", + "net_speed_test", + "course", +] diff --git a/office/api/video.py b/office/api/video.py index 3a5f817..11b9b1e 100644 --- a/office/api/video.py +++ b/office/api/video.py @@ -1,11 +1,13 @@ -"""Video processing functionality module. +# -*- coding: UTF-8 -*- +"""视频处理功能模块。 -视频处理功能模块。 +本模块按 https://www.python-office.com/modules/video/api 官方文档定义, +共 4 个函数,对应子包 ``povideo``: -This module provides video processing capabilities including format conversion, -audio extraction, watermark addition, text-to-speech, and more. - -该模块提供了视频处理功能,包括格式转换、音频提取、水印添加、文本转语音等。 + 1. video2mp3 - 视频提取音频 + 2. audio2txt - 音频转文字(调用腾讯云语音识别) + 3. mark2video - 视频加文字水印 + 4. txt2mp3 - 文本转语音 Author: 程序员晚枫 @@ -14,97 +16,146 @@ https://www.python-office.com """ +from __future__ import annotations + +from typing import Optional + import povideo -# 从视频里提取音频 +__all__ = ["video2mp3", "audio2txt", "mark2video", "txt2mp3"] + + +# ===================================================================== +# 1. video2mp3 - 视频提取音频 +# ===================================================================== +def video2mp3( + path: str, + mp3_name: Optional[str] = None, + output_path: str = "./", +) -> str: + """Extract audio from video and save as MP3. + 将视频文件提取为 MP3 音频。 + + Documentation: https://www.python-office.com/modules/video/api#video2mp3 -def video2mp3(path, mp3_name=None, output_path=r'./'): - """Convert video file to mp3 audio file. - - 将视频文件转换为mp3音频文件。 - Args: - path (str): video file path / 视频文件的路径 - mp3_name (str, optional): output mp3 filename / 输出mp3文件的名称。If not provided, defaults to original video filename / 如果未提供,默认为原视频文件名 - output_path (str, optional): output mp3 file path / 输出mp3文件的路径。Default / 默认: current directory / 当前目录 - + path: 输入视频文件路径 + mp3_name: 输出 MP3 文件名(不含后缀),留空则用原视频文件名。Default: ``None`` + output_path: 输出目录。Default: ``'./'`` + Returns: - None: generates mp3 file in specified output path / 在指定输出路径下生成mp3文件 + str: 输出 MP3 文件路径 """ povideo.video2mp3(path=path, mp3_name=mp3_name, output_path=output_path) + from pathlib import Path + name = mp3_name or Path(path).stem + out = str(Path(output_path) / f"{name}.mp3") + print(f"[python-office] video2mp3 输出:{out}") + return out -def audio2txt(audio_path, appid, secret_id, secret_key): - """Extract text from audio. - - 从音频里提取文字。 - - Note: Local audio file cannot exceed 5MB. - 注意:本地语音文件不能大于5MB。 - - Args: - audio_path (str): audio file path / 音频文件路径 - appid (str): speech recognition API application ID / 语音识别API的应用ID - secret_id (str): speech recognition API secret ID / 语音识别API的密钥ID - secret_key (str): speech recognition API secret key / 语音识别API的密钥 - - Returns: - None - """ - povideo.audio2txt(audio_path=audio_path, appid=appid, secret_id=secret_id, secret_key=secret_key) +# ===================================================================== +# 2. audio2txt - 音频转文字 +# ===================================================================== +def audio2txt(audio_path: str, appid: str, secret_id: str, secret_key: str) -> None: + """Convert audio to text using Tencent Cloud ASR. -def mark2video(video_path, output_path=r'./', output_name=r'mark2video.mp4', mark_str: str = "www.python-office.com", - font_size=28, - font_type='Arial', font_color='white'): - """Add watermark to video. - - 给视频添加水印。 - - Args: - video_path (str): video file path / 视频地址 - output_path (str, optional): output path / 输出地址。Default / 默认: current directory / 当前目录 - output_name (str, optional): output filename, remember to include '.mp4' / 输出名称,记得带'.mp4'。Default / 默认: 'mark2video.mp4' - mark_str (str, optional): watermark content, only supports English / 水印内容,只支持英文。Default / 默认: 'www.python-office.com' - font_size (int, optional): watermark font size / 水印字体大小。Default / 默认: 28 - font_type (str, optional): watermark font type / 水印字体类型。Default / 默认: 'Arial' - font_color (str, optional): watermark color / 水印颜色。Default / 默认: 'white' - - Returns: - None - """ - povideo.mark2video(video_path=video_path, output_path=output_path, output_name=output_name, mark_str=mark_str, font_size=font_size, font_type=font_type, font_color=font_color) + 调用腾讯云语音识别 API,把音频文件转为文字。 + 注意:本地音频文件不能大于 5MB。 + Documentation: https://www.python-office.com/modules/video/api#audio2txt -def txt2mp3(content='程序员晚枫', file=None, mp3=r'./程序员晚枫.mp3', speak=True): - """Convert text to speech. - - 文本转语音。 - Args: - content (str, optional): content to convert / 需要转换的内容。Default / 默认: '程序员晚枫' - file (str, optional): specify file to read, highest priority / 指定读取的文件,优先级最高 - mp3 (str, optional): mp3 save location and name / 需要保存的mp3位置和名称。Fill None to not save / 填None不保存。Default / 默认: './程序员晚枫.mp3' - speak (bool, optional): whether to read aloud / 是否阅读。Default / 默认: True - - Returns: - None + audio_path: 输入音频文件路径 + appid: 腾讯云语音识别应用的 appid + secret_id: 腾讯云 API SecretId + secret_key: 腾讯云 API SecretKey """ - return povideo.txt2mp3(content=content, file=file, mp3=mp3, speak=speak) + povideo.audio2txt( + audio_path=audio_path, + appid=appid, + secret_id=secret_id, + secret_key=secret_key, + ) + print(f"[python-office] audio2txt 源:{audio_path}") + + +# ===================================================================== +# 3. mark2video - 视频加文字水印 +# ===================================================================== + +def mark2video( + video_path: str, + output_path: str = "./", + output_name: str = "mark2video.mp4", + mark_str: str = "www.python-office.com", + font_size: int = 28, + font_type: str = "Arial", + font_color: str = "white", +) -> str: + """Add text watermark to video. + + 给视频添加文字水印(默认滚动)。 + + Documentation: https://www.python-office.com/modules/video/api#mark2video + Args: + video_path: 输入视频文件路径 + output_path: 输出目录。Default: ``'./'`` + output_name: 输出文件名(**记得带 .mp4 后缀**)。Default: ``'mark2video.mp4'`` + mark_str: 水印文字内容。Default: ``'www.python-office.com'`` + font_size: 水印字号。Default: ``28`` + font_type: 字体名称或字体文件路径。Default: ``'Arial'`` + font_color: 字体颜色。Default: ``'white'`` -from office.lib.video.video_edit_service import ( - _parse_time, - cut_video, - cut_audio, - crop_video, - concat_videos, - concat_audios, - add_audio_to_video, -) + Returns: + str: 完整输出文件路径 + """ + povideo.mark2video( + video_path=video_path, + output_path=output_path, + output_name=output_name, + mark_str=mark_str, + font_size=font_size, + font_type=font_type, + font_color=font_color, + ) + from pathlib import Path + out = str(Path(output_path) / output_name) + print(f"[python-office] mark2video 输出:{out} 水印:{mark_str!r}") + return out + + +# ===================================================================== +# 4. txt2mp3 - 文本转语音 +# ===================================================================== + +def txt2mp3( + content: str = "程序员晚枫", + file: Optional[str] = None, + mp3: str = "./程序员晚枫.mp3", + speak: bool = True, +) -> str: + """Convert text to speech and save as MP3. + + 调用本地 TTS 引擎把文本转为 MP3 语音。 + + Documentation: https://www.python-office.com/modules/video/api#txt2mp3 + Args: + content: 要朗读的文本内容。Default: ``'程序员晚枫'`` + file: 可选:从指定文件读取文本(优先级最高)。Default: ``None`` + mp3: 输出 MP3 文件路径。Default: ``'./程序员晚枫.mp3'`` + speak: True=边合成边朗读,False=只生成文件。Default: ``True`` + Returns: + str: 输出 MP3 文件路径 + """ + result = povideo.txt2mp3(content=content, file=file, mp3=mp3, speak=speak) + print(f"[python-office] txt2mp3 输出:{mp3} speak={speak}") + return result if isinstance(result, str) else mp3 diff --git a/office/api/web.py b/office/api/web.py index af6bc22..f24d36a 100644 --- a/office/api/web.py +++ b/office/api/web.py @@ -1,23 +1,6 @@ # -*- coding:utf-8 -*- - -def _load_pospider(): - try: - import pospider - except ModuleNotFoundError as exc: - if exc.name != "pospider": - raise - raise ModuleNotFoundError( - "网页转电子书功能依赖 pospider,请先安装 pospider。" - ) from exc - try: - pospider.url.url2ebook - except AttributeError as exc: - raise ImportError( - "当前 pospider 版本不提供 url.url2ebook,请安装兼容版本。" - ) from exc - return pospider - +import pospider def url2ebook(url, tile): """将指定的URL转换为电子书格式。 @@ -31,5 +14,4 @@ def url2ebook(url, tile): Returns: None,但会生成电子书文件 """ - pospider = _load_pospider() pospider.url.url2ebook(url=url, tile=tile) diff --git a/office/api/wechat.py b/office/api/wechat.py index 6dccac1..17606d4 100644 --- a/office/api/wechat.py +++ b/office/api/wechat.py @@ -1,12 +1,24 @@ # -*- coding: UTF-8 -*- -"""WeChat automation functionality module. +"""微信机器人功能模块。 -微信自动化功能模块。 +本模块按 https://www.python-office.com/modules/wechat/api 官方文档定义, +共 7 个函数,对应子包 ``PyOfficeRobot``: -This module provides WeChat automation capabilities including sending messages, -sending files, scheduled messages, keyword-based chat, and more. + 1. send_message - 发送消息 (chat.send_message) + 2. send_message_by_time - 定时发送 (chat.send_message_by_time) + 3. chat_by_keywords - 关键词自动回复 (chat.chat_by_keywords) + 4. send_file - 发送文件 (file.send_file) + 5. group_send - 群发消息 (group.send) + 6. receive_message - 接收消息 (chat.receive_message) + 7. chat_robot - 智能聊天 (chat.chat_robot) -该模块提供了微信自动化功能,包括发送消息、发送文件、定时消息、关键词聊天等。 +注意:微信 API 不是 ``office.wechat.*``,而是 ``PyOfficeRobot.*``。 + +使用提醒: + 1. 不要频繁发消息(建议间隔 1 秒以上) + 2. 不要群发广告(容易被封号) + 3. 个人微信慎用,推荐用企业微信 + 4. 依赖 PC 微信客户端,仅 Windows Author: 程序员晚枫 @@ -15,130 +27,164 @@ https://www.python-office.com """ +from __future__ import annotations + +from typing import Dict, Optional + +import PyOfficeRobot + + +__all__ = [ + "send_message", + "send_message_by_time", + "chat_by_keywords", + "send_file", + "group_send", + "receive_message", + "chat_robot", +] -def _load_py_office_robot(): - try: - import PyOfficeRobot - except ModuleNotFoundError as exc: - if exc.name != "PyOfficeRobot": - raise - raise ModuleNotFoundError( - "微信自动化功能依赖 PyOfficeRobot,该功能仅支持安装了桌面微信 " - "和 PyOfficeRobot 的 Windows 环境。" - ) from exc - return PyOfficeRobot +# ===================================================================== +# 1. send_message - 发送消息 +# ===================================================================== -def send_message(who: str, message: str): +def send_message(who: str, message: str) -> None: """Send message to specified contact. - - 发送消息给指定联系人。 - + + 发送消息给指定好友 / 文件传输助手 / 群。 + + Documentation: https://www.python-office.com/modules/wechat/api#send_message + Args: - who (str): contact name to receive message / 接收消息的联系人名称 - message (str): message content to send / 要发送的消息内容 - - Returns: - None + who: 好友昵称 / 群名(如 ``'文件传输助手'`` / ``'家人'``) + message: 要发送的消息内容 """ - PyOfficeRobot = _load_py_office_robot() PyOfficeRobot.chat.send_message(who=who, message=message) + print(f"[python-office] send_message → {who}: {message[:30]!r}") + +# ===================================================================== +# 2. send_message_by_time - 定时发送 +# ===================================================================== -def send_message_by_time(who, message, time): +def send_message_by_time(who: str, message: str, time: str) -> None: """Send message to specified contact at specified time. - - 在指定时间发送消息给指定联系人。 - + + 在指定时间自动发送消息给好友 / 群。 + + Documentation: https://www.python-office.com/modules/wechat/api#send_message_by_time + Args: - who (str): contact name to receive message / 接收消息的联系人名称 - message (str): message content to send / 要发送的消息内容 - time (str): scheduled time to send message / 发送消息的预定时间 - - Returns: - None + who: 好友昵称 / 群名 + message: 要发送的消息内容 + time: 定时发送时间,格式 ``'YYYY-MM-DD HH:MM:SS'``,如 ``'2026-06-15 09:00:00'`` """ - PyOfficeRobot = _load_py_office_robot() PyOfficeRobot.chat.send_message_by_time(who=who, message=message, time=time) + print( + f"[python-office] send_message_by_time → {who} @ {time}: {message[:30]!r}" + ) + +# ===================================================================== +# 3. chat_by_keywords - 关键词自动回复 +# ===================================================================== + +def chat_by_keywords(who: str, keywords: Dict[str, str]) -> None: + """Auto-reply to a contact based on keyword mapping. + + 根据关键词字典自动回复好友 / 群。 + + Documentation: https://www.python-office.com/modules/wechat/api#chat_by_keywords -def chat_by_keywords(who, keywords): - """Chat with specified contact based on keywords. - - 根据关键词与指定联系人聊天。 - Args: - who (str): contact name for chatting / 进行聊天的联系人名称 - keywords (list): keyword list to trigger chat / 触发聊天的关键词列表 - - Returns: - None + who: 要监听的好友 / 群名 + keywords: ``{关键词: 回复内容}`` 字典,匹配到任一关键词时自动回复 """ - PyOfficeRobot = _load_py_office_robot() PyOfficeRobot.chat.chat_by_keywords(who=who, keywords=keywords) + print( + f"[python-office] chat_by_keywords {who}: " + f"{len(keywords)} 条规则" + ) -def send_file(who, file): - """Send file to specified contact. - - 发送文件给指定联系人。 - +# ===================================================================== +# 4. send_file - 发送文件 +# ===================================================================== + +def send_file(who: str, file: str) -> None: + """Send a file to specified contact. + + 发送本地文件给好友 / 群。 + + Documentation: https://www.python-office.com/modules/wechat/api#send_file + Args: - who (str): contact name to receive file / 接收文件的联系人名称 - file (str): file path to send / 要发送的文件路径 - - Returns: - None + who: 好友昵称 / 群名 + file: 要发送的文件路径 """ - PyOfficeRobot = _load_py_office_robot() PyOfficeRobot.file.send_file(who=who, file=file) + print(f"[python-office] send_file → {who}: {file}") -def group_send(): - """Send group messages. - - 群发消息。 - - Returns: - None +# ===================================================================== +# 5. group_send - 群发消息 +# ===================================================================== + +def group_send() -> None: + """Send group messages to multiple contacts at once. + + 群发消息(按预设的群组列表)。 + + Documentation: https://www.python-office.com/modules/wechat/api#group_send """ - PyOfficeRobot = _load_py_office_robot() PyOfficeRobot.group.send() + print("[python-office] group_send 群发消息完成") + +# ===================================================================== +# 6. receive_message - 接收消息 +# ===================================================================== + +def receive_message( + who: str = "文件传输助手", + txt: str = "userMessage.txt", + output_path: str = "./", +) -> str: + """Receive WeChat messages and save to file. + + 接收指定好友 / 群的消息并保存到文件。 + + Documentation: https://www.python-office.com/modules/wechat/api#receive_message -def receive_message(who='文件传输助手', txt='userMessage.txt', output_path='./'): - """Receive WeChat robot messages and save to specified path. - - 接收微信机器人消息并保存到指定路径。 - - This function receives messages from WeChat robot by calling receive_message function in PyOfficeRobot library, - and saves received messages to specified file. This is a way to automate WeChat message reception. - - 该函数通过调用PyOfficeRobot库中的receive_message函数来接收微信机器人的消息, - 并将接收到的消息保存到指定的文件中。这是实现微信消息接收自动化的一种方式。 - Args: - who (str, optional): WeChat contact who sent message / 发送消息的微信联系人。Default / 默认: '文件传输助手' - txt (str, optional): message content text filename / 消息内容的文本文件名。Default / 默认: 'userMessage.txt' - output_path (str, optional): message content save path / 消息内容的保存路径。Default / 默认: current directory / 当前目录 - + who: 要监听的好友 / 群名。Default: ``'文件传输助手'`` + txt: 消息保存的文本文件名。Default: ``'userMessage.txt'`` + output_path: 消息文件保存目录。Default: ``'./'`` + Returns: - None: function result is saving messages to specified file and path / 函数的执行结果是将消息保存到指定的文件和路径中 + str: 实际保存路径(``output_path/txt``) """ - PyOfficeRobot = _load_py_office_robot() PyOfficeRobot.chat.receive_message(who=who, txt=txt, output_path=output_path) + from pathlib import Path + out = str(Path(output_path) / txt) + print(f"[python-office] receive_message {who} → {out}") + return out + +# ===================================================================== +# 7. chat_robot - 智能聊天 +# ===================================================================== + +def chat_robot(who: str = "程序员晚枫") -> None: + """Enable intelligent chat with specified contact. + + 启动与指定好友的智能聊天(需配置 OpenAI Key 等模型凭据)。 + + Documentation: https://www.python-office.com/modules/wechat/api#chat_robot -def chat_robot(who='程序员晚枫'): - """Intelligent chat. - - 智能聊天。 - Args: - who (str, optional): specify chat target / 指定聊天对象。Can be remark name, does not support special characters / 可以是备注名称,不支持特殊字符。Default / 默认: '程序员晚枫' - - Returns: - None + who: 聊天对象(备注名称,不支持特殊字符)。Default: ``'程序员晚枫'`` """ - PyOfficeRobot = _load_py_office_robot() PyOfficeRobot.chat.chat_robot(who=who) + print(f"[python-office] chat_robot 已启动智能聊天:{who}") diff --git a/office/api/word.py b/office/api/word.py index 423b56f..eed8cbf 100644 --- a/office/api/word.py +++ b/office/api/word.py @@ -1,12 +1,17 @@ # -*- coding: UTF-8 -*- -"""Word processing functionality module. +"""Word 处理功能模块。 -Word处理功能模块。 +本模块按 https://www.python-office.com/modules/word/api 官方文档定义, +共 5 个函数,对应子包 ``poword``: -This module provides Word document processing capabilities including format conversion, -file merging, image extraction, and more. + 1. docx2pdf - Word 转 PDF(支持单个文件或整个文件夹批量) + 2. merge4docx - 合并多个 Word + 3. doc2docx - 旧版 .doc 转为 .docx + 4. docx2doc - .docx 转回旧版 .doc + 5. docx4imgs - 从 Word 提取图片 -该模块提供了Word文档处理功能,包括格式转换、文件合并、图片提取等。 +所有函数依赖 Microsoft Word / WPS / LibreOffice,**仅 Windows / macOS / Linux 桌面环境**可用。 +CI / 无头服务器调用会失败。 Author: 程序员晚枫 @@ -14,105 +19,179 @@ Project: https://www.python-office.com """ -from pathlib import Path +from __future__ import annotations -def _load_poword(): - try: - import poword - except ModuleNotFoundError as exc: - if exc.name != "poword": - raise - raise ModuleNotFoundError( - "Word处理功能依赖 poword,该功能仅支持安装了 Microsoft Word " - "和 poword 的 Windows 环境。" - ) from exc - return poword +import os +from typing import Optional +import poword -def docx2pdf(path: str, output_path: str = None): + +# ===================================================================== +# 1. docx2pdf - Word 转 PDF +# ===================================================================== + +def docx2pdf(path: str, output_path: Optional[str] = None) -> str: """Convert Word to PDF. - - 将Word转换为PDF。 - + + 将 Word 文档转换为 PDF。**支持单个文件或整个文件夹批量转换**。 + + - 输入 ``path`` 是文件 → 转换该文件 + - 输入 ``path`` 是目录 → 批量转换目录下所有 .docx / .doc + + 平台要求:依赖 Microsoft Word / WPS / LibreOffice。 + - Windows:装 Office 或 WPS + - macOS:``brew install --cask libreoffice`` + - Linux:``sudo apt install libreoffice`` + + Documentation: https://www.python-office.com/modules/word/api#docx2pdf + Args: - path (str): Word file location / Word文件的位置。Supports batch processing / 支持批量处理: fill in folder location / 填写文件夹位置 - output_path (str, optional): output location after conversion / 转换后的输出位置。Will be created automatically if not exists / 如果不存在会自动创建 - + path: Word 文件路径,或包含多个 Word 文件的目录路径 + output_path: 转换后的 PDF 输出目录;不存在会自动创建。 + 留空则输出到 ``path`` 所在目录。 + Returns: - None + str: 实际输出目录路径 """ if output_path is None: output_path = path - poword = _load_poword() poword.docx2pdf(path=path, output_path=output_path) + print(f"[python-office] docx2pdf 输出目录:{output_path}") + return output_path + + +# ===================================================================== +# 2. merge4docx - 合并多个 Word +# ===================================================================== + +def merge4docx( + input_path: str, + output_path: str, + new_word_name: str = "merge4docx", +) -> str: + """Merge multiple Word files into one. + + 合并多个 .docx 文件为一个文件。 + + Documentation: https://www.python-office.com/modules/word/api#merge4docx -def merge4docx(input_path: str, output_path: str, new_word_name: str = 'merge4docx'): - """Merge multiple Docx files into one file. - - 合并多个Docx文件为一个文件。 - Args: - input_path (str): input file path / 输入文件的路径。Can be a single file or folder path / 可以是单个文件或文件夹路径 - output_path (str): output path for merged file / 输出合并后文件的路径 - new_word_name (str, optional): name of merged new file / 合并后新文件的名称。Default / 默认: 'merge4docx' - + input_path: 包含多个 .docx 的目录路径 + output_path: 合并后文件保存目录 + new_word_name: 合并后新文件的名称(不含 .docx 后缀)。Default: ``'merge4docx'`` + Returns: - None + str: 合并后新文件的完整路径 """ - poword = _load_poword() - poword.merge4docx(input_path=input_path, output_path=output_path, new_word_name=new_word_name) + poword.merge4docx( + input_path=input_path, + output_path=output_path, + new_word_name=new_word_name, + ) + out_dir = output_path.rstrip("/\\") + full = f"{out_dir}/{new_word_name}.docx" + print(f"[python-office] merge4docx 输出文件:{full}") + return full + +# ===================================================================== +# 3. doc2docx - .doc 转为 .docx +# ===================================================================== + +def doc2docx( + input_path: str, + output_path: str = "./", + output_name: Optional[str] = None, +) -> str: + """Convert legacy .doc to .docx. + + 将旧版 .doc 文档转换为新版 .docx。 + + Documentation: https://www.python-office.com/modules/word/api#doc2docx -def doc2docx(input_path: str, output_path: str = r'./', output_name: str = None): - """Convert Doc file to Docx file. - - 将Doc文件转换为Docx文件。 - Args: - input_path (str): input Doc file path / 输入Doc文件的路径 - output_path (str, optional): output Docx file path / 输出Docx文件的路径。Can be a directory or a .docx file path / 可以是目录或 .docx 文件路径。Default / 默认: current directory / 当前目录 - output_name (str, optional): output Docx file name / 输出Docx文件的名称。Default / 默认: original filename / 原文件名 - + input_path: 要转换的 .doc 文件路径 + output_path: 输出的 .docx 保存目录。Default: ``'./'`` + output_name: 输出的 .docx 文件名(不含后缀),留空则与原文件同名。Default: ``None`` + Returns: - None + str: 转换后的 .docx 完整路径 """ - 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 + poword.doc2docx( + input_path=input_path, output_path=output_path, output_name=output_name, + ) + base = output_name or os.path.basename(input_path).rsplit(".", 1)[0] + out_dir = output_path.rstrip("/\\") + full = f"{out_dir}/{base}.docx" + print(f"[python-office] doc2docx 输出文件:{full}") + return full - poword = _load_poword() - poword.doc2docx(input_path=input_path, output_path=output_path, output_name=output_name) +# ===================================================================== +# 4. docx2doc - .docx 转回 .doc +# ===================================================================== + +def docx2doc( + input_path: str, + output_path: str = "./", + output_name: Optional[str] = None, +) -> str: + """Convert .docx back to legacy .doc. + + 将 .docx 文档转回旧版 .doc。 + + Documentation: https://www.python-office.com/modules/word/api#docx2doc -def docx2doc(input_path: str, output_path: str = r'./', output_name: str = None): - """Convert Docx file to Doc file. - - 将Docx文件转换为Doc文件。 - Args: - input_path (str): input Docx file path / 输入Docx文件的路径 - output_path (str, optional): output Doc file path / 输出Doc文件的路径。Default / 默认: current directory / 当前目录 - output_name (str, optional): output Doc file name / 输出Doc文件的名称。Default / 默认: original filename / 原文件名 - + input_path: 要转换的 .docx 文件路径 + output_path: 输出的 .doc 保存目录。Default: ``'./'`` + output_name: 输出的 .doc 文件名(不含后缀),留空则与原文件同名。Default: ``None`` + Returns: - None + str: 转换后的 .doc 完整路径 """ - poword = _load_poword() - poword.docx2doc(input_path=input_path, output_path=output_path, output_name=output_name) - -def docx4imgs(word_path, img_path): - """Extract images from Word document. - - 从Word里提取图片。 - + poword.docx2doc( + input_path=input_path, output_path=output_path, output_name=output_name, + ) + base = output_name or os.path.basename(input_path).rsplit(".", 1)[0] + out_dir = output_path.rstrip("/\\") + full = f"{out_dir}/{base}.doc" + print(f"[python-office] docx2doc 输出文件:{full}") + return full + + +# ===================================================================== +# 5. docx4imgs - 从 Word 提取图片 +# ===================================================================== + +def docx4imgs(word_path: str, img_path: str) -> str: + """Extract images from a Word document. + + 从 Word 文档中提取所有图片到指定目录。 + 会在 ``img_path`` 下自动按 Word 名称生成一个子目录。 + + Documentation: https://www.python-office.com/modules/word/api#docx4imgs + Args: - word_path (str): Word document path / Word文档的路径 - img_path (str): storage location for extracted images / 提取图片的存储位置。Will automatically generate a subdirectory / 会自动根据word名称在指定文件夹下生成一个子目录 - + word_path: .docx 文件路径 + img_path: 图片输出根目录 + Returns: - None + str: 实际图片输出目录(``img_path/``) """ - poword = _load_poword() poword.docx4imgs(word_path=word_path, img_path=img_path) + from pathlib import Path + out = str(Path(img_path) / Path(word_path).stem) + print(f"[python-office] docx4imgs 输出目录:{out}") + return out + + +__all__ = [ + "docx2pdf", + "merge4docx", + "doc2docx", + "docx2doc", + "docx4imgs", +] diff --git a/office/compatibility.py b/office/compatibility.py index c7375a6..20fd097 100644 --- a/office/compatibility.py +++ b/office/compatibility.py @@ -13,44 +13,40 @@ class CrossPlatformCompatibility: """跨平台兼容性检查器。""" - + def __init__(self): self.is_windows = platform.system() == 'Windows' self.is_macos = platform.system() == 'Darwin' self.is_linux = platform.system() == 'Linux' self.mark_file = Path.home() / '.python-office' / 'first_run_mark' self.is_first_run = self._check_first_run() - + def _check_first_run(self) -> bool: """检查是否是首次运行。 - + Returns: bool: 如果是首次运行返回True,否则返回False """ - 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 + # 创建标记目录 + 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 return False - + def get_compatibility_info(self) -> Dict[str, List[str]]: """获取兼容性信息。 - + Returns: Dict[str, List[str]]: 包含兼容性信息的字典 """ return { "fully_supported": [ "Excel处理 (poexcel)", - "PDF处理 (popdf)", + "PDF处理 (popdf)", "图片处理 (poimage)", "文件管理 (pofile)", "邮件发送 (poemail)", @@ -74,17 +70,17 @@ def get_compatibility_info(self) -> Dict[str, List[str]]: "文件搜索: 可以使用系统自带的搜索工具或find命令" ] } - + def display_warning(self): """显示兼容性警告信息。""" if self.is_windows: return # Windows系统不需要警告 - + if not self.is_first_run: return # 非首次运行不需要重复显示 - + compat_info = self.get_compatibility_info() - + # 使用rich库进行彩色输出(如果可用) try: from rich.console import Console @@ -92,68 +88,68 @@ def display_warning(self): from rich.table import Table from rich.text import Text from rich import print as rprint - + console = Console() - + # 标题 title = Text("⚠️ Python-Office 跨平台兼容性提示", style="bold red") - + # 创建兼容性表格 table = Table(title="功能兼容性概览", show_header=True, header_style="bold magenta") table.add_column("状态", style="cyan", width=12) table.add_column("功能模块", style="white") - + # 添加完全支持的功能 for func in compat_info["fully_supported"]: table.add_row("✅ 完全支持", func) - + # 添加仅Windows支持的功能 for func in compat_info["windows_only"]: table.add_row("❌ 仅Windows", func) - + # 创建解决方案表格 solution_table = Table(title="替代解决方案", show_header=True, header_style="bold green") solution_table.add_column("Windows功能", style="yellow") solution_table.add_column("替代方案", style="white") - + for workaround in compat_info["workarounds"]: parts = workaround.split(": ") if len(parts) == 2: solution_table.add_row(parts[0], parts[1]) - + # 输出面板 console.print(Panel( title, style="bright_yellow", width=80 )) - + console.print(f"\n📋 检测到您正在使用: {platform.system()} {platform.release()}") console.print("💡 这是您首次在此系统上运行python-office库\n") - + console.print(table) console.print("\n") console.print(solution_table) - + # 资源链接 resources_text = Text("\n🔗 相关资源链接:") resources_text.append("\n• 官方文档: https://www.python-office.com") resources_text.append("\n• 问题反馈: https://github.com/CoderWanFeng/python-office/issues") resources_text.append("\n• 交流群: https://www.python4office.cn/wechat-group") - + console.print(Panel( resources_text, title="💬 获取帮助", style="blue" )) - + except ImportError: # 如果rich不可用,使用普通输出 self._display_plain_warning(compat_info) - + def _display_plain_warning(self, compat_info: Dict[str, List[str]]): """使用普通文本显示警告。 - + Args: compat_info (Dict[str, List[str]]): 兼容性信息字典 """ @@ -162,36 +158,36 @@ def _display_plain_warning(self, compat_info: Dict[str, List[str]]): print("=" * 80) print(f"\n📋 检测到您正在使用: {platform.system()} {platform.release()}") print("💡 这是您首次在此系统上运行python-office库\n") - + print("📊 功能兼容性概览:") print("-" * 40) - + print("\n✅ 完全支持的功能:") for func in compat_info["fully_supported"]: print(f" • {func}") - + print("\n❌ 仅Windows支持的功能:") for func in compat_info["windows_only"]: print(f" • {func}") - + print("\n🛠️ 替代解决方案:") print("-" * 40) for workaround in compat_info["workarounds"]: print(f" • {workaround}") - + print("\n🔗 相关资源链接:") print("-" * 40) print(" • 官方文档: https://www.python-office.com") print(" • 问题反馈: https://github.com/CoderWanFeng/python-office/issues") print(" • 交流群: https://www.python4office.cn/wechat-group") print("\n" + "=" * 80) - + def check_module_compatibility(self, module_name: str) -> bool: """检查特定模块的兼容性。 - + Args: module_name (str): 模块名称 - + Returns: bool: 如果模块兼容返回True,否则返回False """ @@ -199,14 +195,14 @@ def check_module_compatibility(self, module_name: str) -> bool: 'poppt', 'poword', 'search4file', 'PyOfficeRobot', 'ppt', 'word', 'wechat' } - + if module_name in windows_only_modules and not self.is_windows: return False return True - + def get_platform_specific_advice(self) -> str: """获取平台特定的建议。 - + Returns: str: 平台特定的建议信息 """ @@ -230,7 +226,7 @@ def get_platform_specific_advice(self) -> str: def check_compatibility(): """检查兼容性的主函数。 - + Returns: CrossPlatformCompatibility: 兼容性检查器实例 """ @@ -240,11 +236,9 @@ def check_compatibility(): if __name__ == "__main__": - # 测试代码 + # 显式触发:``python -m office.compatibility`` checker = CrossPlatformCompatibility() print(f"系统: {platform.system()}") print(f"首次运行: {checker.is_first_run}") print(f"标记文件: {checker.mark_file}") - - # 显示警告(仅在非Windows且首次运行时) - checker.display_warning() + checker.display_warning() \ No newline at end of file diff --git a/office/lib/decorator_utils/__init__.py b/office/lib/decorator_utils/__init__.py index e69de29..1163172 100644 --- a/office/lib/decorator_utils/__init__.py +++ b/office/lib/decorator_utils/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +"""装饰器工具集合。""" + +from office.lib.decorator_utils.deprecated import deprecated_params +from office.lib.decorator_utils.instruction_url import instruction + +__all__ = ["deprecated_params", "instruction"] diff --git a/office/lib/decorator_utils/deprecated.py b/office/lib/decorator_utils/deprecated.py new file mode 100644 index 0000000..b9a9763 --- /dev/null +++ b/office/lib/decorator_utils/deprecated.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +"""deprecated 参数兼容装饰器。 + +旧版 python-office 各 API 使用过许多旧参数名(file_path / pdf_path / +out_dir / one_by_one / output / pdf_file / pdf_file_in / pdf_file_out / +mark_str / ...),每个函数里都重复 5~15 行的 DeprecationWarning + 回退 +映射,逻辑雷同、容易漏改。 + +本装饰器把"哪一组旧参数名 -> 新参数名"的映射抽出来,重复模式收敛到一处, +调用方只需要声明映射,函数体保持原本的转发逻辑。 + +使用示例:: + + from office.lib.decorator_utils import deprecated_params + + @deprecated_params({ + 'file_path': 'input_file', + 'out_dir': 'output_file', + }) + def pdf2imgs(input_file=None, output_file=None, file_path=None, + out_dir=None, merge=False): + ... + +调用 ``pdf2imgs(file_path='a.pdf')`` 时会自动发出 DeprecationWarning +并把 ``file_path`` 的值映射到 ``input_file``。 +""" + +from __future__ import annotations + +import warnings +from functools import wraps +from typing import Callable, Mapping + + +def deprecated_params(mapping: Mapping[str, str]) -> Callable: + """装饰器:把一组"旧参数名 -> 新参数名"映射集中处理。 + + Args: + mapping: 旧参数名 -> 新参数名 的字典。仅当调用方传入了旧参数 + 且新参数未传入时触发回退;同时会发出 DeprecationWarning, + stacklevel=2 让警告指向调用方而非装饰器内部。 + + Returns: + Callable: 装饰后的函数,签名不变。 + """ + + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(*args, **kwargs): + for old, new in mapping.items(): + if old in kwargs and kwargs[old] is not None: + warnings.warn( + f"参数 '{old}' 已被弃用,将在后续版本中移除。" + f"请改用 '{new}'。", + DeprecationWarning, + stacklevel=2, + ) + if new not in kwargs or kwargs[new] is None: + kwargs[new] = kwargs.pop(old) + else: + kwargs.pop(old) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +__all__ = ["deprecated_params"] diff --git a/office/lib/decorator_utils/instruction_url.py b/office/lib/decorator_utils/instruction_url.py index 885aa69..0978527 100644 --- a/office/lib/decorator_utils/instruction_url.py +++ b/office/lib/decorator_utils/instruction_url.py @@ -119,11 +119,10 @@ def instruction(func): @wraps(func) def instruction_wrapper(*args, **kwargs): func_filename = os.path.basename(func.__code__.co_filename) # 取出方法所在的文件名 - instruction_url = instruction_file_dict.get(func_filename, {}).get(func.__name__) # 如果有这个文件,并且已经配置了方法名对应的说明链接,则打印出来 - if instruction_url: + if func_filename in instruction_file_dict.keys() and instruction_file_dict[func_filename][func.__name__]: random_color_print( - f'正在运行:office.{os.path.basename(func_filename)[:-3]}.{func.__name__} , 这个方法的使用说明:{instruction_url}') + f'正在运行:office.{os.path.basename(func_filename)[:-3]}.{func.__name__} , 这个方法的使用说明:{instruction_file_dict[func_filename][func.__name__]}') random_color_print(SPLIT_LINE) instruction_res = func(*args, **kwargs) return instruction_res diff --git a/office/lib/excel/SplitExcel.py b/office/lib/excel/SplitExcel.py index 9b67e13..9586155 100644 --- a/office/lib/excel/SplitExcel.py +++ b/office/lib/excel/SplitExcel.py @@ -2,7 +2,6 @@ import xlrd, xlwt import openpyxl import datetime -from tqdm import tqdm # @@ -42,8 +41,8 @@ def process_xls(filepath: str, column: int, worksheet_name: str = None) -> str: """ try: workbook = xlrd.open_workbook(filepath, formatting_info=True) - except Exception as e: - return "文件读取异常:{},错误信息:{}".format(filepath, str(e)) + except: + return "文件读取异常:{}".format(filepath) if worksheet_name: worksheet = workbook.sheet_by_name(worksheet_name) else: @@ -95,26 +94,22 @@ def process_xlsx(filepath: str, column: int, worksheet_name: str = None) -> str: """ try: workbook = openpyxl.load_workbook(filepath, read_only=True, data_only=True) - try: - if worksheet_name: - worksheet = workbook[worksheet_name] - else: - worksheet = workbook.active - if worksheet.max_column < column: - return "最大列数是{},取不到第{}列".format(worksheet.max_column, column) + except: + return "文件读取异常:{}".format(filepath) + if worksheet_name: + worksheet = workbook.get_sheet_by_name(worksheet_name) + else: + worksheet = workbook.active + if worksheet.max_column < column: + return "最大列数是{},取不到第{}列".format(worksheet.max_column, column) - split_data_dict = {} - for row in worksheet.rows: - row_data = [cell.value if cell.value else ' ' for cell in row] - temp_data = row_data[column - 1] - temp_data_list = split_data_dict.get(temp_data, []) - temp_data_list.append(row_data) - split_data_dict[temp_data] = temp_data_list - finally: - workbook.close() - except Exception as e: - return "处理异常:{},错误信息:{}".format(filepath, str(e)) - + split_data_dict = {} + for row in worksheet.rows: + row_data = [cell.value if cell.value else ' ' for cell in row] + temp_data = row_data[column - 1] + temp_data_list = split_data_dict.get(temp_data, []) + temp_data_list.append(row_data) + split_data_dict[temp_data] = temp_data_list new_filepath = generate_xlsx(filepath, split_data_dict) return "数据保存在新文件中,文件名:{}".format(new_filepath) diff --git a/office/lib/image/eliminate_background.py b/office/lib/image/eliminate_background.py index bcb56c2..50fde0e 100644 --- a/office/lib/image/eliminate_background.py +++ b/office/lib/image/eliminate_background.py @@ -9,7 +9,8 @@ def _hex_to_rgb(hex): 十六进制转RGB """ if hex[0] != '#' or len(hex) != 7: - raise ValueError('十六进制格式颜色错误,请输入7位以\'#\'开头的字符串,例如 #FFFFFF') + print('注意:十六进制格式颜色错误,请输入7位以\'#\'开头的字符串\n') + return None else: r = int('0x' + hex[1:3], 16) g = int('0x' + hex[3:5], 16) @@ -37,9 +38,11 @@ def eliminate_bc(src_img_path: str, save_img_path: str, margin: int = 30, bc_col r, g, b = bc_color else: # 未给定背景色,拾取图片左上角颜色作为背景色 - rgb_img = img.convert("RGB") - pix = rgb_img.load() - r, g, b = pix[int(width / 20), int(height / 20)] + pix = img.load() + if src_img_path.endswith('.jpg'): + r, g, b = pix[int(width / 20), int(height / 20)] + elif src_img_path.endswith('.png'): + r, g, b, _ = pix[int(width / 20), int(height / 20)] img = img.convert("RGBA") datas = img.getdata() diff --git a/office/lib/pdf/add_watermark_service.py b/office/lib/pdf/add_watermark_service.py index 4fcf0a8..5ed5c7c 100644 --- a/office/lib/pdf/add_watermark_service.py +++ b/office/lib/pdf/add_watermark_service.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- import reportlab -from PyPDF2 import PdfReader, PdfWriter +from PyPDF2 import PdfFileWriter, PdfFileReader, PdfReader, PdfWriter from reportlab.pdfgen import canvas from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.pdfmetrics import registerFont @@ -44,42 +44,29 @@ def pdf_add_watermark(pdf_file_in: str, pdf_file_mark: str, pdf_file_out: str) - """ # print(pdf_file_out) pdf_output = PdfWriter() - input_stream = None - mark_stream = None - try: - input_stream = open(pdf_file_in, 'rb') - pdf_input = PdfReader(input_stream, strict=False) - # 获取PDF文件的页数 - if pdf_input.is_encrypted: - print("文件已被加密") - PDF_Passwd = input("请输入PDF密码:") - # 尝试用空密码解密 - try: - pdf_input.decrypt(PDF_Passwd) - except Exception: - print(f"尝试用密码{PDF_Passwd}解密失败.") - return False - pageNum = len(pdf_input.pages) - # 读入水印pdf文件 - # print(pdf_file_mark) - mark_stream = open(pdf_file_mark, mode='rb') - pdf_watermark = PdfReader(mark_stream, strict=False) - # 检查水印PDF是否有效(至少包含一页) - if len(pdf_watermark.pages) == 0: - print(f"水印文件不包含任何页面:{pdf_file_mark}") + input_stream = open(pdf_file_in, 'rb') + pdf_input = PdfReader(input_stream, strict=False) + # 获取PDF文件的页数 + if pdf_input.is_encrypted: + print("文件已被加密") + PDF_Passwd = input("请输入PDF密码:") + # 尝试用空密码解密 + try: + pdf_input.decrypt(PDF_Passwd) + except Exception: + print(f"尝试用密码{PDF_Passwd}解密失败.") return False - # 给每一页打水印 - for pageNumber in tqdm(range(pageNum)): - page = pdf_input.pages[pageNumber] - page.merge_page(pdf_watermark.pages[0]) - page.compress_content_streams() # 压缩内容 - pdf_output.add_page(page) - with open(pdf_file_out, 'wb') as pdf_file_out_f: - pdf_output.write(pdf_file_out_f) - return True - finally: - # 无论成功失败,都确保输入流和水印流被关闭,避免批量处理时文件句柄泄漏 - if input_stream is not None: - input_stream.close() - if mark_stream is not None: - mark_stream.close() + pageNum = len(pdf_input.pages) + # 读入水印pdf文件 + # print(pdf_file_mark) + mark_stream = open(pdf_file_mark, mode='rb') + pdf_watermark = PdfReader(mark_stream, strict=False) + # 给每一页打水印 + for pageNumber in tqdm(range(pageNum)): + page = pdf_input.pages[pageNumber] + page.merge_page(pdf_watermark.pages[0]) + page.compress_content_streams() # 压缩内容 + pdf_output.add_page(page) + with open(pdf_file_out, 'wb') as pdf_file_out_f: + pdf_output.write(pdf_file_out_f) + return True diff --git a/scripts/build-gui-nuitka.ps1 b/scripts/build-gui-nuitka.ps1 new file mode 100644 index 0000000..55a5e66 --- /dev/null +++ b/scripts/build-gui-nuitka.ps1 @@ -0,0 +1,397 @@ +param( + [switch]$OneFile, + [switch]$AssumeYesForDownloads, + [switch]$SkipDependencyCopy +) + +$ErrorActionPreference = "Stop" + +$Root = Split-Path -Parent $PSScriptRoot +$Entry = Join-Path $Root "gui\run.py" +$OutDir = Join-Path $Root "dist-nuitka" +$CacheDir = Join-Path $Root ".nuitka-cache" +$IconPath = Join-Path $Root "gui\assets\python-office.ico" + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +New-Item -ItemType Directory -Force -Path $CacheDir | Out-Null + +$env:NUITKA_CACHE_DIR = $CacheDir +$env:PYTHONPATH = $Root + +$modeArgs = @("--standalone") +if ($OneFile) { + $modeArgs = @("--onefile") +} + +$downloadArgs = @() +if ($AssumeYesForDownloads) { + $downloadArgs += "--assume-yes-for-downloads" +} + +$PreviousErrorActionPreference = $ErrorActionPreference +$ErrorActionPreference = "Continue" +python -m nuitka ` + $Entry ` + @modeArgs ` + @downloadArgs ` + --mingw64 ` + --enable-plugin=pyside6 ` + --windows-console-mode=disable ` + --windows-icon-from-ico=$IconPath ` + --output-dir=$OutDir ` + --output-filename=python-office-gui ` + --include-data-file="$IconPath=gui/assets/python-office.ico" ` + --show-progress ` + --show-memory ` + --include-module=gui.run ` + --include-module=gui.app ` + --include-module=gui.main_window ` + --include-module=gui.panels ` + --include-module=gui.registry ` + --include-module=gui.styles ` + --include-module=gui.workers ` + --include-module=gui.widgets ` + --include-module=gui.widgets.param_form ` + --include-module=office ` + --include-module=office.api ` + --include-module=office.api.pdf ` + --include-module=office.api.excel ` + --include-module=office.api.word ` + --include-module=office.api.ppt ` + --include-module=office.api.image ` + --include-module=office.api.file ` + --include-module=office.api.video ` + --include-module=office.api.email ` + --include-module=office.api.ocr ` + --include-module=office.api.markdown ` + --include-module=office.api.tools ` + --include-module=office.api.finance ` + --include-module=office.lib.decorator_utils ` + --include-package=ctypes ` + --include-package=email.mime ` + --include-package=http ` + --include-package=logging ` + --include-package=urllib ` + --include-package=xml ` + --include-module=secrets ` + --include-module=smtplib ` + --include-module=socket ` + --nofollow-import-to=gui.pdf2word ` + --nofollow-import-to=gui.qtpy ` + --nofollow-import-to=popdf ` + --nofollow-import-to=poexcel ` + --nofollow-import-to=pofile ` + --nofollow-import-to=poimage ` + --nofollow-import-to=poocr ` + --nofollow-import-to=pomarkdown ` + --nofollow-import-to=poemail ` + --nofollow-import-to=povideo ` + --nofollow-import-to=wftools ` + --nofollow-import-to=poprogress ` + --nofollow-import-to=pocode ` + --nofollow-import-to=poppt ` + --nofollow-import-to=poword ` + --nofollow-import-to=search4file ` + --nofollow-import-to=loguru ` + --nofollow-import-to=you_get ` + --nofollow-import-to=openpyxl ` + --nofollow-import-to=pandas ` + --nofollow-import-to=numpy ` + --nofollow-import-to=requests ` + --nofollow-import-to=xlrd ` + --nofollow-import-to=xlwt ` + --nofollow-import-to=xlwings ` + --nofollow-import-to=pdf2docx ` + --nofollow-import-to=pypdf ` + --nofollow-import-to=PyPDF2 ` + --nofollow-import-to=reportlab ` + --nofollow-import-to=pymupdf ` + --nofollow-import-to=fitz ` + --nofollow-import-to=fontTools ` + --nofollow-import-to=PIL ` + --nofollow-import-to=cv2 ` + --nofollow-import-to=moviepy ` + --nofollow-import-to=wordcloud ` + --nofollow-import-to=PyQt5 ` + --nofollow-import-to=PyQt6 ` + --nofollow-import-to=qfluentwidgets ` + --nofollow-import-to=scrapy ` + --nofollow-import-to=PyOfficeRobot ` + --nofollow-import-to=pospider ` + --nofollow-import-to=matplotlib ` + --nofollow-import-to=scipy ` + --nofollow-import-to=tkinter ` + --remove-output +$NuitkaExitCode = $LASTEXITCODE +$ErrorActionPreference = $PreviousErrorActionPreference + +if ($NuitkaExitCode -ne 0) { + exit $NuitkaExitCode +} + +if ($SkipDependencyCopy) { + exit 0 +} + +$DistRuntime = Join-Path $OutDir "run.dist" +$DistLib = Join-Path $DistRuntime "lib" +New-Item -ItemType Directory -Force -Path $DistLib | Out-Null + +$UsageGuide = Join-Path $Root "docs\使用教程.md" +if (Test-Path $UsageGuide) { + Copy-Item -LiteralPath $UsageGuide -Destination (Join-Path $DistRuntime "使用教程.md") -Force +} + +$copyDepsScript = @' +import importlib.metadata as md +import re +import shutil +import sys +from pathlib import Path + +try: + from packaging.requirements import Requirement +except Exception: + Requirement = None + +target = Path(sys.argv[1]) +roots = sys.argv[2:] +target.mkdir(parents=True, exist_ok=True) + +name_re = re.compile(r"^\s*([A-Za-z0-9_.-]+)") +seen = set() +queue = list(roots) +copied = [] + +def normalize(name: str) -> str: + return name.lower().replace("_", "-") + +def iter_runtime_requirements(requires): + for req in requires or []: + if Requirement is not None: + try: + parsed = Requirement(req) + except Exception: + parsed = None + if parsed is not None: + if parsed.marker and not parsed.marker.evaluate({"extra": ""}): + continue + yield parsed.name + continue + + match = name_re.match(req) + if match: + yield match.group(1) + +def safe_target_path(record_file): + rel = Path(record_file) + if rel.is_absolute() or any(part in ("..", "") for part in rel.parts): + print(f"[skip] unsafe RECORD path: {record_file}") + return None + return target / rel + +def should_skip_package_file(record_file): + rel = Path(record_file) + rel_text = rel.as_posix().lower() + if rel_text == "pandas/util/_tester.py" or rel_text.startswith("pandas/_testing/"): + return False + parts = {part.lower() for part in rel.parts} + if parts & {"__pycache__", "test", "tests", "testing", "doc", "docs", "example", "examples", "sample", "samples"}: + return True + name = rel.name.lower() + if name.endswith((".pyc", ".pyo", ".c", ".cpp", ".h", ".hpp", ".pxd", ".pyx")): + return True + if name in {"license", "copying", "authors", "contributors", "changelog"}: + return True + if name.endswith((".md", ".rst")): + return True + return False + +while queue: + raw_name = queue.pop(0) + key = normalize(raw_name) + if key in seen: + continue + try: + dist = md.distribution(raw_name) + except md.PackageNotFoundError: + print(f"[skip] distribution not found: {raw_name}") + seen.add(key) + continue + + seen.add(normalize(dist.metadata["Name"])) + files = list(dist.files or []) + if not files: + print(f"[warn] no RECORD files for: {dist.metadata['Name']}") + for file in files: + if should_skip_package_file(file): + continue + src = Path(dist.locate_file(file)) + if not src.exists(): + continue + dst = safe_target_path(file) + if dst is None: + continue + dst.parent.mkdir(parents=True, exist_ok=True) + if src.is_dir(): + if dst.exists(): + continue + shutil.copytree(src, dst, dirs_exist_ok=True) + else: + if dst.exists() and dst.stat().st_size == src.stat().st_size: + continue + shutil.copy2(src, dst) + copied.append(dist.metadata["Name"]) + + for dep in iter_runtime_requirements(dist.requires): + if normalize(dep) not in seen: + queue.append(dep) + +print(f"Copied {len(copied)} distributions into {target}") +for name in sorted(copied, key=str.lower): + print(f" - {name}") +'@ + +$roots = @( + "popdf", "poexcel", "pofile", "poimage", "poocr", "pomarkdown", + "poemail", "povideo", "wftools", "poprogress", "pocode", "poppt", + "poword", "search4file", "loguru" +) + +$copyDepsScript | python - $DistLib @roots +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$copyStdlibScript = @' +import shutil +import sys +import sysconfig +from pathlib import Path + +target = Path(sys.argv[1]) +stdlib = Path(sysconfig.get_paths()["stdlib"]) +dlls = Path(sys.base_prefix) / "DLLs" + +skip_dirs = { + "__pycache__", "site-packages", "idlelib", "tkinter", "turtledemo", + "ensurepip", "venv", "lib2to3", "distutils", "test", "tests", +} + +def should_skip(path: Path) -> bool: + return any(part in skip_dirs for part in path.parts) + +def copy_file(src: Path, dst: Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + if dst.exists() and dst.stat().st_size == src.stat().st_size: + return + shutil.copy2(src, dst) + +count = 0 +for src in stdlib.rglob("*"): + rel = src.relative_to(stdlib) + if should_skip(rel): + continue + if src.is_dir(): + continue + if src.suffix.lower() not in {".py", ".pyi", ".txt", ".dat", ".pem"}: + continue + copy_file(src, target / rel) + count += 1 + +if dlls.is_dir(): + for src in dlls.iterdir(): + if src.name.lower().startswith(("_test", "_ctypes_test", "_tkinter")): + continue + if src.name.lower() in {"tcl86t.dll", "tk86t.dll"}: + continue + if src.is_file() and src.suffix.lower() in {".pyd", ".dll"}: + copy_file(src, target / src.name) + count += 1 + +print(f"Copied Python stdlib runtime files into {target}: {count}") +'@ + +$copyStdlibScript | python - $DistLib +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$pruneScript = @' +import shutil +import sys +from pathlib import Path + +target = Path(sys.argv[1]) +remove_dirs = [ + "site-packages", "__pycache__", "idlelib", "tkinter", "turtledemo", + "ensurepip", "venv", "lib2to3", "distutils", "test", "tests", +] +remove_file_prefixes = ("_test", "_ctypes_test", "_tkinter") +remove_file_names = {"tcl86t.dll", "tk86t.dll", "pywin32.chm"} +remove_suffixes = {".pyc", ".pyo", ".c", ".cpp", ".h", ".hpp", ".pxd", ".pyx", ".md", ".rst"} + +removed = 0 +for name in remove_dirs: + path = target / name + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + removed += 1 + +for path in list(target.rglob("*")): + if not path.exists(): + continue + if path.is_dir(): + rel_text = path.relative_to(target).as_posix().lower() + if path.name.lower() == "__pycache__": + shutil.rmtree(path, ignore_errors=True) + removed += 1 + continue + if rel_text.startswith("pandas/_testing"): + continue + if path.name.lower() in {"__pycache__", "test", "tests", "testing", "doc", "docs", "example", "examples", "sample", "samples"}: + shutil.rmtree(path, ignore_errors=True) + removed += 1 + continue + rel_text = path.relative_to(target).as_posix().lower() + if rel_text == "使用教程.md": + continue + if rel_text == "pandas/util/_tester.py" or rel_text.startswith("pandas/_testing/"): + continue + name = path.name.lower() + if name in remove_file_names or name.startswith(remove_file_prefixes) or path.suffix.lower() in remove_suffixes: + try: + path.unlink() + removed += 1 + except OSError: + pass + +print(f"Pruned runtime payload entries: {removed}") +'@ + +$pruneScript | python - $DistLib +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +$copyUsageGuideScript = @' +import shutil +import sys +from pathlib import Path + +root = Path(sys.argv[1]) +dist_runtime = Path(sys.argv[2]) +name = "\u4f7f\u7528\u6559\u7a0b.md" +source = root / "docs" / name +target = dist_runtime / name +if source.exists(): + shutil.copy2(source, target) + print(f"Copied usage guide: {target}") +else: + print(f"[warn] usage guide not found: {source}") +'@ + +$copyUsageGuideScript | python - $Root $DistRuntime +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/setup.cfg b/setup.cfg index 0deb58f..de09e17 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,3 +42,17 @@ install_requires = python_requires = >=3.6 include_package_data = True zip_safe = False + +[options.extras_require] +gui = + PySide6>=6.6 +build-gui = + nuitka + +[options.entry_points] +console_scripts = + python-office-gui = gui.run:main + +[options.package_data] +gui = + assets/*