update at 2026-02-08 18:28:39
This commit is contained in:
192
miniprogram/utils/core/svg-builder.js
Normal file
192
miniprogram/utils/core/svg-builder.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const { wrapTextByChars } = require('./text-layout')
|
||||
|
||||
function formatNumber(value) {
|
||||
const text = Number(value).toFixed(2).replace(/\.?0+$/, '')
|
||||
return text || '0'
|
||||
}
|
||||
|
||||
function getGlyphPath(glyph) {
|
||||
const path = glyph && glyph.path
|
||||
if (!path || !Array.isArray(path.commands) || !path.commands.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const commands = []
|
||||
for (const cmd of path.commands) {
|
||||
switch (cmd.type) {
|
||||
case 'M':
|
||||
commands.push(`M${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'L':
|
||||
commands.push(`L${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'Q':
|
||||
commands.push(`Q${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'C':
|
||||
commands.push(`C${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x2)} ${formatNumber(cmd.y2)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
|
||||
break
|
||||
case 'Z':
|
||||
commands.push('Z')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return commands.join(' ')
|
||||
}
|
||||
|
||||
function getGlyphBounds(glyph) {
|
||||
const path = glyph && glyph.path
|
||||
if (!path || !Array.isArray(path.commands) || !path.commands.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let xMin = Infinity
|
||||
let yMin = Infinity
|
||||
let xMax = -Infinity
|
||||
let yMax = -Infinity
|
||||
|
||||
for (const cmd of path.commands) {
|
||||
if (typeof cmd.x === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x)
|
||||
xMax = Math.max(xMax, cmd.x)
|
||||
yMin = Math.min(yMin, cmd.y)
|
||||
yMax = Math.max(yMax, cmd.y)
|
||||
}
|
||||
if (typeof cmd.x1 === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x1)
|
||||
xMax = Math.max(xMax, cmd.x1)
|
||||
yMin = Math.min(yMin, cmd.y1)
|
||||
yMax = Math.max(yMax, cmd.y1)
|
||||
}
|
||||
if (typeof cmd.x2 === 'number') {
|
||||
xMin = Math.min(xMin, cmd.x2)
|
||||
xMax = Math.max(xMax, cmd.x2)
|
||||
yMin = Math.min(yMin, cmd.y2)
|
||||
yMax = Math.max(yMax, cmd.y2)
|
||||
}
|
||||
}
|
||||
|
||||
if (xMin === Infinity) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { xMin, yMin, xMax, yMax }
|
||||
}
|
||||
|
||||
function generateSvgFromFont(options) {
|
||||
const {
|
||||
text,
|
||||
font,
|
||||
fontSize = 100,
|
||||
fillColor = '#000000',
|
||||
letterSpacing = 0,
|
||||
maxCharsPerLine,
|
||||
} = options
|
||||
|
||||
if (!text || !String(text).trim()) {
|
||||
throw new Error('文本内容不能为空')
|
||||
}
|
||||
|
||||
if (!font) {
|
||||
throw new Error('字体对象不能为空')
|
||||
}
|
||||
|
||||
const normalizedText = wrapTextByChars(text, maxCharsPerLine)
|
||||
|
||||
const scale = fontSize / font.unitsPerEm
|
||||
const letterSpacingRaw = letterSpacing * font.unitsPerEm
|
||||
|
||||
const glyphRuns = []
|
||||
let minX = null
|
||||
let minY = null
|
||||
let maxX = null
|
||||
let maxY = null
|
||||
let maxLineAdvance = 0
|
||||
|
||||
const ascender = Number.isFinite(font.ascender) ? font.ascender : font.unitsPerEm * 0.8
|
||||
const descender = Number.isFinite(font.descender) ? font.descender : -font.unitsPerEm * 0.2
|
||||
const lineAdvance = Math.max(font.unitsPerEm * 1.2, ascender - descender)
|
||||
const lines = normalizedText.split('\n')
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const line = lines[lineIndex] || ''
|
||||
const yPos = -lineIndex * lineAdvance
|
||||
let xPos = 0
|
||||
|
||||
for (const char of Array.from(line)) {
|
||||
const glyph = font.charToGlyph(char)
|
||||
glyphRuns.push({ glyph, xPos, yPos })
|
||||
|
||||
const bounds = getGlyphBounds(glyph)
|
||||
if (bounds) {
|
||||
const adjustedXMin = bounds.xMin + xPos
|
||||
const adjustedYMin = bounds.yMin + yPos
|
||||
const adjustedXMax = bounds.xMax + xPos
|
||||
const adjustedYMax = bounds.yMax + yPos
|
||||
|
||||
minX = minX === null ? adjustedXMin : Math.min(minX, adjustedXMin)
|
||||
minY = minY === null ? adjustedYMin : Math.min(minY, adjustedYMin)
|
||||
maxX = maxX === null ? adjustedXMax : Math.max(maxX, adjustedXMax)
|
||||
maxY = maxY === null ? adjustedYMax : Math.max(maxY, adjustedYMax)
|
||||
}
|
||||
|
||||
xPos += (glyph.advanceWidth || 0) + letterSpacingRaw
|
||||
}
|
||||
|
||||
maxLineAdvance = Math.max(maxLineAdvance, xPos)
|
||||
}
|
||||
|
||||
if (minX === null || maxX === null) {
|
||||
minX = 0
|
||||
maxX = maxLineAdvance
|
||||
}
|
||||
|
||||
if (minX === null || minY === null || maxX === null || maxY === null) {
|
||||
throw new Error('未生成有效字形轮廓')
|
||||
}
|
||||
|
||||
const width = (maxX - minX) * scale
|
||||
const height = (maxY - minY) * scale
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error('计算得到的 SVG 尺寸无效')
|
||||
}
|
||||
|
||||
const paths = []
|
||||
for (const run of glyphRuns) {
|
||||
const d = getGlyphPath(run.glyph)
|
||||
if (!d) {
|
||||
continue
|
||||
}
|
||||
const transform = `translate(${formatNumber(run.xPos)} ${formatNumber(run.yPos)})`
|
||||
paths.push(` <path d=\"${d}\" transform=\"${transform}\"/>`)
|
||||
}
|
||||
|
||||
if (!paths.length) {
|
||||
throw new Error('未生成任何路径')
|
||||
}
|
||||
|
||||
const viewBox = `${formatNumber(minX)} 0 ${formatNumber(maxX - minX)} ${formatNumber(maxY - minY)}`
|
||||
const groupTransform = `translate(0 ${formatNumber(maxY)}) scale(1 -1)`
|
||||
|
||||
const svg = `<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${viewBox}\" width=\"${formatNumber(width)}\" height=\"${formatNumber(height)}\" preserveAspectRatio=\"xMidYMid meet\">\n <g transform=\"${groupTransform}\" fill=\"${fillColor}\" stroke=\"none\">\n${paths.join('\n')}\n </g>\n</svg>`
|
||||
|
||||
const fontName =
|
||||
(font.names && font.names.fontFamily && (font.names.fontFamily.en || font.names.fontFamily.zh)) ||
|
||||
(font.names && font.names.fullName && (font.names.fullName.en || font.names.fullName.zh)) ||
|
||||
'Unknown'
|
||||
|
||||
return {
|
||||
svg,
|
||||
width,
|
||||
height,
|
||||
fontName,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateSvgFromFont,
|
||||
}
|
||||
36
miniprogram/utils/core/text-layout.js
Normal file
36
miniprogram/utils/core/text-layout.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const MAX_CHARS_PER_LINE = 45
|
||||
|
||||
function normalizeLineBreaks(text) {
|
||||
return String(text || '').replace(/\r\n?/g, '\n')
|
||||
}
|
||||
|
||||
function wrapTextByChars(text, maxCharsPerLine = MAX_CHARS_PER_LINE) {
|
||||
if (maxCharsPerLine <= 0) {
|
||||
return normalizeLineBreaks(text)
|
||||
}
|
||||
|
||||
const normalized = normalizeLineBreaks(text)
|
||||
const lines = normalized.split('\n')
|
||||
const wrappedLines = []
|
||||
|
||||
for (const line of lines) {
|
||||
const chars = Array.from(line)
|
||||
|
||||
if (!chars.length) {
|
||||
wrappedLines.push('')
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < chars.length; i += maxCharsPerLine) {
|
||||
wrappedLines.push(chars.slice(i, i + maxCharsPerLine).join(''))
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedLines.join('\n')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CHARS_PER_LINE,
|
||||
normalizeLineBreaks,
|
||||
wrapTextByChars,
|
||||
}
|
||||
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