init the project
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# Vision LLM wrapper for analyzing document images (type + description).
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from server.core.llm_provider.router import router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROMPT_IMAGE = """请分析这张图片,判断类型并输出文字描述。
|
||||
|
||||
## 判断图片类型
|
||||
|
||||
如果是 **流程图 / 架构图 / 状态图 / 时序图 / 活动图**,详细描述:
|
||||
- 图中所有节点/步骤/状态/组件的名称
|
||||
- 所有连线/箭头/转换关系及其方向
|
||||
- 所有分支条件、判断逻辑和判断结果
|
||||
- 所有文字标注、注释、标签
|
||||
- 图的整体结构和逻辑流程
|
||||
- 如果图片包含多个子图,拆解描述
|
||||
|
||||
如果是 **其他类型**(UI原型图 / 界面截图 / 设计稿 / 手机屏幕截图 / 网页截图等),简要描述图片内容。
|
||||
|
||||
## 输出格式
|
||||
|
||||
**1. 类型标签(单独一行):**
|
||||
type: <flowchart|architecture|state|sequence|activity|other>
|
||||
|
||||
**2. 文字描述:**
|
||||
该图片的详细文字描述。
|
||||
|
||||
不要输出 ---YAML--- 分隔符或 YAML 内容,不要添加任何额外的解释或问候语。"""
|
||||
|
||||
|
||||
class ImageParser:
|
||||
"""Analyze document images using Qwen VL model.
|
||||
|
||||
Usage::
|
||||
|
||||
parser = ImageParser()
|
||||
result = parser.parse_image("images/flow.png")
|
||||
# {"type": "flowchart", "description": "..."}
|
||||
"""
|
||||
|
||||
_VALID_TYPES = {"flowchart", "architecture", "state", "sequence", "activity", "other"}
|
||||
|
||||
def __init__(self):
|
||||
self._llm = router.get_image_client()
|
||||
|
||||
@property
|
||||
def usage(self) -> dict:
|
||||
return self._llm.usage
|
||||
|
||||
def parse_image(self, image_path: str) -> Optional[dict]:
|
||||
"""Parse an image and return {type, description}."""
|
||||
logger.info("Parsing image: %s", image_path)
|
||||
|
||||
try:
|
||||
content = self._llm.chat_with_image(
|
||||
model=router.image_model,
|
||||
image_path=image_path,
|
||||
prompt=PROMPT_IMAGE,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Image parsing failed: %s", e)
|
||||
return {"type": "other", "description": "", "error": str(e)}
|
||||
|
||||
return self._parse_response(content)
|
||||
|
||||
def _parse_response(self, content: str) -> dict:
|
||||
"""Extract (type, description) from vision model response."""
|
||||
content = content.strip()
|
||||
parsed_type = "other"
|
||||
desc_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if (stripped.startswith("type:") or stripped.startswith("类型:")):
|
||||
type_val = stripped.split(":", 1)[1].strip().lower()
|
||||
if type_val in self._VALID_TYPES:
|
||||
parsed_type = type_val
|
||||
else:
|
||||
desc_lines.append(line)
|
||||
|
||||
return {"type": parsed_type, "description": "\n".join(desc_lines).strip()}
|
||||
@@ -0,0 +1,260 @@
|
||||
# PRD 管理服务:上传、解析、版本快照
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from server.config import settings
|
||||
from server.services.prd_manager.word_parser import WordParser
|
||||
from server.services.prd_manager.image_parser import ImageParser
|
||||
|
||||
logger = logging.getLogger("testflow")
|
||||
RATE_LIMIT_DELAY = 0.5
|
||||
|
||||
|
||||
class PRDService:
|
||||
"""PRD management: handle file upload, text extraction, and version snapshots."""
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = settings.OUTPUT_DIR
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
self._prds: dict[str, dict] = {}
|
||||
|
||||
async def upload_and_parse(self, filename: str, content: bytes) -> dict:
|
||||
prd_id = str(uuid.uuid4())[:8]
|
||||
basename = Path(filename).stem
|
||||
ext = Path(filename).suffix.lower()
|
||||
|
||||
upload_dir = self.output_dir / prd_id
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
filepath = upload_dir / filename
|
||||
|
||||
t0 = time.time()
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info("[UPLOAD] 收到文件: %s (%.1f KB, %s)", filename, len(content) / 1024, ext)
|
||||
|
||||
if ext in (".txt", ".md"):
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
sections = self._parse_markdown_sections(text)
|
||||
parsed = {
|
||||
"source": str(filepath), "sections": sections,
|
||||
"image_sources": {}, "image_analysis": [], "full_text": text,
|
||||
}
|
||||
logger.info("[PARSE] %.4s %s → %d 章节, %d 字符, %.1fs",
|
||||
prd_id, ext, len(sections), len(text), time.time() - t0)
|
||||
|
||||
elif ext == ".docx":
|
||||
logger.info("[PARSE] %.4s → 开始解析 .docx...", prd_id)
|
||||
parsed = await self._parse_docx(str(filepath), upload_dir)
|
||||
logger.info("[PARSE] %.4s .docx 完成: %d 章节, %.1fs",
|
||||
prd_id, len(parsed.get("sections", [])), time.time() - t0)
|
||||
|
||||
elif ext == ".pdf":
|
||||
logger.info("[PARSE] %.4s → 开始解析 .pdf...", prd_id)
|
||||
parsed = await self._parse_pdf(str(filepath), upload_dir)
|
||||
logger.info("[PARSE] %.4s .pdf 完成: %d 页, %.1fs",
|
||||
prd_id, len(parsed.get("sections", [])), time.time() - t0)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file format: {ext}")
|
||||
|
||||
parsed_path = upload_dir / f"{basename}_parsed.json"
|
||||
with open(parsed_path, "w", encoding="utf-8") as f:
|
||||
json.dump(parsed, f, ensure_ascii=False, indent=2)
|
||||
|
||||
prd_version = {
|
||||
"prd_id": prd_id, "filename": filename,
|
||||
"uploaded_at": datetime.now(timezone.utc).isoformat(), "status": "ready",
|
||||
"parsed_path": str(parsed_path), "full_text": parsed.get("full_text", ""),
|
||||
"sections": parsed.get("sections", []), "images": parsed.get("image_analysis", []),
|
||||
}
|
||||
self._prds[prd_id] = prd_version
|
||||
|
||||
n_paras = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "para")
|
||||
n_tables = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "table")
|
||||
logger.info("[UPLOAD] %.4s 解析完成: %d章节 %d段落 %d表格 %d图片 → %s",
|
||||
prd_id, len(parsed.get("sections", [])), n_paras, n_tables,
|
||||
len(parsed.get("image_analysis", [])), parsed_path)
|
||||
return prd_version
|
||||
|
||||
async def upload_quick(self, filename: str, content: bytes) -> dict:
|
||||
"""Quick save — returns immediately, parsing happens in background."""
|
||||
prd_id = str(uuid.uuid4())[:8]
|
||||
upload_dir = self.output_dir / prd_id
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
filepath = upload_dir / filename
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info("[UPLOAD] %.4s 快速保存: %s (%.1f KB)", prd_id, filename, len(content) / 1024)
|
||||
|
||||
prd_version = {
|
||||
"prd_id": prd_id, "filename": filename,
|
||||
"uploaded_at": datetime.now(timezone.utc).isoformat(),
|
||||
"status": "parsing", "full_text": "", "sections": [], "images": [],
|
||||
"parsed_path": "", "filepath": str(filepath),
|
||||
}
|
||||
self._prds[prd_id] = prd_version
|
||||
return prd_version
|
||||
|
||||
async def parse_async(self, prd_id: str, filename: str, content: bytes):
|
||||
"""Full parse (text + images + vision) — called in background thread."""
|
||||
basename = Path(filename).stem
|
||||
ext = Path(filename).suffix.lower()
|
||||
upload_dir = self.output_dir / prd_id
|
||||
filepath = upload_dir / filename
|
||||
t0 = time.time()
|
||||
|
||||
# Parse
|
||||
if ext in (".txt", ".md"):
|
||||
text = content.decode("utf-8", errors="replace")
|
||||
sections = self._parse_markdown_sections(text)
|
||||
parsed = {"source": str(filepath), "sections": sections,
|
||||
"image_sources": {}, "image_analysis": [], "full_text": text}
|
||||
elif ext == ".docx":
|
||||
parsed = await self._parse_docx(str(filepath), upload_dir)
|
||||
elif ext == ".pdf":
|
||||
parsed = await self._parse_pdf(str(filepath), upload_dir)
|
||||
else:
|
||||
self.set_status(prd_id, "error", f"Unsupported format: {ext}")
|
||||
return
|
||||
|
||||
# Save parsed
|
||||
parsed_path = upload_dir / f"{basename}_parsed.json"
|
||||
with open(parsed_path, "w", encoding="utf-8") as f:
|
||||
json.dump(parsed, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# Update in-memory
|
||||
prd = self._prds.get(prd_id, {})
|
||||
prd.update({
|
||||
"status": "ready", "parsed_path": str(parsed_path),
|
||||
"full_text": parsed.get("full_text", ""),
|
||||
"sections": parsed.get("sections", []),
|
||||
"images": parsed.get("image_analysis", []),
|
||||
})
|
||||
|
||||
n_paras = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "para")
|
||||
n_tables = sum(1 for s in parsed.get("sections", []) for b in s.get("blocks", []) if b["type"] == "table")
|
||||
logger.info("[UPLOAD] %.4s 后台解析完成: %d章节 %d段落 %d表格 %d图片, %.1fs",
|
||||
prd_id, len(parsed.get("sections", [])), n_paras, n_tables,
|
||||
len(parsed.get("image_analysis", [])), time.time() - t0)
|
||||
|
||||
def set_status(self, prd_id: str, status: str, error: str = ""):
|
||||
if prd_id in self._prds:
|
||||
self._prds[prd_id]["status"] = status
|
||||
if error:
|
||||
self._prds[prd_id]["error"] = error
|
||||
|
||||
async def get_status(self, prd_id: str) -> dict | None:
|
||||
prd = self._prds.get(prd_id)
|
||||
if not prd:
|
||||
return None
|
||||
return {"prd_id": prd_id, "status": prd.get("status", "ready"),
|
||||
"error": prd.get("error", ""),
|
||||
"sections_count": len(prd.get("sections", [])),
|
||||
"images_count": len(prd.get("images", []))}
|
||||
|
||||
async def get_prd(self, prd_id: str) -> dict | None:
|
||||
return self._prds.get(prd_id)
|
||||
|
||||
# ---- internal parsers ----
|
||||
|
||||
async def _parse_docx(self, filepath: str, output_dir: Path) -> dict:
|
||||
word = WordParser(filepath)
|
||||
sections, image_sources = word.extract_sections()
|
||||
full_text = word.extract_full_text()
|
||||
|
||||
images_dir = output_dir / "images"
|
||||
images = word.extract_images(str(images_dir))
|
||||
image_analysis = []
|
||||
n_chart_images = 0
|
||||
|
||||
if images:
|
||||
parser = ImageParser()
|
||||
logger.info("[IMAGE] %.4s 发现 %d 张图片,开始视觉分析...", output_dir.parent.name, len(images))
|
||||
for i, img in enumerate(images):
|
||||
t_img = time.time()
|
||||
logger.info("[IMAGE] [%d/%d] rid=%s, 调用 Qwen VL...", i + 1, len(images), img["rid"])
|
||||
result = parser.parse_image(img["path"])
|
||||
if result is None:
|
||||
result = {"type": "other", "description": ""}
|
||||
result["rid"] = img["rid"]
|
||||
result["path"] = img["path"]
|
||||
if img["rid"] in image_sources:
|
||||
result["context"] = image_sources[img["rid"]]
|
||||
image_analysis.append(result)
|
||||
if result.get("type") in ("flowchart", "architecture", "state", "sequence", "activity"):
|
||||
n_chart_images += 1
|
||||
logger.info("[IMAGE] [%d/%d] rid=%s 完成,类型=%s (%.1fs)",
|
||||
i + 1, len(images), img["rid"], result.get("type", "?"), time.time() - t_img)
|
||||
if i < len(images) - 1:
|
||||
time.sleep(RATE_LIMIT_DELAY)
|
||||
|
||||
usg = parser.usage
|
||||
logger.info("[IMAGE] %.4s 图片分析完成: %d张, 图表类%d张, tokens(p=%d c=%d t=%d)",
|
||||
output_dir.parent.name, len(images), n_chart_images,
|
||||
usg["prompt_tokens"], usg["completion_tokens"], usg["total_tokens"])
|
||||
else:
|
||||
logger.info("[IMAGE] %.4s 文档无图片", output_dir.parent.name)
|
||||
|
||||
return {
|
||||
"source": filepath, "sections": sections,
|
||||
"image_sources": image_sources, "image_analysis": image_analysis,
|
||||
"full_text": full_text,
|
||||
}
|
||||
|
||||
def _parse_markdown_sections(self, text: str) -> list[dict]:
|
||||
import re
|
||||
sections: list[dict] = []
|
||||
current_source = "正文"
|
||||
current_blocks: list[dict] = []
|
||||
para_idx = 0
|
||||
|
||||
for line in text.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
heading_match = re.match(r"^(#{1,6})\s+(.+)", stripped)
|
||||
if heading_match:
|
||||
if current_blocks:
|
||||
sections.append({"source": current_source, "blocks": current_blocks, "images": []})
|
||||
current_blocks = []
|
||||
level = len(heading_match.group(1))
|
||||
current_source = f"{'#' * level} {heading_match.group(2)}"
|
||||
continue
|
||||
if "|" in stripped and stripped.count("|") >= 2:
|
||||
cells = [c.strip() for c in stripped.split("|") if c.strip()]
|
||||
if cells:
|
||||
current_blocks.append({
|
||||
"type": "table",
|
||||
"table": len([b for b in current_blocks if b["type"] == "table"]) + 1,
|
||||
"headers": cells,
|
||||
"rows": [{"columns": [{"name": c, "row": 1, "col": i + 1, "text": c} for i, c in enumerate(cells)]}],
|
||||
})
|
||||
continue
|
||||
para_idx += 1
|
||||
current_blocks.append({"type": "para", "index": para_idx, "text": stripped})
|
||||
|
||||
if current_blocks:
|
||||
sections.append({"source": current_source, "blocks": current_blocks, "images": []})
|
||||
return sections
|
||||
|
||||
async def _parse_pdf(self, filepath: str, output_dir: Path) -> dict:
|
||||
from PyPDF2 import PdfReader
|
||||
reader = PdfReader(filepath)
|
||||
full_text_parts = []
|
||||
for i, page in enumerate(reader.pages):
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
full_text_parts.append(text)
|
||||
full_text = "\n".join(full_text_parts)
|
||||
sections = [{"source": "PDF 内容", "blocks": [{"type": "para", "index": i + 1, "text": t} for i, t in enumerate(full_text_parts)], "images": []}]
|
||||
return {"source": filepath, "sections": sections, "image_sources": {}, "image_analysis": [], "full_text": full_text}
|
||||
|
||||
|
||||
prd_service = PRDService()
|
||||
@@ -0,0 +1,255 @@
|
||||
# Microsoft Word (.docx) parser — extracts structured sections with typed blocks,
|
||||
# image markers, and detailed location metadata for downstream IR generation.
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from docx import Document
|
||||
from docx.table import Table
|
||||
from docx.text.paragraph import Paragraph
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_EXT = {
|
||||
"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif",
|
||||
"image/bmp": ".bmp", "image/tiff": ".tiff", "image/webp": ".webp",
|
||||
"image/x-emf": ".emf", "image/x-wmf": ".wmf", "image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
HEADER_CELL_MAX_LEN = 20
|
||||
|
||||
WML_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
DRAW_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||||
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
|
||||
|
||||
class WordParser:
|
||||
"""Parse a .docx file — extract images, split body into sections.
|
||||
|
||||
Sections contain typed blocks (para/table) with structured table columns
|
||||
and IMAGE:rid markers, matching the reference document analyzer format.
|
||||
|
||||
Usage::
|
||||
|
||||
parser = WordParser("doc.docx")
|
||||
parser.extract_images("images/")
|
||||
sections, image_sources = parser.extract_sections()
|
||||
"""
|
||||
|
||||
def __init__(self, docx_path: str):
|
||||
if not os.path.isfile(docx_path):
|
||||
raise FileNotFoundError(f"Document not found: {docx_path}")
|
||||
self._doc = Document(docx_path)
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
|
||||
def extract_images(self, images_dir: str) -> list[dict]:
|
||||
"""Save all images to *images_dir*. Returns [{rid, path}, ...]."""
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
images: list[dict] = []
|
||||
for rel in self._doc.part.rels.values():
|
||||
if "image" not in rel.reltype:
|
||||
continue
|
||||
ext = IMAGE_EXT.get(rel.target_part.content_type, ".png")
|
||||
name = f"image_{rel.rId}{ext}"
|
||||
path = os.path.join(images_dir, name)
|
||||
with open(path, "wb") as f:
|
||||
f.write(rel.target_part.blob)
|
||||
images.append({"rid": rel.rId, "path": path})
|
||||
return images
|
||||
|
||||
def extract_sections(self) -> tuple[list[dict], dict[str, dict]]:
|
||||
"""Walk document body and split into sections by heading.
|
||||
|
||||
Returns:
|
||||
*sections* — [{source, blocks, images}, ...]
|
||||
Each block is {type, index, text} (paragraph) or
|
||||
{type, table, headers, rows} (table with structured columns).
|
||||
*image_sources* — rid → {section, table?, row?, column?, name?}
|
||||
"""
|
||||
sections: list[dict] = []
|
||||
current_source = ""
|
||||
blocks: list[dict] = []
|
||||
section_images: list[str] = []
|
||||
image_sources: dict[str, dict] = {}
|
||||
para_idx = 0
|
||||
tbl_idx = 0
|
||||
|
||||
for child in self._doc.element.body:
|
||||
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
|
||||
|
||||
if tag == "p":
|
||||
para = Paragraph(child, self._doc)
|
||||
|
||||
# Heading detection
|
||||
if self._heading_level(para) is not None:
|
||||
heading_text = para.text.strip()
|
||||
if heading_text:
|
||||
# Flush previous section
|
||||
if blocks or section_images:
|
||||
sections.append({
|
||||
"source": current_source,
|
||||
"blocks": blocks,
|
||||
"images": list(section_images),
|
||||
})
|
||||
blocks = []
|
||||
section_images = []
|
||||
para_idx = 0
|
||||
tbl_idx = 0
|
||||
current_source = heading_text
|
||||
continue
|
||||
|
||||
text = para.text.strip()
|
||||
|
||||
# Scan for inline images — append [[IMAGE:rid]] markers
|
||||
for run in para.runs:
|
||||
for rid in self._images_in(run._element):
|
||||
text += f" [[IMAGE:{rid}]]"
|
||||
section_images.append(rid)
|
||||
image_sources[rid] = {"section": current_source}
|
||||
|
||||
if text.strip():
|
||||
blocks.append({"type": "para", "index": para_idx + 1, "text": text.strip()})
|
||||
para_idx += 1
|
||||
|
||||
elif tag == "tbl":
|
||||
tbl_idx += 1
|
||||
table = Table(child, self._doc)
|
||||
|
||||
# Collect all rows with cell text and embedded images
|
||||
all_rows: list[list[str]] = []
|
||||
all_images: list[list[list[str]]] = [] # row → col → [rids]
|
||||
for row in table.rows:
|
||||
row_texts: list[str] = []
|
||||
row_cell_images: list[list[str]] = []
|
||||
for cell in row.cells:
|
||||
cell_text = cell.text.strip()
|
||||
cell_imgs: list[str] = []
|
||||
for cp in cell.paragraphs:
|
||||
for run in cp.runs:
|
||||
for rid in self._images_in(run._element):
|
||||
cell_imgs.append(rid)
|
||||
for rid in cell_imgs:
|
||||
cell_text += f" [[IMAGE:{rid}]]"
|
||||
section_images.append(rid)
|
||||
row_texts.append(cell_text.strip())
|
||||
row_cell_images.append(cell_imgs)
|
||||
if any(row_texts) or any(row_cell_images):
|
||||
all_rows.append(row_texts)
|
||||
all_images.append(row_cell_images)
|
||||
|
||||
if len(all_rows) >= 2:
|
||||
# Header heuristic: first row is header if all cells are short
|
||||
first_row = all_rows[0]
|
||||
has_header = all(len(c) < HEADER_CELL_MAX_LEN for c in first_row)
|
||||
if has_header:
|
||||
headers = first_row
|
||||
data_rows_slice = zip(all_rows[1:], all_images[1:])
|
||||
else:
|
||||
headers = [f"列{ci + 1}" for ci in range(len(first_row))]
|
||||
data_rows_slice = zip(all_rows, all_images)
|
||||
|
||||
data_rows: list[dict] = []
|
||||
for ri, (row_data, row_imgs) in enumerate(data_rows_slice):
|
||||
columns: list[dict] = []
|
||||
max_cols = max(len(headers), len(row_data))
|
||||
for ci in range(max_cols):
|
||||
hdr = headers[ci] if ci < len(headers) else ""
|
||||
txt = row_data[ci] if ci < len(row_data) else ""
|
||||
columns.append({
|
||||
"name": hdr,
|
||||
"row": ri + 1,
|
||||
"col": ci + 1,
|
||||
"text": txt,
|
||||
})
|
||||
|
||||
# Register image sources with structured location
|
||||
imgs = row_imgs[ci] if ci < len(row_imgs) else []
|
||||
for rid in imgs:
|
||||
image_sources[rid] = {
|
||||
"section": current_source,
|
||||
"table": tbl_idx,
|
||||
"row": ri + 1,
|
||||
"column": ci + 1,
|
||||
"name": hdr,
|
||||
}
|
||||
|
||||
data_rows.append({"columns": columns})
|
||||
|
||||
blocks.append({
|
||||
"type": "table",
|
||||
"table": tbl_idx,
|
||||
"headers": headers,
|
||||
"rows": data_rows,
|
||||
})
|
||||
elif all_rows:
|
||||
# Degenerate table — treat as plain rows
|
||||
for ri, row_data in enumerate(all_rows):
|
||||
row_text = " | ".join(row_data)
|
||||
if row_text.strip():
|
||||
blocks.append({
|
||||
"type": "para",
|
||||
"index": para_idx + 1,
|
||||
"text": row_text,
|
||||
})
|
||||
para_idx += 1
|
||||
|
||||
# Flush final section
|
||||
if blocks or section_images:
|
||||
sections.append({
|
||||
"source": current_source,
|
||||
"blocks": blocks,
|
||||
"images": list(section_images),
|
||||
})
|
||||
|
||||
return sections, image_sources
|
||||
|
||||
def extract_full_text(self) -> str:
|
||||
"""Return all text content as a single string."""
|
||||
lines = []
|
||||
for para in self._doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
if self._heading_level(para) is not None:
|
||||
lines.append(f"\n## {text}\n")
|
||||
else:
|
||||
lines.append(text)
|
||||
for table in self._doc.tables:
|
||||
for row in table.rows:
|
||||
lines.append(" | ".join(cell.text.strip() for cell in row.cells))
|
||||
return "\n".join(lines)
|
||||
|
||||
# ---- internals ----------------------------------------------------------
|
||||
|
||||
def _heading_level(self, para: Paragraph) -> int | None:
|
||||
"""Heading level 1-9, or None if not a heading."""
|
||||
if para.style and para.style.name:
|
||||
name = para.style.name
|
||||
for prefix in ("Heading", "标题"):
|
||||
if name.startswith(prefix):
|
||||
try:
|
||||
return int(name.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
pPr = para._element.find(f"{{{WML_NS}}}pPr")
|
||||
if pPr is not None:
|
||||
ol = pPr.find(f"{{{WML_NS}}}outlineLvl")
|
||||
if ol is not None:
|
||||
val = ol.get(f"{{{WML_NS}}}val")
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val) + 1
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _images_in(self, element) -> list[str]:
|
||||
"""Return rId values for drawings embedded in *element*."""
|
||||
rids: list[str] = []
|
||||
for drawing in element.findall(f".//{{{WML_NS}}}drawing"):
|
||||
blip = drawing.find(f".//{{{DRAW_NS}}}blip")
|
||||
if blip is not None:
|
||||
rid = blip.get(f"{{{REL_NS}}}embed")
|
||||
if rid:
|
||||
rids.append(rid)
|
||||
return rids
|
||||
Reference in New Issue
Block a user