fix(export): make AI converter build offline
This commit is contained in:
@@ -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"]
|
||||
|
||||
+71
-22
@@ -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
|
||||
raise RequestError(HTTPStatus.BAD_REQUEST, f"SVG 解析失败: {exc}") from exc
|
||||
return ai.encode("ascii")
|
||||
|
||||
return Response(
|
||||
content=ai.encode("ascii"),
|
||||
media_type="application/postscript",
|
||||
headers={"Content-Disposition": 'attachment; filename="wordcloud.ai"'},
|
||||
|
||||
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()
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn[standard]>=0.30,<1
|
||||
Reference in New Issue
Block a user