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,
|
||||
}
|
||||
Reference in New Issue
Block a user