Files
font2pic/frontend/src/utils/svg-builder.ts
2026-02-07 11:14:09 +08:00

345 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
}
}