189 lines
5.4 KiB
JavaScript
189 lines
5.4 KiB
JavaScript
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,
|
|
}
|