| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- """文档格式转换抽象,支持 LibreOffice 离线转换与测试用的 Fake 实现。"""
- from __future__ import annotations
- import logging
- import os
- import shutil
- import subprocess
- import tempfile
- import threading
- 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-"
- 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
- 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()
- @property
- def available(self) -> bool:
- if not self._enabled:
- return False
- if not self._executable:
- return False
- return Path(self._executable).is_file()
- 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="/:")
- args = [
- self._executable,
- "--headless",
- "--nologo",
- "--nodefault",
- "--nofirststartwizard",
- "--nolockcheck",
- "--convert-to",
- "pdf",
- "--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),
- )
- 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 _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",
- ]
|