update at 2026-02-08 18:28:39

This commit is contained in:
douboer
2026-02-08 18:28:39 +08:00
parent e2a46e413a
commit 0f5a7f0d85
97 changed files with 22029 additions and 59 deletions

View File

@@ -0,0 +1,131 @@
const {
canvasToTempFilePath,
saveImageToPhotosAlbum,
writeFile,
openSetting,
showModal,
} = require('./wx-promisify')
function getWindowDpr() {
if (typeof wx.getWindowInfo === 'function') {
return wx.getWindowInfo().pixelRatio || 1
}
const info = wx.getSystemInfoSync()
return info.pixelRatio || 1
}
function queryCanvasNode(page, selector) {
return new Promise((resolve, reject) => {
const query = wx.createSelectorQuery().in(page)
query
.select(selector)
.fields({ node: true, size: true })
.exec((result) => {
const target = result && result[0]
if (!target || !target.node) {
reject(new Error('未找到导出画布节点'))
return
}
resolve(target)
})
})
}
async function writeSvgTempFile(svgString) {
const path = `${wx.env.USER_DATA_PATH}/font2svg_preview_${Date.now()}.svg`
await writeFile(path, svgString, 'utf8')
return path
}
async function exportSvgToPngByCanvas(page, options) {
const {
svgString,
width,
height,
selector = '#exportCanvas',
backgroundColor = '#ffffff',
} = options
if (!svgString) {
throw new Error('缺少 SVG 内容')
}
const canvasNode = await queryCanvasNode(page, selector)
const canvas = canvasNode.node
const ctx = canvas.getContext('2d')
const dpr = getWindowDpr()
const renderWidth = Math.max(1, Math.min(2048, Math.round(width || canvasNode.width || 1024)))
const renderHeight = Math.max(1, Math.min(2048, Math.round(height || canvasNode.height || 1024)))
canvas.width = renderWidth * dpr
canvas.height = renderHeight * dpr
if (typeof ctx.setTransform === 'function') {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
} else {
ctx.scale(dpr, dpr)
}
ctx.fillStyle = backgroundColor
ctx.fillRect(0, 0, renderWidth, renderHeight)
const svgPath = await writeSvgTempFile(svgString)
const image = canvas.createImage()
await new Promise((resolve, reject) => {
image.onload = resolve
image.onerror = () => reject(new Error('加载 SVG 到画布失败'))
image.src = svgPath
})
ctx.drawImage(image, 0, 0, renderWidth, renderHeight)
const fileRes = await canvasToTempFilePath(
{
canvas,
x: 0,
y: 0,
width: renderWidth,
height: renderHeight,
destWidth: renderWidth,
destHeight: renderHeight,
fileType: 'png',
},
page
)
return fileRes.tempFilePath
}
async function savePngToAlbum(filePath) {
try {
await saveImageToPhotosAlbum(filePath)
return { success: true }
} catch (error) {
const errMsg = String(error && error.errMsg ? error.errMsg : error)
const needAuth = errMsg.includes('auth deny') || errMsg.includes('authorize')
if (needAuth) {
const modalRes = await showModal({
title: '需要相册权限',
content: '请在设置中开启“保存到相册”权限后重试。',
confirmText: '去设置',
})
if (modalRes.confirm) {
await openSetting()
}
}
return {
success: false,
needAuth,
error,
}
}
}
module.exports = {
exportSvgToPngByCanvas,
savePngToAlbum,
}

View File

@@ -0,0 +1,49 @@
const { writeFile } = require('./wx-promisify')
function sanitizeFilename(filename) {
return String(filename || 'font2svg')
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
.replace(/\s+/g, '_')
.slice(0, 80)
}
function buildFilename(fontName, text, ext) {
const safeFont = sanitizeFilename(fontName || 'font')
const safeText = sanitizeFilename(Array.from(text || '').slice(0, 8).join('') || 'text')
return `${safeFont}_${safeText}.${ext}`
}
async function writeTextToUserPath(text, ext, preferredName) {
const filename = preferredName || `font2svg_${Date.now()}.${ext}`
const filePath = `${wx.env.USER_DATA_PATH}/${filename}`
await writeFile(filePath, text, 'utf8')
return filePath
}
async function saveSvgToUserPath(svgText, fontName, text) {
const filename = buildFilename(fontName, text, 'svg')
return writeTextToUserPath(svgText, 'svg', filename)
}
async function shareLocalFile(filePath, fileName) {
if (typeof wx.shareFileMessage !== 'function') {
throw new Error('当前微信版本不支持文件分享')
}
return new Promise((resolve, reject) => {
wx.shareFileMessage({
filePath,
fileName,
success: resolve,
fail: reject,
})
})
}
module.exports = {
sanitizeFilename,
buildFilename,
writeTextToUserPath,
saveSvgToUserPath,
shareLocalFile,
}

View File

