File size: 8,109 Bytes
b76e106
3665577
b76e106
 
 
3665577
b76e106
 
 
 
 
 
 
 
 
 
 
 
 
 
3665577
 
 
b76e106
3665577
 
 
b76e106
3665577
b76e106
3665577
 
b76e106
3665577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76e106
3665577
 
 
b76e106
3665577
 
 
 
 
 
 
 
 
 
 
 
 
b76e106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
"""LaTeX 公式图片下发 / 按需抽取端点。

手表端 ``<image>`` + ``request.download`` 都无法携带自定义 header(平台限制),
所以沿用生图 / TTS 的 capability URL 思路:URL 里带内容寻址 id,
路径里的 id = sha1(latex|fs|fg) 前 20 位,服务端校验 id 与内容一致才渲染。

三个端点:
- ``GET  /v1/xtc/tex/{id}.png``   取 PNG(内容寻址,字节永不变化 → 可长缓存)
- ``POST /v1/xtc/tex/prepare``    批量预热:端上把「本地缓存缺失」的公式一次性送过来,
                                  服务端批量渲染填进进程内 LRU,之后逐个下载短 URL 即可。
                                  这样 URL 里不需要塞 LaTeX(避免超长 URL),
                                  也是「先查端上缓存,没有再请求后端」的服务端一半。
- ``POST /v1/xtc/tex/extract``    按需抽取:历史消息里没有公式清单时(功能上线前的老消息、
                                  或后端当时渲染失败),端上把「当前聚焦那条消息」的正文
                                  送过来,服务端重新抽取并渲染,回一份 texs 让端上落盘缓存。

为什么 ``{id}.png`` 要支持 ``?f=``:
  进程内 LRU 在 Space 重建 / 重启后必然为空。若 URL 里不带公式内容,重启后老消息的
  公式就永久 404(这正是改造前「很多公式渲染不出来」的根因之一)。
  带上 ``?f=``(以及 fs/fg)后,LRU 未命中也能按内容校验并重新渲染,URL 永久自洽。
"""
from __future__ import annotations

import asyncio
import logging
import time

from fastapi import APIRouter, Depends, Query, Request, Response

from ..auth import require_access_key
from ..errors import HttpError
from ..services import formula
from ._common import CORS_HEADERS, ok_with_cors, read_json_body

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/v1/xtc/tex", tags=["formula"])

# 每分钟最多按需渲染多少次(命中 LRU 不计入)。防公开端点被刷 CPU。
_RENDER_BUDGET = 600
_window_start = 0.0
_window_used = 0


def _allow_render() -> bool:
    global _window_start, _window_used
    now = time.time()
    if now - _window_start >= 60.0:
        _window_start = now
        _window_used = 0
    if _window_used >= _RENDER_BUDGET:
        return False
    _window_used += 1
    return True


@router.get("/{tex_id}.png")
async def get_tex_png(
    tex_id: str,
    f: str = Query(default="", description="LaTeX 正文(不带 $)"),
    fs: int = Query(default=formula.DEFAULT_FS),
    fg: str = Query(default=formula.DEFAULT_FG),
) -> Response:
    """返回公式 PNG(透明底)。端上用 request.download 拉取后落盘缓存。"""
    entry = formula.get_png(tex_id)
    if entry is None:
        # LRU 未命中:校验内容寻址 id 后按需渲染(Space 重启后老消息仍可显示)
        latex = str(f or "").strip()
        if not latex or len(latex) > formula._MAX_LATEX_LEN:
            raise HttpError("tex not found", status=404, code="not_found")
        if formula.formula_id(latex, fs, fg) != tex_id:
            raise HttpError("tex id mismatch", status=403, code="forbidden")
        if not _allow_render():
            raise HttpError("render rate limited", status=429, code="rate_limited")
        png = await asyncio.to_thread(formula.render_png, latex, fs, formula.DEFAULT_SCALE, fg)
        if png is None:
            raise HttpError("tex render failed", status=404, code="not_found")
        entry = png

    return Response(
        content=entry,
        media_type="image/png",
        headers={
            **CORS_HEADERS,
            # 内容寻址 → 同一 id 的字节永不变化,可长缓存
            "Cache-Control": "public, max-age=31536000, immutable",
        },
    )


