document_service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. """document_service.py — 文档 CRUD,含 Word 下载解析。"""
  2. import re
  3. import tempfile
  4. from datetime import datetime, timezone
  5. from pathlib import Path
  6. import httpx
  7. from docx import Document as DocxDocument
  8. from docx.oxml.ns import qn
  9. from sqlalchemy import func, select
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from app.core.exceptions import DocumentNotFoundError, DocumentParseError
  12. from app.models.document import Document
  13. from app.schemas.document import CreateDocumentRequest, UpdateDocumentRequest
  14. # ------------------------------------------------------------------ #
  15. # Word → Markdown 解析(完整实现,支持往返转换)
  16. # ------------------------------------------------------------------ #
  17. # 标准样式列表(不需要添加样式注释)
  18. STANDARD_STYLES = {
  19. "Normal",
  20. "Heading 1", "Heading 2", "Heading 3", "Heading 4", "Heading 5", "Heading 6",
  21. "List Bullet", "List Bullet 2", "List Bullet 3",
  22. "List Number", "List Number 2", "List Number 3",
  23. "Quote", "Quote Char",
  24. "No Spacing",
  25. "Table Grid",
  26. }
  27. def _is_standard_style(style_name: str) -> bool:
  28. """判断是否为标准样式"""
  29. if not style_name or style_name in STANDARD_STYLES:
  30. return True
  31. if style_name.startswith("Heading ") or "List Bullet" in style_name or "List Number" in style_name:
  32. return True
  33. return False
  34. def _render_run_with_format(run) -> str:
  35. """将单个 Run 转换为带格式的 Markdown 文本"""
  36. text = run.text
  37. if not text:
  38. return ""
  39. # 检测代码格式(Courier New 字体)
  40. is_code = run.font.name == "Courier New"
  41. # 删除线
  42. has_strike = run.font.strike if run.font.strike is not None else False
  43. # 粗体
  44. has_bold = run.bold if run.bold is not None else False
  45. # 斜体
  46. has_italic = run.italic if run.italic is not None else False
  47. # 应用格式(注意顺序:代码 > 删除线 > 粗体/斜体)
  48. if is_code:
  49. text = f"`{text}`"
  50. elif has_strike:
  51. text = f"~~{text}~~"
  52. elif has_bold and has_italic:
  53. # 粗斜体组合
  54. text = f"***{text}***"
  55. elif has_bold:
  56. text = f"**{text}**"
  57. elif has_italic:
  58. text = f"*{text}*"
  59. return text
  60. def _render_para_with_inline_format(para) -> str:
  61. """将段落的所有 Run 转换为带格式的 Markdown 文本"""
  62. parts = []
  63. for run in para.runs:
  64. formatted_text = _render_run_with_format(run)
  65. parts.append(formatted_text)
  66. return "".join(parts)
  67. def _is_code_block(para) -> bool:
  68. """检测段落是否为代码块(所有 Run 都是 Courier New + No Spacing 样式)"""
  69. if not para.runs:
  70. return False
  71. # 检查样式
  72. style_name = para.style.name if para.style else ""
  73. if style_name != "No Spacing":
  74. return False
  75. # 检查所有非空 Run 是否都使用 Courier New
  76. non_empty_runs = [run for run in para.runs if run.text.strip()]
  77. if not non_empty_runs:
  78. return False
  79. return all(run.font.name == "Courier New" for run in non_empty_runs)
  80. def _convert_table_to_markdown(table) -> str:
  81. """将 Word 表格转换为 Markdown 表格格式"""
  82. if not table.rows:
  83. return ""
  84. md_lines = []
  85. # 表头(第一行)
  86. header_cells = []
  87. for cell in table.rows[0].cells:
  88. # 对单元格内容也应用格式解析
  89. cell_text = []
  90. for para in cell.paragraphs:
  91. para_style = para.style.name if para.style else "Normal"
  92. para_text = _render_para_with_inline_format(para).strip()
  93. # 移除表头自动添加的粗体格式
  94. # 因为导出时会自动给表头加粗,但原始Markdown可能没有
  95. # 这里需要判断:如果所有文本都是粗体,则移除粗体标记
  96. if para_text.startswith("**") and para_text.endswith("**") and para_text.count("**") == 2:
  97. para_text = para_text[2:-2]
  98. if para_text:
  99. # 如果表头单元格使用自定义样式,添加注释
  100. if not _is_standard_style(para_style) and para_style not in ("Normal", "Table Grid"):
  101. para_text = f"<!-- style: {para_style} -->{para_text}"
  102. cell_text.append(para_text)
  103. header_cells.append(" ".join(cell_text))
  104. md_lines.append("| " + " | ".join(header_cells) + " |")
  105. # 分隔行
  106. md_lines.append("| " + " | ".join(["---"] * len(header_cells)) + " |")
  107. # 数据行
  108. for row in table.rows[1:]:
  109. cells = []
  110. for cell in row.cells[:len(header_cells)]: # 确保列数一致
  111. # 对单元格内容也应用格式解析
  112. cell_text = []
  113. for para in cell.paragraphs:
  114. para_style = para.style.name if para.style else "Normal"
  115. para_text = _render_para_with_inline_format(para).strip()
  116. if para_text:
  117. # 如果单元格段落使用自定义样式,添加注释
  118. if not _is_standard_style(para_style) and para_style not in ("Normal", "Table Grid"):
  119. para_text = f"<!-- style: {para_style} -->{para_text}"
  120. cell_text.append(para_text)
  121. cells.append(" ".join(cell_text))
  122. md_lines.append("| " + " | ".join(cells) + " |")
  123. return "\n".join(md_lines)
  124. def _get_list_info(para) -> tuple[str, int]:
  125. """获取列表类型和深度
  126. 返回:(list_type, depth)
  127. list_type: "bullet" 或 "number" 或 None
  128. depth: 1, 2, 3... 或 0
  129. """
  130. style_name = para.style.name if para.style else ""
  131. if not style_name:
  132. return None, 0
  133. # 直接根据样式名判断
  134. # 注意:python-docx 生成的列表可能使用 "List Bullet 2" 等样式
  135. if "List Bullet" in style_name:
  136. # 提取数字
  137. if "List Bullet 3" in style_name:
  138. return "bullet", 3
  139. elif "List Bullet 2" in style_name:
  140. return "bullet", 2
  141. else:
  142. return "bullet", 1
  143. elif "List Number" in style_name:
  144. if "List Number 3" in style_name:
  145. return "number", 3
  146. elif "List Number 2" in style_name:
  147. return "number", 2
  148. else:
  149. return "number", 1
  150. return None, 0
  151. def _has_bottom_border(para) -> bool:
  152. """检测段落是否有底部边框(用于分隔线)"""
  153. try:
  154. pPr = para._p.pPr
  155. if pPr is None:
  156. return False
  157. pBdr = pPr.find(qn('w:pBdr'))
  158. if pBdr is None:
  159. return False
  160. bottom = pBdr.find(qn('w:bottom'))
  161. return bottom is not None
  162. except Exception:
  163. return False
  164. def _docx_to_markdown(path: Path) -> str:
  165. """将 .docx 文件解析为完整 Markdown 文本(支持往返转换)"""
  166. doc = DocxDocument(str(path))
  167. # 提取所有图片及其位置信息
  168. from app.services.image_service import extract_images_from_word
  169. images = extract_images_from_word(doc)
  170. # 按段落索引建立映射,方便查找
  171. image_map = {}
  172. for img in images:
  173. para_idx = img['paragraph_index']
  174. if para_idx not in image_map:
  175. image_map[para_idx] = []
  176. image_map[para_idx].append(img)
  177. # 收集所有元素(段落和表格)并按文档顺序排列
  178. elements = []
  179. # 通过 XML body 遍历,保持原始顺序
  180. body = doc.element.body
  181. para_map = {p._element: p for p in doc.paragraphs}
  182. table_map = {t._element: t for t in doc.tables}
  183. for child in body:
  184. tag = child.tag
  185. if tag.endswith('p'):
  186. para = para_map.get(child)
  187. if para:
  188. elements.append(('para', para))
  189. elif tag.endswith('tbl'):
  190. table = table_map.get(child)
  191. if table:
  192. elements.append(('table', table))
  193. # 转换为 Markdown
  194. lines = []
  195. in_code_block = False
  196. code_block_lines = []
  197. # 记录实际的段落索引到元素索引的映射
  198. para_idx_in_elements = {}
  199. actual_para_idx = 0
  200. for elem_idx, (elem_type, elem) in enumerate(elements):
  201. if elem_type == "para":
  202. para_idx_in_elements[actual_para_idx] = elem_idx
  203. actual_para_idx += 1
  204. for elem_idx, (elem_type, elem) in enumerate(elements):
  205. if elem_type == "para":
  206. para = elem
  207. style_name = para.style.name if para.style else ""
  208. # 检测代码块开始/结束
  209. if _is_code_block(para):
  210. if not in_code_block:
  211. in_code_block = True
  212. code_block_lines = []
  213. code_block_lines.append(para.text) # 使用原始文本,不要strip
  214. continue
  215. else:
  216. # 如果之前在代码块中,现在结束了
  217. if in_code_block:
  218. in_code_block = False
  219. lines.append("```")
  220. # 移除首尾空行,但保留中间的空行
  221. while code_block_lines and not code_block_lines[0].strip():
  222. code_block_lines.pop(0)
  223. while code_block_lines and not code_block_lines[-1].strip():
  224. code_block_lines.pop()
  225. lines.extend(code_block_lines)
  226. lines.append("```")
  227. code_block_lines = []
  228. # 带格式的文本
  229. text = _render_para_with_inline_format(para).strip()
  230. # 空段落
  231. if not text:
  232. # 检测是否是分隔线(只有底部边框的空段落)
  233. if _has_bottom_border(para):
  234. lines.append("---")
  235. # 空段落也要检查是否有图片
  236. current_para_idx = None
  237. for para_idx, e_idx in para_idx_in_elements.items():
  238. if e_idx == elem_idx:
  239. current_para_idx = para_idx
  240. break
  241. if current_para_idx is not None and current_para_idx in image_map:
  242. from app.services.image_service import create_image_markdown
  243. for img in image_map[current_para_idx]:
  244. img_md = create_image_markdown(
  245. img['data_url'],
  246. img['style'],
  247. alt=f"图片"
  248. )
  249. lines.append(img_md.strip())
  250. continue
  251. # 标题
  252. if style_name.startswith("Heading "):
  253. try:
  254. level = int(style_name.split()[-1])
  255. except ValueError:
  256. level = 1
  257. level = min(max(level, 1), 6)
  258. lines.append(f"{'#' * level} {text}")
  259. # 列表
  260. elif (list_info := _get_list_info(para))[0]:
  261. list_type, depth = list_info
  262. indent = " " * (depth - 1)
  263. if list_type == "bullet":
  264. lines.append(f"{indent}- {text}")
  265. else: # number
  266. lines.append(f"{indent}1. {text}")
  267. # 块引用
  268. elif style_name == "Quote":
  269. lines.append(f"> {text}")
  270. # 普通段落
  271. else:
  272. # 如果是自定义样式,添加样式注释(和文本在同一行)
  273. if not _is_standard_style(style_name) and style_name != "Normal":
  274. lines.append(f"<!-- style: {style_name} -->{text}")
  275. else:
  276. lines.append(text)
  277. # 段落处理完成后,检查是否有图片
  278. # 找到当前 elem_idx 对应的实际段落索引
  279. current_para_idx = None
  280. for para_idx, e_idx in para_idx_in_elements.items():
  281. if e_idx == elem_idx:
  282. current_para_idx = para_idx
  283. break
  284. if current_para_idx is not None and current_para_idx in image_map:
  285. # 在段落后输出图片
  286. from app.services.image_service import create_image_markdown
  287. for img in image_map[current_para_idx]:
  288. img_md = create_image_markdown(
  289. img['data_url'],
  290. img['style'],
  291. alt=f"图片"
  292. )
  293. lines.append(img_md.strip())
  294. elif elem_type == "table":
  295. # 如果之前在代码块中,先结束
  296. if in_code_block:
  297. in_code_block = False
  298. lines.append("```")
  299. lines.extend(code_block_lines)
  300. lines.append("```")
  301. code_block_lines = []
  302. table = elem
  303. table_md = _convert_table_to_markdown(table)
  304. if table_md:
  305. lines.append(table_md)
  306. # 处理未结束的代码块
  307. if in_code_block:
  308. lines.append("```")
  309. # 移除首尾空行
  310. while code_block_lines and not code_block_lines[0].strip():
  311. code_block_lines.pop(0)
  312. while code_block_lines and not code_block_lines[-1].strip():
  313. code_block_lines.pop()
  314. lines.extend(code_block_lines)
  315. lines.append("```")
  316. # 过滤空字符串,用双换行连接
  317. filtered_lines = [line for line in lines if line]
  318. result = "\n\n".join(filtered_lines).strip()
  319. # 修复列表项之间的额外换行
  320. # 递归替换,直到没有匹配项
  321. import re
  322. # 匹配模式:(可选缩进)(列表标记 - 或 1.)(内容)(双换行)(可选缩进)(列表标记)
  323. pattern = r'((?:^|\n)( )?(?:-|\d+\.)\s+[^\n]+)\n\n( )?(?=(?:-|\d+\.)\s+)'
  324. while True:
  325. new_result = re.sub(pattern, r'\1\n\3', result, flags=re.MULTILINE)
  326. if new_result == result:
  327. break
  328. result = new_result
  329. # 确保以单个换行符结尾(Markdown 标准)
  330. return result + "\n" if result else ""
  331. async def _download_and_parse(file_url: str) -> str:
  332. """下载 Word 文档到临时文件,解析为 Markdown,自动清理临时文件。"""
  333. suffix = Path(file_url.split("?")[0]).suffix.lower() or ".docx"
  334. if suffix not in (".doc", ".docx"):
  335. raise DocumentParseError(f"不支持的文件格式: {suffix}")
  336. try:
  337. async with httpx.AsyncClient(timeout=60) as client:
  338. resp = await client.get(file_url)
  339. resp.raise_for_status()
  340. except httpx.HTTPError as exc:
  341. raise DocumentParseError(f"文件下载失败: {exc}") from exc
  342. with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
  343. tmp_path = Path(tmp.name)
  344. tmp_path.write_bytes(resp.content)
  345. try:
  346. content = _docx_to_markdown(tmp_path)
  347. except Exception as exc:
  348. raise DocumentParseError(f"Word 解析失败: {exc}") from exc
  349. finally:
  350. tmp_path.unlink(missing_ok=True)
  351. return content
  352. # ------------------------------------------------------------------ #
  353. # 服务类
  354. # ------------------------------------------------------------------ #
  355. class DocumentService:
  356. def __init__(self, db: AsyncSession) -> None:
  357. self.db = db
  358. async def create_document(self, data: CreateDocumentRequest) -> Document:
  359. content = await _download_and_parse(data.file_url)
  360. doc = Document(
  361. content=content,
  362. format="markdown",
  363. session_id=data.session_id,
  364. created_by=data.user_id,
  365. )
  366. self.db.add(doc)
  367. await self.db.commit()
  368. await self.db.refresh(doc)
  369. return doc
  370. async def get_document(self, document_id: str) -> Document:
  371. result = await self.db.execute(
  372. select(Document).where(Document.id == document_id)
  373. )
  374. doc = result.scalar_one_or_none()
  375. if doc is None:
  376. raise DocumentNotFoundError(document_id)
  377. return doc
  378. async def list_documents(
  379. self,
  380. user_id: str,
  381. page: int = 1,
  382. page_size: int = 20,
  383. session_id: str | None = None,
  384. sort_by: str = "updated_at",
  385. sort_order: str = "desc",
  386. ) -> tuple[list[Document], int]:
  387. query = select(Document).where(Document.created_by == user_id)
  388. if session_id:
  389. query = query.where(Document.session_id == session_id)
  390. sort_col = getattr(Document, sort_by, Document.updated_at)
  391. query = query.order_by(sort_col.asc() if sort_order == "asc" else sort_col.desc())
  392. count_q = select(func.count()).select_from(query.subquery())
  393. total: int = (await self.db.execute(count_q)).scalar_one()
  394. offset = (page - 1) * page_size
  395. result = await self.db.execute(query.offset(offset).limit(page_size))
  396. return list(result.scalars().all()), total
  397. async def update_document(self, document_id: str, data: UpdateDocumentRequest) -> Document:
  398. doc = await self.get_document(document_id)
  399. if data.content is not None:
  400. doc.content = data.content
  401. elif data.blocks is not None:
  402. doc.content = self._apply_block_updates(doc.content, data.blocks)
  403. doc.updated_at = datetime.now(timezone.utc)
  404. await self.db.commit()
  405. await self.db.refresh(doc)
  406. return doc
  407. async def delete_document(self, document_id: str) -> None:
  408. doc = await self.get_document(document_id)
  409. await self.db.delete(doc)
  410. await self.db.commit()
  411. async def delete_documents_by_session(self, session_id: str) -> int:
  412. """删除指定 sessionId 的所有文档,返回删除数量。"""
  413. result = await self.db.execute(
  414. select(Document).where(Document.session_id == session_id)
  415. )
  416. docs = list(result.scalars().all())
  417. for doc in docs:
  418. await self.db.delete(doc)
  419. await self.db.commit()
  420. return len(docs)
  421. @staticmethod
  422. def _apply_block_updates(content: str, blocks: list) -> str:
  423. """按 level+index 定位标题块并替换,其余内容不变。"""
  424. lines = content.split("\n")
  425. heading_pattern = re.compile(r"^(#{1,6})\s+")
  426. # 收集标题位置和 index
  427. level_counter: dict[int, int] = {}
  428. heading_info: list[tuple[int, int, int]] = [] # (line_idx, level, index)
  429. for i, line in enumerate(lines):
  430. m = heading_pattern.match(line)
  431. if m:
  432. level = len(m.group(1))
  433. idx = level_counter.get(level, 0)
  434. heading_info.append((i, level, idx))
  435. level_counter[level] = idx + 1
  436. # 切分为块
  437. split_points = [hi[0] for hi in heading_info] + [len(lines)]
  438. pre_end = split_points[0] if split_points else len(lines)
  439. chunks: list[str] = ["\n".join(lines[:pre_end])]
  440. key_to_chunk: dict[tuple[int, int], int] = {}
  441. for i, sp in enumerate(split_points[:-1]):
  442. end = split_points[i + 1]
  443. chunks.append("\n".join(lines[sp:end]))
  444. _, level, index = heading_info[i]
  445. key_to_chunk[(level, index)] = len(chunks) - 1
  446. # 替换
  447. for block in blocks:
  448. key = (block.level, block.index)
  449. if key in key_to_chunk:
  450. chunks[key_to_chunk[key]] = block.content
  451. return "\n".join(chunks).strip() + "\n"