update at 2026-02-08 18:28:39
This commit is contained in:
105
miniprogram/workers/svg-generator/index.js
Normal file
105
miniprogram/workers/svg-generator/index.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const opentype = require('./vendor/opentype.js')
|
||||
const { generateSvgFromFont } = require('./svg-builder')
|
||||
|
||||
const MAX_FONT_CACHE = 4
|
||||
const fontCache = new Map()
|
||||
|
||||
function touchCache(key, value) {
|
||||
if (fontCache.has(key)) {
|
||||
fontCache.delete(key)
|
||||
}
|
||||
fontCache.set(key, value)
|
||||
|
||||
while (fontCache.size > MAX_FONT_CACHE) {
|
||||
const firstKey = fontCache.keys().next().value
|
||||
fontCache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
|
||||
function sendResult(requestId, success, data, error) {
|
||||
worker.postMessage({
|
||||
requestId,
|
||||
success,
|
||||
data,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
function handleLoadFont(requestId, payload) {
|
||||
const { fontId, fontBuffer } = payload || {}
|
||||
if (!fontId || !fontBuffer) {
|
||||
throw new Error('加载字体参数无效')
|
||||
}
|
||||
|
||||
const font = opentype.parse(fontBuffer)
|
||||
touchCache(fontId, {
|
||||
font,
|
||||
loadedAt: Date.now(),
|
||||
})
|
||||
|
||||
return { fontId }
|
||||
}
|
||||
|
||||
function handleGenerateSvg(payload) {
|
||||
const {
|
||||
fontId,
|
||||
text,
|
||||
fontSize,
|
||||
fillColor,
|
||||
letterSpacing,
|
||||
maxCharsPerLine,
|
||||
} = payload || {}
|
||||
|
||||
if (!fontId) {
|
||||
throw new Error('缺少 fontId')
|
||||
}
|
||||
|
||||
const cached = fontCache.get(fontId)
|
||||
if (!cached || !cached.font) {
|
||||
throw new Error('字体未加载,请先加载字体')
|
||||
}
|
||||
|
||||
touchCache(fontId, cached)
|
||||
|
||||
return generateSvgFromFont({
|
||||
text,
|
||||
font: cached.font,
|
||||
fontSize,
|
||||
fillColor,
|
||||
letterSpacing,
|
||||
maxCharsPerLine,
|
||||
})
|
||||
}
|
||||
|
||||
worker.onMessage((message) => {
|
||||
const { requestId, type, payload } = message || {}
|
||||
|
||||
try {
|
||||
if (!requestId) {
|
||||
throw new Error('缺少 requestId')
|
||||
}
|
||||
|
||||
if (type === 'load-font') {
|
||||
const data = handleLoadFont(requestId, payload)
|
||||
sendResult(requestId, true, data)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'generate-svg') {
|
||||
const data = handleGenerateSvg(payload)
|
||||
sendResult(requestId, true, data)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'clear-cache') {
|
||||
fontCache.clear()
|
||||
sendResult(requestId, true, { ok: true })
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`未知的任务类型: ${type}`)
|
||||
} catch (error) {
|
||||
const messageText = error && error.message ? error.message : String(error)
|
||||
sendResult(requestId, false, null, messageText)
|
||||
}
|
||||
})
|
||||
188
miniprogram/workers/svg-generator/svg-builder.js
Normal file
188
miniprogram/workers/svg-generator/svg-builder.js
Normal file
@@ -0,0 +1,188 @@
|
||||
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('文本内容不能为空')
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
34
miniprogram/workers/svg-generator/text-layout.js
Normal file
34
miniprogram/workers/svg-generator/text-layout.js
Normal file
@@ -0,0 +1,34 @@
|
||||
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,
|
||||
wrapTextByChars,
|
||||
}
|
||||
14477
miniprogram/workers/svg-generator/vendor/opentype.js
vendored
Normal file
14477
miniprogram/workers/svg-generator/vendor/opentype.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user