update at 2026-02-08 22:31:25
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
`apiserver/` 提供微信小程序用的远端渲染接口:
|
`apiserver/` 提供微信小程序用的远端渲染接口:
|
||||||
- 小程序只上传参数(字体 ID、文字、字号、颜色等)
|
- 小程序只上传参数(字体 ID、文字、字号、颜色等)
|
||||||
- 服务端读取 `fonts.json` + `fonts/`,生成 SVG 后返回
|
- 服务端读取 `fonts.json` + `fonts/`,生成 SVG/PNG 后返回
|
||||||
|
|
||||||
## 1. 启动
|
## 1. 启动
|
||||||
|
|
||||||
@@ -43,6 +43,11 @@ python3 apiserver/server.py \
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### POST `/api/render-png`
|
||||||
|
|
||||||
|
请求体与 `/api/render-svg` 相同,成功时直接返回 `image/png` 二进制。
|
||||||
|
小程序应使用 `wx.request({ responseType: 'arraybuffer' })` 接收。
|
||||||
|
|
||||||
成功响应:
|
成功响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -82,8 +87,20 @@ location /api/ {
|
|||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
## 4. 约束
|
## 4. systemd(可选)
|
||||||
|
|
||||||
|
仓库内提供示例:`apiserver/font2svg-api.service.example`,可复制到:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp apiserver/font2svg-api.service.example /etc/systemd/system/font2svg-api.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now font2svg-api
|
||||||
|
sudo systemctl status font2svg-api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 约束
|
||||||
|
|
||||||
- 字体解析完全基于 `fonts.json`,`fontId` 必须存在。
|
- 字体解析完全基于 `fonts.json`,`fontId` 必须存在。
|
||||||
- 服务端启用 CORS,允许小程序访问。
|
- 服务端启用 CORS,允许小程序访问。
|
||||||
- 不依赖 Flask/FastAPI,使用 Python 标准库 HTTP 服务。
|
- 不依赖 Flask/FastAPI,使用 Python 标准库 HTTP 服务。
|
||||||
|
- `/api/render-png` 依赖 `node + sharp`(使用 `apiserver/svg_to_png.js` 转换)。
|
||||||
|
|||||||
14
apiserver/font2svg-api.service.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Font2SVG API Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=gavin
|
||||||
|
WorkingDirectory=/home/gavin/font2svg
|
||||||
|
ExecStart=/home/gavin/font2svg/.venv/bin/python /home/gavin/font2svg/apiserver/server.py --host 127.0.0.1 --port 9300 --static-root /home/gavin/font2svg
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
55
apiserver/png_renderer.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""服务端 PNG 渲染:通过 sharp 将 SVG 转为 PNG。"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def _script_path():
|
||||||
|
return os.path.join(os.path.dirname(__file__), "svg_to_png.js")
|
||||||
|
|
||||||
|
|
||||||
|
def render_png_from_svg(svg_text, width, height, *, timeout_seconds=20):
|
||||||
|
if not svg_text or not str(svg_text).strip():
|
||||||
|
raise ValueError("SVG 内容为空")
|
||||||
|
|
||||||
|
script = _script_path()
|
||||||
|
if not os.path.isfile(script):
|
||||||
|
raise FileNotFoundError(f"未找到 SVG 转 PNG 脚本: {script}")
|
||||||
|
|
||||||
|
safe_width = max(1, min(4096, int(round(float(width or 0) or 0))))
|
||||||
|
safe_height = max(1, min(4096, int(round(float(height or 0) or 0))))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"node",
|
||||||
|
script,
|
||||||
|
"--width",
|
||||||
|
str(safe_width),
|
||||||
|
"--height",
|
||||||
|
str(safe_height),
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
input=str(svg_text).encode("utf-8"),
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout_seconds,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as error:
|
||||||
|
raise RuntimeError("未找到 node,请先安装 Node.js") from error
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
stderr = completed.stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
if "Cannot find module 'sharp'" in stderr:
|
||||||
|
raise RuntimeError("缺少 sharp 依赖,请在项目根目录执行: npm install")
|
||||||
|
raise RuntimeError(stderr or f"PNG 渲染失败,退出码: {completed.returncode}")
|
||||||
|
|
||||||
|
png_bytes = completed.stdout
|
||||||
|
if not png_bytes:
|
||||||
|
raise RuntimeError("PNG 渲染失败,返回空内容")
|
||||||
|
|
||||||
|
return png_bytes
|
||||||
@@ -14,6 +14,10 @@ try:
|
|||||||
from .renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
|
from .renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
|
from renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
|
||||||
|
try:
|
||||||
|
from .png_renderer import render_png_from_svg
|
||||||
|
except ImportError:
|
||||||
|
from png_renderer import render_png_from_svg
|
||||||
|
|
||||||
LOGGER = logging.getLogger("font2svg.api")
|
LOGGER = logging.getLogger("font2svg.api")
|
||||||
|
|
||||||
@@ -175,6 +179,66 @@ class RenderHandler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_binary(self, status_code, body, content_type):
|
||||||
|
self.send_response(status_code)
|
||||||
|
self._set_cors_headers()
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _parse_render_payload(self):
|
||||||
|
try:
|
||||||
|
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("请求长度无效") from None
|
||||||
|
|
||||||
|
if content_length <= 0 or content_length > 256 * 1024:
|
||||||
|
raise ValueError("请求体大小无效")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_body = self.rfile.read(content_length)
|
||||||
|
payload = json.loads(raw_body.decode("utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise ValueError("请求体不是有效 JSON") from None
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("请求体格式错误")
|
||||||
|
|
||||||
|
font_id = str(payload.get("fontId") or "").strip()
|
||||||
|
text = str(payload.get("text") or "")
|
||||||
|
if not font_id:
|
||||||
|
raise ValueError("缺少 fontId")
|
||||||
|
if not text.strip():
|
||||||
|
raise ValueError("文本内容不能为空")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fontId": font_id,
|
||||||
|
"text": text,
|
||||||
|
"fontSize": _safe_number(payload.get("fontSize"), 120.0, 8.0, 1024.0, float),
|
||||||
|
"letterSpacing": _safe_number(payload.get("letterSpacing"), 0.0, -2.0, 5.0, float),
|
||||||
|
"maxCharsPerLine": _safe_number(
|
||||||
|
payload.get("maxCharsPerLine"),
|
||||||
|
MAX_CHARS_PER_LINE,
|
||||||
|
1,
|
||||||
|
300,
|
||||||
|
int,
|
||||||
|
),
|
||||||
|
"fillColor": _normalize_hex_color(payload.get("fillColor"), "#000000"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _render_svg_core(self, render_params):
|
||||||
|
font_info = self.catalog.get(render_params["fontId"])
|
||||||
|
result = render_svg_from_font_file(
|
||||||
|
font_info["path"],
|
||||||
|
render_params["text"],
|
||||||
|
font_size=render_params["fontSize"],
|
||||||
|
fill_color=render_params["fillColor"],
|
||||||
|
letter_spacing=render_params["letterSpacing"],
|
||||||
|
max_chars_per_line=render_params["maxCharsPerLine"],
|
||||||
|
)
|
||||||
|
return font_info, result
|
||||||
|
|
||||||
def do_OPTIONS(self): # noqa: N802
|
def do_OPTIONS(self): # noqa: N802
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
self._set_cors_headers()
|
self._set_cors_headers()
|
||||||
@@ -195,61 +259,13 @@ class RenderHandler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_POST(self): # noqa: N802
|
def do_POST(self): # noqa: N802
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
if parsed.path != "/api/render-svg":
|
if parsed.path not in ("/api/render-svg", "/api/render-png"):
|
||||||
self._send_json(404, {"ok": False, "error": "Not Found"})
|
self._send_json(404, {"ok": False, "error": "Not Found"})
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
render_params = self._parse_render_payload()
|
||||||
except ValueError:
|
font_info, result = self._render_svg_core(render_params)
|
||||||
self._send_json(400, {"ok": False, "error": "请求长度无效"})
|
|
||||||
return
|
|
||||||
|
|
||||||
if content_length <= 0 or content_length > 256 * 1024:
|
|
||||||
self._send_json(400, {"ok": False, "error": "请求体大小无效"})
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
raw_body = self.rfile.read(content_length)
|
|
||||||
payload = json.loads(raw_body.decode("utf-8"))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
self._send_json(400, {"ok": False, "error": "请求体不是有效 JSON"})
|
|
||||||
return
|
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
self._send_json(400, {"ok": False, "error": "请求体格式错误"})
|
|
||||||
return
|
|
||||||
|
|
||||||
font_id = str(payload.get("fontId") or "").strip()
|
|
||||||
text = str(payload.get("text") or "")
|
|
||||||
if not font_id:
|
|
||||||
self._send_json(400, {"ok": False, "error": "缺少 fontId"})
|
|
||||||
return
|
|
||||||
if not text.strip():
|
|
||||||
self._send_json(400, {"ok": False, "error": "文本内容不能为空"})
|
|
||||||
return
|
|
||||||
|
|
||||||
font_size = _safe_number(payload.get("fontSize"), 120.0, 8.0, 1024.0, float)
|
|
||||||
letter_spacing = _safe_number(payload.get("letterSpacing"), 0.0, -2.0, 5.0, float)
|
|
||||||
max_chars_per_line = _safe_number(
|
|
||||||
payload.get("maxCharsPerLine"),
|
|
||||||
MAX_CHARS_PER_LINE,
|
|
||||||
1,
|
|
||||||
300,
|
|
||||||
int,
|
|
||||||
)
|
|
||||||
fill_color = _normalize_hex_color(payload.get("fillColor"), "#000000")
|
|
||||||
|
|
||||||
try:
|
|
||||||
font_info = self.catalog.get(font_id)
|
|
||||||
result = render_svg_from_font_file(
|
|
||||||
font_info["path"],
|
|
||||||
text,
|
|
||||||
font_size=font_size,
|
|
||||||
fill_color=fill_color,
|
|
||||||
letter_spacing=letter_spacing,
|
|
||||||
max_chars_per_line=max_chars_per_line,
|
|
||||||
)
|
|
||||||
except KeyError as error:
|
except KeyError as error:
|
||||||
self._send_json(404, {"ok": False, "error": str(error)})
|
self._send_json(404, {"ok": False, "error": str(error)})
|
||||||
return
|
return
|
||||||
@@ -264,12 +280,27 @@ class RenderHandler(BaseHTTPRequestHandler):
|
|||||||
self._send_json(500, {"ok": False, "error": str(error)})
|
self._send_json(500, {"ok": False, "error": str(error)})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if parsed.path == "/api/render-png":
|
||||||
|
try:
|
||||||
|
png_bytes = render_png_from_svg(
|
||||||
|
result["svg"],
|
||||||
|
result["width"],
|
||||||
|
result["height"],
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
LOGGER.exception("PNG 渲染失败")
|
||||||
|
self._send_json(500, {"ok": False, "error": str(error)})
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send_binary(200, png_bytes, "image/png")
|
||||||
|
return
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"svg": result["svg"],
|
"svg": result["svg"],
|
||||||
"width": result["width"],
|
"width": result["width"],
|
||||||
"height": result["height"],
|
"height": result["height"],
|
||||||
"fontName": result.get("fontName") or font_info.get("name") or "Unknown",
|
"fontName": result.get("fontName") or font_info.get("name") or "Unknown",
|
||||||
"fontId": font_id,
|
"fontId": render_params["fontId"],
|
||||||
}
|
}
|
||||||
self._send_json(200, {"ok": True, "data": response_data})
|
self._send_json(200, {"ok": True, "data": response_data})
|
||||||
|
|
||||||
|
|||||||
61
apiserver/svg_to_png.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const sharp = require('sharp')
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const parsed = {}
|
||||||
|
for (let i = 2; i < argv.length; i += 1) {
|
||||||
|
const key = argv[i]
|
||||||
|
const value = argv[i + 1]
|
||||||
|
if (key === '--width' && value) {
|
||||||
|
parsed.width = Number(value)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (key === '--height' && value) {
|
||||||
|
parsed.height = Number(value)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv)
|
||||||
|
const chunks = []
|
||||||
|
|
||||||
|
process.stdin.on('data', (chunk) => {
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
|
||||||
|
process.stdin.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const svgBuffer = Buffer.concat(chunks)
|
||||||
|
if (!svgBuffer.length) {
|
||||||
|
throw new Error('empty svg input')
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Number.isFinite(args.width) && args.width > 0 ? Math.round(args.width) : null
|
||||||
|
const height = Number.isFinite(args.height) && args.height > 0 ? Math.round(args.height) : null
|
||||||
|
|
||||||
|
let pipeline = sharp(svgBuffer, { density: 300 })
|
||||||
|
if (width || height) {
|
||||||
|
pipeline = pipeline.resize({
|
||||||
|
width: width || undefined,
|
||||||
|
height: height || undefined,
|
||||||
|
fit: 'contain',
|
||||||
|
background: { r: 255, g: 255, b: 255, alpha: 1 },
|
||||||
|
withoutEnlargement: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pngBuffer = await pipeline.png({ compressionLevel: 9 }).toBuffer()
|
||||||
|
process.stdout.write(pngBuffer)
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`svg_to_png_error: ${error && error.message ? error.message : String(error)}\n`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
49
fonts.conf
@@ -1,5 +1,5 @@
|
|||||||
# Font2SVG - Nginx 配置(fonts.biboer.cn)
|
# Font2SVG - Nginx 配置(fonts.biboer.cn)
|
||||||
# 用途:为微信小程序提供 fonts.json 与字体文件静态托管
|
# 用途:为微信小程序提供静态字体资源 + 远端 SVG 渲染 API
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
@@ -30,8 +30,8 @@ server {
|
|||||||
|
|
||||||
# 小程序跨域访问
|
# 小程序跨域访问
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always;
|
||||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" always;
|
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always;
|
||||||
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
|
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
|
||||||
|
|
||||||
# MIME
|
# MIME
|
||||||
@@ -44,8 +44,12 @@ server {
|
|||||||
application/vnd.ms-fontobject eot;
|
application/vnd.ms-fontobject eot;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SVG 渲染 API(独立 Python 服务)
|
# SVG 渲染 API(独立 Python 服务,systemd 监听 127.0.0.1:9300)
|
||||||
location /api/ {
|
location ^~ /api/ {
|
||||||
|
# 预检请求:直接返回 204(CORS 头由 server 级 add_header 提供)
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
proxy_pass http://127.0.0.1:9300;
|
proxy_pass http://127.0.0.1:9300;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -57,13 +61,23 @@ server {
|
|||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 健康检查(可选)
|
||||||
|
location = /healthz {
|
||||||
|
proxy_pass http://127.0.0.1:9300/healthz;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
# fonts.json:短缓存,便于更新
|
# fonts.json:短缓存,便于更新
|
||||||
location = /fonts.json {
|
location = /fonts.json {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
add_header Cache-Control "public, must-revalidate" always;
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always;
|
||||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" always;
|
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always;
|
||||||
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
|
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
@@ -71,27 +85,16 @@ server {
|
|||||||
# 字体文件:长缓存
|
# 字体文件:长缓存
|
||||||
location ~* \.(ttf|otf|woff|woff2|eot)$ {
|
location ~* \.(ttf|otf|woff|woff2|eot)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable" always;
|
||||||
add_header Access-Control-Allow-Origin "*" always;
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS" always;
|
add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" always;
|
||||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" always;
|
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type,Authorization" always;
|
||||||
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
|
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 默认仅允许静态文件
|
# 默认仅提供静态文件
|
||||||
location / {
|
location / {
|
||||||
# 处理预检请求(if 在 location 内合法)
|
|
||||||
if ($request_method = OPTIONS) {
|
|
||||||
add_header Access-Control-Allow-Origin "*";
|
|
||||||
add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS";
|
|
||||||
add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type";
|
|
||||||
add_header Access-Control-Max-Age 1728000;
|
|
||||||
add_header Content-Length 0;
|
|
||||||
add_header Content-Type "text/plain; charset=utf-8";
|
|
||||||
return 204;
|
|
||||||
}
|
|
||||||
|
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
- 远端 API 生成 SVG(服务端读取字体并渲染)
|
- 远端 API 生成 SVG(服务端读取字体并渲染)
|
||||||
- SVG 预览
|
- SVG 预览
|
||||||
- 导出 SVG 并调用 `wx.shareFileMessage` 分享
|
- 导出 SVG 并调用 `wx.shareFileMessage` 分享
|
||||||
- SVG 渲染到 Canvas 并导出 PNG,保存到系统相册
|
- 远端 API 生成 PNG,保存到系统相册
|
||||||
|
|
||||||
## 目录说明
|
## 目录说明
|
||||||
|
|
||||||
@@ -34,6 +34,11 @@ miniprogram/
|
|||||||
5. Nginx 配置 `/api/` 反向代理到渲染服务。
|
5. Nginx 配置 `/api/` 反向代理到渲染服务。
|
||||||
6. 编译运行。
|
6. 编译运行。
|
||||||
|
|
||||||
|
## 导出说明
|
||||||
|
|
||||||
|
- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。
|
||||||
|
- `PNG`:由服务端 `POST /api/render-png` 直接返回二进制,小程序仅负责保存到相册。
|
||||||
|
|
||||||
## 字体清单格式(由服务端解析)
|
## 字体清单格式(由服务端解析)
|
||||||
|
|
||||||
`assets/fonts.json` 每项字段:
|
`assets/fonts.json` 每项字段:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
- 新增 `apiserver/` 目录,服务端读取 `fonts.json` + `fonts/` 并渲染 SVG。
|
- 新增 `apiserver/` 目录,服务端读取 `fonts.json` + `fonts/` 并渲染 SVG。
|
||||||
- 小程序新增 `miniprogram/utils/mp/render-api.js`,调用 `https://fonts.biboer.cn/api/render-svg`。
|
- 小程序新增 `miniprogram/utils/mp/render-api.js`,调用 `https://fonts.biboer.cn/api/render-svg`。
|
||||||
|
- 小程序 PNG 导出改为调用 `https://fonts.biboer.cn/api/render-png`,不再依赖真机 Canvas 加载 SVG。
|
||||||
- `miniprogram/pages/index/index.js` 移除本地 `loadFontBuffer + worker.generateSvg` 依赖,改为远端渲染。
|
- `miniprogram/pages/index/index.js` 移除本地 `loadFontBuffer + worker.generateSvg` 依赖,改为远端渲染。
|
||||||
- `miniprogram/app.js` 新增全局配置:
|
- `miniprogram/app.js` 新增全局配置:
|
||||||
- `svgRenderApiUrl`
|
- `svgRenderApiUrl`
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 392 B |
3
miniprogram/assets/icons/checkbox-no.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="none" viewBox="0 0 12 12">
|
||||||
|
<rect width="9.917" height="9.917" x=".875" y=".875" stroke="#C9CDD4" stroke-width=".583" rx="4.958"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 208 B |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
4
miniprogram/assets/icons/content.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="23" height="36" fill="none" viewBox="0 0 23 36">
|
||||||
|
<path fill="#3EE4C3" d="M3.42 4.593v13.353H0V2.27h9.135V0h3.52v2.271h9.26v13.354a2.15 2.15 0 0 1-.662 1.597c-.44.433-.977.649-1.61.649h-4.168l.749-2.047h1.747a.57.57 0 0 0 .4-.162.537.537 0 0 0 .174-.412l.025-10.657h-5.915v.524l-.624 1.248h2.72l2.87 8.111h-3.519l-2.52-7.188-3.57 7.188H4.493l4.642-9.36v-.523H3.42Z"/>
|
||||||
|
<path fill="#3EE4C3" d="M3.47 21.224v1.348H0v-3.82h9.01l-.374-.873h3.769l.4.874h6.564a2.716 2.716 0 0 1 1.947.799 2.735 2.735 0 0 1 .798 1.947v1.073h-3.47v-.574c0-.217-.074-.4-.224-.55a.747.747 0 0 0-.549-.224H3.47ZM1.272 29.46h19.494v3.793a2.716 2.716 0 0 1-.798 1.947 2.735 2.735 0 0 1-1.948.8H1.273v-6.539Zm15.05 2.146H5.717v2.221h9.434c.333 0 .612-.112.837-.336a1.14 1.14 0 0 0 .337-.837v-1.048Zm-5.191-5.965-5.167 3.195H.05l8.286-4.967h5.666l-2.645 1.647h5.116l5.491 3.32H16.15l-5.017-3.195Zm1.473-3.47h4.917l4.268 2.621h-4.892l-4.293-2.62ZM5.49 24.793H.6l4.293-2.62h4.892l-4.293 2.62Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1019 B |
|
Before Width: | Height: | Size: 1.4 KiB |
4
miniprogram/assets/icons/export-png-s.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="12" fill="none" viewBox="0 0 25 12">
|
||||||
|
<rect width="25" height="11.908" fill="#2420A8" rx="4"/>
|
||||||
|
<path fill="#F3EDF7" d="M3 9.908V2h2.762c.535 0 .94.033 1.216.1.275.067.539.199.79.396.685.535 1.027 1.357 1.027 2.466 0 .842-.252 1.523-.755 2.042a2.27 2.27 0 0 1-.868.567c-.326.118-.726.177-1.198.177H4.582v2.16H3Zm2.656-6.68H4.582V6.52h.967c.512 0 .886-.11 1.122-.33.33-.291.495-.74.495-1.346 0-.519-.13-.918-.39-1.198-.259-.279-.633-.419-1.12-.419Zm4.296 4.52V2h3.045c.456 0 .805.033 1.044.1.24.067.459.187.655.36.402.37.602.952.602 1.747v3.54h-1.581V4.03c0-.283-.063-.488-.189-.614s-.334-.189-.626-.189h-1.369v4.52H9.952ZM19.335 2h2.95v5.713c0 .322-.037.61-.111.861a1.802 1.802 0 0 1-.325.637c-.181.22-.4.372-.655.455-.256.082-.628.124-1.116.124h-3.15V8.562h2.938c.346 0 .573-.053.679-.159.106-.106.16-.325.16-.655h-1.453c-.464 0-.848-.05-1.15-.148a2.173 2.173 0 0 1-.82-.49c-.575-.542-.862-1.29-.862-2.242 0-1.086.35-1.877 1.05-2.372.252-.181.518-.31.797-.384.28-.075.635-.112 1.068-.112Zm1.37 4.52V3.227h-1.311c-.913 0-1.37.551-1.37 1.653 0 .535.125.942.373 1.221.248.28.608.42 1.08.42h1.227Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
4
miniprogram/assets/icons/export-svg-s.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="12" fill="none" viewBox="0 0 25 12">
|
||||||
|
<rect width="25" height="11.921" fill="#8552A1" rx="4"/>
|
||||||
|
<path fill="#F3EDF7" d="M4.968 2H8.4v1.248H5.556c-.328 0-.536.016-.624.048-.184.064-.276.22-.276.468 0 .208.088.368.264.48.096.064.356.096.78.096h.948c.608 0 1.088.128 1.44.384.392.288.588.712.588 1.272 0 .424-.12.812-.36 1.164-.176.28-.39.464-.642.552-.252.088-.674.132-1.266.132H3.084V6.596h2.868c.352 0 .592-.004.72-.012.288-.032.432-.196.432-.492 0-.24-.096-.404-.288-.492-.096-.048-.328-.072-.696-.072h-.972c-.384 0-.678-.024-.882-.072a1.575 1.575 0 0 1-.57-.264 1.542 1.542 0 0 1-.51-.63A2.019 2.019 0 0 1 3 3.704c0-.584.212-1.048.636-1.392.256-.208.7-.312 1.332-.312Zm5.995 0 1.488 4.02L14.083 2h1.704l-2.52 5.844h-1.728L9.21 2h1.752Zm8.322 0h3v5.808c0 .328-.037.62-.113.876a1.834 1.834 0 0 1-.33.648 1.412 1.412 0 0 1-.666.462c-.26.084-.638.126-1.134.126h-3.205V8.672h2.989c.351 0 .581-.054.69-.162.108-.108.162-.33.162-.666H19.2c-.472 0-.862-.05-1.17-.15a2.21 2.21 0 0 1-.834-.498c-.584-.552-.876-1.312-.876-2.28 0-1.104.356-1.908 1.068-2.412.256-.184.526-.314.81-.39.284-.076.646-.114 1.086-.114Zm1.392 4.596V3.248h-1.331c-.929 0-1.393.56-1.393 1.68 0 .544.126.958.378 1.242.252.284.618.426 1.099.426h1.247Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 585 B After Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
|
|
||||||
<path fill="#000" d="M7.5 0a7.5 7.5 0 0 0 0 15 7.5 7.5 0 0 0 0-15Zm4.242 6.567L8.03 10.28a.751.751 0 0 1-1.062 0l-3.71-3.713A.751.751 0 0 1 4.32 5.505L7.5 8.688l3.183-3.18a.75.75 0 1 1 1.06 1.06Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 304 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
|
|
||||||
<path fill="#0079F5" d="M12.391 16H3.61A3.61 3.61 0 0 1 0 12.391V3.61A3.609 3.609 0 0 1 3.609 0h8.782A3.61 3.61 0 0 1 16 3.609v8.782A3.61 3.61 0 0 1 12.391 16Z"/>
|
|
||||||
<path fill="#fff" d="M10.404 4.972a.6.6 0 0 0-1.103 0L7.18 9.94a.402.402 0 1 0 .74.312l.244-.588c.21-.506.704-.835 1.252-.835h.873c.547 0 1.041.33 1.251.835l.244.588a.402.402 0 1 0 .74-.312l-2.12-4.968ZM10.5 7.16a.7.7 0 1 1-1.293.54.7.7 0 0 1 1.293-.54Zm-5.018-.978a.422.422 0 0 0-.775 0L3.416 9.206a.301.301 0 1 0 .555.233l.134-.323a.825.825 0 0 1 .762-.509h.455c.333 0 .634.201.762.51l.134.322a.301.301 0 1 0 .555-.233L5.483 6.18Zm-.388 1.89a.37.37 0 1 1 0-.74.37.37 0 0 1 0 .74Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 755 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
|
|
||||||
<path fill="#000" d="M7.5 0a7.5 7.5 0 0 0 0 15 7.5 7.5 0 0 0 0-15Zm4.242 6.567L8.03 10.28a.751.751 0 0 1-1.062 0l-3.71-3.713A.751.751 0 0 1 4.32 5.505L7.5 8.688l3.183-3.18a.75.75 0 1 1 1.06 1.06Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 304 B |
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" fill="none" viewBox="0 0 10 10">
|
|
||||||
<path fill="#0079F5" d="M7.432 9.596H2.164A2.165 2.165 0 0 1 0 7.432V2.164C0 .97.97 0 2.164 0h5.268c1.195 0 2.164.97 2.164 2.164v5.268c0 1.195-.97 2.164-2.164 2.164Z"/>
|
|
||||||
<path fill="#fff" d="M6.24 2.982a.36.36 0 0 0-.662 0l-1.272 2.98a.24.24 0 1 0 .444.186l.147-.352a.813.813 0 0 1 .75-.501h.524c.328 0 .624.198.75.5l.147.353a.24.24 0 1 0 .444-.187L6.24 2.982Zm.058 1.312a.42.42 0 1 1-.776.324.42.42 0 0 1 .776-.324Zm-3.01-.587a.253.253 0 0 0-.465 0l-.774 1.814a.18.18 0 1 0 .333.14l.08-.194a.495.495 0 0 1 .457-.305h.273c.2 0 .38.12.457.305l.08.194a.18.18 0 1 0 .333-.14l-.774-1.814ZM3.055 4.84a.222.222 0 1 1 0-.443.222.222 0 0 1 0 .443Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 749 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" fill="none" viewBox="0 0 22 20">
|
|
||||||
<path fill="#5C5C66" d="M14.503 20a.5.5 0 0 1-.466-.319L11.826 14h-8.65L.97 19.681a.501.501 0 0 1-.928.024.5.5 0 0 1-.004-.386l7-18a.5.5 0 0 1 .932 0l7 18a.5.5 0 0 1-.466.681ZM3.567 13h7.871L7.503 2.88 3.567 13Zm14.936-6a.5.5 0 0 1-.5-.5V4h-2.5a.5.5 0 0 1 0-1h2.5V.5a.5.5 0 0 1 1 0V3h2.5a.5.5 0 1 1 0 1h-2.5v2.5a.5.5 0 0 1-.5.5Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 437 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="6" fill="none" viewBox="0 0 15 6">
|
|
||||||
<path fill="#000" fill-opacity=".8" d="m0-.271 5.657 5.657a2 2 0 0 0 2.828 0l5.657-5.657H0Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 198 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="9" fill="none" viewBox="0 0 11 9">
|
|
||||||
<path fill="#fff" d="M9.222.118a.5.5 0 0 0-.704.06L3.859 5.685 1.396 3.57a.5.5 0 0 0-.706.054l-.57.664a.5.5 0 0 0 .054.705l2.778 2.384c.02.026.044.05.07.072l.668.565a.5.5 0 0 0 .422.109.498.498 0 0 0 .284-.166l.57-.664a.503.503 0 0 0 .056-.08l4.927-5.826A.5.5 0 0 0 9.89.683L9.222.118Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 392 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="9" fill="none" viewBox="0 0 11 9">
|
|
||||||
<path fill="#fff" d="M9.222.118a.5.5 0 0 0-.704.06L3.859 5.685 1.396 3.57a.5.5 0 0 0-.706.054l-.57.664a.5.5 0 0 0 .054.705l2.778 2.384c.02.026.044.05.07.072l.668.565a.5.5 0 0 0 .422.109.498.498 0 0 0 .284-.166l.57-.664a.503.503 0 0 0 .056-.08l4.927-5.826A.5.5 0 0 0 9.89.683L9.222.118Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 392 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="13" fill="none" viewBox="0 0 15 13">
|
|
||||||
<path fill="#5C5C66" d="M9.668 12.667a.333.333 0 0 1-.31-.213L7.885 8.667H2.119L.646 12.454a.333.333 0 1 1-.622-.242l4.667-12a.333.333 0 0 1 .621 0l4.667 12a.333.333 0 0 1-.31.455ZM2.378 8h5.248L5.002 1.253 2.378 8Zm11.957-6h-4a.333.333 0 1 1 0-.667h4a.333.333 0 0 1 0 .667Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 383 B |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
@@ -1,28 +1,33 @@
|
|||||||
const { loadFontsManifest } = require('../../utils/mp/font-loader')
|
const { loadFontsManifest } = require('../../utils/mp/font-loader')
|
||||||
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
|
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
|
||||||
const { saveSvgToUserPath, shareLocalFile, buildFilename } = require('../../utils/mp/file-export')
|
const {
|
||||||
const { exportSvgToPngByCanvas, savePngToAlbum } = require('../../utils/mp/canvas-export')
|
shareSvgFromUserTap,
|
||||||
const { renderSvgByApi } = require('../../utils/mp/render-api')
|
} = require('../../utils/mp/file-export')
|
||||||
|
const { savePngToAlbum } = require('../../utils/mp/canvas-export')
|
||||||
|
const { renderSvgByApi, renderPngByApi } = require('../../utils/mp/render-api')
|
||||||
// const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释
|
// const { ICON_PATHS } = require('../../config/cdn') // CDN 方案暂时注释
|
||||||
|
|
||||||
const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
|
const COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
|
||||||
|
|
||||||
// 临时使用本地图标
|
// 临时使用本地图标 - 根据Figma annotation配置
|
||||||
const LOCAL_ICON_PATHS = {
|
const LOCAL_ICON_PATHS = {
|
||||||
logo: '/assets/icons/webicon.png',
|
logo: '/assets/icons/webicon.png',
|
||||||
fontSizeDecrease: '/assets/icons/font-size-decrease.png',
|
fontSizeDecrease: '/assets/icons/font-size-decrease.svg',
|
||||||
fontSizeIncrease: '/assets/icons/font-size-increase.png',
|
fontSizeIncrease: '/assets/icons/font-size-increase.svg',
|
||||||
chooseColor: '/assets/icons/choose-color.png',
|
chooseColor: '/assets/icons/choose-color.svg',
|
||||||
export: '/assets/icons/export.png',
|
// 导出按钮(根据Figma annotation)
|
||||||
exportSvg: '/assets/icons/export-svg.png',
|
exportSvg: '/assets/icons/export-svg-s.svg', // 紫色SVG导出按钮
|
||||||
exportPng: '/assets/icons/export-png.png',
|
exportPng: '/assets/icons/export-png-s.svg', // 蓝色PNG导出按钮
|
||||||
// 字体树图标(参考web项目)
|
// 输入框图标
|
||||||
fontIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_18.svg', // 字体item图标
|
content: '/assets/icons/content.svg', // 输入框左侧绿色图标
|
||||||
expandIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_12.svg', // 展开分类图标
|
// 字体树图标(根据Figma annotation)
|
||||||
collapseIcon: 'https://fonts.biboer.cn/assets/icons/zhedie.svg', // 折叠分类图标
|
fontIcon: '/assets/icons/font-icon.svg', // 字体item图标
|
||||||
favoriteIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_19.svg', // 收藏图标
|
expandIcon: '/assets/icons/expand.svg', // 展开分类图标
|
||||||
checkbox: '/assets/icons/checkbox.png', // 预览checkbox
|
collapseIcon: '/assets/icons/expand.svg', // 折叠使用同一图标,通过旋转实现
|
||||||
search: 'https://fonts.biboer.cn/assets/icons/search.svg' // 搜索图标
|
favoriteIcon: '/assets/icons/favorite.svg', // 收藏图标(未收藏白色底,收藏红色底)
|
||||||
|
checkbox: '/assets/icons/checkbox.svg', // 复选框(未选中)
|
||||||
|
checkboxChecked: '/assets/icons/checkbox-no.svg', // 复选框(已选中)
|
||||||
|
search: '/assets/icons/search.svg', // 搜索图标
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSvgDataUri(svg) {
|
function toSvgDataUri(svg) {
|
||||||
@@ -38,6 +43,32 @@ function normalizeHexColor(input) {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(error, fallback) {
|
||||||
|
if (!error) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
const errMsg = error.errMsg || error.message || String(error)
|
||||||
|
return errMsg || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function showExportError(title, error, fallback) {
|
||||||
|
const message = extractErrorMessage(error, fallback)
|
||||||
|
wx.showModal({
|
||||||
|
title,
|
||||||
|
content: message,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePngBufferToTempFile(pngBuffer, fontName) {
|
||||||
|
const safeName = String(fontName || 'font').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 60) || 'font'
|
||||||
|
const filePath = `${wx.env.USER_DATA_PATH}/${safeName}_${Date.now()}.png`
|
||||||
|
const fs = wx.getFileSystemManager()
|
||||||
|
fs.writeFileSync(filePath, pngBuffer)
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
Page({
|
Page({
|
||||||
data: {
|
data: {
|
||||||
inputText: '星程字体转换',
|
inputText: '星程字体转换',
|
||||||
@@ -324,10 +355,19 @@ Page({
|
|||||||
selectedFonts[index].svg = result.svg
|
selectedFonts[index].svg = result.svg
|
||||||
selectedFonts[index].width = result.width
|
selectedFonts[index].width = result.width
|
||||||
selectedFonts[index].height = result.height
|
selectedFonts[index].height = result.height
|
||||||
|
selectedFonts[index].previewError = ''
|
||||||
this.setData({ selectedFonts })
|
this.setData({ selectedFonts })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('生成预览失败', error)
|
console.error('生成预览失败', error)
|
||||||
|
const selectedFonts = this.data.selectedFonts
|
||||||
|
const index = selectedFonts.findIndex(f => f.id === fontId)
|
||||||
|
if (index >= 0) {
|
||||||
|
selectedFonts[index].previewSrc = ''
|
||||||
|
selectedFonts[index].svg = ''
|
||||||
|
selectedFonts[index].previewError = (error && error.message) ? error.message : '预览生成失败'
|
||||||
|
this.setData({ selectedFonts })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -471,21 +511,14 @@ Page({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wx.showLoading({ title: '导出 SVG 中', mask: true })
|
wx.showModal({
|
||||||
|
title: '微信限制说明',
|
||||||
|
content: '微信要求 shareFileMessage 必须由单次点击直接触发,暂不支持批量自动分享 SVG,请逐个字体导出。',
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '知道了',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
try {
|
|
||||||
for (const font of selectedFonts) {
|
|
||||||
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
|
|
||||||
const filename = buildFilename(font.name, this.data.inputText, 'svg')
|
|
||||||
await shareLocalFile(filePath, filename)
|
|
||||||
}
|
|
||||||
wx.showToast({ title: 'SVG 导出完成', icon: 'success' })
|
|
||||||
} catch (error) {
|
|
||||||
const message = error && error.errMsg ? error.errMsg : error.message
|
|
||||||
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
|
|
||||||
} finally {
|
|
||||||
wx.hideLoading()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async exportAllPng() {
|
async exportAllPng() {
|
||||||
@@ -500,21 +533,24 @@ Page({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const font of selectedFonts) {
|
for (const font of selectedFonts) {
|
||||||
const width = Math.max(64, Math.round(font.width || 1024))
|
const pngBuffer = await renderPngByApi({
|
||||||
const height = Math.max(64, Math.round(font.height || 1024))
|
fontId: font.id,
|
||||||
|
text: this.data.inputText,
|
||||||
const pngPath = await exportSvgToPngByCanvas(this, {
|
fontSize: Number(this.data.fontSize),
|
||||||
svgString: font.svg,
|
fillColor: normalizeHexColor(this.data.textColor),
|
||||||
width,
|
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||||
height,
|
maxCharsPerLine: 45,
|
||||||
})
|
})
|
||||||
|
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
|
||||||
|
|
||||||
await savePngToAlbum(pngPath)
|
const saveResult = await savePngToAlbum(pngPath)
|
||||||
|
if (!saveResult.success) {
|
||||||
|
throw saveResult.error || new Error('保存 PNG 失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error && error.errMsg ? error.errMsg : error.message
|
showExportError('导出 PNG 失败', error, '请稍后重试')
|
||||||
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
|
|
||||||
} finally {
|
} finally {
|
||||||
wx.hideLoading()
|
wx.hideLoading()
|
||||||
}
|
}
|
||||||
@@ -531,17 +567,11 @@ Page({
|
|||||||
// 如果只有一个字体,直接导出
|
// 如果只有一个字体,直接导出
|
||||||
if (selectedFonts.length === 1) {
|
if (selectedFonts.length === 1) {
|
||||||
const font = selectedFonts[0]
|
const font = selectedFonts[0]
|
||||||
wx.showLoading({ title: '导出 SVG 中', mask: true })
|
|
||||||
try {
|
try {
|
||||||
const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
|
await shareSvgFromUserTap(font.svg, font.name, this.data.inputText)
|
||||||
const filename = buildFilename(font.name, this.data.inputText, 'svg')
|
|
||||||
await shareLocalFile(filePath, filename)
|
|
||||||
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
|
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error && error.errMsg ? error.errMsg : error.message
|
showExportError('导出 SVG 失败', error, '请稍后重试')
|
||||||
wx.showToast({ title: message || '导出 SVG 失败', icon: 'none', duration: 2400 })
|
|
||||||
} finally {
|
|
||||||
wx.hideLoading()
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.exportAllSvg()
|
this.exportAllSvg()
|
||||||
@@ -562,24 +592,24 @@ Page({
|
|||||||
wx.showLoading({ title: '导出 PNG 中', mask: true })
|
wx.showLoading({ title: '导出 PNG 中', mask: true })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const width = Math.max(64, Math.round(font.width || 1024))
|
const pngBuffer = await renderPngByApi({
|
||||||
const height = Math.max(64, Math.round(font.height || 1024))
|
fontId: font.id,
|
||||||
|
text: this.data.inputText,
|
||||||
const pngPath = await exportSvgToPngByCanvas(this, {
|
fontSize: Number(this.data.fontSize),
|
||||||
svgString: font.svg,
|
fillColor: normalizeHexColor(this.data.textColor),
|
||||||
width,
|
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||||
height,
|
maxCharsPerLine: 45,
|
||||||
})
|
})
|
||||||
|
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
|
||||||
|
|
||||||
const saveResult = await savePngToAlbum(pngPath)
|
const saveResult = await savePngToAlbum(pngPath)
|
||||||
if (saveResult.success) {
|
if (saveResult.success) {
|
||||||
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||||
} else {
|
} else {
|
||||||
wx.showToast({ title: '保存失败,请重试', icon: 'none' })
|
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error && error.errMsg ? error.errMsg : error.message
|
showExportError('导出 PNG 失败', error, '请稍后重试')
|
||||||
wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
|
|
||||||
} finally {
|
} finally {
|
||||||
wx.hideLoading()
|
wx.hideLoading()
|
||||||
}
|
}
|
||||||
@@ -588,6 +618,59 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 单个字体导出
|
||||||
|
async onExportSingleSvg(e) {
|
||||||
|
const fontId = e.currentTarget.dataset.fontId
|
||||||
|
const font = this.data.selectedFonts.find(f => f.id === fontId)
|
||||||
|
|
||||||
|
if (!font || !font.svg) {
|
||||||
|
wx.showToast({ title: '请先生成预览', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await shareSvgFromUserTap(font.svg, font.name, this.data.inputText)
|
||||||
|
wx.showToast({ title: 'SVG 已分享', icon: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
showExportError('导出 SVG 失败', error, '请稍后重试')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async onExportSinglePng(e) {
|
||||||
|
const fontId = e.currentTarget.dataset.fontId
|
||||||
|
const font = this.data.selectedFonts.find(f => f.id === fontId)
|
||||||
|
|
||||||
|
if (!font || !font.svg) {
|
||||||
|
wx.showToast({ title: '请先生成预览', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.showLoading({ title: '导出 PNG 中', mask: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pngBuffer = await renderPngByApi({
|
||||||
|
fontId: font.id,
|
||||||
|
text: this.data.inputText,
|
||||||
|
fontSize: Number(this.data.fontSize),
|
||||||
|
fillColor: normalizeHexColor(this.data.textColor),
|
||||||
|
letterSpacing: Number(this.data.letterSpacingInput || 0),
|
||||||
|
maxCharsPerLine: 45,
|
||||||
|
})
|
||||||
|
const pngPath = writePngBufferToTempFile(pngBuffer, font.name)
|
||||||
|
|
||||||
|
const saveResult = await savePngToAlbum(pngPath)
|
||||||
|
if (saveResult.success) {
|
||||||
|
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
|
||||||
|
} else {
|
||||||
|
showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showExportError('导出 PNG 失败', error, '请稍后重试')
|
||||||
|
} finally {
|
||||||
|
wx.hideLoading()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// 搜索功能
|
// 搜索功能
|
||||||
onToggleSearch() {
|
onToggleSearch() {
|
||||||
this.setData({ showSearch: !this.data.showSearch })
|
this.setData({ showSearch: !this.data.showSearch })
|
||||||
|
|||||||
@@ -38,8 +38,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 输入栏和导出按钮 -->
|
<!-- 输入栏 -->
|
||||||
<view class="input-row">
|
<view class="input-row">
|
||||||
|
<image class="content-icon" src="{{icons.content}}" />
|
||||||
<view class="text-input-container">
|
<view class="text-input-container">
|
||||||
<input
|
<input
|
||||||
class="text-input"
|
class="text-input"
|
||||||
@@ -50,17 +51,6 @@
|
|||||||
bindconfirm="onRegenerate"
|
bindconfirm="onRegenerate"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="export-buttons">
|
|
||||||
<view class="export-btn" bindtap="onShowExportOptions">
|
|
||||||
<image class="export-icon" src="{{icons.export}}" />
|
|
||||||
</view>
|
|
||||||
<view class="export-btn" bindtap="onExportSvg">
|
|
||||||
<image class="export-icon" src="{{icons.exportSvg}}" />
|
|
||||||
</view>
|
|
||||||
<view class="export-btn" bindtap="onExportPng">
|
|
||||||
<image class="export-icon" src="{{icons.exportPng}}" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 效果预览区域 -->
|
<!-- 效果预览区域 -->
|
||||||
@@ -71,9 +61,12 @@
|
|||||||
<view class="preview-header">
|
<view class="preview-header">
|
||||||
<image class="font-icon" src="{{icons.fontIcon}}" />
|
<image class="font-icon" src="{{icons.fontIcon}}" />
|
||||||
<view class="font-name-text">{{item.name}}</view>
|
<view class="font-name-text">{{item.name}}</view>
|
||||||
<view class="preview-checkbox" bindtap="onTogglePreviewFont" data-font-id="{{item.id}}">
|
<view class="export-btns-inline">
|
||||||
<view class="checkbox-wrapper {{item.showInPreview ? 'checked' : ''}}">
|
<view class="export-btn-sm export-svg-btn" bindtap="onExportSingleSvg" data-font-id="{{item.id}}">
|
||||||
<image wx:if="{{item.showInPreview}}" class="checkbox-icon" src="{{icons.checkbox}}" />
|
<image class="export-icon-sm" src="{{icons.exportSvg}}" />
|
||||||
|
</view>
|
||||||
|
<view class="export-btn-sm export-png-btn" bindtap="onExportSinglePng" data-font-id="{{item.id}}">
|
||||||
|
<image class="export-icon-sm" src="{{icons.exportPng}}" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -84,6 +77,7 @@
|
|||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
src="{{item.previewSrc}}"
|
src="{{item.previewSrc}}"
|
||||||
/>
|
/>
|
||||||
|
<view wx:elif="{{item.previewError}}" class="preview-error">{{item.previewError}}</view>
|
||||||
<view wx:else class="preview-loading">生成中...</view>
|
<view wx:else class="preview-loading">生成中...</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -106,7 +100,7 @@
|
|||||||
bindinput="onSearchInput"
|
bindinput="onSearchInput"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="search-toggle" bindtap="onToggleSearch">
|
<view class="search-toggle" wx:if="{{!showSearch}}" bindtap="onToggleSearch">
|
||||||
<image class="search-icon" src="{{icons.search}}" />
|
<image class="search-icon" src="{{icons.search}}" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -115,7 +109,8 @@
|
|||||||
<view class="category-header" bindtap="onToggleCategory" data-category="{{item.category}}">
|
<view class="category-header" bindtap="onToggleCategory" data-category="{{item.category}}">
|
||||||
<image
|
<image
|
||||||
class="expand-icon"
|
class="expand-icon"
|
||||||
src="{{item.expanded ? icons.collapseIcon : icons.expandIcon}}"
|
src="{{icons.expandIcon}}"
|
||||||
|
style="transform: rotate({{item.expanded ? '90deg' : '0deg'}})"
|
||||||
/>
|
/>
|
||||||
<view class="category-name">{{item.category}}</view>
|
<view class="category-name">{{item.category}}</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -131,7 +126,7 @@
|
|||||||
<view class="font-item-actions">
|
<view class="font-item-actions">
|
||||||
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
|
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
|
||||||
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
|
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
|
||||||
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkbox}}" />
|
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkboxChecked}}" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
||||||
@@ -156,7 +151,8 @@
|
|||||||
<view class="category-header" bindtap="onToggleFavoriteCategory" data-category="{{item.category}}">
|
<view class="category-header" bindtap="onToggleFavoriteCategory" data-category="{{item.category}}">
|
||||||
<image
|
<image
|
||||||
class="expand-icon"
|
class="expand-icon"
|
||||||
src="{{item.expanded ? icons.collapseIcon : icons.expandIcon}}"
|
src="{{icons.expandIcon}}"
|
||||||
|
style="transform: rotate({{item.expanded ? '90deg' : '0deg'}})"
|
||||||
/>
|
/>
|
||||||
<view class="category-name">{{item.category}}</view>
|
<view class="category-name">{{item.category}}</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -172,7 +168,7 @@
|
|||||||
<view class="font-item-actions">
|
<view class="font-item-actions">
|
||||||
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
|
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
|
||||||
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
|
<view class="checkbox-wrapper {{font.selected ? 'checked' : ''}}">
|
||||||
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkbox}}" />
|
<image wx:if="{{font.selected}}" class="checkbox-icon-sm" src="{{icons.checkboxChecked}}" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
<view class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
||||||
|
|||||||
@@ -71,12 +71,20 @@
|
|||||||
margin-top: 16rpx;
|
margin-top: 16rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-icon {
|
||||||
|
width: 44rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.text-input-container {
|
.text-input-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #F7F8FA;
|
background: #F7F8FA;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
padding: 0 6rpx;
|
padding: 0 6rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
@@ -86,30 +94,6 @@
|
|||||||
color: #4E5969;
|
color: #4E5969;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-buttons {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12rpx;
|
|
||||||
height: 100%;
|
|
||||||
background: #fff;
|
|
||||||
border: 1rpx solid #E5E6EB;
|
|
||||||
border-radius: 10rpx;
|
|
||||||
padding: 5rpx 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-btn {
|
|
||||||
width: 62rpx;
|
|
||||||
height: 62rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.export-icon {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 效果预览区域 */
|
/* 效果预览区域 */
|
||||||
.preview-section {
|
.preview-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -162,6 +146,35 @@
|
|||||||
color: #86909C;
|
color: #86909C;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-btns-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn-sm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 4rpx 6rpx;
|
||||||
|
width: 50rpx;
|
||||||
|
height: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn-sm.export-svg-btn {
|
||||||
|
background: #8552A1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-btn-sm.export-png-btn {
|
||||||
|
background: #2420A8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-icon-sm {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-checkbox {
|
.preview-checkbox {
|
||||||
width: 14rpx;
|
width: 14rpx;
|
||||||
height: 14rpx;
|
height: 14rpx;
|
||||||
@@ -206,6 +219,13 @@
|
|||||||
color: #86909C;
|
color: #86909C;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-error {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #dc2626;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.preview-empty {
|
.preview-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 80rpx 0;
|
padding: 80rpx 0;
|
||||||
@@ -240,6 +260,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
padding: 4rpx 0;
|
padding: 4rpx 0;
|
||||||
|
height: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-header .section-title {
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 56rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
@@ -250,7 +277,7 @@
|
|||||||
background: #F7F8FA;
|
background: #F7F8FA;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
padding: 4rpx 12rpx;
|
padding: 4rpx 12rpx;
|
||||||
height: 56rpx;
|
height: 40rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
@@ -264,11 +291,12 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #4E5969;
|
color: #4E5969;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-toggle {
|
.search-toggle {
|
||||||
width: 56rpx;
|
width: 40rpx;
|
||||||
height: 56rpx;
|
height: 40rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ async function saveSvgToUserPath(svgText, fontName, text) {
|
|||||||
return writeTextToUserPath(svgText, 'svg', filename)
|
return writeTextToUserPath(svgText, 'svg', filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveSvgToUserPathSync(svgText, fontName, text) {
|
||||||
|
const filename = buildFilename(fontName, text, 'svg')
|
||||||
|
const filePath = `${wx.env.USER_DATA_PATH}/${filename}`
|
||||||
|
const fs = wx.getFileSystemManager()
|
||||||
|
fs.writeFileSync(filePath, svgText, 'utf8')
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
fileName: filename,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function shareLocalFile(filePath, fileName) {
|
async function shareLocalFile(filePath, fileName) {
|
||||||
if (typeof wx.shareFileMessage !== 'function') {
|
if (typeof wx.shareFileMessage !== 'function') {
|
||||||
throw new Error('当前微信版本不支持文件分享')
|
throw new Error('当前微信版本不支持文件分享')
|
||||||
@@ -40,10 +51,29 @@ async function shareLocalFile(filePath, fileName) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shareSvgFromUserTap(svgText, fontName, text) {
|
||||||
|
if (typeof wx.shareFileMessage !== 'function') {
|
||||||
|
throw new Error('当前微信版本不支持文件分享')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { filePath, fileName } = saveSvgToUserPathSync(svgText, fontName, text)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
wx.shareFileMessage({
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sanitizeFilename,
|
sanitizeFilename,
|
||||||
buildFilename,
|
buildFilename,
|
||||||
writeTextToUserPath,
|
writeTextToUserPath,
|
||||||
saveSvgToUserPath,
|
saveSvgToUserPath,
|
||||||
|
saveSvgToUserPathSync,
|
||||||
shareLocalFile,
|
shareLocalFile,
|
||||||
|
shareSvgFromUserTap,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ function normalizeResult(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeArrayBuffer(buffer) {
|
||||||
|
try {
|
||||||
|
if (!buffer) return ''
|
||||||
|
if (typeof buffer === 'string') return buffer
|
||||||
|
if (typeof TextDecoder === 'function') {
|
||||||
|
return new TextDecoder('utf-8').decode(buffer)
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
let text = ''
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
text += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return decodeURIComponent(escape(text))
|
||||||
|
} catch (error) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function renderSvgByApi(payload) {
|
async function renderSvgByApi(payload) {
|
||||||
const app = getApp()
|
const app = getApp()
|
||||||
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
||||||
@@ -62,6 +80,52 @@ async function renderSvgByApi(payload) {
|
|||||||
return normalizeResult(body.data)
|
return normalizeResult(body.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renderPngByApi(payload) {
|
||||||
|
const app = getApp()
|
||||||
|
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
|
||||||
|
? Number(app.globalData.apiTimeoutMs)
|
||||||
|
: 30000
|
||||||
|
const baseApiUrl = buildApiUrl()
|
||||||
|
const apiUrl = /\/api\/render-svg$/.test(baseApiUrl)
|
||||||
|
? baseApiUrl.replace(/\/api\/render-svg$/, '/api/render-png')
|
||||||
|
: `${baseApiUrl.replace(/\/$/, '')}/render-png`
|
||||||
|
|
||||||
|
const response = await request({
|
||||||
|
url: apiUrl,
|
||||||
|
method: 'POST',
|
||||||
|
timeout,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
header: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
accept: 'image/png',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
fontId: payload.fontId,
|
||||||
|
text: payload.text,
|
||||||
|
fontSize: payload.fontSize,
|
||||||
|
fillColor: payload.fillColor,
|
||||||
|
letterSpacing: payload.letterSpacing,
|
||||||
|
maxCharsPerLine: payload.maxCharsPerLine,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response || response.statusCode !== 200) {
|
||||||
|
let message = `PNG 渲染服务请求失败,状态码: ${response && response.statusCode}`
|
||||||
|
const maybeText = decodeArrayBuffer(response && response.data)
|
||||||
|
if (maybeText && maybeText.includes('error')) {
|
||||||
|
message = maybeText
|
||||||
|
}
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(response.data instanceof ArrayBuffer)) {
|
||||||
|
throw new Error('PNG 渲染服务返回格式无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
renderSvgByApi,
|
renderSvgByApi,
|
||||||
|
renderPngByApi,
|
||||||
}
|
}
|
||||||
|
|||||||