update at 2026-02-07 11:14:09

This commit is contained in:
douboer
2026-02-07 11:14:09 +08:00
parent 2d18aa5137
commit 591bd9ba05
67 changed files with 4885 additions and 0 deletions

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