document_converter.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. """文档格式转换抽象,支持 LibreOffice 离线转换与测试用的 Fake 实现。"""
  2. from __future__ import annotations
  3. import logging
  4. import os
  5. import shutil
  6. import subprocess
  7. import tempfile
  8. import threading
  9. from abc import ABC, abstractmethod
  10. from pathlib import Path
  11. from typing import Any
  12. from urllib.parse import quote
  13. import fitz
  14. from dms.common.errors import (
  15. PreviewConversionFailedError,
  16. PreviewConverterUnavailableError,
  17. PreviewConversionTimeoutError,
  18. )
  19. logger = logging.getLogger(__name__)
  20. _PDF_MAGIC = b"%PDF-"
  21. class DocumentConverter(ABC):
  22. """文档转换器接口;业务层只依赖此接口,方便后续替换实现。"""
  23. @abstractmethod
  24. def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
  25. """将源文件转换为 PDF 并返回二进制内容。
  26. 实现必须保证:
  27. - 不依赖当前工作目录;
  28. - 异常转换为 PreviewConversion* 业务错误;
  29. - 不泄露临时目录或命令行。
  30. """
  31. raise NotImplementedError
  32. @property
  33. @abstractmethod
  34. def available(self) -> bool:
  35. """转换器是否可用。"""
  36. raise NotImplementedError
  37. class LibreOfficeDocumentConverter(DocumentConverter):
  38. """使用 LibreOffice headless 将 DOC/DOCX 转换为 PDF。"""
  39. def __init__(
  40. self,
  41. executable: str,
  42. timeout_seconds: int,
  43. max_concurrency: int,
  44. enabled: bool = True,
  45. ) -> None:
  46. self._executable = executable
  47. self._timeout_seconds = timeout_seconds
  48. self._semaphore = threading.Semaphore(max(1, max_concurrency))
  49. self._enabled = enabled
  50. self._in_flight: dict[str, LibreOfficeDocumentConverter._ConversionTask] = {}
  51. self._lock = threading.Lock()
  52. @property
  53. def available(self) -> bool:
  54. if not self._enabled:
  55. return False
  56. if not self._executable:
  57. return False
  58. return Path(self._executable).is_file()
  59. class _ConversionTask:
  60. __slots__ = ("event", "result")
  61. def __init__(self) -> None:
  62. self.event = threading.Event()
  63. self.result: bytes | None = None
  64. def _join_or_register(self, file_hash: str) -> tuple[_ConversionTask, bool]:
  65. """注册新的转换任务或加入已有任务。
  66. 返回 (task, is_owner)。is_owner=True 表示当前线程需要执行转换。
  67. 非 owner 线程持有 task 引用,即使 owner 清理 _in_flight 也能读取结果。
  68. """
  69. with self._lock:
  70. if file_hash in self._in_flight:
  71. return self._in_flight[file_hash], False
  72. task = self._ConversionTask()
  73. self._in_flight[file_hash] = task
  74. return task, True
  75. def _set_result(self, task: _ConversionTask, result: bytes | None) -> None:
  76. task.result = result
  77. task.event.set()
  78. def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
  79. if not self.available:
  80. raise PreviewConverterUnavailableError()
  81. task, is_owner = self._join_or_register(file_hash)
  82. if not is_owner:
  83. if not task.event.wait(self._timeout_seconds):
  84. raise PreviewConversionTimeoutError()
  85. if task.result is None:
  86. raise PreviewConversionFailedError()
  87. return task.result
  88. try:
  89. result = self._convert_locked(source_path, file_hash)
  90. self._set_result(task, result)
  91. return result
  92. except Exception:
  93. self._set_result(task, None)
  94. raise
  95. finally:
  96. with self._lock:
  97. self._in_flight.pop(file_hash, None)
  98. def _convert_locked(self, source_path: Path, file_hash: str) -> bytes:
  99. work_dir = Path(tempfile.mkdtemp(prefix=f"dms-lo-{file_hash[:8]}-"))
  100. user_dir = Path(tempfile.mkdtemp(prefix=f"dms-locfg-{file_hash[:8]}-"))
  101. try:
  102. output_dir = work_dir / "out"
  103. output_dir.mkdir(parents=True, exist_ok=True)
  104. input_file = work_dir / source_path.name
  105. input_file.write_bytes(source_path.read_bytes())
  106. # LibreOffice 在 headless 模式下输出文件名与输入文件名一致,扩展名改为 pdf
  107. base_name = source_path.stem
  108. expected_output = output_dir / f"{base_name}.pdf"
  109. user_url = quote(str(user_dir.as_posix()), safe="/:")
  110. args = [
  111. self._executable,
  112. "--headless",
  113. "--nologo",
  114. "--nodefault",
  115. "--nofirststartwizard",
  116. "--nolockcheck",
  117. "--convert-to",
  118. "pdf",
  119. "--outdir",
  120. str(output_dir),
  121. f"-env:UserInstallation=file:///{user_url}",
  122. str(input_file),
  123. ]
  124. with self._semaphore:
  125. try:
  126. process = subprocess.Popen(
  127. args,
  128. stdout=subprocess.PIPE,
  129. stderr=subprocess.PIPE,
  130. cwd=str(work_dir),
  131. )
  132. except OSError as exc:
  133. logger.error("启动 LibreOffice 失败:%s", exc.__class__.__name__)
  134. raise PreviewConverterUnavailableError() from exc
  135. try:
  136. stdout, stderr = process.communicate(
  137. timeout=self._timeout_seconds
  138. )
  139. except subprocess.TimeoutExpired as exc:
  140. logger.warning("LibreOffice 转换超时")
  141. _terminate_process(process)
  142. raise PreviewConversionTimeoutError() from exc
  143. finally:
  144. if process.poll() is None:
  145. _terminate_process(process)
  146. if process.returncode != 0:
  147. logger.error(
  148. "LibreOffice 退出码非零:returncode=%s",
  149. process.returncode,
  150. )
  151. raise PreviewConversionFailedError()
  152. if not expected_output.is_file():
  153. logger.error("LibreOffice 未生成预期 PDF 文件")
  154. raise PreviewConversionFailedError()
  155. pdf_bytes = expected_output.read_bytes()
  156. if not pdf_bytes.startswith(_PDF_MAGIC):
  157. logger.error("LibreOffice 输出文件头不是 PDF")
  158. raise PreviewConversionFailedError()
  159. return pdf_bytes
  160. finally:
  161. try:
  162. shutil.rmtree(work_dir, ignore_errors=True)
  163. shutil.rmtree(user_dir, ignore_errors=True)
  164. except Exception:
  165. logger.exception("清理 LibreOffice 临时目录失败")
  166. def _terminate_process(process: subprocess.Popen[Any]) -> None:
  167. """终止 LibreOffice 进程及其子进程。"""
  168. try:
  169. process.terminate()
  170. process.wait(timeout=5)
  171. except Exception:
  172. try:
  173. process.kill()
  174. except Exception:
  175. pass
  176. class FakeDocumentConverter(DocumentConverter):
  177. """测试用转换器:将源文件内容复制为 PDF 字节或按规则生成 PDF。"""
  178. def __init__(self, enabled: bool = True) -> None:
  179. self._enabled = enabled
  180. self.calls: list[tuple[Path, str]] = []
  181. @property
  182. def available(self) -> bool:
  183. return self._enabled
  184. def convert_to_pdf(self, source_path: Path, file_hash: str) -> bytes:
  185. self.calls.append((source_path, file_hash))
  186. if not self._enabled:
  187. raise PreviewConverterUnavailableError()
  188. source_text = source_path.read_bytes().decode("utf-8", errors="replace")
  189. document = fitz.open()
  190. try:
  191. page = document.new_page()
  192. page.insert_text((72, 72), source_text[:4096])
  193. return document.tobytes()
  194. finally:
  195. document.close()
  196. def create_converter(config: dict[str, Any]) -> DocumentConverter:
  197. """根据 Flask 配置创建默认转换器。"""
  198. return LibreOfficeDocumentConverter(
  199. executable=config.get("DMS_LIBREOFFICE_EXECUTABLE", ""),
  200. timeout_seconds=config.get("DMS_LIBREOFFICE_TIMEOUT_SECONDS", 60),
  201. max_concurrency=config.get("DMS_LIBREOFFICE_MAX_CONCURRENCY", 2),
  202. enabled=config.get("DMS_OFFICE_PREVIEW_ENABLED", True),
  203. )
  204. __all__ = [
  205. "DocumentConverter",
  206. "LibreOfficeDocumentConverter",
  207. "FakeDocumentConverter",
  208. "create_converter",
  209. ]