From a490b2642cd79a4b01fa0ed93aa8d6827ba2c6ab Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Mon, 16 Mar 2026 22:49:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20PPT=20=E6=97=A7?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=94=AF=E6=8C=81=EF=BC=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20LibreOffice=20=E8=BD=AC=E6=8D=A2=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PPT (旧格式) 解析器 - 重构 _utils.py,提取通用 convert_via_libreoffice 函数 - 更新依赖配置,添加 PPT 相关依赖 - 完善文档,更新 README 和 SKILL.md - 添加 PPT 文件检测函数 - 新增 PPT 解析器测试用例 --- README.md | 3 +- SKILL.md | 7 +- openspec/specs/ppt-reader/spec.md | 58 ++++++++ openspec/specs/reader-internal-utils/spec.md | 34 +++++ scripts/config.py | 25 ++++ scripts/readers/__init__.py | 3 + scripts/readers/_utils.py | 125 +++++++++++------- scripts/readers/ppt/__init__.py | 46 +++++++ scripts/readers/ppt/libreoffice.py | 37 ++++++ scripts/utils/__init__.py | 2 + scripts/utils/file_detection.py | 5 + tests/test_readers/test_ppt/__init__.py | 1 + .../test_readers/test_ppt/test_consistency.py | 25 ++++ .../test_readers/test_ppt/test_libreoffice.py | 35 +++++ 14 files changed, 355 insertions(+), 51 deletions(-) create mode 100644 openspec/specs/ppt-reader/spec.md create mode 100644 scripts/readers/ppt/__init__.py create mode 100644 scripts/readers/ppt/libreoffice.py create mode 100644 tests/test_readers/test_ppt/__init__.py create mode 100644 tests/test_readers/test_ppt/test_consistency.py create mode 100644 tests/test_readers/test_ppt/test_libreoffice.py diff --git a/README.md b/README.md index e2fd2d3..6fbd803 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # lyxy-document -统一文档解析工具 - 将 DOC、DOCX、XLS、XLSX、PPTX、PDF、HTML/URL 转换为 Markdown +统一文档解析工具 - 将 DOC、DOCX、XLS、XLSX、PPT、PPTX、PDF、HTML/URL 转换为 Markdown ## 项目概述 @@ -30,6 +30,7 @@ scripts/ │ ├── docx/ # DOCX 解析器 │ ├── xls/ # XLS 解析器(旧格式) │ ├── xlsx/ # XLSX 解析器 +│ ├── ppt/ # PPT 解析器(旧格式) │ ├── pptx/ # PPTX 解析器 │ ├── pdf/ # PDF 解析器 │ └── html/ # HTML/URL 解析器 diff --git a/SKILL.md b/SKILL.md index df09b8f..7764511 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: lyxy-document-reader -description: 统一文档解析工具 - 将 DOC、DOCX、XLS、XLSX、PPTX、PDF、HTML/URL 转换为 Markdown。支持全文输出、字数统计、行数统计、标题提取、章节提取、正则搜索。当用户要求"读取/解析/打开文档"、上传 .doc/.docx/.xls/.xlsx/.pptx/.pdf/.html 文件、或提供 URL 时使用。 +description: 统一文档解析工具 - 将 DOC、DOCX、XLS、XLSX、PPT、PPTX、PDF、HTML/URL 转换为 Markdown。支持全文输出、字数统计、行数统计、标题提取、章节提取、正则搜索。当用户要求"读取/解析/打开文档"、上传 .doc/.docx/.xls/.xlsx/.ppt/.pptx/.pdf/.html 文件、或提供 URL 时使用。 license: MIT compatibility: Requires Python 3.11+。优先使用 lyxy-runner-python skill,次选 uv run --with,降级到主机 Python。 --- @@ -30,6 +30,7 @@ python scripts/lyxy_document_reader.py <文件路径或URL> - DOCX(Word 文档) - XLS(Excel 旧格式) - XLSX(Excel 表格) +- PPT(PowerPoint 旧格式) - PPTX(PowerPoint 演示文稿) - PDF(PDF 文档,支持 OCR) - HTML / URL(网页内容) @@ -45,8 +46,8 @@ python scripts/lyxy_document_reader.py <文件路径或URL> ### 触发词 - 中文:"读取/解析/打开 文档/Word/Excel/PPT/PDF/网页" -- 英文:"read/parse/extract document/doc/docx/xls/xlsx/pptx/pdf/html" -- 文件扩展名:`.doc`、`.docx`、`.xls`、`.xlsx`、`.pptx`、`.pdf`、`.html`、`.htm` +- 英文:"read/parse/extract document/doc/docx/xls/xlsx/ppt/pptx/pdf/html" +- 文件扩展名:`.doc`、`.docx`、`.xls`、`.xlsx`、`.ppt`、`.pptx`、`.pdf`、`.html`、`.htm` - URL:`http://`、`https://` ## Quick Reference diff --git a/openspec/specs/ppt-reader/spec.md b/openspec/specs/ppt-reader/spec.md new file mode 100644 index 0000000..71ddbda --- /dev/null +++ b/openspec/specs/ppt-reader/spec.md @@ -0,0 +1,58 @@ +## Purpose + +PPT 文档解析能力,支持解析 Microsoft PowerPoint 97-2003 旧格式文档。 + +## Requirements + +### Requirement: PPT 文档解析 +系统 SHALL 支持解析 .ppt 格式文档,使用 LibreOffice 解析器。 + +#### Scenario: 使用 LibreOffice 解析器 +- **WHEN** 解析 PPT 文档 +- **THEN** 系统使用 LibreOffice soffice 将 PPT 转换为 PPTX +- **AND** 复用 PptxReader 解析转换后的 PPTX + +#### Scenario: 成功解析 +- **WHEN** 解析器成功 +- **THEN** 系统返回解析结果 + +#### Scenario: 解析器失败 +- **WHEN** 解析器失败 +- **THEN** 系统返回失败列表并退出非零状态码 + +### Requirement: LibreOffice 解析器 +系统 SHALL 支持使用 LibreOffice soffice 命令行解析 PPT。 + +#### Scenario: LibreOffice 解析成功 +- **WHEN** soffice 可用且文档有效 +- **THEN** 系统返回 Markdown 内容 + +#### Scenario: LibreOffice 未安装 +- **WHEN** soffice 未在 PATH 中 +- **THEN** 系统返回失败信息 + +#### Scenario: LibreOffice 转换超时 +- **WHEN** soffice 执行超过 60 秒 +- **THEN** 系统返回超时错误 + +#### Scenario: LibreOffice 转换失败 +- **WHEN** soffice 返回非零退出码 +- **THEN** 系统返回失败信息 + +#### Scenario: 临时文件自动清理 +- **WHEN** 解析完成(无论成功或失败) +- **THEN** 转换过程中生成的临时 PPTX 文件被自动清理 + +### Requirement: 解析器独立文件 +系统 SHALL 将解析器实现为独立的单文件模块。 + +#### Scenario: LibreOffice 解析器在独立文件 +- **WHEN** 使用 LibreOffice 解析器 +- **THEN** 从 readers/ppt/libreoffice.py 导入 + +### Requirement: PPT Reader 测试使用静态文件 +PPT Reader 测试 MUST 使用 `tests/test_readers/fixtures/ppt/` 下的静态文件。 + +#### Scenario: 测试使用 simple.ppt +- **WHEN** 测试 PPT Reader 基础解析能力 +- **THEN** 使用 `simple.ppt` 静态文件 diff --git a/openspec/specs/reader-internal-utils/spec.md b/openspec/specs/reader-internal-utils/spec.md index 348549e..728d944 100644 --- a/openspec/specs/reader-internal-utils/spec.md +++ b/openspec/specs/reader-internal-utils/spec.md @@ -93,3 +93,37 @@ #### Scenario: 匹配页码 - **WHEN** 文本匹配 `_UNSTRUCTURED_PAGE_NUMBER_PATTERN`(如 "— 3 —") - **THEN** 系统将其识别为噪声并过滤 + +### Requirement: 通用 LibreOffice 格式转换 +系统 SHALL 提供通用的 LibreOffice 格式转换函数,支持在不同格式间转换。 + +#### Scenario: 转换文件到指定格式 +- **WHEN** 调用 `convert_via_libreoffice(input_path, target_format, output_dir)` +- **THEN** 系统使用 soffice --headless --convert-to 进行转换 +- **AND** 输出文件写入 output_dir +- **AND** 成功时返回 (output_path, None) +- **AND** 失败时返回 (None, error_message) + +#### Scenario: LibreOffice 未安装 +- **WHEN** soffice 未在 PATH 中 +- **THEN** 系统返回 (None, "LibreOffice 未安装") + +#### Scenario: 转换超时 +- **WHEN** soffice 执行超过 timeout 秒(默认 60 秒) +- **THEN** 系统返回 (None, "LibreOffice 转换超时") + +#### Scenario: 转换失败 +- **WHEN** soffice 返回非零退出码 +- **THEN** 系统返回 (None, "LibreOffice 转换失败 (code: {code})") + +#### Scenario: 输出文件未生成 +- **WHEN** soffice 执行成功但未生成输出文件 +- **THEN** 系统返回 (None, "LibreOffice 未生成输出文件") + +#### Scenario: 可自定义输出后缀 +- **WHEN** 提供 output_suffix 参数 +- **THEN** 系统使用该后缀作为输出文件后缀,而不是 target_format + +#### Scenario: 调用者管理输出目录生命周期 +- **WHEN** convert_via_libreoffice 执行完成 +- **THEN** 输出文件保留在 output_dir 中,由调用者负责清理 diff --git a/scripts/config.py b/scripts/config.py index 0734fc5..aa835e6 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -116,5 +116,30 @@ DEPENDENCIES = { "python": None, "dependencies": [] } + }, + "ppt": { + "default": { + "python": None, + "dependencies": [ + "docling", + "unstructured[pptx]", + "markitdown[pptx]", + "python-pptx", + "markdownify", + "olefile" + ] + }, + "Darwin-x86_64": { + "python": "3.12", + "dependencies": [ + "docling==2.40.0", + "docling-parse==4.0.0", + "numpy<2", + "markitdown[pptx]", + "python-pptx", + "markdownify", + "olefile" + ] + } } } diff --git a/scripts/readers/__init__.py b/scripts/readers/__init__.py index e43de0f..4bbee5e 100644 --- a/scripts/readers/__init__.py +++ b/scripts/readers/__init__.py @@ -8,6 +8,7 @@ from .pptx import PptxReader from .pdf import PdfReader from .html import HtmlReader from .xls import XlsReader +from .ppt import PptReader READERS = [ DocxReader, @@ -17,6 +18,7 @@ READERS = [ PdfReader, HtmlReader, XlsReader, + PptReader, ] __all__ = [ @@ -28,5 +30,6 @@ __all__ = [ "PdfReader", "HtmlReader", "XlsReader", + "PptReader", "READERS", ] diff --git a/scripts/readers/_utils.py b/scripts/readers/_utils.py index 29e5f93..6573953 100644 --- a/scripts/readers/_utils.py +++ b/scripts/readers/_utils.py @@ -66,6 +66,75 @@ def parse_via_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]: return None, f"docling 解析失败: {str(e)}" +def convert_via_libreoffice( + input_path: str, + target_format: str, + output_dir: Path, + output_suffix: Optional[str] = None, + timeout: int = 60 +) -> Tuple[Optional[Path], Optional[str]]: + """使用 LibreOffice soffice 命令行转换文件格式。 + + Args: + input_path: 输入文件路径 + target_format: 目标格式(如 "md", "pptx") + output_dir: 输出目录(调用者负责生命周期管理) + output_suffix: 可选,输出文件后缀(不指定则使用 target_format) + timeout: 超时时间(秒) + + Returns: + (output_path, error_message): 成功时 (Path, None),失败时 (None, error) + """ + # 检测 soffice 是否在 PATH 中 + soffice_path = shutil.which("soffice") + if not soffice_path: + return None, "LibreOffice 未安装" + + input_file = Path(input_path) + suffix = output_suffix if output_suffix else target_format + expected_output = output_dir / (input_file.stem + "." + suffix) + + # 构建命令 + cmd = [ + soffice_path, + "--headless", + "--convert-to", target_format, + "--outdir", str(output_dir), + str(input_file) + ] + + # 执行命令 + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout + ) + except subprocess.TimeoutExpired: + return None, f"LibreOffice 转换超时 ({timeout}秒)" + + # 检查返回码 + if result.returncode != 0: + return None, f"LibreOffice 转换失败 (code: {result.returncode})" + + # 检查输出文件是否存在 + output_file = None + if expected_output.exists(): + output_file = expected_output + else: + # Fallback: 遍历目录找任意匹配后缀的文件 + pattern = "*." + suffix + files = list(output_dir.glob(pattern)) + if files: + output_file = files[0] + + if not output_file: + return None, "LibreOffice 未生成输出文件" + + return output_file, None + + def parse_via_libreoffice(file_path: str) -> Tuple[Optional[str], Optional[str]]: """使用 LibreOffice soffice 命令行转换文件为 Markdown。 @@ -77,56 +146,18 @@ def parse_via_libreoffice(file_path: str) -> Tuple[Optional[str], Optional[str]] 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 未生成输出文件" + output_path, error = convert_via_libreoffice( + input_path=file_path, + target_format="md", + output_dir=Path(temp_dir), + timeout=60 + ) + if error: + return None, error # 读取输出内容 - content = output_file.read_text(encoding="utf-8", errors="replace") + content = output_path.read_text(encoding="utf-8", errors="replace") content = content.strip() if not content: diff --git a/scripts/readers/ppt/__init__.py b/scripts/readers/ppt/__init__.py new file mode 100644 index 0000000..406fccc --- /dev/null +++ b/scripts/readers/ppt/__init__.py @@ -0,0 +1,46 @@ +"""PPT 文件阅读器,使用 LibreOffice 解析。""" + +import os +from typing import List, Optional, Tuple + +from readers.base import BaseReader +from utils import is_valid_ppt + +from . import libreoffice + + +PARSERS = [ + ("LibreOffice", libreoffice.parse), +] + + +class PptReader(BaseReader): + """PPT 文件阅读器""" + + def supports(self, file_path: str) -> bool: + return file_path.lower().endswith('.ppt') + + 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_ppt(file_path): + return None, ["不是有效的 PPT 文件"] + + 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/ppt/libreoffice.py b/scripts/readers/ppt/libreoffice.py new file mode 100644 index 0000000..eab97aa --- /dev/null +++ b/scripts/readers/ppt/libreoffice.py @@ -0,0 +1,37 @@ +"""使用 LibreOffice soffice 命令行转换 PPT 为 PPTX 后复用 PptxReader 解析""" + +import tempfile +from pathlib import Path +from typing import Optional, Tuple + +from readers._utils import convert_via_libreoffice +from readers.pptx import PptxReader + + +def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]: + """使用 LibreOffice soffice 解析 PPT 文件 + + Args: + file_path: PPT 文件路径 + + Returns: + (markdown_content, error_message): 成功时 (content, None),失败时 (None, error) + """ + with tempfile.TemporaryDirectory() as temp_dir: + # 将 PPT 转换为 PPTX + pptx_path, error = convert_via_libreoffice( + input_path=file_path, + target_format="pptx", + output_dir=Path(temp_dir), + timeout=60 + ) + if error: + return None, error + + # 复用 PptxReader 解析转换后的 PPTX + reader = PptxReader() + content, failures = reader.parse(str(pptx_path)) + if content is not None: + return content, None + else: + return None, f"转换成功但 PPTX 解析失败: {failures}" diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py index 05519ab..46b94a3 100644 --- a/scripts/utils/__init__.py +++ b/scripts/utils/__init__.py @@ -7,6 +7,7 @@ from .file_detection import ( is_valid_xlsx, is_valid_pdf, is_valid_xls, + is_valid_ppt, is_html_file, is_url, ) @@ -18,6 +19,7 @@ __all__ = [ "is_valid_xlsx", "is_valid_pdf", "is_valid_xls", + "is_valid_ppt", "is_html_file", "is_url", ] diff --git a/scripts/utils/file_detection.py b/scripts/utils/file_detection.py index 58b2f3e..55e8603 100644 --- a/scripts/utils/file_detection.py +++ b/scripts/utils/file_detection.py @@ -58,6 +58,11 @@ def is_valid_doc(file_path: str) -> bool: return _is_valid_ole(file_path) +def is_valid_ppt(file_path: str) -> bool: + """验证文件是否为有效的 PPT 格式(OLE2)""" + return _is_valid_ole(file_path) + + def is_valid_pdf(file_path: str) -> bool: """验证文件是否为有效的 PDF 格式""" try: diff --git a/tests/test_readers/test_ppt/__init__.py b/tests/test_readers/test_ppt/__init__.py new file mode 100644 index 0000000..b1485ed --- /dev/null +++ b/tests/test_readers/test_ppt/__init__.py @@ -0,0 +1 @@ +"""Tests for PPT readers.""" diff --git a/tests/test_readers/test_ppt/test_consistency.py b/tests/test_readers/test_ppt/test_consistency.py new file mode 100644 index 0000000..61bc0b7 --- /dev/null +++ b/tests/test_readers/test_ppt/test_consistency.py @@ -0,0 +1,25 @@ +"""测试所有 PPT Readers 的一致性。""" + +import pytest +from readers.ppt import libreoffice + + +class TestPptReadersConsistency: + """验证所有 PPT 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_ppt(self, simple_ppt_path): + """测试 LibreOffice 解析简单文件。""" + content, error = libreoffice.parse(simple_ppt_path) + # LibreOffice 可能未安装,所以不强制断言成功 + if content is not None: + assert content.strip() != "" diff --git a/tests/test_readers/test_ppt/test_libreoffice.py b/tests/test_readers/test_ppt/test_libreoffice.py new file mode 100644 index 0000000..bdf5f6c --- /dev/null +++ b/tests/test_readers/test_ppt/test_libreoffice.py @@ -0,0 +1,35 @@ +"""测试 LibreOffice PPT Reader 的解析功能。""" + +import pytest +import os +from readers.ppt import libreoffice + + +class TestLibreOfficePptReaderParse: + """测试 LibreOffice PPT Reader 的 parse 方法。""" + + def test_simple_ppt(self, simple_ppt_path): + """测试简单 PPT 文件解析。""" + content, error = libreoffice.parse(simple_ppt_path) + if content is not None: + # 至少能解析出一些内容 + assert content.strip() != "" + + def test_multiple_slides_ppt(self, multiple_slides_ppt_path): + """测试多幻灯片 PPT 文件解析。""" + content, error = libreoffice.parse(multiple_slides_ppt_path) + if content is not None: + assert content.strip() != "" + + def test_with_images_ppt(self, with_images_ppt_path): + """测试带图片的 PPT 文件解析。""" + content, error = libreoffice.parse(with_images_ppt_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.ppt") + content, error = libreoffice.parse(non_existent_file) + assert content is None + assert error is not None