345 lines
9.6 KiB
TypeScript
345 lines
9.6 KiB
TypeScript
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,
|
||
}
|
||
}
|