update at 2026-02-08 22:31:25

This commit is contained in:
douboer
2026-02-08 22:31:25 +08:00
parent 0f5a7f0d85
commit f078dd3261
39 changed files with 587 additions and 213 deletions

View File

@@ -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` 每项字段:

View File

@@ -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`

View File

Before

Width:  |  Height:  |  Size: 392 B

After

Width:  |  Height:  |  Size: 392 B

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 585 B

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -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 })

View File

@@ -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}}">

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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,
}