Files
zTools2/app/services/pdf_converter.py
zikai 9af28f41b4 refactor: 清理死代码/提前失败/日志/高内聚低耦合
死代码移除:
- whiteboard_hub.py: 移除未引用的 reset_hub 单例重置函数
- tunnel_service.py: 移除未引用的 is_port_allowed (逻辑已在 sftp_server 内联)
- tunnel_session_dao.py: 移除未引用的 get_active_by_port
- pdf_job_dao.py: 移除未用 datetime 导入
- pdf_converter.py: 移除未用 shutil 导入
- pdf_service.py: 移除未用 PdfSubmitResponse 导入 + _do_convert 内未用 hashlib 导入
- upload_html.py: 移除未用 escape 导入 (JS 侧自有 escapeHtml)
- pdf_controller.py: 移除 _resolve_cookie 内未用 cfg 局部变量

提前失败/分层修复:
- database.py init_db_schema: 建表后用 inspector 校验既有表列与模型一致,
  缺列即抛 RuntimeError (fail-fast on schema drift), 避免运行期才暴露
- whiteboard_dao.get_or_create: 仅 IntegrityError 才回滚重读, 其他异常向上抛
  (原 except Exception 会掩盖 schema/连接等真实故障)
- pdf_service.admin_delete/_safe_delete_file: 改用 PdfJobDAO.delete /
  UploadedFileDAO.delete, 不再直接操作 job_dao.db / file_dao.db (修复分层契约:
  DAO 头注释声明 service 不直接操作 session)
- PdfJobDAO 新增 delete(job) 方法

日志补全 (8 处 silent catch):
- whiteboard_hub.py disconnect/close_board 关闭 ws: logger.debug
- whiteboard_controller _safe_send/_safe_close: logger.debug
- sftp_server _close_tunnel_dao/读用户名: logger.debug
- sftp_server validate_public_key: logger.warning (auth 路径, 避免静默失败)

文档:
- 新增 docs/routes.md, docs/configuration.md, docs/error-handling.md
- README.md 精简为简介/结构/外部依赖/apache2 配置/Ubuntu 安装/docs 链接
2026-07-28 11:34:35 +08:00

105 lines
4.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""文件 -> PDF 转换器(纯 Python无 calibre/xvfb 系统依赖)。
当前支持 epub必要能力ebooklib 解析 epub 文档项(按 spine 顺序),拼接为
完整 HTML 后交 weasyprint 渲染为 PDF。epub 内的相对资源(图片/CSS经 base_url
指向 epub 解包目录解析。
接口抽象为 ``convert_to_pdf(src_path, dst_path)``:未来新增格式只需在本模块内
按扩展名分支调用方PdfService无需改动。
"""
from __future__ import annotations
import logging
import tempfile
import zipfile
from pathlib import Path
logger = logging.getLogger("zikai.pdf")
# 支持的输入格式(小写扩展名 -> 是否可转)。新增格式在此登记并实现分支即可。
# epub 为必要能力;其余为 ebooklib/weasyprint 路径天然支持的电子书结构,
# 实测对纯 HTML 类 epub 同样有效,故一并放行。
SUPPORTED_EXTENSIONS = {".epub"}
def is_supported(filename: str) -> bool:
"""文件名扩展名是否在支持列表内。"""
return Path(filename).suffix.lower() in SUPPORTED_EXTENSIONS
def convert_to_pdf(src_path: Path, dst_path: Path) -> None:
"""把 src_path 指向的文件转为 PDF 写入 dst_path。
失败抛 RuntimeError调用方捕获后写 error_message。按扩展名分发
当前仅 epub 分支;新增格式在此 elif 扩展。
"""
ext = src_path.suffix.lower()
if ext == ".epub":
_epub_to_pdf(src_path, dst_path)
else:
raise RuntimeError(f"不支持的文件格式:{ext}(仅支持 epub")
def _epub_to_pdf(src_path: Path, dst_path: Path) -> None:
"""epub -> PDF解包 epub -> 按 spine 顺序取文档项 HTML -> weasyprint 渲染。
epub 本质是 zip。先解包到临时目录用 ebooklib 读取(其内部按 zip 解析,
base_url 指向解包后的 OEBPS/ 内容目录使相对图片/CSS 可被 weasyprint 解析)。
"""
# 延迟导入weasyprint 首次 import 较重(加载 pango/cairo且仅在真正转换时需要
import ebooklib # noqa: F401
from ebooklib import epub
from weasyprint import HTML
with tempfile.TemporaryDirectory(prefix="zpdf_") as tmp:
tmp_dir = Path(tmp)
# epub 是 zip解包到临时目录便于 weasyprint 解析相对资源
try:
with zipfile.ZipFile(src_path, "r") as zf:
zf.extractall(tmp_dir)
except zipfile.BadZipFile as exc:
raise RuntimeError(f"epub 文件损坏(非有效 zip{exc}") from exc
try:
book = epub.read_epub(str(src_path), {"ignore_ncx": True})
except Exception as exc:
raise RuntimeError(f"epub 解析失败:{exc}") from exc
# 按 spine 顺序收集文档项XHTML保证章节顺序正确
docs: list[str] = []
for idref, _linear in book.spine:
item = book.get_item_with_id(idref)
if item is not None:
docs.append(item.get_content().decode("utf-8", errors="replace"))
if not docs:
# spine 为空时回退:取所有文档项
docs = [
it.get_content().decode("utf-8", errors="replace")
for it in book.get_items_of_type(ebooklib.ITEM_DOCUMENT)
]
if not docs:
raise RuntimeError("epub 内无可转换的文档内容")
full_html = "\n".join(docs)
# 定位资源根目录(含图片/CSS 的目录):通常是 OEBPS/ 或根目录。
# epub 内资源(图片/CSS相对文档项引用文档项与资源同处 OPF 所在目录。
# 故以 OPF 文件所在目录作为 base_url使相对路径正确解析。
base_url = str(tmp_dir)
opf_files = list(tmp_dir.rglob("*.opf"))
if opf_files:
opf_dir = opf_files[0].parent
# OPF 可能在根目录,此时 base_url 保持 tmp_dir
if str(opf_dir) != str(tmp_dir):
base_url = str(opf_dir)
try:
HTML(string=full_html, base_url=base_url).write_pdf(str(dst_path))
except Exception as exc:
raise RuntimeError(f"PDF 渲染失败:{exc}") from exc
if not dst_path.exists() or dst_path.stat().st_size == 0:
raise RuntimeError("PDF 渲染未产出有效文件")
logger.info("epub->pdf 完成: %s -> %s (%d bytes)", src_path.name, dst_path.name, dst_path.stat().st_size)