update at 2026-02-08 22:31:25
@@ -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` 每项字段:
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
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 { 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 })
|
||||
|
||||
@@ -38,8 +38,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 输入栏和导出按钮 -->
|
||||
<!-- 输入栏 -->
|
||||
<view class="input-row">
|
||||
<image class="content-icon" src="{{icons.content}}" />
|
||||
<view class="text-input-container">
|
||||
<input
|
||||
class="text-input"
|
||||
@@ -50,17 +51,6 @@
|
||||
bindconfirm="onRegenerate"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- 效果预览区域 -->
|
||||
@@ -71,9 +61,12 @@
|
||||
<view class="preview-header">
|
||||
<image class="font-icon" src="{{icons.fontIcon}}" />
|
||||
<view class="font-name-text">{{item.name}}</view>
|
||||
<view class="preview-checkbox" bindtap="onTogglePreviewFont" data-font-id="{{item.id}}">
|
||||
<view class="checkbox-wrapper {{item.showInPreview ? 'checked' : ''}}">
|
||||
<image wx:if="{{item.showInPreview}}" class="checkbox-icon" src="{{icons.checkbox}}" />
|
||||
<view class="export-btns-inline">
|
||||
<view class="export-btn-sm export-svg-btn" bindtap="onExportSingleSvg" data-font-id="{{item.id}}">
|
||||
<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>
|
||||
@@ -84,6 +77,7 @@
|
||||
mode="widthFix"
|
||||
src="{{item.previewSrc}}"
|
||||
/>
|
||||
<view wx:elif="{{item.previewError}}" class="preview-error">{{item.previewError}}</view>
|
||||
<view wx:else class="preview-loading">生成中...</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -106,7 +100,7 @@
|
||||
bindinput="onSearchInput"
|
||||
/>
|
||||
</view>
|
||||
<view class="search-toggle" bindtap="onToggleSearch">
|
||||
<view class="search-toggle" wx:if="{{!showSearch}}" bindtap="onToggleSearch">
|
||||
<image class="search-icon" src="{{icons.search}}" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -115,7 +109,8 @@
|
||||
<view class="category-header" bindtap="onToggleCategory" data-category="{{item.category}}">
|
||||
<image
|
||||
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>
|
||||
@@ -131,7 +126,7 @@
|
||||
<view class="font-item-actions">
|
||||
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
|
||||
<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 class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
||||
@@ -156,7 +151,8 @@
|
||||
<view class="category-header" bindtap="onToggleFavoriteCategory" data-category="{{item.category}}">
|
||||
<image
|
||||
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>
|
||||
@@ -172,7 +168,7 @@
|
||||
<view class="font-item-actions">
|
||||
<view class="font-checkbox" bindtap="onToggleFont" data-font-id="{{font.id}}">
|
||||
<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 class="favorite-btn" bindtap="onToggleFavorite" data-font-id="{{font.id}}">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||