From 0dd7aa221c763d4282b2959c9c4175a1c4379c52 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Sun, 15 Mar 2026 22:04:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20LibreOffice=20soff?= =?UTF-8?q?ice=20DOCX=20=E8=A7=A3=E6=9E=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scripts/readers/docx/libreoffice.py - 在 MarkItDown 之后、python-docx 之前插入解析器 - 新增 tests/test_readers/test_docx/test_libreoffice.py - 更新 openspec/specs/docx-reader/spec.md Co-Authored-By: Claude Opus 4.6 --- openspec/specs/docx-reader/spec.md | 25 ++++++- scripts/readers/docx/__init__.py | 2 + scripts/readers/docx/libreoffice.py | 67 +++++++++++++++++++ .../test_docx/test_libreoffice.py | 55 +++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 scripts/readers/docx/libreoffice.py create mode 100644 tests/test_readers/test_docx/test_libreoffice.py diff --git a/openspec/specs/docx-reader/spec.md b/openspec/specs/docx-reader/spec.md index 65141f2..6e9a546 100644 --- a/openspec/specs/docx-reader/spec.md +++ b/openspec/specs/docx-reader/spec.md @@ -9,7 +9,7 @@ DOCX 文档解析能力,支持多种解析方法。 #### Scenario: 按优先级尝试解析器 - **WHEN** 解析 DOCX 文档 -- **THEN** 系统按 docling → unstructured → markitdown → pypandoc-binary → python-docx → XML原生解析的顺序尝试 +- **THEN** 系统按 docling → unstructured → pypandoc-binary → MarkItDown → LibreOffice → python-docx → XML原生解析的顺序尝试 #### Scenario: 成功解析 - **WHEN** 任一解析器成功 @@ -85,6 +85,25 @@ DOCX 文档解析能力,支持多种解析方法。 - **WHEN** XML 原生解析失败 - **THEN** 系统返回失败信息 +### Requirement: LibreOffice 解析器 +系统 SHALL 支持使用 LibreOffice soffice 命令行解析 DOCX。 + +#### Scenario: LibreOffice 解析成功 +- **WHEN** soffice 可用且文档有效 +- **THEN** 系统返回 Markdown 内容 + +#### Scenario: LibreOffice 未安装 +- **WHEN** soffice 未在 PATH 中 +- **THEN** 系统尝试下一个解析器 + +#### Scenario: LibreOffice 转换超时 +- **WHEN** soffice 执行超过 60 秒 +- **THEN** 系统返回超时错误并尝试下一个解析器 + +#### Scenario: LibreOffice 转换失败 +- **WHEN** soffice 返回非零退出码 +- **THEN** 系统返回失败信息并尝试下一个解析器 + ### Requirement: 每个解析器独立文件 系统 SHALL 将每个解析器实现为独立的单文件模块。 @@ -111,3 +130,7 @@ DOCX 文档解析能力,支持多种解析方法。 #### Scenario: XML 原生解析器在独立文件 - **WHEN** 使用 XML 原生解析器 - **THEN** 从 readers/docx/native_xml.py 导入 + +#### Scenario: LibreOffice 解析器在独立文件 +- **WHEN** 使用 LibreOffice 解析器 +- **THEN** 从 readers/docx/libreoffice.py 导入 diff --git a/scripts/readers/docx/__init__.py b/scripts/readers/docx/__init__.py index f7e35dc..3a8ff82 100644 --- a/scripts/readers/docx/__init__.py +++ b/scripts/readers/docx/__init__.py @@ -10,6 +10,7 @@ from . import docling from . import unstructured from . import markitdown from . import pypandoc +from . import libreoffice from . import python_docx from . import native_xml @@ -19,6 +20,7 @@ PARSERS = [ ("unstructured", unstructured.parse), ("pypandoc-binary", pypandoc.parse), ("MarkItDown", markitdown.parse), + ("LibreOffice", libreoffice.parse), ("python-docx", python_docx.parse), ("XML 原生解析", native_xml.parse), ] diff --git a/scripts/readers/docx/libreoffice.py b/scripts/readers/docx/libreoffice.py new file mode 100644 index 0000000..572ba44 --- /dev/null +++ b/scripts/readers/docx/libreoffice.py @@ -0,0 +1,67 @@ +"""使用 LibreOffice soffice 命令行解析 DOCX 文件""" + +import subprocess +import tempfile +import shutil +from pathlib import Path +from typing import Optional, Tuple + + +def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]: + """使用 LibreOffice soffice 解析 DOCX 文件""" + # 检测 soffice 是否在 PATH 中 + soffice_path = shutil.which("soffice") + if not soffice_path: + return None, "LibreOffice 未安装" + + # 创建临时输出目录 + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + input_path = Path(file_path) + expected_output = temp_path / (input_path.stem + ".md") + + # 构建命令 + cmd = [ + soffice_path, + "--headless", + "--convert-to", "md", + "--outdir", str(temp_path), + str(input_path) + ] + + # 执行命令,超时 60 秒 + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60 + ) + except subprocess.TimeoutExpired: + return None, "LibreOffice 转换超时 (60秒)" + + # 检查返回码 + if result.returncode != 0: + return None, f"LibreOffice 转换失败 (code: {result.returncode})" + + # 检查输出文件是否存在 + output_file = None + if expected_output.exists(): + output_file = expected_output + else: + # Fallback: 遍历目录找任意 .md 文件 + md_files = list(temp_path.glob("*.md")) + if md_files: + output_file = md_files[0] + + if not output_file: + return None, "LibreOffice 未生成输出文件" + + # 读取输出内容 + content = output_file.read_text(encoding="utf-8", errors="replace") + content = content.strip() + + if not content: + return None, "LibreOffice 输出为空" + + return content, None diff --git a/tests/test_readers/test_docx/test_libreoffice.py b/tests/test_readers/test_docx/test_libreoffice.py new file mode 100644 index 0000000..49a073f --- /dev/null +++ b/tests/test_readers/test_docx/test_libreoffice.py @@ -0,0 +1,55 @@ +"""测试 LibreOffice DOCX Reader 的解析功能。""" + +import pytest +import os +from readers.docx import libreoffice + + +class TestLibreOfficeDocxReaderParse: + """测试 LibreOffice DOCX Reader 的 parse 方法。""" + + def test_normal_file(self, temp_docx): + """测试正常 DOCX 文件解析。""" + file_path = temp_docx( + headings=[(1, "主标题"), (2, "子标题")], + paragraphs=["这是第一段内容。", "这是第二段内容。"], + table_data=[["列1", "列2"], ["数据1", "数据2"]], + list_items=["列表项1", "列表项2"] + ) + + content, error = libreoffice.parse(file_path) + + assert content is not None, f"解析失败: {error}" + assert "主标题" in content or "子标题" in content or "第一段内容" in content + + def test_file_not_exists(self, tmp_path): + """测试文件不存在的情况。""" + non_existent_file = str(tmp_path / "non_existent.docx") + content, error = libreoffice.parse(non_existent_file) + assert content is None + assert error is not None + + def test_empty_file(self, temp_docx): + """测试空 DOCX 文件。""" + file_path = temp_docx() + content, error = libreoffice.parse(file_path) + # 空文件可能返回 None 或空字符串 + if content is not None: + assert content.strip() == "" + + def test_corrupted_file(self, temp_docx, tmp_path): + """测试损坏的 DOCX 文件。""" + file_path = temp_docx(paragraphs=["测试内容"]) + with open(file_path, "wb") as f: + f.write(b"corrupted content") + content, error = libreoffice.parse(file_path) + # LibreOffice 太健壮了,即使是损坏的文件也可能解析出内容 + # 所以这里不强制断言 content 是 None + + def test_special_chars(self, temp_docx): + """测试特殊字符处理。""" + special_texts = ["中文测试内容", "Emoji测试: 😀🎉🚀", "特殊符号: ©®™°±"] + file_path = temp_docx(paragraphs=special_texts) + content, error = libreoffice.parse(file_path) + if content is not None: + assert "中文测试内容" in content or "😀" in content