@@ -0,0 +1,130 @@
const { request, downloadFile, readFile } = require('./wx-promisify')
const localFonts = require('../../assets/fonts')
const fontBufferCache = new Map()
const MAX_FONT_CACHE = 4
function normalizePath(path, baseUrl) {
if (!path) {
return ''
}
if (/^https?:\/\//i.test(path)) {
return path
}
if (path.startsWith('//')) {
return `https:${path}`
}
if (path.startsWith('/')) {
return `${baseUrl}${path}`
}
return `${baseUrl}/${path}`
}
function normalizeFontItem(item, baseUrl) {
const path = item.path || item.url || ''
const normalizedPath = normalizePath(path, baseUrl)
const filename = item.filename || normalizedPath.split('/').pop() || `${item.name || 'font'}.ttf`
return {
id: item.id || `${item.category || '默认'}/${item.name || filename}`,
name: item.name || filename.replace(/\.[^.]+$/, ''),
category: item.category || '默认',
filename,
path,
url: normalizedPath,
}
}
function normalizeManifest(fonts, baseUrl) {
if (!Array.isArray(fonts)) {
return []
}
return fonts
.map((item) => normalizeFontItem(item, baseUrl))
.filter((item) => item.url)
}
async function loadFontsManifest(options = {}) {
const app = getApp()
const manifestUrl = options.manifestUrl || app.globalData.fontsManifestUrl
const baseUrl = options.baseUrl || app.globalData.fontsBaseUrl
if (Array.isArray(app.globalData.fonts) && app.globalData.fonts.length > 0) {
return app.globalData.fonts
}
try {
const response = await request({
url: manifestUrl,
method: 'GET',
timeout: 10000,
})
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`获取字体清单失败,状态码: ${response.statusCode}`)
}
const fonts = normalizeManifest(response.data, baseUrl)
if (!fonts.length) {
throw new Error('字体清单为空')
}
app.globalData.fonts = fonts
return fonts
} catch (error) {
console.warn('远程字体清单加载失败,回退到本地清单:', error)
const fallbackFonts = normalizeManifest(localFonts, baseUrl)
app.globalData.fonts = fallbackFonts
return fallbackFonts
}
}
function setLruCache(key, value) {
if (fontBufferCache.has(key)) {
fontBufferCache.delete(key)
}
fontBufferCache.set(key, value)
while (fontBufferCache.size > MAX_FONT_CACHE) {
const firstKey = fontBufferCache.keys().next().value
fontBufferCache.delete(firstKey)
}
}
async function loadFontBuffer(fontItem) {
const cacheKey = fontItem.id
if (fontBufferCache.has(cacheKey)) {
const cached = fontBufferCache.get(cacheKey)
setLruCache(cacheKey, cached)
return cached
}
if (!fontItem.url) {
throw new Error('字体地址为空')
}
const downloadRes = await downloadFile({ url: fontItem.url })
if (downloadRes.statusCode < 200 || downloadRes.statusCode >= 300) {
throw new Error(`字体下载失败,状态码: ${downloadRes.statusCode}`)
}
const readRes = await readFile(downloadRes.tempFilePath)
const result = {
tempFilePath: downloadRes.tempFilePath,
buffer: readRes.data,
}
setLruCache(cacheKey, result)
return result
}
function listCategories(fonts) {
const set = new Set(fonts.map((font) => font.category || '默认'))
return ['全部', '收藏', ...Array.from(set)]
}
module.exports = {
loadFontsManifest,
loadFontBuffer,
listCategories,
}

View File

@@ -0,0 +1,67 @@
const { request } = require('./wx-promisify')
function buildApiUrl() {
const app = getApp()
const apiUrl = app && app.globalData ? app.globalData.svgRenderApiUrl : ''
if (!apiUrl) {
throw new Error('未配置渲染 API 地址')
}
return apiUrl
}
function normalizeResult(data) {
if (!data || typeof data !== 'object') {
throw new Error('渲染服务返回格式无效')
}
if (typeof data.svg !== 'string' || !data.svg.trim()) {
throw new Error('渲染服务未返回有效 SVG')
}
return {
svg: data.svg,
width: Number(data.width) || 0,
height: Number(data.height) || 0,
fontName: data.fontName || 'Unknown',
fontId: data.fontId || '',
}
}
async function renderSvgByApi(payload) {
const app = getApp()
const timeout = app && app.globalData && app.globalData.apiTimeoutMs
? Number(app.globalData.apiTimeoutMs)
: 30000
const response = await request({
url: buildApiUrl(),
method: 'POST',
timeout,
header: {
'content-type': 'application/json',
},
data: {
fontId: payload.fontId,
text: payload.text,
fontSize: payload.fontSize,
fillColor: payload.fillColor,
letterSpacing: payload.letterSpacing,
maxCharsPerLine: payload.maxCharsPerLine,
},
})
if (!response || response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`渲染服务请求失败,状态码: ${response && response.statusCode}`)
}
const body = response.data || {}
if (!body.ok) {
throw new Error(body.error || '渲染服务返回错误')
}
return normalizeResult(body.data)
}
module.exports = {
renderSvgByApi,
}

View File

