From 18d46ca04a6d930303ced609c5874ed48b70e0e6 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 26 Nov 2025 18:14:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=96=87=E6=A1=A3=E8=A7=A3=E6=9E=90):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9EWord=E6=96=87=E6=A1=A3=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=94=AF=E6=8C=81=E5=B9=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=87=B3MinIO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增对Word文档中图片的解析支持,能够提取文档中的图片并上传至MinIO存储,同时生成包含图片链接的Markdown格式文本。已支持Docx格式的Word文档处理,完善了文档解析功能。 --- docs/latest/changelog/roadmap.md | 3 +- src/knowledge/indexing.py | 87 +++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/docs/latest/changelog/roadmap.md b/docs/latest/changelog/roadmap.md index b770fc2c..10cb51e8 100644 --- a/docs/latest/changelog/roadmap.md +++ b/docs/latest/changelog/roadmap.md @@ -11,7 +11,7 @@ - 开发与生产环境隔离,构建生产镜像 - 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息 - 集成 neo4j mcp (或者自己构建工具) -- 文档解析部分的 markdown 中的图片替换为内部可访问的链接 +- 文档解析部分的 markdown 中的图片替换为内部可访问的链接(还有word等) ### Bugs - 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279) @@ -28,6 +28,7 @@ - 新增基于知识库文件生成示例问题功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425)) - 新增知识库支持文件夹/压缩包上传的功能([#335](https://github.com/xerrors/Yuxi-Know/pull/335#issuecomment-3530976425)) - 新增自定义模型支持、新增 dashscope rerank/embeddings 模型的支持 +- 新增文档解析的图片支持,已支持 MinerU Officical、Docs、Markdown Zip格式 ### 修复 - 修复重排序模型实际未生效的问题 diff --git a/src/knowledge/indexing.py b/src/knowledge/indexing.py index 30f6e936..282bf573 100644 --- a/src/knowledge/indexing.py +++ b/src/knowledge/indexing.py @@ -2,6 +2,7 @@ import asyncio import os import re import zipfile +import xml.etree.ElementTree as ET from pathlib import Path from langchain_community.document_loaders import ( @@ -72,6 +73,85 @@ def _extract_word_text(file_path: Path) -> str: raise ValueError(f"无法解析 Word 文档: {file_path.name}") from unstructured_error +def _extract_docx_markdown_with_images(file_path: Path, params: dict | None = None) -> str: + params = params or {} + db_id = params.get("db_id") or "word-docs" + minio_client = get_minio_client() + bucket_name = "kb-images" + minio_client.ensure_bucket_exists(bucket_name) + file_id = hashstr(file_path.name, length=16) + + with zipfile.ZipFile(file_path, "r") as zf: + rels_path = "word/_rels/document.xml.rels" + rid_to_target: dict[str, str] = {} + try: + rels_xml = zf.read(rels_path).decode("utf-8") + rels_root = ET.fromstring(rels_xml) + for rel in list(rels_root): + rid = rel.attrib.get("Id") + target = rel.attrib.get("Target") + rtype = rel.attrib.get("Type") + if rid and target and rtype and rtype.endswith("/image"): + rid_to_target[rid] = target + except Exception: + rid_to_target = {} + + doc_xml = zf.read("word/document.xml").decode("utf-8") + ns = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + } + root = ET.fromstring(doc_xml) + md_lines: list[str] = [] + + for p in root.findall(".//w:p", ns): + texts = [t.text or "" for t in p.findall(".//w:t", ns)] + para_text = "".join(texts).strip() + image_urls: list[tuple[str, str]] = [] + + for blip in p.findall(".//a:blip", ns): + rid = blip.attrib.get(f"{{{ns['r']}}}embed") + if not rid: + continue + target = rid_to_target.get(rid) + if not target: + continue + media_path = target if target.startswith("word/") else f"word/{target}" + try: + data = zf.read(media_path) + object_name = f"{db_id}/{file_id}/images/{Path(target).name}" + suffix = Path(target).suffix.lower() + content_type = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + }.get(suffix, "image/jpeg") + result = minio_client.upload_file( + bucket_name=bucket_name, + object_name=object_name, + data=data, + content_type=content_type, + ) + image_urls.append((Path(target).name, result.url)) + except Exception as e: + logger.error(f"上传图片失败 {Path(target).name}: {e}") + continue + + line = para_text + for name, url in image_urls: + line = f"{line}\n![{name}]({url})" + if line: + md_lines.append(line) + + return "\n\n".join(md_lines) + + def chunk_with_parser(file_path, params=None): """ 使用文件解析器将文件切分成固定大小的块 @@ -294,8 +374,11 @@ async def process_file_to_markdown(file_path: str, params: dict | None = None) - content = f.read() return f"# {file_path_obj.name}\n\n{content}" - elif file_ext in [".doc", ".docx"]: - # 处理 Word 文档 + elif file_ext == ".docx": + text = _extract_docx_markdown_with_images(file_path_obj, params=params) + return f"# {file_path_obj.name}\n\n" + text + + elif file_ext == ".doc": text = _extract_word_text(file_path_obj) return f"# {file_path_obj.name}\n\n{text}"