fix(export): make AI converter build offline
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
fastapi>=0.115,<1
|
|
||||||
uvicorn[standard]>=0.30,<1
|
|
||||||
Reference in New Issue
Block a user