feat: 新增 PPT 旧格式支持,重构 LibreOffice 转换工具

- 新增 PPT (旧格式) 解析器
- 重构 _utils.py,提取通用 convert_via_libreoffice 函数
- 更新依赖配置,添加 PPT 相关依赖
- 完善文档,更新 README 和 SKILL.md
- 添加 PPT 文件检测函数
- 新增 PPT 解析器测试用例
This commit is contained in:
2026-03-16 22:49:04 +08:00
parent 1306dd5971
commit a490b2642c
14 changed files with 355 additions and 51 deletions

View File

@@ -1,6 +1,6 @@
# lyxy-document # 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 解析器 │ ├── docx/ # DOCX 解析器
│ ├── xls/ # XLS 解析器(旧格式) │ ├── xls/ # XLS 解析器(旧格式)
│ ├── xlsx/ # XLSX 解析器 │ ├── xlsx/ # XLSX 解析器
│ ├── ppt/ # PPT 解析器(旧格式)
│ ├── pptx/ # PPTX 解析器 │ ├── pptx/ # PPTX 解析器
│ ├── pdf/ # PDF 解析器 │ ├── pdf/ # PDF 解析器
│ └── html/ # HTML/URL 解析器 │ └── html/ # HTML/URL 解析器

View File

@@ -1,6 +1,6 @@
--- ---
name: lyxy-document-reader 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 license: MIT
compatibility: Requires Python 3.11+。优先使用 lyxy-runner-python skill次选 uv run --with降级到主机 Python。 compatibility: Requires Python 3.11+。优先使用 lyxy-runner-python skill次选 uv run --with降级到主机 Python。
--- ---
@@ -30,6 +30,7 @@ python scripts/lyxy_document_reader.py <文件路径或URL>
- DOCXWord 文档) - DOCXWord 文档)
- XLSExcel 旧格式) - XLSExcel 旧格式)
- XLSXExcel 表格) - XLSXExcel 表格)
- PPTPowerPoint 旧格式)
- PPTXPowerPoint 演示文稿) - PPTXPowerPoint 演示文稿)
- PDFPDF 文档,支持 OCR - PDFPDF 文档,支持 OCR
- HTML / URL网页内容 - HTML / URL网页内容
@@ -45,8 +46,8 @@ python scripts/lyxy_document_reader.py <文件路径或URL>
### 触发词 ### 触发词
- 中文:"读取/解析/打开 文档/Word/Excel/PPT/PDF/网页" - 中文:"读取/解析/打开 文档/Word/Excel/PPT/PDF/网页"
- 英文:"read/parse/extract document/doc/docx/xls/xlsx/pptx/pdf/html" - 英文:"read/parse/extract document/doc/docx/xls/xlsx/ppt/pptx/pdf/html"
- 文件扩展名:`.doc``.docx``.xls``.xlsx``.pptx``.pdf``.html``.htm` - 文件扩展名:`.doc``.docx``.xls``.xlsx``.ppt``.pptx``.pdf``.html``.htm`
- URL`http://``https://` - URL`http://``https://`
## Quick Reference ## Quick Reference

View File

@@ -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` 静态文件

View File

@@ -93,3 +93,37 @@
#### Scenario: 匹配页码 #### Scenario: 匹配页码
- **WHEN** 文本匹配 `_UNSTRUCTURED_PAGE_NUMBER_PATTERN`(如 "— 3 —" - **WHEN** 文本匹配 `_UNSTRUCTURED_PAGE_NUMBER_PATTERN`(如 "— 3 —"
- **THEN** 系统将其识别为噪声并过滤 - **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 中,由调用者负责清理

View File

@@ -116,5 +116,30 @@ DEPENDENCIES = {
"python": None, "python": None,
"dependencies": [] "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"
]
}
} }
} }

View File

@@ -8,6 +8,7 @@ from .pptx import PptxReader
from .pdf import PdfReader from .pdf import PdfReader
from .html import HtmlReader from .html import HtmlReader
from .xls import XlsReader from .xls import XlsReader
from .ppt import PptReader
READERS = [ READERS = [
DocxReader, DocxReader,
@@ -17,6 +18,7 @@ READERS = [
PdfReader, PdfReader,
HtmlReader, HtmlReader,
XlsReader, XlsReader,
PptReader,
] ]
__all__ = [ __all__ = [
@@ -28,5 +30,6 @@ __all__ = [
"PdfReader", "PdfReader",
"HtmlReader", "HtmlReader",
"XlsReader", "XlsReader",
"PptReader",
"READERS", "READERS",
] ]

View File

@@ -66,6 +66,75 @@ def parse_via_docling(file_path: str) -> Tuple[Optional[str], Optional[str]]:
return None, f"docling 解析失败: {str(e)}" 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]]: def parse_via_libreoffice(file_path: str) -> Tuple[Optional[str], Optional[str]]:
"""使用 LibreOffice soffice 命令行转换文件为 Markdown。 """使用 LibreOffice soffice 命令行转换文件为 Markdown。
@@ -77,56 +146,18 @@ def parse_via_libreoffice(file_path: str) -> Tuple[Optional[str], Optional[str]]
Returns: Returns:
(markdown_content, error_message): 成功时 (content, None),失败时 (None, error) (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: with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir) output_path, error = convert_via_libreoffice(
input_path = Path(file_path) input_path=file_path,
expected_output = temp_path / (input_path.stem + ".md") target_format="md",
output_dir=Path(temp_dir),
# 构建命令
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 timeout=60
) )
except subprocess.TimeoutExpired: if error:
return None, "LibreOffice 转换超时 (60秒)" return None, error
# 检查返回码
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 = output_path.read_text(encoding="utf-8", errors="replace")
content = content.strip() content = content.strip()
if not content: if not content:

View File

@@ -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

View File

@@ -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}"

View File

@@ -7,6 +7,7 @@ from .file_detection import (
is_valid_xlsx, is_valid_xlsx,
is_valid_pdf, is_valid_pdf,
is_valid_xls, is_valid_xls,
is_valid_ppt,
is_html_file, is_html_file,
is_url, is_url,
) )
@@ -18,6 +19,7 @@ __all__ = [
"is_valid_xlsx", "is_valid_xlsx",
"is_valid_pdf", "is_valid_pdf",
"is_valid_xls", "is_valid_xls",
"is_valid_ppt",
"is_html_file", "is_html_file",
"is_url", "is_url",
] ]

View File

@@ -58,6 +58,11 @@ def is_valid_doc(file_path: str) -> bool:
return _is_valid_ole(file_path) 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: def is_valid_pdf(file_path: str) -> bool:
"""验证文件是否为有效的 PDF 格式""" """验证文件是否为有效的 PDF 格式"""
try: try:

View File

@@ -0,0 +1 @@
"""Tests for PPT readers."""

View File

@@ -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() != ""

View File

@@ -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