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
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt \
&& useradd --system --no-create-home converter
RUN useradd --system --no-create-home converter
COPY app.py ./
USER converter
EXPOSE 8090
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8090"]
CMD ["python", "app.py"]
+73 -24
View File
@@ -7,17 +7,15 @@ features that would otherwise produce a silently corrupted AI document.
from __future__ import annotations
import json
import math
import re
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Iterable
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
SVG_NS = "{http://www.w3.org/2000/svg}"
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*\(([^)]*)\)")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/convert")
async def convert(request: Request) -> Response:
raw = await request.body()
def convert_svg(raw: bytes) -> bytes:
if not raw:
raise HTTPException(status_code=400, detail="SVG 内容不能为空")
raise RequestError(HTTPStatus.BAD_REQUEST, "SVG 内容不能为空")
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():
raise HTTPException(status_code=400, detail="不支持包含 DTD 或实体的 SVG")
raise RequestError(HTTPStatus.BAD_REQUEST, "不支持包含 DTD 或实体的 SVG")
try:
root = ET.fromstring(raw)
ai = svg_to_ai(root)
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:
raise HTTPException(status_code=400, detail=f"SVG 解析失败: {exc}") from exc
return Response(
content=ai.encode("ascii"),
media_type="application/postscript",
headers={"Content-Disposition": 'attachment; filename="wordcloud.ai"'},
)
raise RequestError(HTTPStatus.BAD_REQUEST, f"SVG 解析失败: {exc}") from exc
return ai.encode("ascii")
class RequestError(ValueError):
def __init__(self, status: HTTPStatus, detail: str):
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):
pass
@@ -410,3 +455,7 @@ def format_number(value: float) -> str:
def n(value: float) -> str:
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