120 lines
3.3 KiB
Python
120 lines
3.3 KiB
Python
"""OEM 品牌图:落盘至项目根目录 images/,库内仅存相对路径。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import re
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||
IMAGES_DIR = PROJECT_ROOT / "images"
|
||
|
||
DATA_URL_PATTERN = re.compile(r"^data:image/([\w+.-]+);base64,(.+)$", re.DOTALL)
|
||
MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||
|
||
EXT_ALIASES = {"jpeg": "jpg", "svg+xml": "svg"}
|
||
|
||
|
||
class BrandingImageError(Exception):
|
||
def __init__(self, message: str) -> None:
|
||
self.message = message
|
||
super().__init__(message)
|
||
|
||
|
||
def ensure_images_dir() -> None:
|
||
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def is_data_url(value: str) -> bool:
|
||
return value.strip().startswith("data:image/")
|
||
|
||
|
||
def normalize_relative_path(value: str | None) -> str | None:
|
||
"""从完整 URL 或相对路径得到 images/xxx 形式。"""
|
||
if not value:
|
||
return None
|
||
text = value.strip()
|
||
if not text or is_data_url(text):
|
||
return None
|
||
marker = "/images/"
|
||
if marker in text:
|
||
suffix = text.split(marker, 1)[1].split("?")[0]
|
||
return f"images/{suffix}"
|
||
normalized = text.replace("\\", "/").lstrip("/")
|
||
if normalized.startswith("images/"):
|
||
return normalized
|
||
return None
|
||
|
||
|
||
def absolute_path(relative_path: str | None) -> Path | None:
|
||
rel = normalize_relative_path(relative_path)
|
||
if not rel:
|
||
return None
|
||
name = rel.removeprefix("images/").lstrip("/")
|
||
if not name or ".." in Path(name).parts:
|
||
return None
|
||
return IMAGES_DIR / name
|
||
|
||
|
||
def delete_local_image(relative_path: str | None) -> None:
|
||
path = absolute_path(relative_path)
|
||
if path is not None and path.is_file():
|
||
path.unlink()
|
||
|
||
|
||
def save_data_url(data_url: str, *, file_prefix: str) -> str:
|
||
match = DATA_URL_PATTERN.match(data_url.strip())
|
||
if not match:
|
||
raise BrandingImageError("无效的图片数据")
|
||
|
||
ext = match.group(1).lower()
|
||
ext = EXT_ALIASES.get(ext, ext)
|
||
if ext not in {"png", "jpg", "gif", "webp", "svg"}:
|
||
raise BrandingImageError(f"不支持的图片格式: {ext}")
|
||
|
||
try:
|
||
raw = base64.b64decode(match.group(2), validate=True)
|
||
except Exception as exc:
|
||
raise BrandingImageError("图片 Base64 解码失败") from exc
|
||
|
||
if len(raw) > MAX_IMAGE_BYTES:
|
||
raise BrandingImageError("单张图片不能超过 5MB")
|
||
|
||
ensure_images_dir()
|
||
filename = f"oem_{file_prefix}_{uuid.uuid4().hex[:12]}.{ext}"
|
||
target = IMAGES_DIR / filename
|
||
target.write_bytes(raw)
|
||
return f"images/{filename}"
|
||
|
||
|
||
def resolve_image_field(
|
||
new_value: str | None,
|
||
old_path: str | None,
|
||
*,
|
||
file_prefix: str,
|
||
) -> str | None:
|
||
"""
|
||
- 空:删除旧文件,返回 None
|
||
- data URL:写入新文件,删除旧文件
|
||
- 已是 images/ 路径(或与旧路径相同):原样保留
|
||
"""
|
||
if new_value is None or not str(new_value).strip():
|
||
delete_local_image(old_path)
|
||
return None
|
||
|
||
text = str(new_value).strip()
|
||
if is_data_url(text):
|
||
delete_local_image(old_path)
|
||
return save_data_url(text, file_prefix=file_prefix)
|
||
|
||
rel = normalize_relative_path(text)
|
||
if rel is None:
|
||
raise BrandingImageError("图片须为上传数据或 images/ 下的路径")
|
||
|
||
old_rel = normalize_relative_path(old_path)
|
||
if rel == old_rel:
|
||
return rel
|
||
|
||
return rel
|