update at 2026-02-07 11:14:09
This commit is contained in:
344
frontend/src/utils/svg-builder.ts
Normal file
344
frontend/src/utils/svg-builder.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import type { Glyph } from 'opentype.js'
|
||||
import type { SvgGenerateOptions, SvgGenerateResult } from '../types/font'
|
||||
import { shapeText } from './harfbuzz'
|
||||
import { wrapTextByChars } from './text-layout'
|
||||
|
||||
/**
|
||||
* 格式化数字(移除尾随零)
|
||||
*/
|
||||
function formatNumber(value: number): string {
|
||||
const text = value.toFixed(2).replace(/\.?0+$/, '')
|
||||
return text || '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字形路径的 SVG path d 属性
|
||||
*/
|
||||
function getGlyphPath(glyph: Glyph): string {
|
||||
const path = glyph.path
|
||||
if (!path || !path.commands || path.commands.length === 0) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const commands: string[] = []
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return commands.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算字形的边界框
|
||||
*/
|
||||
function getGlyphBounds(glyph: Glyph): { xMin: number; yMin: number; xMax: number; yMax: number } | null {
|
||||
const path = glyph.path
|
||||
if (!path || !path.commands || path.commands.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let xMin = Infinity
|
||||
let yMin = Infinity
|
||||
let xMax = -Infinity
|
||||
let yMax = -Infinity
|
||||
|
||||
for (const cmd of path.commands) {
|
||||
if ('x' in cmd) {
|
||||
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 ('x1' in cmd) {
|
||||
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 ('x2' in cmd) {
|
||||
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 }
|
||||
}
|
||||
|
||||
interface GlyphRun {
|
||||
glyph: Glyph
|
||||
xPos: number
|
||||
yPos: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 SVG(使用 HarfBuzz 进行 text shaping)
|
||||
*/
|
||||
export async function generateSvg(options: SvgGenerateOptions): Promise<SvgGenerateResult> {
|
||||
const {
|
||||
text,
|
||||
font,
|
||||
fontSize = 100,
|
||||
fillColor = '#000000',
|
||||
letterSpacing = 0,
|
||||
// enableLigatures = true, // 暂时不使用
|
||||
} = options
|
||||
|
||||
if (!text || text.trim() === '') {
|
||||
throw new Error('文本内容不能为空')
|
||||
}
|
||||
|
||||
const normalizedText = wrapTextByChars(text)
|
||||
|
||||
// 获取字体的 ArrayBuffer(用于 HarfBuzz)
|
||||
// 注意:opentype.js 的 Font 对象没有直接访问 ArrayBuffer 的方法
|
||||
// 我们需要从原始数据获取,这里假设 font 对象有 outlinesFormat 属性
|
||||
// 实际使用时,需要保存原始的 ArrayBuffer
|
||||
|
||||
// 这里先使用简化版本,不使用 HarfBuzz,直接使用 opentype.js 的基本功能
|
||||
// 后续可以优化为使用 HarfBuzz
|
||||
|
||||
const scale = fontSize / font.unitsPerEm
|
||||
const letterSpacingEm = letterSpacing * font.unitsPerEm
|
||||
|
||||
// 获取字形(支持手动换行 + 按 45 字自动换行)
|
||||
const glyphRuns: GlyphRun[] = []
|
||||
let minX: number | null = null
|
||||
let minY: number | null = null
|
||||
let maxX: number | null = null
|
||||
let maxY: number | null = 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++) {
|
||||
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) + letterSpacingEm
|
||||
}
|
||||
|
||||
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: string[] = []
|
||||
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 === 0) {
|
||||
throw new Error('未生成任何路径')
|
||||
}
|
||||
|
||||
// 构建 SVG
|
||||
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"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${formatNumber(width)}" height="${formatNumber(height)}" preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="${groupTransform}" fill="${fillColor}" stroke="none">
|
||||
${paths.join('\n')}
|
||||
</g>
|
||||
</svg>`
|
||||
|
||||
const fontName = font.names.fontFamily?.en || font.names.fullName?.en || 'Unknown'
|
||||
|
||||
return {
|
||||
svg,
|
||||
width,
|
||||
height,
|
||||
fontName,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 SVG(使用 HarfBuzz,高级版本)
|
||||
* 此版本需要原始字体的 ArrayBuffer
|
||||
*/
|
||||
export async function generateSvgWithHarfbuzz(
|
||||
options: SvgGenerateOptions,
|
||||
fontBuffer: ArrayBuffer
|
||||
): Promise<SvgGenerateResult> {
|
||||
const {
|
||||
text,
|
||||
font,
|
||||
fontSize = 100,
|
||||
fillColor = '#000000',
|
||||
letterSpacing = 0,
|
||||
} = options
|
||||
|
||||
if (!text || text.trim() === '') {
|
||||
throw new Error('文本内容不能为空')
|
||||
}
|
||||
|
||||
// 使用 HarfBuzz 进行 text shaping
|
||||
const shapedGlyphs = await shapeText(fontBuffer, text)
|
||||
|
||||
const scale = fontSize / font.unitsPerEm
|
||||
const letterSpacingRaw = letterSpacing * font.unitsPerEm
|
||||
|
||||
// 计算字形位置和路径
|
||||
const glyphRuns: GlyphRun[] = []
|
||||
let x = 0
|
||||
let y = 0
|
||||
|
||||
// 确定 position scale(参考 Python 版本的 _positions_scale 函数)
|
||||
let posScale = 1.0
|
||||
const sampleAdvance = shapedGlyphs.find(g => g.xAdvance)?.xAdvance || 0
|
||||
if (Math.abs(sampleAdvance) > font.unitsPerEm * 4) {
|
||||
posScale = 1 / 64.0
|
||||
}
|
||||
|
||||
const spacingRaw = letterSpacingRaw / posScale
|
||||
|
||||
for (const shaped of shapedGlyphs) {
|
||||
const glyph = font.glyphs.get(shaped.glyphIndex)
|
||||
|
||||
const xPos = (x + shaped.xOffset) * posScale
|
||||
const yPos = (y + shaped.yOffset) * posScale
|
||||
|
||||
glyphRuns.push({
|
||||
glyph,
|
||||
xPos,
|
||||
yPos,
|
||||
})
|
||||
|
||||
x += shaped.xAdvance + spacingRaw
|
||||
y += shaped.yAdvance
|
||||
}
|
||||
|
||||
// 计算总边界框
|
||||
let minX: number | null = null
|
||||
let minY: number | null = null
|
||||
let maxX: number | null = null
|
||||
let maxY: number | null = null
|
||||
|
||||
for (const run of glyphRuns) {
|
||||
const bounds = getGlyphBounds(run.glyph)
|
||||
if (!bounds) continue
|
||||
|
||||
const { xMin, yMin, xMax, yMax } = bounds
|
||||
const adjustedXMin = xMin + run.xPos
|
||||
const adjustedYMin = yMin + run.yPos
|
||||
const adjustedXMax = xMax + run.xPos
|
||||
const adjustedYMax = yMax + run.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)
|
||||
}
|
||||
|
||||
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: string[] = []
|
||||
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 === 0) {
|
||||
throw new Error('未生成任何路径')
|
||||
}
|
||||
|
||||
// 构建 SVG
|
||||
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"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="${groupTransform}" fill="${fillColor}" stroke="none">
|
||||
${paths.join('\n')}
|
||||
</g>
|
||||
</svg>`
|
||||
|
||||
const fontName = font.names.fontFamily?.en || font.names.fullName?.en || 'Unknown'
|
||||
|
||||
return {
|
||||
svg,
|
||||
width,
|
||||
height,
|
||||
fontName,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user