diff --git a/apiserver/README.md b/apiserver/README.md
index 0e78536..19862f2 100644
--- a/apiserver/README.md
+++ b/apiserver/README.md
@@ -2,7 +2,7 @@
`apiserver/` 提供微信小程序用的远端渲染接口:
- 小程序只上传参数(字体 ID、文字、字号、颜色等)
-- 服务端读取 `fonts.json` + `fonts/`,生成 SVG 后返回
+- 服务端读取 `fonts.json` + `fonts/`,生成 SVG/PNG 后返回
## 1. 启动
@@ -43,6 +43,11 @@ python3 apiserver/server.py \
}
```
+### POST `/api/render-png`
+
+请求体与 `/api/render-svg` 相同,成功时直接返回 `image/png` 二进制。
+小程序应使用 `wx.request({ responseType: 'arraybuffer' })` 接收。
+
成功响应:
```json
@@ -82,8 +87,20 @@ location /api/ {
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` 必须存在。
- 服务端启用 CORS,允许小程序访问。
- 不依赖 Flask/FastAPI,使用 Python 标准库 HTTP 服务。
+- `/api/render-png` 依赖 `node + sharp`(使用 `apiserver/svg_to_png.js` 转换)。
diff --git a/apiserver/font2svg-api.service.example b/apiserver/font2svg-api.service.example
new file mode 100644
index 0000000..2881903
--- /dev/null
+++ b/apiserver/font2svg-api.service.example
@@ -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
diff --git a/apiserver/png_renderer.py b/apiserver/png_renderer.py
new file mode 100644
index 0000000..8cf763b
--- /dev/null
+++ b/apiserver/png_renderer.py
@@ -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
diff --git a/apiserver/server.py b/apiserver/server.py
index 703b477..966e6a7 100644
--- a/apiserver/server.py
+++ b/apiserver/server.py
@@ -14,6 +14,10 @@ try:
from .renderer import MAX_CHARS_PER_LINE, render_svg_from_font_file
except ImportError:
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")
@@ -175,6 +179,66 @@ class RenderHandler(BaseHTTPRequestHandler):
self.end_headers()
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
self.send_response(204)
self._set_cors_headers()
@@ -195,61 +259,13 @@ class RenderHandler(BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
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"})
return
try:
- content_length = int(self.headers.get("Content-Length", "0") or "0")
- except ValueError:
- 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,
- )
+ render_params = self._parse_render_payload()
+ font_info, result = self._render_svg_core(render_params)
except KeyError as error:
self._send_json(404, {"ok": False, "error": str(error)})
return
@@ -264,12 +280,27 @@ class RenderHandler(BaseHTTPRequestHandler):
self._send_json(500, {"ok": False, "error": str(error)})
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 = {
"svg": result["svg"],
"width": result["width"],
"height": result["height"],
"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})
diff --git a/apiserver/svg_to_png.js b/apiserver/svg_to_png.js
new file mode 100644
index 0000000..d8b00b8
--- /dev/null
+++ b/apiserver/svg_to_png.js
@@ -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()
diff --git a/fonts.conf b/fonts.conf
index f7579c5..da1d983 100644
--- a/fonts.conf
+++ b/fonts.conf
@@ -1,5 +1,5 @@
# Font2SVG - Nginx 配置(fonts.biboer.cn)
-# 用途:为微信小程序提供 fonts.json 与字体文件静态托管
+# 用途:为微信小程序提供静态字体资源 + 远端 SVG 渲染 API
server {
listen 80;
@@ -30,8 +30,8 @@ server {
# 小程序跨域访问
add_header Access-Control-Allow-Origin "*" always;
- add_header Access-Control-Allow-Methods "GET,HEAD,OPTIONS" always;
- add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" always;
+ add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" 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;
# MIME
@@ -44,8 +44,12 @@ server {
application/vnd.ms-fontobject eot;
}
- # SVG 渲染 API(独立 Python 服务)
- location /api/ {
+ # SVG 渲染 API(独立 Python 服务,systemd 监听 127.0.0.1:9300)
+ location ^~ /api/ {
+ # 预检请求:直接返回 204(CORS 头由 server 级 add_header 提供)
+ if ($request_method = OPTIONS) {
+ return 204;
+ }
proxy_pass http://127.0.0.1:9300;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -57,13 +61,23 @@ server {
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:短缓存,便于更新
location = /fonts.json {
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-Methods "GET,HEAD,OPTIONS" always;
- add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" always;
+ add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" 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;
try_files $uri =404;
}
@@ -71,27 +85,16 @@ server {
# 字体文件:长缓存
location ~* \.(ttf|otf|woff|woff2|eot)$ {
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-Methods "GET,HEAD,OPTIONS" always;
- add_header Access-Control-Allow-Headers "Origin,Range,Accept,Content-Type" always;
+ add_header Access-Control-Allow-Methods "GET,HEAD,POST,OPTIONS" 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;
try_files $uri =404;
}
- # 默认仅允许静态文件
+ # 默认仅提供静态文件
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;
}
diff --git a/miniprogram/README.md b/miniprogram/README.md
index a633f31..c146d3b 100644
--- a/miniprogram/README.md
+++ b/miniprogram/README.md
@@ -8,7 +8,7 @@
- 远端 API 生成 SVG(服务端读取字体并渲染)
- SVG 预览
- 导出 SVG 并调用 `wx.shareFileMessage` 分享
-- SVG 渲染到 Canvas 并导出 PNG,保存到系统相册
+- 远端 API 生成 PNG,保存到系统相册
## 目录说明
@@ -34,6 +34,11 @@ miniprogram/
5. Nginx 配置 `/api/` 反向代理到渲染服务。
6. 编译运行。
+## 导出说明
+
+- `SVG`:受微信限制,`shareFileMessage` 需由单次点击直接触发,建议逐个字体导出。
+- `PNG`:由服务端 `POST /api/render-png` 直接返回二进制,小程序仅负责保存到相册。
+
## 字体清单格式(由服务端解析)
`assets/fonts.json` 每项字段:
diff --git a/miniprogram/UPDATE_LOG.md b/miniprogram/UPDATE_LOG.md
index 31ae53c..c068bfd 100644
--- a/miniprogram/UPDATE_LOG.md
+++ b/miniprogram/UPDATE_LOG.md
@@ -9,6 +9,7 @@
- 新增 `apiserver/` 目录,服务端读取 `fonts.json` + `fonts/` 并渲染 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/app.js` 新增全局配置:
- `svgRenderApiUrl`
diff --git a/miniprogram/assets/icons/icons_idx _34.svg b/miniprogram/assets/icons/check.svg
similarity index 100%
rename from miniprogram/assets/icons/icons_idx _34.svg
rename to miniprogram/assets/icons/check.svg
diff --git a/miniprogram/assets/icons/checkbox-no.svg b/miniprogram/assets/icons/checkbox-no.svg
new file mode 100644
index 0000000..3827bda
--- /dev/null
+++ b/miniprogram/assets/icons/checkbox-no.svg
@@ -0,0 +1,3 @@
+
diff --git a/miniprogram/assets/icons/checkbox.png b/miniprogram/assets/icons/checkbox.png
deleted file mode 100644
index a203dba..0000000
Binary files a/miniprogram/assets/icons/checkbox.png and /dev/null differ
diff --git a/miniprogram/assets/icons/choose-color.png b/miniprogram/assets/icons/choose-color.png
deleted file mode 100644
index 600f74a..0000000
Binary files a/miniprogram/assets/icons/choose-color.png and /dev/null differ
diff --git a/miniprogram/assets/icons/content.svg b/miniprogram/assets/icons/content.svg
new file mode 100644
index 0000000..ca82dfa
--- /dev/null
+++ b/miniprogram/assets/icons/content.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/expand.png b/miniprogram/assets/icons/expand.png
deleted file mode 100644
index 29f40e0..0000000
Binary files a/miniprogram/assets/icons/expand.png and /dev/null differ
diff --git a/miniprogram/assets/icons/export-png-s.svg b/miniprogram/assets/icons/export-png-s.svg
new file mode 100644
index 0000000..195d038
--- /dev/null
+++ b/miniprogram/assets/icons/export-png-s.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/export-png.png b/miniprogram/assets/icons/export-png.png
deleted file mode 100644
index 8a68af4..0000000
Binary files a/miniprogram/assets/icons/export-png.png and /dev/null differ
diff --git a/miniprogram/assets/icons/export-svg-s.svg b/miniprogram/assets/icons/export-svg-s.svg
new file mode 100644
index 0000000..c463242
--- /dev/null
+++ b/miniprogram/assets/icons/export-svg-s.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/export-svg.png b/miniprogram/assets/icons/export-svg.png
deleted file mode 100644
index 6343e04..0000000
Binary files a/miniprogram/assets/icons/export-svg.png and /dev/null differ
diff --git a/miniprogram/assets/icons/export.png b/miniprogram/assets/icons/export.png
deleted file mode 100644
index 1d6d5c1..0000000
Binary files a/miniprogram/assets/icons/export.png and /dev/null differ
diff --git a/miniprogram/assets/icons/icons_idx _19.svg b/miniprogram/assets/icons/favorite.svg
similarity index 100%
rename from miniprogram/assets/icons/icons_idx _19.svg
rename to miniprogram/assets/icons/favorite.svg
diff --git a/miniprogram/assets/icons/font-icon.png b/miniprogram/assets/icons/font-icon.png
deleted file mode 100644
index 50652ef..0000000
Binary files a/miniprogram/assets/icons/font-icon.png and /dev/null differ
diff --git a/miniprogram/assets/icons/font-size-decrease.png b/miniprogram/assets/icons/font-size-decrease.png
deleted file mode 100644
index 427f6ab..0000000
Binary files a/miniprogram/assets/icons/font-size-decrease.png and /dev/null differ
diff --git a/miniprogram/assets/icons/font-size-increase.png b/miniprogram/assets/icons/font-size-increase.png
deleted file mode 100644
index 6b05641..0000000
Binary files a/miniprogram/assets/icons/font-size-increase.png and /dev/null differ
diff --git a/miniprogram/assets/icons/icons_idx _12.svg b/miniprogram/assets/icons/icons_idx _12.svg
deleted file mode 100644
index 1eb83c1..0000000
--- a/miniprogram/assets/icons/icons_idx _12.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _18.svg b/miniprogram/assets/icons/icons_idx _18.svg
deleted file mode 100644
index a9d2261..0000000
--- a/miniprogram/assets/icons/icons_idx _18.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _29.svg b/miniprogram/assets/icons/icons_idx _29.svg
deleted file mode 100644
index 1eb83c1..0000000
--- a/miniprogram/assets/icons/icons_idx _29.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _32.svg b/miniprogram/assets/icons/icons_idx _32.svg
deleted file mode 100644
index a24279a..0000000
--- a/miniprogram/assets/icons/icons_idx _32.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _33.svg b/miniprogram/assets/icons/icons_idx _33.svg
deleted file mode 100644
index 0d38a96..0000000
--- a/miniprogram/assets/icons/icons_idx _33.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _35.svg b/miniprogram/assets/icons/icons_idx _35.svg
deleted file mode 100644
index b0168a5..0000000
--- a/miniprogram/assets/icons/icons_idx _35.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _36.svg b/miniprogram/assets/icons/icons_idx _36.svg
deleted file mode 100644
index b760809..0000000
--- a/miniprogram/assets/icons/icons_idx _36.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _37.svg b/miniprogram/assets/icons/icons_idx _37.svg
deleted file mode 100644
index b760809..0000000
--- a/miniprogram/assets/icons/icons_idx _37.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/miniprogram/assets/icons/icons_idx _38.svg b/miniprogram/assets/icons/icons_idx _38.svg
deleted file mode 100644
index 700c186..0000000
--- a/miniprogram/assets/icons/icons_idx _38.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/miniprogram/assets/icons/selectall.png b/miniprogram/assets/icons/selectall.png
deleted file mode 100644
index 433ee7a..0000000
Binary files a/miniprogram/assets/icons/selectall.png and /dev/null differ
diff --git a/miniprogram/assets/icons/unselectall.png b/miniprogram/assets/icons/unselectall.png
deleted file mode 100644
index 9f0291e..0000000
Binary files a/miniprogram/assets/icons/unselectall.png and /dev/null differ
diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js
index 872fcef..1eb407a 100644
--- a/miniprogram/pages/index/index.js
+++ b/miniprogram/pages/index/index.js
@@ -1,28 +1,33 @@
const { loadFontsManifest } = require('../../utils/mp/font-loader')
const { loadAppState, saveAppState, loadFavorites, saveFavorites } = require('../../utils/mp/storage')
-const { saveSvgToUserPath, shareLocalFile, buildFilename } = require('../../utils/mp/file-export')
-const { exportSvgToPngByCanvas, savePngToAlbum } = require('../../utils/mp/canvas-export')
-const { renderSvgByApi } = require('../../utils/mp/render-api')
+const {
+ shareSvgFromUserTap,
+} = 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 COLOR_PALETTE = ['#000000', '#1d4ed8', '#047857', '#b45309', '#dc2626', '#7c3aed']
-// 临时使用本地图标
+// 临时使用本地图标 - 根据Figma annotation配置
const LOCAL_ICON_PATHS = {
logo: '/assets/icons/webicon.png',
- fontSizeDecrease: '/assets/icons/font-size-decrease.png',
- fontSizeIncrease: '/assets/icons/font-size-increase.png',
- chooseColor: '/assets/icons/choose-color.png',
- export: '/assets/icons/export.png',
- exportSvg: '/assets/icons/export-svg.png',
- exportPng: '/assets/icons/export-png.png',
- // 字体树图标(参考web项目)
- fontIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_18.svg', // 字体item图标
- expandIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_12.svg', // 展开分类图标
- collapseIcon: 'https://fonts.biboer.cn/assets/icons/zhedie.svg', // 折叠分类图标
- favoriteIcon: 'https://fonts.biboer.cn/assets/icons/icons_idx%20_19.svg', // 收藏图标
- checkbox: '/assets/icons/checkbox.png', // 预览checkbox
- search: 'https://fonts.biboer.cn/assets/icons/search.svg' // 搜索图标
+ fontSizeDecrease: '/assets/icons/font-size-decrease.svg',
+ fontSizeIncrease: '/assets/icons/font-size-increase.svg',
+ chooseColor: '/assets/icons/choose-color.svg',
+ // 导出按钮(根据Figma annotation)
+ exportSvg: '/assets/icons/export-svg-s.svg', // 紫色SVG导出按钮
+ exportPng: '/assets/icons/export-png-s.svg', // 蓝色PNG导出按钮
+ // 输入框图标
+ content: '/assets/icons/content.svg', // 输入框左侧绿色图标
+ // 字体树图标(根据Figma annotation)
+ fontIcon: '/assets/icons/font-icon.svg', // 字体item图标
+ expandIcon: '/assets/icons/expand.svg', // 展开分类图标
+ collapseIcon: '/assets/icons/expand.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) {
@@ -38,6 +43,32 @@ function normalizeHexColor(input) {
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({
data: {
inputText: '星程字体转换',
@@ -324,10 +355,19 @@ Page({
selectedFonts[index].svg = result.svg
selectedFonts[index].width = result.width
selectedFonts[index].height = result.height
+ selectedFonts[index].previewError = ''
this.setData({ selectedFonts })
}
} catch (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
}
- 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() {
@@ -500,21 +533,24 @@ Page({
try {
for (const font of selectedFonts) {
- const width = Math.max(64, Math.round(font.width || 1024))
- const height = Math.max(64, Math.round(font.height || 1024))
-
- const pngPath = await exportSvgToPngByCanvas(this, {
- svgString: font.svg,
- width,
- height,
+ 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)
- await savePngToAlbum(pngPath)
+ const saveResult = await savePngToAlbum(pngPath)
+ if (!saveResult.success) {
+ throw saveResult.error || new Error('保存 PNG 失败')
+ }
}
wx.showToast({ title: 'PNG 已保存到相册', icon: 'success' })
} catch (error) {
- const message = error && error.errMsg ? error.errMsg : error.message
- wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
+ showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
wx.hideLoading()
}
@@ -531,17 +567,11 @@ Page({
// 如果只有一个字体,直接导出
if (selectedFonts.length === 1) {
const font = selectedFonts[0]
- wx.showLoading({ title: '导出 SVG 中', mask: true })
try {
- const filePath = await saveSvgToUserPath(font.svg, font.name, this.data.inputText)
- const filename = buildFilename(font.name, this.data.inputText, 'svg')
- await shareLocalFile(filePath, filename)
+ await shareSvgFromUserTap(font.svg, font.name, this.data.inputText)
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()
+ showExportError('导出 SVG 失败', error, '请稍后重试')
}
} else {
this.exportAllSvg()
@@ -562,24 +592,24 @@ Page({
wx.showLoading({ title: '导出 PNG 中', mask: true })
try {
- const width = Math.max(64, Math.round(font.width || 1024))
- const height = Math.max(64, Math.round(font.height || 1024))
-
- const pngPath = await exportSvgToPngByCanvas(this, {
- svgString: font.svg,
- width,
- height,
+ 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 {
- wx.showToast({ title: '保存失败,请重试', icon: 'none' })
+ showExportError('导出 PNG 失败', saveResult.error, '保存失败,请检查相册权限')
}
} catch (error) {
- const message = error && error.errMsg ? error.errMsg : error.message
- wx.showToast({ title: message || '导出 PNG 失败', icon: 'none', duration: 2400 })
+ showExportError('导出 PNG 失败', error, '请稍后重试')
} finally {
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() {
this.setData({ showSearch: !this.data.showSearch })
diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml
index d777289..f22b64b 100644
--- a/miniprogram/pages/index/index.wxml
+++ b/miniprogram/pages/index/index.wxml
@@ -38,8 +38,9 @@
-
+
+
-
-
-
-
-
-
-
-
-
-
-
@@ -71,9 +61,12 @@
@@ -106,7 +100,7 @@
bindinput="onSearchInput"
/>
-
+
@@ -115,7 +109,8 @@
@@ -131,7 +126,7 @@
-
+
@@ -156,7 +151,8 @@
@@ -172,7 +168,7 @@
-
+
diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss
index ae079a2..2a824eb 100644
--- a/miniprogram/pages/index/index.wxss
+++ b/miniprogram/pages/index/index.wxss
@@ -71,12 +71,20 @@
margin-top: 16rpx;
}
+.content-icon {
+ width: 44rpx;
+ height: 72rpx;
+ flex-shrink: 0;
+}
+
.text-input-container {
flex: 1;
height: 100%;
background: #F7F8FA;
border-radius: 12rpx;
padding: 0 6rpx;
+ display: flex;
+ align-items: center;
}
.text-input {
@@ -86,30 +94,6 @@
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 {
flex: 1;
@@ -162,6 +146,35 @@
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 {
width: 14rpx;
height: 14rpx;
@@ -206,6 +219,13 @@
color: #86909C;
}
+.preview-error {
+ font-size: 22rpx;
+ color: #dc2626;
+ text-align: center;
+ padding: 0 12rpx;
+}
+
.preview-empty {
text-align: center;
padding: 80rpx 0;
@@ -240,6 +260,13 @@
align-items: center;
gap: 8rpx;
padding: 4rpx 0;
+ height: 56rpx;
+}
+
+.selection-header .section-title {
+ padding: 0;
+ height: auto;
+ line-height: 56rpx;
}
.search-container {
@@ -250,7 +277,7 @@
background: #F7F8FA;
border-radius: 8rpx;
padding: 4rpx 12rpx;
- height: 56rpx;
+ height: 40rpx;
}
.search-icon {
@@ -264,11 +291,12 @@
flex: 1;
font-size: 22rpx;
color: #4E5969;
+ height: 100%;
}
.search-toggle {
- width: 56rpx;
- height: 56rpx;
+ width: 40rpx;
+ height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
diff --git a/miniprogram/utils/mp/file-export.js b/miniprogram/utils/mp/file-export.js
index 46ba55f..2d91f61 100644
--- a/miniprogram/utils/mp/file-export.js
+++ b/miniprogram/utils/mp/file-export.js
@@ -25,6 +25,17 @@ async function saveSvgToUserPath(svgText, fontName, text) {
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) {
if (typeof wx.shareFileMessage !== 'function') {
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 = {
sanitizeFilename,
buildFilename,
writeTextToUserPath,
saveSvgToUserPath,
+ saveSvgToUserPathSync,
shareLocalFile,
+ shareSvgFromUserTap,
}
diff --git a/miniprogram/utils/mp/render-api.js b/miniprogram/utils/mp/render-api.js
index 12ebb5a..f3cc0c0 100644
--- a/miniprogram/utils/mp/render-api.js
+++ b/miniprogram/utils/mp/render-api.js
@@ -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) {
const app = getApp()
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
@@ -62,6 +80,52 @@ async function renderSvgByApi(payload) {
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 = {
renderSvgByApi,
+ renderPngByApi,
}