fix(export): make AI converter build offline
Build, Push and Deploy / build (push) Successful in 6s
Build, Push and Deploy / deploy (push) Successful in 14s

This commit is contained in:
lai_hong
2026-09-13 13:12:07 +08:00
parent 7a506114a2
commit d870d0ecf8
3 changed files with 75 additions and 30 deletions
+2 -4
View File
@@ -4,13 +4,11 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY requirements.txt ./ RUN useradd --system --no-create-home converter
RUN pip install --no-cache-dir -r requirements.txt \
&& useradd --system --no-create-home converter
COPY app.py ./ COPY app.py ./
USER converter USER converter
EXPOSE 8090 EXPOSE 8090
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"] CMD ["python", "app.py"]
+71 -22
View File
@@ -7,17 +7,15 @@ features that would otherwise produce a silently corrupted AI document.
from __future__ import annotations from __future__ import annotations
import json
import math import math
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Iterable from typing import Iterable
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
app = FastAPI(title="WordCloud AI Converter", version="0.1.0")
MAX_SVG_BYTES = 25 * 1024 * 1024 MAX_SVG_BYTES = 25 * 1024 * 1024
SVG_NS = "{http://www.w3.org/2000/svg}" SVG_NS = "{http://www.w3.org/2000/svg}"
NUMBER = r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?" NUMBER = r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?"
@@ -25,36 +23,83 @@ PATH_TOKENS = re.compile(rf"[AaCcHhLlMmQqSsTtVvZz]|{NUMBER}")
TRANSFORM = re.compile(rf"([A-Za-z]+)\s*\(([^)]*)\)") TRANSFORM = re.compile(rf"([A-Za-z]+)\s*\(([^)]*)\)")
@app.get("/health") def convert_svg(raw: bytes) -> bytes:
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/convert")
async def convert(request: Request) -> Response:
raw = await request.body()
if not raw: if not raw:
raise HTTPException(status_code=400, detail="SVG 内容不能为空") raise RequestError(HTTPStatus.BAD_REQUEST, "SVG 内容不能为空")
if len(raw) > MAX_SVG_BYTES: if len(raw) > MAX_SVG_BYTES:
raise HTTPException(status_code=413, detail="SVG 文件超过 25 MB 限制") raise RequestError(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "SVG 文件超过 25 MB 限制")
if b"<!DOCTYPE" in raw.upper() or b"<!ENTITY" in raw.upper(): if b"<!DOCTYPE" in raw.upper() or b"<!ENTITY" in raw.upper():
raise HTTPException(status_code=400, detail="不支持包含 DTD 或实体的 SVG") raise RequestError(HTTPStatus.BAD_REQUEST, "不支持包含 DTD 或实体的 SVG")
try: try:
root = ET.fromstring(raw) root = ET.fromstring(raw)
ai = svg_to_ai(root) ai = svg_to_ai(root)
except UnsupportedSvg as exc: except UnsupportedSvg as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise RequestError(HTTPStatus.UNPROCESSABLE_ENTITY, str(exc)) from exc
except (ET.ParseError, ValueError) as exc: except (ET.ParseError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"SVG 解析失败: {exc}") from exc raise RequestError(HTTPStatus.BAD_REQUEST, f"SVG 解析失败: {exc}") from exc
return ai.encode("ascii")
return Response(
content=ai.encode("ascii"), class RequestError(ValueError):
media_type="application/postscript", def __init__(self, status: HTTPStatus, detail: str):
headers={"Content-Disposition": 'attachment; filename="wordcloud.ai"'}, self.status = status
self.detail = detail
super().__init__(detail)
class ConverterHandler(BaseHTTPRequestHandler):
server_version = "WordCloudAIConverter/0.1"
def do_GET(self) -> None:
if self.path == "/health":
self.send_bytes(HTTPStatus.OK, b'{"status":"ok"}', "application/json")
return
self.send_error(HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
if self.path != "/convert":
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
length = int(self.headers.get("Content-Length", ""))
if length < 0:
raise ValueError
except ValueError:
self.send_json_error(HTTPStatus.BAD_REQUEST, "缺少或无效 Content-Length")
return
if length > MAX_SVG_BYTES:
self.send_json_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "SVG 文件超过 25 MB 限制")
return
raw = self.rfile.read(length)
try:
ai = convert_svg(raw)
except RequestError as exc:
self.send_json_error(exc.status, exc.detail)
return
self.send_bytes(
HTTPStatus.OK,
ai,
"application/postscript",
{"Content-Disposition": 'attachment; filename="wordcloud.ai"'},
) )
def send_json_error(self, status: HTTPStatus, detail: str) -> None:
body = json.dumps({"detail": detail}, ensure_ascii=False).encode("utf-8")
self.send_bytes(status, body, "application/json; charset=utf-8")
def send_bytes(self, status: HTTPStatus, body: bytes, content_type: str, headers: dict[str, str] | None = None) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
for key, value in (headers or {}).items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def log_message(self, _format: str, *_args: object) -> None:
# The reverse proxy already logs requests; avoid echoing SVG payloads.
return
class UnsupportedSvg(ValueError): class UnsupportedSvg(ValueError):
pass pass
@@ -410,3 +455,7 @@ def format_number(value: float) -> str:
def n(value: float) -> str: def n(value: float) -> str:
return format_number(value) return format_number(value)
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8090), ConverterHandler).serve_forever()
-2
View File
@@ -1,2 +0,0 @@
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1