mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-01 04:47:53 +08:00
fix(qa_expert): 修复 Markdown 附件水印下载 500 (#2363)
## Summary - 修复专家问答预览里下载 `.md` 附件时后端 500、前端报「网络请求失败」 - 根因:文本附件误走只接受 `.doc/.docx` 的 `convert_docx_to_pdf`,返回 `False` 后继续读路径崩溃 - `.md/.txt/.html` 优先走统一 PDF 转换器,失败回退 fitz 纯文本 PDF;Office 走 LibreOffice 转换器 ## Test plan - [x] `uv run pytest test/qa_expert/test_watermarked_download.py -q` - [ ] 测试环境专家问答详情:预览 Markdown 附件并点击下载,应得到带水印 PDF Made with [Cursor](https://cursor.com)
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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-"
|
||||
|
||||
Reference in New Issue
Block a user