## 功能特性 - 建立统一的项目结构,包含 core/、readers/、utils/、tests/ 模块 - 迁移 lyxy-reader-office 的所有解析器(docx、xlsx、pptx、pdf) - 迁移 lyxy-reader-html 的所有解析器(html、url 下载) - 统一 CLI 入口为 lyxy_document_reader.py - 统一 Markdown 后处理逻辑 - 按文件类型组织 readers,每个解析器独立文件 - 依赖分组按文件类型细分(docx、xlsx、pptx、pdf、html、http) - PDF OCR 解析器优先,无参数控制 - 使用 logging 模块替代简单 print - 设计完整的单元测试结构 - 重写项目文档 ## 新增目录/文件 - core/ - 核心模块(异常体系、Markdown 工具、解析调度器) - readers/ - 格式阅读器(base.py + docx/xlsx/pptx/pdf/html) - utils/ - 工具函数(文件类型检测) - tests/ - 测试(conftest.py + test_core/ + test_readers/ + test_utils/) - lyxy_document_reader.py - 统一 CLI 入口 ## 依赖分组 - docx - DOCX 文档解析支持 - xlsx - XLSX 文档解析支持 - pptx - PPTX 文档解析支持 - pdf - PDF 文档解析支持(含 OCR) - html - HTML/URL 解析支持 - http - HTTP/URL 下载支持 - office - Office 格式组合(docx/xlsx/pptx/pdf) - web - Web 格式组合(html/http) - full - 完整功能 - dev - 开发依赖
171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
"""使用 XML 原生解析 PPTX 文件"""
|
|
|
|
import re
|
|
import xml.etree.ElementTree as ET
|
|
import zipfile
|
|
from typing import Any, List, Optional, Tuple
|
|
|
|
from core import build_markdown_table, flush_list_stack
|
|
|
|
|
|
def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
|
"""使用 XML 原生解析 PPTX 文件"""
|
|
pptx_namespace = {
|
|
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
|
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
|
|
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
}
|
|
|
|
def extract_text_with_formatting(text_elem: Any, namespaces: dict) -> str:
|
|
result = []
|
|
runs = text_elem.findall(".//a:r", namespaces=namespaces)
|
|
for run in runs:
|
|
t_elem = run.find(".//a:t", namespaces=namespaces)
|
|
if t_elem is None or not t_elem.text:
|
|
continue
|
|
|
|
text = t_elem.text
|
|
|
|
rPr = run.find(".//a:rPr", namespaces=namespaces)
|
|
is_bold = False
|
|
is_italic = False
|
|
|
|
if rPr is not None:
|
|
is_bold = rPr.find(".//a:b", namespaces=namespaces) is not None
|
|
is_italic = rPr.find(".//a:i", namespaces=namespaces) is not None
|
|
|
|
if is_bold and is_italic:
|
|
text = f"***{text}***"
|
|
elif is_bold:
|
|
text = f"**{text}**"
|
|
elif is_italic:
|
|
text = f"*{text}*"
|
|
|
|
result.append(text)
|
|
|
|
return "".join(result).strip() if result else ""
|
|
|
|
def convert_table_to_md(table_elem: Any, namespaces: dict) -> str:
|
|
rows = table_elem.findall(".//a:tr", namespaces=namespaces)
|
|
if not rows:
|
|
return ""
|
|
|
|
rows_data = []
|
|
for row in rows:
|
|
cells = row.findall(".//a:tc", namespaces=namespaces)
|
|
row_data = []
|
|
for cell in cells:
|
|
cell_text = extract_text_with_formatting(cell, namespaces)
|
|
if cell_text:
|
|
cell_text = cell_text.replace("\n", " ").replace("\r", "")
|
|
row_data.append(cell_text if cell_text else "")
|
|
rows_data.append(row_data)
|
|
return build_markdown_table(rows_data)
|
|
|
|
def is_list_item(p_elem: Any, namespaces: dict) -> Tuple[bool, bool]:
|
|
if p_elem is None:
|
|
return False, False
|
|
|
|
pPr = p_elem.find(".//a:pPr", namespaces=namespaces)
|
|
if pPr is None:
|
|
return False, False
|
|
|
|
buChar = pPr.find(".//a:buChar", namespaces=namespaces)
|
|
if buChar is not None:
|
|
return True, False
|
|
|
|
buAutoNum = pPr.find(".//a:buAutoNum", namespaces=namespaces)
|
|
if buAutoNum is not None:
|
|
return True, True
|
|
|
|
return False, False
|
|
|
|
def get_indent_level(p_elem: Any, namespaces: dict) -> int:
|
|
if p_elem is None:
|
|
return 0
|
|
|
|
pPr = p_elem.find(".//a:pPr", namespaces=namespaces)
|
|
if pPr is None:
|
|
return 0
|
|
|
|
lvl = pPr.get("lvl")
|
|
return int(lvl) if lvl else 0
|
|
|
|
try:
|
|
md_content = []
|
|
|
|
with zipfile.ZipFile(file_path) as zip_file:
|
|
slide_files = [
|
|
f
|
|
for f in zip_file.namelist()
|
|
if re.match(r"ppt/slides/slide\d+\.xml$", f)
|
|
]
|
|
slide_files.sort(
|
|
key=lambda f: int(re.search(r"slide(\d+)\.xml$", f).group(1))
|
|
)
|
|
|
|
for slide_idx, slide_file in enumerate(slide_files, 1):
|
|
md_content.append("\n## Slide {}\n".format(slide_idx))
|
|
|
|
with zip_file.open(slide_file) as slide_xml:
|
|
slide_root = ET.parse(slide_xml).getroot()
|
|
|
|
tx_bodies = slide_root.findall(
|
|
".//p:sp/p:txBody", namespaces=pptx_namespace
|
|
)
|
|
|
|
tables = slide_root.findall(".//a:tbl", namespaces=pptx_namespace)
|
|
for table in tables:
|
|
table_md = convert_table_to_md(table, pptx_namespace)
|
|
if table_md:
|
|
md_content.append(table_md)
|
|
|
|
for tx_body in tx_bodies:
|
|
paragraphs = tx_body.findall(
|
|
".//a:p", namespaces=pptx_namespace
|
|
)
|
|
list_stack = []
|
|
|
|
for para in paragraphs:
|
|
is_list, is_ordered = is_list_item(para, pptx_namespace)
|
|
|
|
if is_list:
|
|
level = get_indent_level(para, pptx_namespace)
|
|
|
|
while len(list_stack) <= level:
|
|
list_stack.append("")
|
|
|
|
text = extract_text_with_formatting(
|
|
para, pptx_namespace
|
|
)
|
|
if text:
|
|
marker = "1. " if is_ordered else "- "
|
|
indent = " " * level
|
|
list_stack[level] = f"{indent}{marker}{text}"
|
|
|
|
for i in range(len(list_stack)):
|
|
if list_stack[i]:
|
|
md_content.append(list_stack[i] + "\n")
|
|
list_stack[i] = ""
|
|
else:
|
|
if list_stack:
|
|
flush_list_stack(list_stack, md_content)
|
|
|
|
text = extract_text_with_formatting(
|
|
para, pptx_namespace
|
|
)
|
|
if text:
|
|
md_content.append(f"{text}\n")
|
|
|
|
if list_stack:
|
|
flush_list_stack(list_stack, md_content)
|
|
|
|
md_content.append("---\n")
|
|
|
|
content = "\n".join(md_content)
|
|
if not content.strip():
|
|
return None, "文档为空"
|
|
return content, None
|
|
except Exception as e:
|
|
return None, f"XML 解析失败: {str(e)}"
|