document_converter.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. """文档格式转换抽象,支持 LibreOffice 离线转换与测试用的 Fake 实现。"""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. import re
  6. import shutil
  7. import subprocess
  8. import sys
  9. import tempfile
  10. import threading
  11. import time
  12. from abc import ABC, abstractmethod
  13. from pathlib import Path
  14. from typing import Any
  15. from urllib.parse import quote
  16. import fitz
  17. from dms.common.errors import (
  18. PreviewConversionFailedError,
  19. PreviewConverterUnavailableError,
  20. PreviewConversionTimeoutError,
  21. )
  22. logger = logging.getLogger(__name__)
  23. _PDF_MAGIC = b"%PDF-"
  24. # Windows 下宿主进程无控制台(如打包 exe)时启动控制台子程序(soffice.com)
  25. # 会闪现黑框;CREATE_NO_WINDOW 使子进程在无窗口控制台运行,管道输出不受影响。
  26. _NO_WINDOW_FLAGS = (
  27. subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
  28. )
  29. # 表格类扩展名:预览转 PDF 时启用 SinglePageSheets——每个工作表按实际
  30. # 内容区域导出为单页,忽略纸张版型(宽表不再被 A4 等纸张分页截断)。
  31. _SPREADSHEET_EXTENSIONS = {"xls", "xlsx", "et"}
  32. _SINGLE_PAGE_FILTER = (
  33. 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":true}}'
  34. )
  35. # 版本探测失败后的重试间隔:失败结果只短期缓存,避免一次瞬时异常
  36. # (如残留的挂起 soffice 进程占用默认 profile)导致整个进程生命周期回退分页。
  37. _SINGLE_PAGE_DETECT_RETRY_SECONDS = 300.0
  38. class DocumentConverter(ABC):
  39. """文档转换器接口;业务层只依赖此接口,方便后续替换实现。"""
  40. @abstractmethod
  41. def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
  42. """将源文件转换为 PDF 并返回二进制内容。
  43. 实现必须保证:
  44. - 不依赖当前工作目录;
  45. - 异常转换为 PreviewConversion* 业务错误;
  46. - 不泄露临时目录或命令行。
  47. """
  48. raise NotImplementedError
  49. @property
  50. @abstractmethod
  51. def available(self) -> bool:
  52. """转换器是否可用。"""
  53. raise NotImplementedError
  54. class LibreOfficeDocumentConverter(DocumentConverter):
  55. """使用 LibreOffice headless 将 DOC/DOCX 转换为 PDF。"""
  56. def __init__(
  57. self,
  58. executable: str,
  59. timeout_seconds: int,
  60. max_concurrency: int,
  61. enabled: bool = True,
  62. ) -> None:
  63. self._executable = executable
  64. self._timeout_seconds = timeout_seconds
  65. self._semaphore = threading.Semaphore(max(1, max_concurrency))
  66. self._enabled = enabled
  67. self._in_flight: dict[str, LibreOfficeDocumentConverter._ConversionTask] = {}
  68. self._lock = threading.Lock()
  69. self._version_lock = threading.Lock()
  70. self._single_page_supported: bool | None = None
  71. self._single_page_detected_at: float | None = None
  72. @property
  73. def available(self) -> bool:
  74. if not self._enabled:
  75. return False
  76. if not self._executable:
  77. return False
  78. return Path(self._executable).is_file()
  79. def _version_probe_executable(self) -> str:
  80. """--version 探测用可执行文件。
  81. Windows 上 soffice.exe(GUI 子系统)执行 --version 会派生
  82. soffice.bin(控制台子系统),父链无控制台可继承时新建可见控制台
  83. 窗口打印版本并停在 ``Press Enter to continue...`` 等待输入,
  84. 导致探测超时挂起并闪现黑框。soffice.com 为控制台包装器,stdout
  85. 走管道即打即退(配合 CREATE_NO_WINDOW 无窗口),故优先使用。
  86. """
  87. if sys.platform == "win32" and self._executable:
  88. candidate = Path(self._executable).with_name("soffice.com")
  89. if candidate.is_file():
  90. return str(candidate)
  91. return self._executable
  92. def _supports_single_page_sheets(self) -> bool:
  93. """检测 LibreOffice 是否支持 SinglePageSheets 滤镜(需 ≥7.4)。
  94. 首次调用执行 ``--version`` 探测并缓存结果;探测成功永久缓存,
  95. 失败结果仅缓存 ``_SINGLE_PAGE_DETECT_RETRY_SECONDS`` 秒后重试,
  96. 避免一次瞬时失败使整个进程生命周期回退标准纸张分页转换。
  97. """
  98. with self._version_lock:
  99. if self._single_page_supported:
  100. return True
  101. if (
  102. self._single_page_supported is False
  103. and self._single_page_detected_at is not None
  104. and time.monotonic() - self._single_page_detected_at
  105. < _SINGLE_PAGE_DETECT_RETRY_SECONDS
  106. ):
  107. return False
  108. supported = False
  109. try:
  110. completed = subprocess.run(
  111. [self._version_probe_executable(), "--version"],
  112. capture_output=True,
  113. text=True,
  114. timeout=15,
  115. check=False,
  116. creationflags=_NO_WINDOW_FLAGS,
  117. )
  118. output = (completed.stdout or "") + (completed.stderr or "")
  119. supported = _version_at_least_7_4(output)
  120. except Exception:
  121. supported = False
  122. self._single_page_supported = supported
  123. self._single_page_detected_at = time.monotonic()
  124. if not supported:
  125. logger.info(
  126. "LibreOffice 不支持 SinglePageSheets(版本<7.4 或探测失败),"
  127. "表格预览回退标准纸张分页(%.0f 秒后重试探测)",
  128. _SINGLE_PAGE_DETECT_RETRY_SECONDS,
  129. )
  130. return supported
  131. class _ConversionTask:
  132. __slots__ = ("event", "result")
  133. def __init__(self) -> None:
  134. self.event = threading.Event()
  135. self.result: bytes | None = None
  136. def _join_or_register(self, file_hash: str) -> tuple[_ConversionTask, bool]:
  137. """注册新的转换任务或加入已有任务。
  138. 返回 (task, is_owner)。is_owner=True 表示当前线程需要执行转换。
  139. 非 owner 线程持有 task 引用,即使 owner 清理 _in_flight 也能读取结果。
  140. """
  141. with self._lock:
  142. if file_hash in self._in_flight:
  143. return self._in_flight[file_hash], False
  144. task = self._ConversionTask()
  145. self._in_flight[file_hash] = task
  146. return task, True
  147. def _set_result(self, task: _ConversionTask, result: bytes | None) -> None:
  148. task.result = result
  149. task.event.set()
  150. def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
  151. if not self.available:
  152. raise PreviewConverterUnavailableError()
  153. task, is_owner = self._join_or_register(file_hash)
  154. if not is_owner:
  155. if not task.event.wait(self._timeout_seconds):
  156. raise PreviewConversionTimeoutError()
  157. if task.result is None:
  158. raise PreviewConversionFailedError()
  159. return task.result
  160. try:
  161. result = self._convert_locked(source_path, file_hash)
  162. self._set_result(task, result)
  163. return result
  164. except Exception:
  165. self._set_result(task, None)
  166. raise
  167. finally:
  168. with self._lock:
  169. self._in_flight.pop(file_hash, None)
  170. def _convert_locked(self, source_path: Path, file_hash: str) -> bytes:
  171. work_dir = Path(tempfile.mkdtemp(prefix=f"dms-lo-{file_hash[:8]}-"))
  172. user_dir = Path(tempfile.mkdtemp(prefix=f"dms-locfg-{file_hash[:8]}-"))
  173. try:
  174. output_dir = work_dir / "out"
  175. output_dir.mkdir(parents=True, exist_ok=True)
  176. input_file = work_dir / source_path.name
  177. input_file.write_bytes(source_path.read_bytes())
  178. # LibreOffice 在 headless 模式下输出文件名与输入文件名一致,扩展名改为 pdf
  179. base_name = source_path.stem
  180. expected_output = output_dir / f"{base_name}.pdf"
  181. user_url = quote(str(user_dir.as_posix()), safe="/:")
  182. # 表格类且 LibreOffice ≥7.4:按内容区域单页导出;其余维持标准 pdf 转换
  183. extension = source_path.suffix.lower().lstrip(".")
  184. if (
  185. extension in _SPREADSHEET_EXTENSIONS
  186. and self._supports_single_page_sheets()
  187. ):
  188. convert_target = _SINGLE_PAGE_FILTER
  189. else:
  190. convert_target = "pdf"
  191. args = [
  192. self._executable,
  193. "--headless",
  194. "--nologo",
  195. "--nodefault",
  196. "--nofirststartwizard",
  197. "--nolockcheck",
  198. "--convert-to",
  199. convert_target,
  200. "--outdir",
  201. str(output_dir),
  202. f"-env:UserInstallation=file:///{user_url}",
  203. str(input_file),
  204. ]
  205. with self._semaphore:
  206. try:
  207. process = subprocess.Popen(
  208. args,
  209. stdout=subprocess.PIPE,
  210. stderr=subprocess.PIPE,
  211. cwd=str(work_dir),
  212. creationflags=_NO_WINDOW_FLAGS,
  213. )
  214. except OSError as exc:
  215. logger.error("启动 LibreOffice 失败:%s", exc.__class__.__name__)
  216. raise PreviewConverterUnavailableError() from exc
  217. try:
  218. stdout, stderr = process.communicate(
  219. timeout=self._timeout_seconds
  220. )
  221. except subprocess.TimeoutExpired as exc:
  222. logger.warning("LibreOffice 转换超时")
  223. _terminate_process(process)
  224. raise PreviewConversionTimeoutError() from exc
  225. finally:
  226. if process.poll() is None:
  227. _terminate_process(process)
  228. if process.returncode != 0:
  229. logger.error(
  230. "LibreOffice 退出码非零:returncode=%s",
  231. process.returncode,
  232. )
  233. raise PreviewConversionFailedError()
  234. if not expected_output.is_file():
  235. logger.error("LibreOffice 未生成预期 PDF 文件")
  236. raise PreviewConversionFailedError()
  237. pdf_bytes = expected_output.read_bytes()
  238. if not pdf_bytes.startswith(_PDF_MAGIC):
  239. logger.error("LibreOffice 输出文件头不是 PDF")
  240. raise PreviewConversionFailedError()
  241. return pdf_bytes
  242. finally:
  243. try:
  244. shutil.rmtree(work_dir, ignore_errors=True)
  245. shutil.rmtree(user_dir, ignore_errors=True)
  246. except Exception:
  247. logger.exception("清理 LibreOffice 临时目录失败")
  248. def _version_at_least_7_4(output: str) -> bool:
  249. """解析 ``soffice --version`` 输出,判定是否 ≥ 7.4(JSON 滤镜参数要求)。
  250. 新版日历式版本号(如 24.8 / 25.2)主版本大于 7,同样支持。
  251. """
  252. match = re.search(r"(\d+)\.(\d+)", output or "")
  253. if not match:
  254. return False
  255. major, minor = int(match.group(1)), int(match.group(2))
  256. return major > 7 or (major == 7 and minor >= 4)
  257. def _terminate_process(process: subprocess.Popen[Any]) -> None:
  258. """终止 LibreOffice 进程及其子进程。"""
  259. try:
  260. process.terminate()
  261. process.wait(timeout=5)
  262. except Exception:
  263. try:
  264. process.kill()
  265. except Exception:
  266. pass
  267. class FakeDocumentConverter(DocumentConverter):
  268. """测试用转换器:将源文件内容复制为 PDF 字节或按规则生成 PDF。"""
  269. def __init__(self, enabled: bool = True) -> None:
  270. self._enabled = enabled
  271. self.calls: list[tuple[Path, str]] = []
  272. @property
  273. def available(self) -> bool:
  274. return self._enabled
  275. def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
  276. self.calls.append((source_path, file_hash))
  277. if not self._enabled:
  278. raise PreviewConverterUnavailableError()
  279. source_text = source_path.read_bytes().decode("utf-8", errors="replace")
  280. document = fitz.open()
  281. try:
  282. page = document.new_page()
  283. page.insert_text((72, 72), source_text[:4096])
  284. return document.tobytes()
  285. finally:
  286. document.close()
  287. def create_converter(config: dict[str, Any]) -> DocumentConverter:
  288. """根据 Flask 配置创建默认转换器。"""
  289. return LibreOfficeDocumentConverter(
  290. executable=config.get("DMS_LIBREOFFICE_EXECUTABLE", ""),
  291. timeout_seconds=config.get("DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60),
  292. max_concurrency=config.get("DMS_LIBREOFFICE_MAX_CONCURRENCY", 2),
  293. enabled=config.get("DMS_OFFICE_PREVIEW_ENABLED", True),
  294. )
  295. __all__ = [
  296. "DocumentConverter",
  297. "LibreOfficeDocumentConverter",
  298. "FakeDocumentConverter",
  299. "create_converter",
  300. ]