@router.post("/prepare")
async def prepare_texs(request: Request, _key: str = Depends(require_access_key)):
    """批量预热公式 PNG 到进程内缓存。

    body: ``{"items": [{"id": "<id>", "latex": "...", "fs": 12}]}``
    返回: ``{"ok": true, "ready": [<id>...], "failed": [{"id","error"}...], "texs": [...]}``

    端上流程:本地 ``file.access`` 命中就跳过;未命中的 id 一次性送这里 → 成功后用
    ``{id}.png`` 短 URL 并发下载。
    """
    body = await read_json_body(request)
    raw = body.get("items")
    items = raw if isinstance(raw, list) else []
    if not items:
        return ok_with_cors({"ready": [], "failed": [], "texs": []})
    if len(items) > formula._MAX_FORMULAS * 2:
        items = items[: formula._MAX_FORMULAS * 2]

    fs_default = int(body.get("fs") or formula.DEFAULT_FS)
    wanted: list[dict] = []
    bad: list[dict] = []
    for it in items:
        if not isinstance(it, dict):
            continue
        latex = str(it.get("latex") or "").strip()
        tid = str(it.get("id") or "").strip()
        fs = int(it.get("fs") or fs_default)
        if not latex or len(latex) > formula._MAX_LATEX_LEN:
            bad.append({"id": tid, "error": "latex 为空或过长"})
            continue
        expect = formula.formula_id(latex, fs, formula.DEFAULT_FG)
        if tid and tid != expect:
            bad.append({"id": tid, "error": "id 与内容不匹配"})
            continue
        wanted.append({"latex": latex, "fs": fs, "id": expect})

    if not formula.is_available():
        raise HttpError("公式渲染不可用", status=503, code="service_unavailable")

    infos = await asyncio.to_thread(
        formula.render_many, [{"latex": w["latex"], "fs": w["fs"]} for w in wanted]
    )

    ready: list[str] = []
    failed: list[dict] = list(bad)
    texs: list[dict] = []
    for w in wanted:
        info = infos.get(w["id"])
        if not info:
            failed.append({"id": w["id"], "error": "渲染失败"})
            continue
        ready.append(w["id"])
        # 返回 w/h,端上不必再单独问尺寸。url 用短路径:
        # 端上在下载前会先调本端点把 LRU 预热,所以短 URL 足够;
        # 用短 URL 也能把会话 JSON 里每条公式的体积压到 ~40B(手表存储紧张)。
        texs.append({
            "id": w["id"],
            "latex": w["latex"],
            "w": info["w"],
            "h": info["h"],
            "url": "/v1/xtc/tex/" + w["id"] + ".png",
        })
    return ok_with_cors({"ready": ready, "failed": failed, "texs": texs})


@router.post("/extract")
async def extract_texs(request: Request, _key: str = Depends(require_access_key)):
    """按需抽取 + 渲染正文里的公式(历史消息没有公式清单时用)。

    body: ``{"text": "<消息正文>", "fs": 12, "limit": 16}``
    返回: ``{"ok": true, "texs": [{id, latex, src, start, end, w, h, url}], "count": n}``

    设计意图:端上只把「当前正在看的那条消息」送过来,避免为整段历史批量渲染
    (手表下载管理器有 70 个文件的限制,服务端渲染也是纯 CPU 开销)。
    """
    body = await read_json_body(request)
    text = str(body.get("text") or "")
    if not text.strip():
        return ok_with_cors({"texs": [], "count": 0})
    # 正文长度上限:单条消息最多 ~20000 字,超出的截断(抽取/渲染只针对可见内容)
    if len(text) > 20000:
        text = text[:20000]
    fs = int(body.get("fs") or formula.DEFAULT_FS)
    try:
        limit = int(body.get("limit") or formula._MAX_FORMULAS)
    except Exception:
        limit = formula._MAX_FORMULAS
    limit = max(1, min(formula._MAX_FORMULAS, limit))

    if not formula.is_available():
        raise HttpError("公式渲染不可用", status=503, code="service_unavailable")

    texs = await asyncio.to_thread(formula.build_texs, text, fs, limit)
    return ok_with_cors({"texs": texs, "count": len(texs)})


@router.get("/stats")
async def tex_stats(_key: str = Depends(require_access_key)):
    """诊断:侧车是否就绪、缓存条数、上限配置。"""
    return ok_with_cors({"stats": formula.stats()})