@@ -0,0 +1,60 @@
const STORAGE_KEYS = {
APP_STATE: 'font2svg:app-state',
FAVORITES: 'font2svg:favorites',
}
function getStorage(key, fallbackValue) {
try {
const value = wx.getStorageSync(key)
if (value === '' || value === undefined || value === null) {
return fallbackValue
}
return value
} catch (error) {
console.warn('读取本地存储失败:', key, error)
return fallbackValue
}
}
function setStorage(key, value) {
try {
wx.setStorageSync(key, value)
} catch (error) {
console.warn('写入本地存储失败:', key, error)
}
}
function loadAppState() {
return getStorage(STORAGE_KEYS.APP_STATE, {})
}
function saveAppState(partialState) {
const current = loadAppState()
const next = {
...current,
...partialState,
updatedAt: Date.now(),
}
setStorage(STORAGE_KEYS.APP_STATE, next)
return next
}
function loadFavorites() {
return getStorage(STORAGE_KEYS.FAVORITES, [])
}
function saveFavorites(favorites) {
const unique = Array.from(new Set(favorites))
setStorage(STORAGE_KEYS.FAVORITES, unique)
return unique
}
module.exports = {
STORAGE_KEYS,
getStorage,
setStorage,
loadAppState,
saveAppState,
loadFavorites,
saveFavorites,
}

View File

@@ -0,0 +1,93 @@
let singleton = null
class SvgWorkerManager {
constructor() {
this.worker = wx.createWorker('workers/svg-generator/index.js')
this.pending = new Map()
this.timeoutMs = 30000
this.worker.onMessage((message) => {
const { requestId, success, data, error } = message || {}
const pendingTask = this.pending.get(requestId)
if (!pendingTask) {
return
}
clearTimeout(pendingTask.timer)
this.pending.delete(requestId)
if (success) {
pendingTask.resolve(data)
} else {
pendingTask.reject(new Error(error || 'Worker 执行失败'))
}
})
this.worker.onError((error) => {
this.rejectAll(error)
})
if (typeof this.worker.onProcessKilled === 'function') {
this.worker.onProcessKilled(() => {
this.rejectAll(new Error('Worker 进程被系统回收'))
})
}
}
rejectAll(error) {
for (const [requestId, pendingTask] of this.pending.entries()) {
clearTimeout(pendingTask.timer)
pendingTask.reject(error)
this.pending.delete(requestId)
}
}
request(type, payload, timeoutMs) {
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2)}`
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId)
reject(new Error(`Worker 超时: ${type}`))
}, timeoutMs || this.timeoutMs)
this.pending.set(requestId, { resolve, reject, timer })
this.worker.postMessage({
requestId,
type,
payload,
})
})
}
loadFont(fontId, fontBuffer) {
return this.request('load-font', {
fontId,
fontBuffer,
}, 45000)
}
generateSvg(params) {
return this.request('generate-svg', params, 45000)
}
clearCache() {
return this.request('clear-cache', {})
}
terminate() {
this.rejectAll(new Error('Worker 已终止'))
this.worker.terminate()
}
}
function getSvgWorkerManager() {
if (!singleton) {
singleton = new SvgWorkerManager()
}
return singleton
}
module.exports = {
getSvgWorkerManager,
}

View File

@@ -0,0 +1,110 @@
function request(options) {
return new Promise((resolve, reject) => {
wx.request({
...options,
success: resolve,
fail: reject,
})
})
}
function downloadFile(options) {
return new Promise((resolve, reject) => {
wx.downloadFile({
...options,
success: resolve,
fail: reject,
})
})
}
function saveImageToPhotosAlbum(filePath) {
return new Promise((resolve, reject) => {
wx.saveImageToPhotosAlbum({
filePath,
success: resolve,
fail: reject,
})
})
}
function canvasToTempFilePath(options, component) {
return new Promise((resolve, reject) => {
wx.canvasToTempFilePath(
{
...options,
success: resolve,
fail: reject,
},
component
)
})
}
function readFile(filePath, encoding) {
return new Promise((resolve, reject) => {
const fs = wx.getFileSystemManager()
fs.readFile({
filePath,
encoding,
success: resolve,
fail: reject,
})
})
}
function writeFile(filePath, data, encoding) {
return new Promise((resolve, reject) => {
const fs = wx.getFileSystemManager()
fs.writeFile({
filePath,
data,
encoding,
success: resolve,
fail: reject,
})
})
}
function saveFile(tempFilePath, filePath) {
return new Promise((resolve, reject) => {
const fs = wx.getFileSystemManager()
fs.saveFile({
tempFilePath,
filePath,
success: resolve,
fail: reject,
})
})
}
function openSetting() {
return new Promise((resolve, reject) => {
wx.openSetting({
success: resolve,
fail: reject,
})
})
}
function showModal(options) {
return new Promise((resolve, reject) => {
wx.showModal({
...options,
success: resolve,
fail: reject,
})
})
}
module.exports = {
request,
downloadFile,
saveImageToPhotosAlbum,
canvasToTempFilePath,
readFile,
writeFile,
saveFile,
openSetting,
showModal,
}