| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- """文档格式转换抽象,支持 LibreOffice 离线转换与测试用的 Fake 实现。"""
- from __future__ import annotations
- import logging
- import os
- import re
- import shutil
- import subprocess
- import sys
- import tempfile
- import threading
- import time
- import zipfile
- from abc import ABC, abstractmethod
- from pathlib import Path
- from typing import Any
- from urllib.parse import quote
- import fitz
- from dms.common.errors import (
- PreviewConversionFailedError,
- PreviewConverterUnavailableError,
- PreviewConversionTimeoutError,
- )
- logger = logging.getLogger(__name__)
- _PDF_MAGIC = b"%PDF-"
- # Windows 下宿主进程无控制台(如打包 exe)时启动控制台子程序(soffice.com)
- # 会闪现黑框;CREATE_NO_WINDOW 使子进程在无窗口控制台运行,管道输出不受影响。
- _NO_WINDOW_FLAGS = (
- subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
- )
- # 表格类扩展名:预览转 PDF 时启用 SinglePageSheets——每个工作表按实际
- # 内容区域导出为单页,忽略纸张版型(宽表不再被 A4 等纸张分页截断)。
- _SPREADSHEET_EXTENSIONS = {"xls", "xlsx", "et"}
- _SINGLE_PAGE_FILTER = (
- 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":true}}'
- )
- # zip 容器魔数(xlsx 等 OOXML 包),用于区分 xls/et 二进制格式
- _ZIP_MAGIC = b"PK\x03\x04"
- # xlsx 包内的工作表 xml 条目(每个 sheet 一份)
- _SHEET_XML_NAME = re.compile(r"xl/worksheets/sheet\d+\.xml")
- # 视图状态块(topLeftCell 视口、selection 选区、pane 冻结窗格):
- # SinglePageSheets 的导出范围受文件保存的视图状态界定,保存文件时视口
- # 若停在 V 列,导出内容即从 V 列附近开始,左侧列全部空白;替换为最简
- # 视图(等价视口复位到 A1)即可让导出覆盖完整内容区域。
- _SHEET_VIEWS_RE = re.compile(r"<sheetViews>.*?</sheetViews>", re.DOTALL)
- _MINIMAL_SHEET_VIEWS = '<sheetViews><sheetView workbookViewId="0"/></sheetViews>'
- # 版本探测失败后的重试间隔:失败结果只短期缓存,避免一次瞬时异常
- # (如残留的挂起 soffice 进程占用默认 profile)导致整个进程生命周期回退分页。
- _SINGLE_PAGE_DETECT_RETRY_SECONDS = 300.0
- class DocumentConverter(ABC):
- """文档转换器接口;业务层只依赖此接口,方便后续替换实现。"""
- @abstractmethod
- def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
- """将源文件转换为 PDF 并返回二进制内容。
- 实现必须保证:
- - 不依赖当前工作目录;
- - 异常转换为 PreviewConversion* 业务错误;
- - 不泄露临时目录或命令行。
- """
- raise NotImplementedError
- @property
- @abstractmethod
- def available(self) -> bool:
- """转换器是否可用。"""
- raise NotImplementedError
- def supports_single_page_sheets(self) -> bool:
- """表格类是否支持按内容区域单页导出(SinglePageSheets)。
- 预览缓存键按此区分单页/分页策略;仅 LibreOffice ≥7.4 实现返回 True,
- 其余实现(含测试 Fake)默认 False。
- """
- return False
- class LibreOfficeDocumentConverter(DocumentConverter):
- """使用 LibreOffice headless 将 DOC/DOCX 转换为 PDF。"""
- def __init__(
- self,
- executable: str,
- timeout_seconds: int,
- max_concurrency: int,
- enabled: bool = True,
- ) -> None:
- self._executable = executable
- self._timeout_seconds = timeout_seconds
- self._semaphore = threading.Semaphore(max(1, max_concurrency))
- self._enabled = enabled
- self._in_flight: dict[str, LibreOfficeDocumentConverter._ConversionTask] = {}
- self._lock = threading.Lock()
- self._version_lock = threading.Lock()
- self._single_page_supported: bool | None = None
- self._single_page_detected_at: float | None = None
- @property
- def available(self) -> bool:
- if not self._enabled:
- return False
- if not self._executable:
- return False
- return Path(self._executable).is_file()
- def _version_probe_executable(self) -> str:
- """--version 探测用可执行文件。
- Windows 上 soffice.exe(GUI 子系统)执行 --version 会派生
- soffice.bin(控制台子系统),父链无控制台可继承时新建可见控制台
- 窗口打印版本并停在 ``Press Enter to continue...`` 等待输入,
- 导致探测超时挂起并闪现黑框。soffice.com 为控制台包装器,stdout
- 走管道即打即退(配合 CREATE_NO_WINDOW 无窗口),故优先使用。
- """
- if sys.platform == "win32" and self._executable:
- candidate = Path(self._executable).with_name("soffice.com")
- if candidate.is_file():
- return str(candidate)
- return self._executable
- def supports_single_page_sheets(self) -> bool:
- """对外暴露单页支持探测结果(预览缓存键分策略用)。"""
- return self._supports_single_page_sheets()
- def _supports_single_page_sheets(self) -> bool:
- """检测 LibreOffice 是否支持 SinglePageSheets 滤镜(需 ≥7.4)。
- 首次调用执行 ``--version`` 探测并缓存结果;探测成功永久缓存,
- 失败结果仅缓存 ``_SINGLE_PAGE_DETECT_RETRY_SECONDS`` 秒后重试,
- 避免一次瞬时失败使整个进程生命周期回退标准纸张分页转换。
- """
- with self._version_lock:
- if self._single_page_supported:
- return True
- if (
- self._single_page_supported is False
- and self._single_page_detected_at is not None
- and time.monotonic() - self._single_page_detected_at
- < _SINGLE_PAGE_DETECT_RETRY_SECONDS
- ):
- return False
- supported = False
- try:
- completed = subprocess.run(
- [self._version_probe_executable(), "--version"],
- capture_output=True,
- text=True,
- timeout=15,
- check=False,
- creationflags=_NO_WINDOW_FLAGS,
- )
- output = (completed.stdout or "") + (completed.stderr or "")
- supported = _version_at_least_7_4(output)
- except Exception:
- supported = False
- self._single_page_supported = supported
- self._single_page_detected_at = time.monotonic()
- if not supported:
- logger.info(
- "LibreOffice 不支持 SinglePageSheets(版本<7.4 或探测失败),"
- "表格预览回退标准纸张分页(%.0f 秒后重试探测)",
- _SINGLE_PAGE_DETECT_RETRY_SECONDS,
- )
- return supported
- class _ConversionTask:
- __slots__ = ("event", "result")
- def __init__(self) -> None:
- self.event = threading.Event()
- self.result: bytes | None = None
- def _join_or_register(self, file_hash: str) -> tuple[_ConversionTask, bool]:
- """注册新的转换任务或加入已有任务。
- 返回 (task, is_owner)。is_owner=True 表示当前线程需要执行转换。
- 非 owner 线程持有 task 引用,即使 owner 清理 _in_flight 也能读取结果。
- """
- with self._lock:
- if file_hash in self._in_flight:
- return self._in_flight[file_hash], False
- task = self._ConversionTask()
- self._in_flight[file_hash] = task
- return task, True
- def _set_result(self, task: _ConversionTask, result: bytes | None) -> None:
- task.result = result
- task.event.set()
- def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
- if not self.available:
- raise PreviewConverterUnavailableError()
- task, is_owner = self._join_or_register(file_hash)
- if not is_owner:
- if not task.event.wait(self._timeout_seconds):
- raise PreviewConversionTimeoutError()
- if task.result is None:
- raise PreviewConversionFailedError()
- return task.result
- try:
- result = self._convert_locked(source_path, file_hash)
- self._set_result(task, result)
- return result
- except Exception:
- self._set_result(task, None)
- raise
- finally:
- with self._lock:
- self._in_flight.pop(file_hash, None)
- def _convert_locked(self, source_path: Path, file_hash: str) -> bytes:
- work_dir = Path(tempfile.mkdtemp(prefix=f"dms-lo-{file_hash[:8]}-"))
- user_dir = Path(tempfile.mkdtemp(prefix=f"dms-locfg-{file_hash[:8]}-"))
- try:
- output_dir = work_dir / "out"
- output_dir.mkdir(parents=True, exist_ok=True)
- input_file = work_dir / source_path.name
- input_file.write_bytes(source_path.read_bytes())
- # LibreOffice 在 headless 模式下输出文件名与输入文件名一致,扩展名改为 pdf
- base_name = source_path.stem
- expected_output = output_dir / f"{base_name}.pdf"
- user_url = quote(str(user_dir.as_posix()), safe="/:")
- extension = source_path.suffix.lower().lstrip(".")
- spreadsheet = extension in _SPREADSHEET_EXTENSIONS
- # 重置文件内保存的视口状态(topLeftCell/选区/冻结窗格),否则
- # SinglePageSheets 的导出范围被视口界定(滚动过的列左侧空白);
- # 非 zip 容器(xls/et 二进制)在函数内自动跳过
- if spreadsheet:
- _reset_spreadsheet_views(input_file)
- # 表格类且 LibreOffice ≥7.4:按内容区域单页导出;其余维持标准 pdf 转换
- if spreadsheet and self._supports_single_page_sheets():
- convert_target = _SINGLE_PAGE_FILTER
- else:
- convert_target = "pdf"
- args = [
- self._executable,
- "--headless",
- "--nologo",
- "--nodefault",
- "--nofirststartwizard",
- "--nolockcheck",
- "--convert-to",
- convert_target,
- "--outdir",
- str(output_dir),
- f"-env:UserInstallation=file:///{user_url}",
- str(input_file),
- ]
- with self._semaphore:
- try:
- process = subprocess.Popen(
- args,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- cwd=str(work_dir),
- creationflags=_NO_WINDOW_FLAGS,
- )
- except OSError as exc:
- logger.error("启动 LibreOffice 失败:%s", exc.__class__.__name__)
- raise PreviewConverterUnavailableError() from exc
- try:
- stdout, stderr = process.communicate(
- timeout=self._timeout_seconds
- )
- except subprocess.TimeoutExpired as exc:
- logger.warning("LibreOffice 转换超时")
- _terminate_process(process)
- raise PreviewConversionTimeoutError() from exc
- finally:
- if process.poll() is None:
- _terminate_process(process)
- if process.returncode != 0:
- logger.error(
- "LibreOffice 退出码非零:returncode=%s",
- process.returncode,
- )
- raise PreviewConversionFailedError()
- if not expected_output.is_file():
- logger.error("LibreOffice 未生成预期 PDF 文件")
- raise PreviewConversionFailedError()
- pdf_bytes = expected_output.read_bytes()
- if not pdf_bytes.startswith(_PDF_MAGIC):
- logger.error("LibreOffice 输出文件头不是 PDF")
- raise PreviewConversionFailedError()
- return pdf_bytes
- finally:
- try:
- shutil.rmtree(work_dir, ignore_errors=True)
- shutil.rmtree(user_dir, ignore_errors=True)
- except Exception:
- logger.exception("清理 LibreOffice 临时目录失败")
- def _reset_spreadsheet_views(path: Path) -> None:
- """重置 zip 容器表格(xlsx)保存的视图状态,best-effort。
- 非 zip 容器(xls/et 二进制)、条目损坏等任何失败均静默跳过,
- 按原文件继续转换,不影响既有流程。
- """
- rewritten: Path | None = None
- try:
- with path.open("rb") as fh:
- if fh.read(4) != _ZIP_MAGIC:
- return
- rewritten = path.with_name(path.name + ".reset.tmp")
- replaced = 0
- with zipfile.ZipFile(path) as source, zipfile.ZipFile(
- rewritten, "w", zipfile.ZIP_DEFLATED
- ) as target:
- for item in source.infolist():
- data = source.read(item.filename)
- if _SHEET_XML_NAME.fullmatch(item.filename):
- new_xml, count = _SHEET_VIEWS_RE.subn(
- _MINIMAL_SHEET_VIEWS, data.decode("utf-8")
- )
- replaced += count
- data = new_xml.encode("utf-8")
- target.writestr(item, data)
- # with 块结束后 zip 已完整落盘,replace 原子替换无 Windows 文件锁问题
- rewritten.replace(path)
- logger.info("表格视图状态已重置:%s(%d 个工作表)", path.name, replaced)
- except Exception:
- logger.debug(
- "表格视图状态重置失败,按原文件继续转换:%s",
- path,
- exc_info=True,
- )
- if rewritten is not None:
- rewritten.unlink(missing_ok=True)
- def _version_at_least_7_4(output: str) -> bool:
- """解析 ``soffice --version`` 输出,判定是否 ≥ 7.4(JSON 滤镜参数要求)。
- 新版日历式版本号(如 24.8 / 25.2)主版本大于 7,同样支持。
- """
- match = re.search(r"(\d+)\.(\d+)", output or "")
- if not match:
- return False
- major, minor = int(match.group(1)), int(match.group(2))
- return major > 7 or (major == 7 and minor >= 4)
- def _terminate_process(process: subprocess.Popen[Any]) -> None:
- """终止 LibreOffice 进程及其子进程。"""
- try:
- process.terminate()
- process.wait(timeout=5)
- except Exception:
- try:
- process.kill()
- except Exception:
- pass
- class FakeDocumentConverter(DocumentConverter):
- """测试用转换器:将源文件内容复制为 PDF 字节或按规则生成 PDF。"""
- def __init__(self, enabled: bool = True) -> None:
- self._enabled = enabled
- self.calls: list[tuple[Path, str]] = []
- @property
- def available(self) -> bool:
- return self._enabled
- def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
- self.calls.append((source_path, file_hash))
- if not self._enabled:
- raise PreviewConverterUnavailableError()
- source_text = source_path.read_bytes().decode("utf-8", errors="replace")
- document = fitz.open()
- try:
- page = document.new_page()
- page.insert_text((72, 72), source_text[:4096])
- return document.tobytes()
- finally:
- document.close()
- def create_converter(config: dict[str, Any]) -> DocumentConverter:
- """根据 Flask 配置创建默认转换器。"""
- return LibreOfficeDocumentConverter(
- executable=config.get("DMS_LIBREOFFICE_EXECUTABLE", ""),
- timeout_seconds=config.get("DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60),
- max_concurrency=config.get("DMS_LIBREOFFICE_MAX_CONCURRENCY", 2),
- enabled=config.get("DMS_OFFICE_PREVIEW_ENABLED", True),
- )
- __all__ = [
- "DocumentConverter",
- "LibreOfficeDocumentConverter",
- "FakeDocumentConverter",
- "create_converter",
- ]
|