fix: adapt frontend image paths for backend port 8002 and use blob download

Backend port changed from 8000 to 8002 due to port conflict (frpc on Windows).
- Add resolveImageUrl() to convert relative image URLs to full backend URLs
- Fix all spatial page image src and download handlers to use backend address
- Fix chat message image rendering to use correct port
- Switch download to blob-based approach to support cross-origin file saving

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 20:52:48 +08:00
parent a6987ff996
commit bbf6b921af
10 changed files with 171 additions and 127 deletions
+1
View File
@@ -43,6 +43,7 @@ dependencies = [
"docx2txt>=0.9",
"pypdf>=6.12.0",
"pymupdf>=1.27.2.3",
"pdf2image>=1.17.0",
"opencv-python-headless>=4.13.0.92",
]
+4
View File
@@ -128,6 +128,10 @@ class Settings(BaseSettings):
# 全局配置实例
settings = Settings()
# 将 HF_ENDPOINT 写入环境变量,供 sentence_transformers/huggingface_hub 使用
if settings.hf_endpoint and not os.environ.get("HF_ENDPOINT"):
os.environ["HF_ENDPOINT"] = settings.hf_endpoint
def get_settings() -> Settings:
"""获取配置实例"""
+77 -85
View File
@@ -2,11 +2,11 @@
LangChain 1.0 文档加载器封装 + PDF图片提取
"""
import logging
import io
from typing import List, Optional, Dict
from pathlib import Path
import fitz # pymupdf
import fitz # pymupdf — 仅用于提取页面文本
from PIL import Image
from pdf2image import convert_from_path
from langchain_community.document_loaders import (
PyPDFLoader,
Docx2txtLoader,
@@ -19,100 +19,92 @@ logger = logging.getLogger(__name__)
class PDFImageExtractor:
"""使用pymupdf从PDF中提取内嵌图片"""
"""使用pdf2image将每页PDF渲染为图片,用pymupdf提取页面文本"""
DEFAULT_DPI = 250
DEFAULT_TARGET_WIDTH = 2500
JPEG_QUALITY = 90
@staticmethod
def extract_images(file_path: str, output_dir: str) -> List[dict]:
"""提取PDF中所有图片,返回图片元数据列表
def extract_images(
file_path: str,
output_dir: str,
dpi: int = None,
target_width: int = None,
) -> List[dict]:
"""将PDF每页渲染为一张图片
对正常页面提取内嵌图片,对瓦片式页面(>50个小图片)渲染整页截图。
Args:
file_path: PDF文件路径
output_dir: 图片输出目录
dpi: 渲染DPI(默认250
target_width: 缩放目标宽度像素(默认2500)
Returns:
图片元数据列表,每个dict包含 path, filename, page, context_text, size
"""
dpi = dpi or PDFImageExtractor.DEFAULT_DPI
target_width = target_width or PDFImageExtractor.DEFAULT_TARGET_WIDTH
quality = PDFImageExtractor.JPEG_QUALITY
Path(output_dir).mkdir(parents=True, exist_ok=True)
# 用pymupdf提取每页文本(作为VLM上下文)
page_texts: list[str] = []
try:
doc = fitz.open(file_path)
for page_num in range(len(doc)):
page_texts.append(doc[page_num].get_text("text"))
doc.close()
except Exception as e:
logger.warning(f"[ImageExtractor] pymupdf文本提取失败: {e}")
# 用pdf2image渲染所有页面为图片
try:
pil_images = convert_from_path(file_path, dpi=dpi, fmt="jpeg")
except Exception as e:
logger.error(f"[ImageExtractor] pdf2image渲染失败: {e}")
return []
images = []
doc = fitz.open(file_path)
for page_num, pil_img in enumerate(pil_images):
try:
w, h = pil_img.size
if w > target_width:
ratio = target_width / w
new_h = int(h * ratio)
pil_img = pil_img.resize(
(target_width, new_h), Image.Resampling.LANCZOS
)
TILE_THRESHOLD = 50
vlm_formats = {"png", "jpg", "jpeg", "webp", "gif"}
dpi = 150 # 页面渲染 DPI
filename = f"page{page_num + 1}.jpg"
output_path = Path(output_dir) / filename
pil_img.save(str(output_path), format="JPEG", quality=quality)
for page_num in range(len(doc)):
page = doc[page_num]
page_text = page.get_text("text")
image_list = page.get_images(full=True)
context_text = ""
if page_num < len(page_texts):
context_text = page_texts[page_num][:600].strip()
if len(image_list) > TILE_THRESHOLD:
# 瓦片式页面:渲染整页为一张完整图片
try:
pix = page.get_pixmap(dpi=dpi)
img_bytes = pix.tobytes("png")
filename = f"page{page_num+1}_full.png"
output_path = Path(output_dir) / filename
output_path.write_bytes(img_bytes)
images.append({
"path": str(output_path),
"filename": filename,
"page": page_num + 1,
"context_text": page_text[:600].strip(),
"size": len(img_bytes),
})
logger.info(f"[ImageExtractor] 瓦片页面渲染为整图 page={page_num+1} size={len(img_bytes)}")
except Exception as e:
logger.warning(f"[ImageExtractor] 页面渲染失败 page={page_num+1}: {e}")
images.append({
"path": str(output_path),
"filename": filename,
"page": page_num + 1,
"context_text": context_text,
"size": output_path.stat().st_size,
})
logger.info(
f"[ImageExtractor] 页面渲染完成 page={page_num + 1} "
f"size={images[-1]['size']}"
)
except Exception as e:
logger.warning(
f"[ImageExtractor] 页面处理失败 page={page_num + 1}: {e}"
)
continue
# 正常页面:提取内嵌图片
for img_idx, img_info in enumerate(image_list):
xref = img_info[0]
try:
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
ext = base_image["ext"]
# 过滤太小的碎片/图标/装饰
if len(image_bytes) < 2048:
continue
w = base_image.get("width", 0)
h = base_image.get("height", 0)
if w < 150 or h < 150:
continue
# 图片周围文本
context_start = max(0, page_text.find(
page_text[:len(page_text)//2]) if len(page_text) > 600
else 0)
context_text = page_text[context_start:context_start+600].strip()
# 不支持的格式(jpx/jpeg2000等)转为 PNG
if ext.lower() not in vlm_formats:
try:
img = Image.open(io.BytesIO(image_bytes))
if img.mode in ("CMYK", "P"):
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, format="PNG")
image_bytes = buf.getvalue()
ext = "png"
except Exception as e:
logger.warning(f"[ImageExtractor] 格式转换失败 page={page_num+1} img={img_idx}: {e}")
continue
filename = f"page{page_num+1}_img{img_idx+1}.{ext}"
output_path = Path(output_dir) / filename
output_path.write_bytes(image_bytes)
images.append({
"path": str(output_path),
"filename": filename,
"page": page_num + 1,
"context_text": context_text,
"size": len(image_bytes),
})
except Exception as e:
logger.warning(f"[ImageExtractor] 提取图片失败 page={page_num+1} img={img_idx}: {e}")
continue
doc.close()
logger.info(
f"[ImageExtractor] 共渲染 {len(images)} 页 from {file_path}"
)
return images
+15 -1
View File
@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.12"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -441,6 +441,7 @@ dependencies = [
{ name = "opencv-python-headless" },
{ name = "pandas" },
{ name = "passlib", extra = ["bcrypt"] },
{ name = "pdf2image" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
@@ -492,6 +493,7 @@ requires-dist = [
{ name = "opencv-python-headless", specifier = ">=4.13.0.92" },
{ name = "pandas", specifier = ">=2.2.3" },
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
{ name = "pdf2image", specifier = ">=1.17.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.5.0" },
{ name = "pydantic-settings", specifier = ">=2.1.0" },
@@ -2396,6 +2398,18 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08" },
]
[[package]]
name = "pdf2image"
version = "1.17.0"
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
dependencies = [
{ name = "pillow" },
]
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/00/d8/b280f01045555dc257b8153c00dee3bc75830f91a744cd5f84ef3a0a64b1/pdf2image-1.17.0.tar.gz", hash = "sha256:eaa959bc116b420dd7ec415fcae49b98100dda3dd18cd2fdfa86d09f112f6d57" }
wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/62/33/61766ae033518957f877ab246f87ca30a85b778ebaad65b7f74fa7e52988/pdf2image-1.17.0-py3-none-any.whl", hash = "sha256:ecdd58d7afb810dffe21ef2b1bbc057ef434dabbac6c33778a38a3f7744a27e2" },
]
[[package]]
name = "pillow"
version = "11.3.0"