innoai commited on
Commit
7d06e7c
·
verified ·
1 Parent(s): 9b008bc

Create main.py

Browse files
Files changed (1) hide show
  1. main.py +272 -0
main.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI 版 SVG ➜ PNG 转换服务
3
+ ──────────────────────────────────────────
4
+ 与 Gradio 版本保持同等功能:
5
+ 1. 自动检测 / 定位 Inkscape
6
+ 2. 自动下载并嵌入 <image href="http/https"> 外链图片(Base64)
7
+ 3. 支持 DPI / 宽高 / 背景色 / 透明 / 导出区域 / 指定元素 ID / 简化 SVG
8
+ 4. 返回生成的 PNG 图片文件
9
+ ──────────────────────────────────────────
10
+ 运行:
11
+ python main.py
12
+ 接口:
13
+ POST /convert ➜ Multipart 表单上传 svg_file + 各参数,返回 image/png
14
+ GET /health ➜ 简单健康检查
15
+ """
16
+
17
+ import os
18
+ import subprocess
19
+ import platform
20
+ import tempfile
21
+ import shutil
22
+ import uuid
23
+ import base64
24
+ import mimetypes
25
+ import urllib.request
26
+ import re
27
+ from pathlib import Path
28
+ from typing import Optional
29
+
30
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
31
+ from fastapi.responses import FileResponse, JSONResponse
32
+
33
+ ###############################################################################
34
+ # 1. Inkscape 检测
35
+ ###############################################################################
36
+ def ensure_inkscape_available() -> tuple[bool, str]:
37
+ """检查 Inkscape 是否可用,不可用时尝试在常见路径中查找"""
38
+ try:
39
+ subprocess.run(
40
+ ["inkscape", "--version"],
41
+ stdout=subprocess.PIPE,
42
+ stderr=subprocess.PIPE,
43
+ check=True,
44
+ )
45
+ return True, "在 PATH 中找到 Inkscape"
46
+ except (subprocess.SubprocessError, FileNotFoundError):
47
+ system = platform.system()
48
+ common_paths: list[str] = []
49
+ if system == "Windows":
50
+ common_paths = [
51
+ r"C:\Program Files\Inkscape\bin\inkscape.exe",
52
+ r"C:\Program Files (x86)\Inkscape\bin\inkscape.exe",
53
+ r"C:\Program Files\Inkscape\inkscape.exe",
54
+ r"C:\Program Files (x86)\Inkscape\inkscape.exe",
55
+ ]
56
+ elif system == "Linux":
57
+ common_paths = [
58
+ "/usr/bin/inkscape",
59
+ "/usr/local/bin/inkscape",
60
+ "/opt/inkscape/bin/inkscape",
61
+ ]
62
+ elif system == "Darwin": # macOS
63
+ common_paths = [
64
+ "/Applications/Inkscape.app/Contents/MacOS/inkscape",
65
+ "/usr/local/bin/inkscape",
66
+ ]
67
+
68
+ for path in common_paths:
69
+ if os.path.exists(path):
70
+ os.environ["PATH"] += os.pathsep + os.path.dirname(path)
71
+ return True, f"在 {path} 找到 Inkscape"
72
+
73
+ return False, "未找到 Inkscape,请安装或手动加入 PATH"
74
+
75
+ ###############################################################################
76
+ # 2. 下载并嵌入外链图片
77
+ ###############################################################################
78
+ # 匹配 <image ... href="http(s)://..."> 或 xlink:href
79
+ IMG_TAG_PATTERN = re.compile(
80
+ r'<image[^>]+(?:href|xlink:href)\s*=\s*["\']([^"\']+)["\']', re.I
81
+ )
82
+
83
+ def download_and_encode(url: str) -> str:
84
+ """下载远程图片并转为 data URI(Base64)"""
85
+ try:
86
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
87
+ with urllib.request.urlopen(req, timeout=15) as resp:
88
+ data = resp.read()
89
+ mime, _ = mimetypes.guess_type(url)
90
+ mime = mime or "image/png"
91
+ b64 = base64.b64encode(data).decode("ascii")
92
+ return f"data:{mime};base64,{b64}"
93
+ except Exception as e:
94
+ # 失败则返回原链接(Inkscape 可能渲染空白 / 红叉)
95
+ print(f"[WARN] 下载外链图片失败 {url}: {e}")
96
+ return url
97
+
98
+ def embed_external_images(svg_path: str) -> str:
99
+ """把 SVG 中的远程图片下载并嵌入(Base64),返回新的临时 SVG 路径"""
100
+ with open(svg_path, "r", encoding="utf-8") as f:
101
+ svg_text = f.read()
102
+
103
+ if "http://" not in svg_text and "https://" not in svg_text:
104
+ return svg_path # 无外链,直接返回
105
+
106
+ def replacer(match: re.Match) -> str:
107
+ url = match.group(1)
108
+ if url.startswith(("http://", "https://")):
109
+ data_uri = download_and_encode(url)
110
+ return match.group(0).replace(url, data_uri)
111
+ return match.group(0)
112
+
113
+ new_svg = IMG_TAG_PATTERN.sub(replacer, svg_text)
114
+
115
+ # 若缺失 xlink 命名空间,则补上
116
+ if "xmlns:xlink" not in new_svg:
117
+ new_svg = new_svg.replace(
118
+ "<svg", '<svg xmlns:xlink="http://www.w3.org/1999/xlink"', 1
119
+ )
120
+
121
+ tmp_dir = tempfile.mkdtemp()
122
+ new_path = os.path.join(tmp_dir, Path(svg_path).name)
123
+ with open(new_path, "w", encoding="utf-8") as f:
124
+ f.write(new_svg)
125
+ return new_path
126
+
127
+ ###############################################################################
128
+ # 3. 核心转换函数
129
+ ###############################################################################
130
+ def convert_svg_to_png(
131
+ src_svg_path: str,
132
+ dpi: int = 96,
133
+ width: Optional[int] = None,
134
+ height: Optional[int] = None,
135
+ background_color: str = "transparent",
136
+ export_area: str = "page",
137
+ export_id: Optional[str] = None,
138
+ export_plain_svg: bool = False,
139
+ ) -> tuple[Optional[str], str]:
140
+ """调用 Inkscape CLI 将 SVG 转为 PNG,返回 (文件路径 or None, 消息)"""
141
+
142
+ ok, msg = ensure_inkscape_available()
143
+ if not ok:
144
+ return None, msg
145
+
146
+ # 预处理外链
147
+ processed_svg = embed_external_images(src_svg_path)
148
+
149
+ tmp_dir = tempfile.mkdtemp()
150
+ output_name = f"{uuid.uuid4()}.png"
151
+ output_path = os.path.join(tmp_dir, output_name)
152
+
153
+ cmd: list[str] = [
154
+ "inkscape",
155
+ processed_svg,
156
+ "--export-filename",
157
+ output_path,
158
+ "--export-dpi",
159
+ str(dpi),
160
+ ]
161
+ if width and width > 0:
162
+ cmd += ["--export-width", str(width)]
163
+ if height and height > 0:
164
+ cmd += ["--export-height", str(height)]
165
+ if background_color != "transparent":
166
+ cmd += ["--export-background", background_color]
167
+
168
+ if export_area == "drawing":
169
+ cmd.append("--export-area-drawing")
170
+ elif export_area == "page":
171
+ cmd.append("--export-area-page")
172
+ elif export_area == "id" and export_id:
173
+ cmd += ["--export-id", export_id]
174
+
175
+ if export_plain_svg:
176
+ cmd.append("--export-plain-svg")
177
+
178
+ try:
179
+ subprocess.run(
180
+ cmd,
181
+ stdout=subprocess.PIPE,
182
+ stderr=subprocess.PIPE,
183
+ text=True,
184
+ check=True,
185
+ )
186
+ if not os.path.exists(output_path):
187
+ return None, "未生成 PNG 文件"
188
+
189
+ # 把结果移动到项目根目录 output 目录
190
+ out_dir = Path(__file__).parent / "output"
191
+ out_dir.mkdir(exist_ok=True)
192
+ final_path = out_dir / output_name
193
+ shutil.copy2(output_path, final_path)
194
+ shutil.rmtree(tmp_dir, ignore_errors=True)
195
+ return str(final_path), "转换成功"
196
+ except subprocess.CalledProcessError as e:
197
+ return None, f"Inkscape 错误:{e.stderr}"
198
+ except Exception as e:
199
+ return None, f"未知错误:{e}"
200
+
201
+ ###############################################################################
202
+ # 4. FastAPI 应用
203
+ ###############################################################################
204
+ app = FastAPI(title="SVG ➜ PNG 转换 API", version="1.0.0")
205
+
206
+ @app.get("/health", summary="健康检查")
207
+ def health():
208
+ return {"status": "ok"}
209
+
210
+ @app.post(
211
+ "/convert",
212
+ summary="SVG 转 PNG",
213
+ response_class=FileResponse,
214
+ responses={
215
+ 200: {"content": {"image/png": {}}},
216
+ 400: {"description": "请求或转换失败"},
217
+ },
218
+ )
219
+ async def convert_endpoint(
220
+ svg_file: UploadFile = File(..., description="SVG 文件"),
221
+ dpi: int = Form(96, ge=72, le=600, description="导出 DPI"),
222
+ width: Optional[int] = Form(None, description="导出宽度(像素,留空保持原比例)"),
223
+ height: Optional[int] = Form(None, description="导出高度(像素,留空保持原比例)"),
224
+ background_color: str = Form("#ffffff", description="背景色(透明时忽略)"),
225
+ transparent: bool = Form(True, description="是否透明背景"),
226
+ export_area: str = Form("page", regex="^(page|drawing|id)$", description="导出区域"),
227
+ export_id: Optional[str] = Form(None, description="当 export_area 为 id 时指定元素 ID"),
228
+ export_plain_svg: bool = Form(False, description="导出前是否简化 SVG"),
229
+ ):
230
+ """
231
+ 使用 Inkscape 将上传的 SVG 转换为 PNG 并返回。
232
+ 所有参数与 Gradio 版本保持一致。
233
+ """
234
+ # 保存上传文件到临时目录
235
+ temp_dir = tempfile.mkdtemp()
236
+ uploaded_path = os.path.join(temp_dir, svg_file.filename)
237
+ with open(uploaded_path, "wb") as f:
238
+ f.write(await svg_file.read())
239
+
240
+ png_path, msg = convert_svg_to_png(
241
+ src_svg_path=uploaded_path,
242
+ dpi=dpi,
243
+ width=width,
244
+ height=height,
245
+ background_color="transparent" if transparent else background_color,
246
+ export_area=export_area,
247
+ export_id=export_id if export_area == "id" else None,
248
+ export_plain_svg=export_plain_svg,
249
+ )
250
+
251
+ shutil.rmtree(temp_dir, ignore_errors=True)
252
+
253
+ if png_path is None:
254
+ raise HTTPException(status_code=400, detail=msg)
255
+
256
+ # 返回 PNG 文件流
257
+ return FileResponse(
258
+ png_path,
259
+ media_type="image/png",
260
+ filename=Path(svg_file.filename).stem + ".png",
261
+ )
262
+
263
+ ###############################################################################
264
+ # 5. 入口
265
+ ###############################################################################
266
+ if __name__ == "__main__":
267
+ import uvicorn
268
+
269
+ # 确保 output 目录存在
270
+ (Path(__file__).parent / "output").mkdir(exist_ok=True)
271
+
272
+ uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)