From 58a34aa7d4d4ee681955ac9ccabc25e4b65a25d9 Mon Sep 17 00:00:00 2001 From: lkk Date: Mon, 24 Aug 2026 16:59:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(qa=5Fexpert):=20=E4=BF=AE=E5=A4=8D=20Markdo?= =?UTF-8?q?wn=20=E9=99=84=E4=BB=B6=E6=B0=B4=E5=8D=B0=E4=B8=8B=E8=BD=BD=205?= =?UTF-8?q?00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .md/.txt 等文本附件不再误走 convert_docx_to_pdf;优先统一 PDF 转换器,失败则回退 fitz 纯文本 PDF,避免测试环境下载报网络失败。 Co-authored-by: Cursor --- .../qa_expert/domain/watermarked_download.py | 142 ++++++++++++++++-- .../qa_expert/test_watermarked_download.py | 43 ++++++ 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/src/backend/bisheng/qa_expert/domain/watermarked_download.py b/src/backend/bisheng/qa_expert/domain/watermarked_download.py index 20ed777ba..b183be1be 100644 --- a/src/backend/bisheng/qa_expert/domain/watermarked_download.py +++ b/src/backend/bisheng/qa_expert/domain/watermarked_download.py @@ -126,6 +126,107 @@ def _image_bytes_to_pdf(data: bytes, image_type: str | None) -> bytes: src.close() +def _decode_attachment_text(data: bytes) -> str: + """解码问答附件文本;无法严格解码时用 replace,避免整条下载失败。""" + for encoding in ("utf-8-sig", "utf-16", "utf-8"): + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + return data.decode("utf-8", errors="replace") + + +def _plain_text_to_pdf(data: bytes) -> bytes: + """不依赖 LibreOffice/Playwright:用 CJK 字体把纯文本按 A4 分页写入 PDF。 + + `.md` / `.html` 按原文落 PDF(不做富文本排版);保证水印下载链路始终可用。 + """ + from bisheng.knowledge.pdf.watermark import PdfWatermarkError, _resolve_cjk_font + + text = _decode_attachment_text(data).replace("\r\n", "\n").replace("\r", "\n") + if not text.strip(): + text = " " + try: + font_sel = _resolve_cjk_font() + except PdfWatermarkError as exc: + raise QaWatermarkDownloadError("CJK font unavailable for text watermarked download") from exc + + font = fitz.Font(fontfile=font_sel.font_file, fontname=font_sel.font_name) + page_width, page_height = 595.0, 842.0 + margin = 48.0 + fontsize = 11.0 + line_height = fontsize * 1.45 + max_width = page_width - margin * 2 + + def iter_wrapped_lines() -> list[str]: + lines: list[str] = [] + for paragraph in text.split("\n"): + if not paragraph: + lines.append("") + continue + buf = "" + for ch in paragraph: + trial = buf + ch + if font.text_length(trial, fontsize=fontsize) <= max_width: + buf = trial + continue + if buf: + lines.append(buf) + buf = ch + lines.append(buf) + return lines + + wrapped = iter_wrapped_lines() + doc = fitz.open() + try: + y = margin + page = doc.new_page(width=page_width, height=page_height) + for line in wrapped: + if y + line_height > page_height - margin: + page = doc.new_page(width=page_width, height=page_height) + y = margin + page.insert_text( + (margin, y + fontsize), + line or " ", + fontsize=fontsize, + fontfile=font_sel.font_file, + fontname=font_sel.font_name, + ) + y += line_height + return doc.tobytes() + finally: + doc.close() + + +def _convert_via_pdf_registry(data: bytes, suffix: str) -> bytes: + """走知识库统一转换器(Office→LibreOffice,md/txt/html→Playwright)。""" + from bisheng.knowledge.pdf.converter import ( + ConversionContext, + PdfConversionError, + PdfConverterRegistry, + ) + + normalized = ".html" if suffix == ".htm" else suffix + with tempfile.TemporaryDirectory(prefix="qa-wm-") as tmp: + tmp_path = Path(tmp) + src_path = tmp_path / f"source{normalized or '.bin'}" + src_path.write_bytes(data) + try: + result = PdfConverterRegistry().convert( + src_path, + tmp_path / "out", + ConversionContext(timeout_seconds=120), + ) + except PdfConversionError as exc: + raise QaWatermarkDownloadError("attachment cannot be converted for watermarked download") from exc + if result.converter == "original-pdf": + return data + pdf_path = Path(result.pdf_path) + if not pdf_path.is_file() or pdf_path.stat().st_size <= 0: + raise QaWatermarkDownloadError("attachment cannot be converted for watermarked download") + return pdf_path.read_bytes() + + def _bytes_to_pdf(data: bytes, filename: str) -> bytes: suffix = Path(filename).suffix.lower() if data[:5] == b"%PDF-" or suffix == ".pdf": @@ -135,24 +236,41 @@ def _bytes_to_pdf(data: bytes, filename: str) -> bytes: if image_type or suffix in _IMAGE_SUFFIXES: return _image_bytes_to_pdf(data, image_type) - office_suffixes = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".et", ".wps", ".dps"} - if suffix in office_suffixes or suffix in {".txt", ".md", ".csv", ".html", ".htm"}: - from bisheng.knowledge.rag.pipeline.loader.utils.libreoffice_converter import ( - convert_docx_to_pdf, - convert_ppt_to_pdf, + # 文本类:优先 Chromium 排版;失败则退回 fitz 纯文本(修复误用 convert_docx_to_pdf 导致 .md 500) + text_suffixes = {".txt", ".md", ".html", ".htm"} + if suffix in text_suffixes: + try: + return _convert_via_pdf_registry(data, suffix) + except QaWatermarkDownloadError as exc: + logger.info("QA text/web PDF converter unavailable, fallback to plain text: {}", exc) + return _plain_text_to_pdf(data) + + office_suffixes = {".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".csv"} + if suffix in office_suffixes: + return _convert_via_pdf_registry(data, suffix) + + # 国产 Office 后缀:LibreOffice 常可转,但不在 PdfConverterRegistry 白名单内 + legacy_office_suffixes = {".et", ".wps", ".dps"} + if suffix in legacy_office_suffixes: + from bisheng.knowledge.pdf.converter import ( + ConversionContext, + OfficePdfConverter, + PdfConversionError, ) with tempfile.TemporaryDirectory(prefix="qa-wm-") as tmp: - src_path = Path(tmp) / f"source{suffix or '.bin'}" + tmp_path = Path(tmp) + src_path = tmp_path / f"source{suffix}" src_path.write_bytes(data) try: - if suffix in {".ppt", ".pptx", ".dps"}: - pdf_path = convert_ppt_to_pdf(str(src_path), tmp) - else: - pdf_path = convert_docx_to_pdf(str(src_path), tmp) - except Exception as exc: + result = OfficePdfConverter().convert( + src_path, + tmp_path / "out", + ConversionContext(timeout_seconds=120), + ) + except PdfConversionError as exc: raise QaWatermarkDownloadError("attachment cannot be converted for watermarked download") from exc - return Path(pdf_path).read_bytes() + return Path(result.pdf_path).read_bytes() raise QaWatermarkDownloadError("unsupported attachment type for watermarked download") diff --git a/src/backend/test/qa_expert/test_watermarked_download.py b/src/backend/test/qa_expert/test_watermarked_download.py index 57083a324..69161cda0 100644 --- a/src/backend/test/qa_expert/test_watermarked_download.py +++ b/src/backend/test/qa_expert/test_watermarked_download.py @@ -3,6 +3,7 @@ from io import BytesIO from PIL import Image from bisheng.qa_expert.domain.watermarked_download import ( + QaWatermarkDownloadError, _bytes_to_pdf, parse_qa_asset_location, resolve_conversion_filename, @@ -65,3 +66,45 @@ def test_bytes_to_pdf_converts_webp_via_pillow_fallback(): # 详情页标题无后缀时,靠 sniff + Pillow pdf2 = _bytes_to_pdf(webp, "问题图片 1") assert pdf2[:5] == b"%PDF-" + + +def test_bytes_to_pdf_converts_markdown_without_docx_converter(monkeypatch): + """`.md` 不得再误走 convert_docx_to_pdf(其只接受 doc/docx,会返回 False 并 500)。""" + calls: list[str] = [] + + def _forbid_docx(*_args, **_kwargs): + calls.append("docx") + raise AssertionError("must not call convert_docx_to_pdf for markdown") + + monkeypatch.setattr( + "bisheng.knowledge.rag.pipeline.loader.utils.libreoffice_converter.convert_docx_to_pdf", + _forbid_docx, + raising=False, + ) + + # 强制走纯文本回退,避免单测依赖本机 Playwright/Chromium + def _fail_registry(*_args, **_kwargs): + + raise QaWatermarkDownloadError("playwright unavailable in unit test") + + monkeypatch.setattr( + "bisheng.qa_expert.domain.watermarked_download._convert_via_pdf_registry", + _fail_registry, + ) + + md = "# 标题\n\n工作流与智能体功能清单\n".encode() + pdf = _bytes_to_pdf(md, "工作流与智能体功能清单-实现方案.md") + assert pdf[:5] == b"%PDF-" + assert calls == [] + + +def test_bytes_to_pdf_converts_plain_text_via_fallback(monkeypatch): + def _fail_registry(*_args, **_kwargs): + raise QaWatermarkDownloadError("no chromium") + + monkeypatch.setattr( + "bisheng.qa_expert.domain.watermarked_download._convert_via_pdf_registry", + _fail_registry, + ) + pdf = _bytes_to_pdf("hello\n第二行".encode(), "note.txt") + assert pdf[:5] == b"%PDF-"