diff --git a/README.md b/README.md index 8d2d4ff..e2fd2d3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # lyxy-document -统一文档解析工具 - 将 DOCX、XLS、XLSX、PPTX、PDF、HTML/URL 转换为 Markdown +统一文档解析工具 - 将 DOC、DOCX、XLS、XLSX、PPTX、PDF、HTML/URL 转换为 Markdown ## 项目概述 @@ -26,6 +26,7 @@ scripts/ │ └── exceptions.py # 异常定义 ├── readers/ # 格式阅读器 │ ├── base.py # Reader 基类 +│ ├── doc/ # DOC 解析器(旧格式) │ ├── docx/ # DOCX 解析器 │ ├── xls/ # XLS 解析器(旧格式) │ ├── xlsx/ # XLSX 解析器 diff --git a/SKILL.md b/SKILL.md index 959f085..df09b8f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: lyxy-document-reader -description: 统一文档解析工具 - 将 DOCX、XLS、XLSX、PPTX、PDF、HTML/URL 转换为 Markdown。支持全文输出、字数统计、行数统计、标题提取、章节提取、正则搜索。当用户要求"读取/解析/打开文档"、上传 .docx/.xls/.xlsx/.pptx/.pdf/.html 文件、或提供 URL 时使用。 +description: 统一文档解析工具 - 将 DOC、DOCX、XLS、XLSX、PPTX、PDF、HTML/URL 转换为 Markdown。支持全文输出、字数统计、行数统计、标题提取、章节提取、正则搜索。当用户要求"读取/解析/打开文档"、上传 .doc/.docx/.xls/.xlsx/.pptx/.pdf/.html 文件、或提供 URL 时使用。 license: MIT compatibility: Requires Python 3.11+。优先使用 lyxy-runner-python skill,次选 uv run --with,降级到主机 Python。 --- @@ -26,6 +26,7 @@ python scripts/lyxy_document_reader.py <文件路径或URL> ## Purpose **支持格式** +- DOC(Word 旧格式) - DOCX(Word 文档) - XLS(Excel 旧格式) - XLSX(Excel 表格) @@ -44,8 +45,8 @@ python scripts/lyxy_document_reader.py <文件路径或URL> ### 触发词 - 中文:"读取/解析/打开 文档/Word/Excel/PPT/PDF/网页" -- 英文:"read/parse/extract document/docx/xls/xlsx/pptx/pdf/html" -- 文件扩展名:`.docx`、`.xls`、`.xlsx`、`.pptx`、`.pdf`、`.html`、`.htm` +- 英文:"read/parse/extract document/doc/docx/xls/xlsx/pptx/pdf/html" +- 文件扩展名:`.doc`、`.docx`、`.xls`、`.xlsx`、`.pptx`、`.pdf`、`.html`、`.htm` - URL:`http://`、`https://` ## Quick Reference diff --git a/openspec/specs/doc-reader/spec.md b/openspec/specs/doc-reader/spec.md new file mode 100644 index 0000000..c34a27f --- /dev/null +++ b/openspec/specs/doc-reader/spec.md @@ -0,0 +1,61 @@ +## Purpose + +DOC 文档解析能力,支持解析 Microsoft Word 97-2003 旧格式文档。 + +## Requirements + +### Requirement: DOC 文档解析 +系统 SHALL 支持解析 .doc 格式文档,使用 LibreOffice 解析器。 + +#### Scenario: 使用 LibreOffice 解析器 +- **WHEN** 解析 DOC 文档 +- **THEN** 系统使用 LibreOffice soffice 命令行进行解析 + +#### Scenario: 成功解析 +- **WHEN** 解析器成功 +- **THEN** 系统返回解析结果 + +#### Scenario: 解析器失败 +- **WHEN** 解析器失败 +- **THEN** 系统返回失败列表并退出非零状态码 + +### Requirement: LibreOffice 解析器 +系统 SHALL 支持使用 LibreOffice soffice 命令行解析 DOC。 + +#### 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 将解析器实现为独立的单文件模块。 + +#### Scenario: LibreOffice 解析器在独立文件 +- **WHEN** 使用 LibreOffice 解析器 +- **THEN** 从 readers/doc/libreoffice.py 导入 + +### Requirement: DOC Reader 测试使用静态文件 +DOC Reader 测试 MUST 使用 `tests/test_readers/fixtures/doc/` 下的静态文件。 + +#### Scenario: 测试使用 simple.doc +- **WHEN** 测试 DOC Reader 基础解析能力 +- **THEN** 使用 `simple.doc` 静态文件 + +#### Scenario: 测试使用 with_headings.doc +- **WHEN** 测试 DOC Reader 标题解析 +- **THEN** 使用 `with_headings.doc` 静态文件 + +#### Scenario: 测试使用 with_table.doc +- **WHEN** 测试 DOC Reader 表格解析 +- **THEN** 使用 `with_table.doc` 静态文件 diff --git a/scripts/config.py b/scripts/config.py index 123a103..0734fc5 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -110,5 +110,11 @@ DEPENDENCIES = { "olefile" ] } + }, + "doc": { + "default": { + "python": None, + "dependencies": [] + } } } diff --git a/scripts/readers/__init__.py b/scripts/readers/__init__.py index 278b6ca..e43de0f 100644 --- a/scripts/readers/__init__.py +++ b/scripts/readers/__init__.py @@ -2,6 +2,7 @@ from .base import BaseReader from .docx import DocxReader +from .doc import DocReader from .xlsx import XlsxReader from .pptx import PptxReader from .pdf import PdfReader @@ -10,6 +11,7 @@ from .xls import XlsReader READERS = [ DocxReader, + DocReader, XlsxReader, PptxReader, PdfReader, @@ -20,6 +22,7 @@ READERS = [ __all__ = [ "BaseReader", "DocxReader", + "DocReader", "XlsxReader", "PptxReader", "PdfReader", diff --git a/scripts/readers/_utils.py b/scripts/readers/_utils.py index 8fc755a..29e5f93 100644 --- a/scripts/readers/_utils.py +++ b/scripts/readers/_utils.py @@ -4,6 +4,9 @@ """ import re +import subprocess +import tempfile +import shutil import zipfile from pathlib import Path from typing import List, Optional, Tuple @@ -63,6 +66,75 @@ def parse_via_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]: return None, f"docling 解析失败: {str(e)}" +def parse_via_libreoffice(file_path: str) -> Tuple[Optional[str], Optional[str]]: + """使用 LibreOffice soffice 命令行转换文件为 Markdown。 + + 支持 .doc/.docx/.odt 等 LibreOffice 可处理的格式。 + + Args: + file_path: 文件路径 + + Returns: + (markdown_content, error_message): 成功时 (content, None),失败时 (None, error) + """ + # 检测 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/scripts/readers/doc/__init__.py b/scripts/readers/doc/__init__.py new file mode 100644 index 0000000..88a5dba --- /dev/null +++ b/scripts/readers/doc/__init__.py @@ -0,0 +1,46 @@ +"""DOC 文件阅读器,使用 LibreOffice 解析。""" + +import os +from typing import List, Optional, Tuple + +from readers.base import BaseReader +from utils import is_valid_doc + +from . import libreoffice + + +PARSERS = [ + ("LibreOffice", libreoffice.parse), +] + + +class DocReader(BaseReader): + """DOC 文件阅读器""" + + def supports(self, file_path: str) -> bool: + return file_path.lower().endswith('.doc') + + def parse(self, file_path: str) -> Tuple[Optional[str], List[str]]: + failures = [] + + # 检查文件是否存在 + if not os.path.exists(file_path): + return None, ["文件不存在"] + + # 验证文件格式 + if not is_valid_doc(file_path): + return None, ["不是有效的 DOC 文件"] + + content = None + + for parser_name, parser_func in PARSERS: + try: + content, error = parser_func(file_path) + if content is not None: + return content, failures + else: + failures.append(f"- {parser_name}: {error}") + except Exception as e: + failures.append(f"- {parser_name}: [意外异常] {type(e).__name__}: {str(e)}") + + return None, failures diff --git a/scripts/readers/doc/libreoffice.py b/scripts/readers/doc/libreoffice.py new file mode 100644 index 0000000..ff9eab3 --- /dev/null +++ b/scripts/readers/doc/libreoffice.py @@ -0,0 +1,9 @@ +"""使用 LibreOffice soffice 命令行解析 DOC 文件""" + +from typing import Optional, Tuple +from readers._utils import parse_via_libreoffice + + +def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]: + """使用 LibreOffice soffice 解析 DOC 文件""" + return parse_via_libreoffice(file_path) diff --git a/scripts/readers/docx/libreoffice.py b/scripts/readers/docx/libreoffice.py index 572ba44..4fedbf7 100644 --- a/scripts/readers/docx/libreoffice.py +++ b/scripts/readers/docx/libreoffice.py @@ -1,67 +1,9 @@ """使用 LibreOffice soffice 命令行解析 DOCX 文件""" -import subprocess -import tempfile -import shutil -from pathlib import Path from typing import Optional, Tuple +from readers._utils import parse_via_libreoffice 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 + return parse_via_libreoffice(file_path) diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py index e5e5d8c..05519ab 100644 --- a/scripts/utils/__init__.py +++ b/scripts/utils/__init__.py @@ -1,6 +1,7 @@ """Utils module for lyxy-document.""" from .file_detection import ( + is_valid_doc, is_valid_docx, is_valid_pptx, is_valid_xlsx, @@ -11,6 +12,7 @@ from .file_detection import ( ) __all__ = [ + "is_valid_doc", "is_valid_docx", "is_valid_pptx", "is_valid_xlsx", diff --git a/scripts/utils/file_detection.py b/scripts/utils/file_detection.py index a61b3a0..58b2f3e 100644 --- a/scripts/utils/file_detection.py +++ b/scripts/utils/file_detection.py @@ -6,7 +6,7 @@ from typing import List, Optional def _is_valid_ole(file_path: str) -> bool: - """验证 OLE2 格式文件(XLS)""" + """验证 OLE2 格式文件(XLS/DOC)""" try: import olefile except ImportError: @@ -53,6 +53,11 @@ def is_valid_xls(file_path: str) -> bool: return _is_valid_ole(file_path) +def is_valid_doc(file_path: str) -> bool: + """验证文件是否为有效的 DOC 格式(OLE2)""" + return _is_valid_ole(file_path) + + def is_valid_pdf(file_path: str) -> bool: """验证文件是否为有效的 PDF 格式""" try: diff --git a/tests/test_readers/test_doc/__init__.py b/tests/test_readers/test_doc/__init__.py new file mode 100644 index 0000000..c10b205 --- /dev/null +++ b/tests/test_readers/test_doc/__init__.py @@ -0,0 +1 @@ +"""测试 DOC Reader 的解析功能。""" diff --git a/tests/test_readers/test_doc/test_consistency.py b/tests/test_readers/test_doc/test_consistency.py new file mode 100644 index 0000000..64a6434 --- /dev/null +++ b/tests/test_readers/test_doc/test_consistency.py @@ -0,0 +1,25 @@ +"""测试所有 DOC Readers 的一致性。""" + +import pytest +from readers.doc import libreoffice + + +class TestDocReadersConsistency: + """验证所有 DOC Readers 解析同一文件时核心文字内容一致。""" + + def test_parsers_importable(self): + """测试所有 parser 模块可以正确导入。""" + # 验证模块导入成功 + assert libreoffice is not None + assert hasattr(libreoffice, 'parse') + + def test_parser_functions_callable(self): + """测试 parse 函数是可调用的。""" + assert callable(libreoffice.parse) + + def test_libreoffice_parse_simple_doc(self, simple_doc_path): + """测试 LibreOffice 解析简单文件。""" + content, error = libreoffice.parse(simple_doc_path) + # LibreOffice 可能未安装,所以不强制断言成功 + if content is not None: + assert content.strip() != "" diff --git a/tests/test_readers/test_doc/test_libreoffice.py b/tests/test_readers/test_doc/test_libreoffice.py new file mode 100644 index 0000000..07c34e1 --- /dev/null +++ b/tests/test_readers/test_doc/test_libreoffice.py @@ -0,0 +1,35 @@ +"""测试 LibreOffice DOC Reader 的解析功能。""" + +import pytest +import os +from readers.doc import libreoffice + + +class TestLibreOfficeDocReaderParse: + """测试 LibreOffice DOC Reader 的 parse 方法。""" + + def test_simple_doc(self, simple_doc_path): + """测试简单 DOC 文件解析。""" + content, error = libreoffice.parse(simple_doc_path) + if content is not None: + # 至少能解析出一些内容 + assert content.strip() != "" + + def test_with_headings_doc(self, with_headings_doc_path): + """测试带标题的 DOC 文件解析。""" + content, error = libreoffice.parse(with_headings_doc_path) + if content is not None: + assert content.strip() != "" + + def test_with_table_doc(self, with_table_doc_path): + """测试带表格的 DOC 文件解析。""" + content, error = libreoffice.parse(with_table_doc_path) + if content is not None: + assert content.strip() != "" + + def test_file_not_exists(self, tmp_path): + """测试文件不存在的情况。""" + non_existent_file = str(tmp_path / "non_existent.doc") + content, error = libreoffice.parse(non_existent_file) + assert content is None + assert error is not None