205 lines
8.1 KiB
Python
205 lines
8.1 KiB
Python
from typing import List, Optional, Union, Dict, Any
|
||
from pathlib import Path
|
||
from langchain_core.documents import Document
|
||
from langchain_community.document_loaders.base import BaseLoader
|
||
from docx import Document as DocxDocument
|
||
import os
|
||
import subprocess
|
||
import logging
|
||
import zipfile
|
||
from lxml import etree as ET
|
||
import re
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class GCYWordLoader(BaseLoader):
|
||
"""用于加载和解析 Word 文档的自定义加载器,支持标题层级结构解析及XML级别内容(段落、表格、页眉页脚、批注、修订、目录)。"""
|
||
|
||
# WordprocessingML命名空间
|
||
ns = {
|
||
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||
}
|
||
|
||
def __init__(
|
||
self,
|
||
file_path: Union[str, Path],
|
||
output_dir: Optional[Union[str, Path]] = None,
|
||
*,
|
||
keep_doc_title: bool = True,
|
||
start_with_title: bool = False,
|
||
max_heading_level: int = 3,
|
||
metadata: Optional[Dict[str, Any]] = None
|
||
):
|
||
self.file_path = str(file_path)
|
||
self.output_dir = str(output_dir) if output_dir else os.path.dirname(self.file_path)
|
||
self.keep_doc_title = keep_doc_title
|
||
self.start_with_title = start_with_title
|
||
self.max_heading_level = min(max(1, max_heading_level), 6)
|
||
self.metadata = metadata or {}
|
||
|
||
# 临时解压目录
|
||
self._work_dir = os.path.join(self.output_dir, '_pyc_work')
|
||
self._doc_dir = os.path.join(self._work_dir, 'word')
|
||
|
||
# 验证
|
||
if not os.path.isfile(self.file_path):
|
||
raise FileNotFoundError(f"文件不存在: {self.file_path}")
|
||
if not self.file_path.lower().endswith(('.doc', '.docx')):
|
||
raise ValueError("仅支持 .doc 或 .docx 格式")
|
||
|
||
def load(self) -> List[Document]:
|
||
try:
|
||
# 预处理(.doc 转 .docx)
|
||
processed_path = self._preprocess_document()
|
||
|
||
# 准备工作目录并解压
|
||
self._prepare_work_directories()
|
||
self._extract_docx(processed_path)
|
||
|
||
# 解析主文档 XML
|
||
self._parse_document_xml()
|
||
|
||
# 构建文档片段列表
|
||
docs: List[Document] = []
|
||
base_meta = {"source": self.file_path, "file_name": os.path.basename(self.file_path), **self.metadata}
|
||
|
||
# 支持保留文档标题
|
||
docx_core = DocxDocument(processed_path)
|
||
if self.keep_doc_title and docx_core.core_properties.title:
|
||
title = docx_core.core_properties.title
|
||
if self.start_with_title:
|
||
docs.append(Document(page_content=title, metadata={**base_meta, "heading": "Document Title"}))
|
||
else:
|
||
docs.append(Document(page_content=title, metadata={**base_meta, "heading": None}))
|
||
|
||
# 段落
|
||
docs.extend(self._parse_paragraphs(base_meta))
|
||
# 表格
|
||
docs.extend(self._parse_tables(base_meta))
|
||
# 页眉与页脚
|
||
docs.extend(self._parse_headers_and_footers(base_meta))
|
||
# 批注
|
||
docs.extend(self._parse_comments(base_meta))
|
||
# 修订
|
||
docs.extend(self._parse_revisions(base_meta))
|
||
# 目录项
|
||
docs.extend(self._parse_toc(base_meta))
|
||
|
||
return docs
|
||
except Exception as e:
|
||
logger.error(f"文档加载失败: {self.file_path}", exc_info=True)
|
||
raise RuntimeError(f"无法加载文档: {e}")
|
||
finally:
|
||
# 清理临时
|
||
if os.path.exists(self._work_dir):
|
||
try:
|
||
import shutil; shutil.rmtree(self._work_dir)
|
||
except:
|
||
pass
|
||
|
||
def _preprocess_document(self) -> str:
|
||
# .docx 直接返回
|
||
if self.file_path.lower().endswith('.docx'):
|
||
return self.file_path
|
||
# .doc 转 .docx
|
||
output_path = os.path.join(self.output_dir, Path(self.file_path).stem + '.docx')
|
||
subprocess.run([
|
||
'soffice', '--headless', '--convert-to', 'docx', '--outdir', self.output_dir, self.file_path
|
||
], check=True, capture_output=True)
|
||
if not os.path.exists(output_path):
|
||
raise RuntimeError('文档格式转换失败')
|
||
return output_path
|
||
|
||
def _prepare_work_directories(self):
|
||
if os.path.exists(self._work_dir):
|
||
import shutil; shutil.rmtree(self._work_dir)
|
||
os.makedirs(self._doc_dir, exist_ok=True)
|
||
|
||
def _extract_docx(self, path: str):
|
||
with zipfile.ZipFile(path, 'r') as z:
|
||
z.extractall(self._work_dir)
|
||
|
||
def _parse_document_xml(self):
|
||
xml_path = os.path.join(self._doc_dir, 'document.xml')
|
||
parser = ET.XMLParser(remove_blank_text=True)
|
||
self._doc_tree = ET.parse(xml_path, parser)
|
||
self._doc_root = self._doc_tree.getroot()
|
||
|
||
def _get_text_from_runs(self, parent) -> str:
|
||
parts = []
|
||
for r in parent.findall('.//w:r', self.ns):
|
||
for t in r.findall('w:t', self.ns):
|
||
if t.text:
|
||
parts.append(t.text)
|
||
return ''.join(parts).strip()
|
||
|
||
def _parse_paragraphs(self, base_meta) -> List[Document]:
|
||
docs = []
|
||
for p in self._doc_root.findall('.//w:p', self.ns):
|
||
text = self._get_text_from_runs(p)
|
||
if not text:
|
||
continue
|
||
docs.append(Document(page_content=text, metadata={**base_meta, 'content_type':'paragraph'}))
|
||
return docs
|
||
|
||
def _parse_tables(self, base_meta) -> List[Document]:
|
||
docs = []
|
||
for tbl in self._doc_root.findall('.//w:tbl', self.ns):
|
||
# 简单按行为 \n 分段
|
||
rows = []
|
||
for row in tbl.findall('.//w:tr', self.ns):
|
||
cells = []
|
||
for cell in row.findall('.//w:tc', self.ns):
|
||
cells.append(self._get_text_from_runs(cell))
|
||
rows.append('|'.join(cells))
|
||
content = '\n'.join(rows)
|
||
docs.append(Document(page_content=content, metadata={**base_meta, 'content_type':'table'}))
|
||
return docs
|
||
|
||
def _parse_headers_and_footers(self, base_meta) -> List[Document]:
|
||
docs = []
|
||
for part in ['header', 'footer']:
|
||
for i in range(1,10):
|
||
path = os.path.join(self._doc_dir, f'{part}{i}.xml')
|
||
if not os.path.exists(path):
|
||
continue
|
||
tree = ET.parse(path)
|
||
root = tree.getroot()
|
||
for p in root.findall('.//w:p', self.ns):
|
||
text = self._get_text_from_runs(p)
|
||
if text:
|
||
docs.append(Document(page_content=text, metadata={**base_meta, 'content_type':part}))
|
||
return docs
|
||
|
||
def _parse_comments(self, base_meta) -> List[Document]:
|
||
docs = []
|
||
path = os.path.join(self._doc_dir, 'comments.xml')
|
||
if os.path.exists(path):
|
||
tree = ET.parse(path)
|
||
root = tree.getroot()
|
||
for c in root.findall('.//w:comment', self.ns):
|
||
text = self._get_text_from_runs(c)
|
||
if text:
|
||
docs.append(Document(page_content=text, metadata={**base_meta, 'content_type':'comment'}))
|
||
return docs
|
||
|
||
def _parse_revisions(self, base_meta) -> List[Document]:
|
||
docs = []
|
||
for tag in ['del','ins']:
|
||
for el in self._doc_root.findall(f'.//w:{tag}', self.ns):
|
||
text = self._get_text_from_runs(el)
|
||
if text:
|
||
docs.append(Document(page_content=text, metadata={**base_meta, 'content_type':'revision'}))
|
||
return docs
|
||
|
||
def _parse_toc(self, base_meta) -> List[Document]:
|
||
docs = []
|
||
for p in self._doc_root.findall('.//w:p', self.ns):
|
||
full = ''.join(t.text or '' for t in p.findall('w:t', self.ns)).strip()
|
||
if len(full)>255: continue
|
||
if p.find('.//w:tab', self.ns) is not None and full and full[-1].isdigit():
|
||
# 简单拆分标题和页码
|
||
parts = re.split(r'\t+', full)
|
||
title = parts[0]
|
||
docs.append(Document(page_content=title, metadata={**base_meta,'content_type':'toc_entry'}))
|
||
return docs
|