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 @@ {{item.name}} - - - + + + + + + @@ -84,6 +77,7 @@ mode="widthFix" src="{{item.previewSrc}}" /> + {{item.previewError}} 生成中... @@ -106,7 +100,7 @@ bindinput="onSearchInput" /> - + @@ -115,7 +109,8 @@ {{item.category}} @@ -131,7 +126,7 @@ - + @@ -156,7 +151,8 @@ {{item.category}} @@ -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, }