update at 2026-02-08 18:28:39
This commit is contained in:
131
miniprogram/utils/mp/canvas-export.js
Normal file
131
miniprogram/utils/mp/canvas-export.js
Normal 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,
|
||||
}
|
||||
49
miniprogram/utils/mp/file-export.js
Normal file
49
miniprogram/utils/mp/file-export.js
Normal 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,
|
||||
}
|
||||
130
miniprogram/utils/mp/font-loader.js
Normal file
130
miniprogram/utils/mp/font-loader.js
Normal 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,
|
||||
}
|
||||
67
miniprogram/utils/mp/render-api.js
Normal file
67
miniprogram/utils/mp/render-api.js
Normal 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,
|
||||
}
|
||||
60
miniprogram/utils/mp/storage.js
Normal file
60
miniprogram/utils/mp/storage.js
Normal 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,
|
||||
}
|
||||
93
miniprogram/utils/mp/worker-manager.js
Normal file
93
miniprogram/utils/mp/worker-manager.js
Normal 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,
|
||||
}
|
||||
110
miniprogram/utils/mp/wx-promisify.js
Normal file
110
miniprogram/utils/mp/wx-promisify.js
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user