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 { 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(` `) } 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 = ` ${paths.join('\n')} ` 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 { 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(` `) } 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 = ` ${paths.join('\n')} ` const fontName = font.names.fontFamily?.en || font.names.fullName?.en || 'Unknown' return { svg, width, height, fontName, } }