refactor: 将核心代码迁移到 scripts 目录
- 创建 scripts/ 目录作为核心代码根目录 - 移动 core/, readers/, utils/ 到 scripts/ 下 - 移动 config.py, lyxy_document_reader.py 到 scripts/ - 移动 encoding_detection.py 到 scripts/utils/ - 更新 pyproject.toml 中的入口点路径和 pytest 配置 - 更新所有内部导入语句为 scripts.* 模块 - 更新 README.md 目录结构说明 - 更新 openspec/config.yaml 添加目录结构说明 - 删除无用的 main.py 此变更使项目结构更清晰,便于区分核心代码与测试、文档等支撑文件。
This commit is contained in:
55
scripts/readers/pptx/__init__.py
Normal file
55
scripts/readers/pptx/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""PPTX 文件阅读器,支持多种解析方法。"""
|
||||
|
||||
import os
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from scripts.readers.base import BaseReader
|
||||
from scripts.utils import is_valid_pptx
|
||||
|
||||
from . import docling
|
||||
from . import unstructured
|
||||
from . import markitdown
|
||||
from . import python_pptx
|
||||
from . import native_xml
|
||||
|
||||
|
||||
PARSERS = [
|
||||
("docling", docling.parse),
|
||||
("unstructured", unstructured.parse),
|
||||
("MarkItDown", markitdown.parse),
|
||||
("python-pptx", python_pptx.parse),
|
||||
("XML 原生解析", native_xml.parse),
|
||||
]
|
||||
|
||||
|
||||
class PptxReader(BaseReader):
|
||||
"""PPTX 文件阅读器"""
|
||||
|
||||
@property
|
||||
def supported_extensions(self) -> List[str]:
|
||||
return [".pptx"]
|
||||
|
||||
def supports(self, file_path: str) -> bool:
|
||||
return file_path.endswith('.pptx')
|
||||
|
||||
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_pptx(file_path):
|
||||
return None, ["不是有效的 PPTX 文件"]
|
||||
|
||||
content = None
|
||||
|
||||
for parser_name, parser_func in PARSERS:
|
||||
content, error = parser_func(file_path)
|
||||
if content is not None:
|
||||
return content, failures
|
||||
else:
|
||||
failures.append(f"- {parser_name}: {error}")
|
||||
|
||||
return None, failures
|
||||
10
scripts/readers/pptx/docling.py
Normal file
10
scripts/readers/pptx/docling.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""使用 docling 库解析 PPTX 文件"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from scripts.core import parse_with_docling
|
||||
|
||||
|
||||
def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 docling 库解析 PPTX 文件"""
|
||||
return parse_with_docling(file_path)
|
||||
10
scripts/readers/pptx/markitdown.py
Normal file
10
scripts/readers/pptx/markitdown.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""使用 MarkItDown 库解析 PPTX 文件"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from scripts.core import parse_with_markitdown
|
||||
|
||||
|
||||
def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 MarkItDown 库解析 PPTX 文件"""
|
||||
return parse_with_markitdown(file_path)
|
||||
170
scripts/readers/pptx/native_xml.py
Normal file
170
scripts/readers/pptx/native_xml.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""使用 XML 原生解析 PPTX 文件"""
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from scripts.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)}"
|
||||
127
scripts/readers/pptx/python_pptx.py
Normal file
127
scripts/readers/pptx/python_pptx.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""使用 python-pptx 库解析 PPTX 文件"""
|
||||
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from scripts.core import build_markdown_table, flush_list_stack
|
||||
|
||||
|
||||
def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 python-pptx 库解析 PPTX 文件"""
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
except ImportError:
|
||||
return None, "python-pptx 库未安装"
|
||||
|
||||
_A_NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
|
||||
|
||||
def extract_formatted_text(runs: List[Any]) -> str:
|
||||
"""从 PPTX 文本运行中提取带有格式的文本"""
|
||||
result = []
|
||||
for run in runs:
|
||||
if not run.text:
|
||||
continue
|
||||
|
||||
text = run.text
|
||||
|
||||
font = run.font
|
||||
is_bold = getattr(font, "bold", False) or False
|
||||
is_italic = getattr(font, "italic", False) or False
|
||||
|
||||
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()
|
||||
|
||||
def convert_table_to_md(table: Any) -> str:
|
||||
"""将 PPTX 表格转换为 Markdown 格式"""
|
||||
rows_data = []
|
||||
for row in table.rows:
|
||||
row_data = []
|
||||
for cell in row.cells:
|
||||
cell_content = []
|
||||
for para in cell.text_frame.paragraphs:
|
||||
text = extract_formatted_text(para.runs)
|
||||
if text:
|
||||
cell_content.append(text)
|
||||
cell_text = " ".join(cell_content).strip()
|
||||
row_data.append(cell_text if cell_text else "")
|
||||
rows_data.append(row_data)
|
||||
return build_markdown_table(rows_data)
|
||||
|
||||
try:
|
||||
prs = Presentation(file_path)
|
||||
md_content = []
|
||||
|
||||
for slide_num, slide in enumerate(prs.slides, 1):
|
||||
md_content.append(f"\n## Slide {slide_num}\n")
|
||||
|
||||
list_stack = []
|
||||
|
||||
for shape in slide.shapes:
|
||||
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
||||
continue
|
||||
|
||||
if hasattr(shape, "has_table") and shape.has_table:
|
||||
if list_stack:
|
||||
flush_list_stack(list_stack, md_content)
|
||||
|
||||
table_md = convert_table_to_md(shape.table)
|
||||
md_content.append(table_md)
|
||||
|
||||
if hasattr(shape, "text_frame"):
|
||||
for para in shape.text_frame.paragraphs:
|
||||
pPr = para._element.pPr
|
||||
is_list = False
|
||||
if pPr is not None:
|
||||
is_list = (
|
||||
para.level > 0
|
||||
or pPr.find(".//a:buChar", namespaces=_A_NS) is not None
|
||||
or pPr.find(".//a:buAutoNum", namespaces=_A_NS) is not None
|
||||
)
|
||||
|
||||
if is_list:
|
||||
level = para.level
|
||||
|
||||
while len(list_stack) <= level:
|
||||
list_stack.append("")
|
||||
|
||||
text = extract_formatted_text(para.runs)
|
||||
if text:
|
||||
is_ordered = (
|
||||
pPr is not None
|
||||
and pPr.find(".//a:buAutoNum", namespaces=_A_NS) is not None
|
||||
)
|
||||
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_formatted_text(para.runs)
|
||||
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"python-pptx 解析失败: {str(e)}"
|
||||
24
scripts/readers/pptx/unstructured.py
Normal file
24
scripts/readers/pptx/unstructured.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""使用 unstructured 库解析 PPTX 文件"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from scripts.core import _unstructured_elements_to_markdown
|
||||
|
||||
|
||||
def parse(file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""使用 unstructured 库解析 PPTX 文件"""
|
||||
try:
|
||||
from unstructured.partition.pptx import partition_pptx
|
||||
except ImportError:
|
||||
return None, "unstructured 库未安装"
|
||||
|
||||
try:
|
||||
elements = partition_pptx(
|
||||
filename=file_path, infer_table_structure=True, include_metadata=True
|
||||
)
|
||||
content = _unstructured_elements_to_markdown(elements)
|
||||
if not content.strip():
|
||||
return None, "文档为空"
|
||||
return content, None
|
||||
except Exception as e:
|
||||
return None, f"unstructured 解析失败: {str(e)}"
|
||||
Reference in New Issue
Block a user