#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ FastAPI 版 SVG ➜ PNG 转换服务 ────────────────────────────────────────── 功能: 1. 自动检测 / 定位 Inkscape 2. 下载并嵌入 外链图片(Base64,支持中文 URL) 3. 支持 DPI / 宽高 / 背景色 / 透明 / 导出区域 / 指定元素 ID / 简化 SVG 4. 返回生成的 PNG 图片文件 运行: python main.py 接口: POST /convert ➜ Multipart 上传 svg_file + 参数,返回 image/png GET /health ➜ 健康检查 """ import os import platform import re import shutil import subprocess import tempfile import uuid from pathlib import Path from typing import Optional import base64 import mimetypes import urllib.request import urllib.parse from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse, JSONResponse # 导入字体安装函数 from install_fonts import install_fonts_from_repository # 安装字体 install_fonts_from_repository() ############################################################################### # 1. Inkscape 检测 ############################################################################### def ensure_inkscape_available() -> tuple[bool, str]: """检查 Inkscape 是否可用,不可用时尝试在常见路径中查找""" try: subprocess.run( ["inkscape", "--version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, ) return True, "在 PATH 中找到 Inkscape" except (subprocess.SubprocessError, FileNotFoundError): system = platform.system() common_paths: list[str] = [] if system == "Windows": common_paths = [ r"C:\Program Files\Inkscape\inkscape.exe", r"C:\Program Files (x86)\Inkscape\inkscape.exe", r"C:\Program Files\Inkscape\bin\inkscape.exe", r"C:\Program Files (x86)\Inkscape\bin\inkscape.exe", ] elif system == "Linux": common_paths = [ "/usr/bin/inkscape", "/usr/local/bin/inkscape", "/opt/inkscape/bin/inkscape", ] elif system == "Darwin": # macOS common_paths = [ "/Applications/Inkscape.app/Contents/MacOS/inkscape", "/usr/local/bin/inkscape", ] for p in common_paths: if os.path.exists(p): os.environ["PATH"] += os.pathsep + os.path.dirname(p) return True, f"在 {p} 找到 Inkscape" return False, "未找到 Inkscape,请安装或手动加入 PATH" ############################################################################### # 2. URL 规范化:解决中文 / 空格 / () 等字符 ############################################################################### #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ URL 规范化工具函数 ----------------- 功能特点 1. 支持中文域名及路径:自动在域名处使用 IDNA(Punycode),在路径等位置做 % 编码 2. **幂等**:已处理过的 URL 再次调用本函数不会出现二次编码 3. 兼容常见 URL 组件:scheme、netloc、path、params、query、fragment """ import urllib.parse as _url def normalize_url(url: str) -> str: """ 将包含中文或其它非 ASCII 字符的 URL 规范化为合法、安全的形式 参数: url (str): 原始 URL,可能含中文、空格、圆括号等字符 返回: str: 规范化后的 URL,可多次调用而结果保持一致 """ # 解析 URL 各组件 parsed = _url.urlparse(url) # -------------------------- 1) 处理域名 -------------------------- netloc = parsed.netloc try: # 若 netloc 已是 ASCII(含 Punycode)则无需转换 netloc.encode("ascii") except UnicodeEncodeError: # 含非 ASCII 字符时才按 IDNA 转为 Punycode netloc = netloc.encode("idna").decode("ascii") # -------------------------- 2) 处理路径等 ------------------------ # 先 unquote 再 quote,可避免二次编码 path = _url.quote(_url.unquote(parsed.path), safe="/") params = _url.quote(_url.unquote(parsed.params), safe=":&=") # query 使用 quote_plus 处理空格(+),同时保留 & = query = _url.quote_plus(_url.unquote_plus(parsed.query), safe="=&") fragment = _url.quote(_url.unquote(parsed.fragment), safe="") # -------------------------- 3) 重新组装 ------------------------- return _url.urlunparse(( parsed.scheme, netloc, path, params, query, fragment, )) ############################################################################### # 3. 预处理:下载并嵌入远程图片 ############################################################################### IMG_TAG_PATTERN = re.compile( r']+(?:href|xlink:href)\s*=\s*["\']([^"\']+)["\']', re.I ) def embed_external_images(svg_path: str) -> str: """把 svg 中的外链图片转为 data URI,返回新的 svg 路径""" with open(svg_path, "r", encoding="utf-8") as f: svg_text = f.read() # 若无 http/https 外链直接返回 if "http://" not in svg_text and "https://" not in svg_text: return svg_path def download_and_encode(url: str) -> str: """下载图片并转 data URI;失败则返回原 URL""" try: safe_url = normalize_url(url) # ⭐ 关键改动 req = urllib.request.Request( safe_url, headers={"User-Agent": "Mozilla/5.0"} ) with urllib.request.urlopen(req, timeout=20) as resp: data = resp.read() mime, _ = mimetypes.guess_type(safe_url) mime = mime or "image/png" b64 = base64.b64encode(data).decode() return f"data:{mime};base64,{b64}" except Exception as e: print(f"[WARN] 下载 {url} 失败:{e}") return url # 失败则保留原链接 # 批量替换 new_svg_text = IMG_TAG_PATTERN.sub( lambda m: m.group(0).replace(m.group(1), download_and_encode(m.group(1))), svg_text ) # 若缺少 xlink 命名空间则补上 if "xmlns:xlink" not in new_svg_text: new_svg_text = new_svg_text.replace( " tuple[Optional[str], str]: """调用 Inkscape CLI 将 SVG 转为 PNG,返回 (文件路径 or None, 消息)""" ok, msg = ensure_inkscape_available() if not ok: return None, msg processed_svg = embed_external_images(src_svg_path) tmp_dir = tempfile.mkdtemp() output_name = f"{uuid.uuid4()}.png" output_path = os.path.join(tmp_dir, output_name) cmd: list[str] = [ "inkscape", processed_svg, "--export-filename", output_path, "--export-dpi", str(dpi), ] if width and width > 0: cmd += ["--export-width", str(width)] if height and height > 0: cmd += ["--export-height", str(height)] if background_color != "transparent": cmd += ["--export-background", background_color] if export_area == "drawing": cmd.append("--export-area-drawing") elif export_area == "page": cmd.append("--export-area-page") elif export_area == "id" and export_id: cmd += ["--export-id", export_id] if export_plain_svg: cmd.append("--export-plain-svg") try: subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) if not os.path.exists(output_path): return None, "未生成 PNG 文件" out_dir = Path(__file__).parent / "output" out_dir.mkdir(exist_ok=True) final_path = out_dir / output_name shutil.copy2(output_path, final_path) shutil.rmtree(tmp_dir, ignore_errors=True) return str(final_path), "转换成功" except subprocess.CalledProcessError as e: return None, f"Inkscape 错误:{e.stderr}" except Exception as e: return None, f"未知错误:{e}" ############################################################################### # 5. FastAPI 应用 ############################################################################### app = FastAPI(title="SVG ➜ PNG 转换 API", version="1.1.0") @app.get("/health", summary="健康检查") def health(): return JSONResponse({"status": "ok"}) @app.post( "/convert", summary="SVG 转 PNG", response_class=FileResponse, responses={ 200: {"content": {"image/png": {}}}, 400: {"description": "请求或转换失败"}, }, ) async def convert_endpoint( svg_file: UploadFile = File(..., description="SVG 文件"), dpi: int = Form(96, ge=72, le=600, description="导出 DPI"), width: Optional[int] = Form(None, description="导出宽度(像素,留空保持原比例)"), height: Optional[int] = Form(None, description="导出高度(像素,留空保持原比例)"), background_color: str = Form("#ffffff", description="背景色(透明时忽略)"), transparent: bool = Form(True, description="是否透明背景"), export_area: str = Form("page", regex="^(page|drawing|id)$", description="导出区域"), export_id: Optional[str] = Form(None, description="当 export_area 为 id 时指定元素 ID"), export_plain_svg: bool = Form(False, description="导出前是否简化 SVG"), ): """ 使用 Inkscape 将上传的 SVG 转换为 PNG 并返回。 """ # 保存上传文件 temp_dir = tempfile.mkdtemp() uploaded_path = os.path.join(temp_dir, svg_file.filename) with open(uploaded_path, "wb") as f: f.write(await svg_file.read()) png_path, msg = convert_svg_to_png( src_svg_path=uploaded_path, dpi=dpi, width=width, height=height, background_color="transparent" if transparent else background_color, export_area=export_area, export_id=export_id if export_area == "id" else None, export_plain_svg=export_plain_svg, ) shutil.rmtree(temp_dir, ignore_errors=True) if png_path is None: raise HTTPException(status_code=400, detail=msg) return FileResponse( png_path, media_type="image/png", filename=Path(svg_file.filename).stem + ".png", ) ############################################################################### # 6. 入口 ############################################################################### if __name__ == "__main__": import uvicorn (Path(__file__).parent / "output").mkdir(exist_ok=True) uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)