update at 2026-02-07 11:14:09
This commit is contained in:
170
frontend/src/utils/download.ts
Normal file
170
frontend/src/utils/download.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 下载文本内容为文件
|
||||
*/
|
||||
export function downloadText(content: string, filename: string, mimeType = 'text/plain') {
|
||||
const blob = new Blob([content], { type: mimeType })
|
||||
downloadBlob(blob, filename)
|
||||
}
|
||||
|
||||
export interface DownloadFileItem {
|
||||
name: string
|
||||
content: string | Blob | ArrayBuffer | Uint8Array
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 Blob 为文件
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 SVG 文件
|
||||
*/
|
||||
export function downloadSvg(svgContent: string, filename: string) {
|
||||
downloadText(svgContent, filename, 'image/svg+xml')
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载文件(使用 JSZip)
|
||||
*/
|
||||
export async function downloadMultipleFiles(files: DownloadFileItem[], zipFilename = 'font2svg-export.zip') {
|
||||
const JSZip = (await import('jszip')).default
|
||||
const zip = new JSZip()
|
||||
|
||||
for (const file of files) {
|
||||
zip.file(file.name, file.content)
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
downloadBlob(blob, zipFilename)
|
||||
}
|
||||
|
||||
function parseLengthValue(value: string | null): number | null {
|
||||
if (!value) return null
|
||||
const match = value.match(/-?\d+(\.\d+)?/)
|
||||
if (!match) return null
|
||||
|
||||
const parsed = Number(match[0])
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function getSvgSize(svgContent: string): { width: number; height: number } {
|
||||
const doc = new DOMParser().parseFromString(svgContent, 'image/svg+xml')
|
||||
const svg = doc.documentElement
|
||||
|
||||
const width = parseLengthValue(svg.getAttribute('width'))
|
||||
const height = parseLengthValue(svg.getAttribute('height'))
|
||||
if (width && height) {
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const viewBox = svg.getAttribute('viewBox')
|
||||
if (viewBox) {
|
||||
const values = viewBox.trim().split(/[\s,]+/).map(Number)
|
||||
if (values.length === 4 && Number.isFinite(values[2]) && Number.isFinite(values[3])) {
|
||||
return { width: Math.max(1, values[2]!), height: Math.max(1, values[3]!) }
|
||||
}
|
||||
}
|
||||
|
||||
return { width: 1024, height: 1024 }
|
||||
}
|
||||
|
||||
export async function convertSvgToPngBlob(
|
||||
svgContent: string,
|
||||
options?: { width?: number; height?: number; scale?: number; backgroundColor?: string }
|
||||
): Promise<Blob> {
|
||||
const size = getSvgSize(svgContent)
|
||||
const scale = options?.scale ?? 1
|
||||
const width = Math.max(1, Math.round((options?.width ?? size.width) * scale))
|
||||
const height = Math.max(1, Math.round((options?.height ?? size.height) * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
throw new Error('无法创建 PNG 画布')
|
||||
}
|
||||
|
||||
if (options?.backgroundColor) {
|
||||
context.fillStyle = options.backgroundColor
|
||||
context.fillRect(0, 0, width, height)
|
||||
} else {
|
||||
context.clearRect(0, 0, width, height)
|
||||
}
|
||||
|
||||
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' })
|
||||
const url = URL.createObjectURL(svgBlob)
|
||||
|
||||
try {
|
||||
const image = new Image()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve()
|
||||
image.onerror = () => reject(new Error('SVG 转 PNG 失败'))
|
||||
image.src = url
|
||||
})
|
||||
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
} finally {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const pngBlob = await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, 'image/png')
|
||||
})
|
||||
|
||||
if (!pngBlob) {
|
||||
throw new Error('PNG 编码失败')
|
||||
}
|
||||
|
||||
return pngBlob
|
||||
}
|
||||
|
||||
export async function downloadPngFromSvg(
|
||||
svgContent: string,
|
||||
filename: string,
|
||||
options?: { width?: number; height?: number; scale?: number; backgroundColor?: string }
|
||||
) {
|
||||
const blob = await convertSvgToPngBlob(svgContent, options)
|
||||
downloadBlob(blob, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件名(移除非法字符)
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
// 移除或替换非法字符
|
||||
return filename
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
||||
.replace(/\s+/g, '_')
|
||||
.substring(0, 200) // 限制长度
|
||||
}
|
||||
|
||||
function generateBaseFilename(text: string, fontName: string): string {
|
||||
const textPart = sanitizeFilename(Array.from(text).slice(0, 8).join(''))
|
||||
const fontPart = sanitizeFilename(fontName.substring(0, 20))
|
||||
return `${fontPart}_${textPart}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认的 SVG 文件名
|
||||
*/
|
||||
export function generateSvgFilename(text: string, fontName: string): string {
|
||||
return `${generateBaseFilename(text, fontName)}.svg`
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成默认的 PNG 文件名
|
||||
*/
|
||||
export function generatePngFilename(text: string, fontName: string): string {
|
||||
return `${generateBaseFilename(text, fontName)}.png`
|
||||
}
|
||||
102
frontend/src/utils/font-loader.ts
Normal file
102
frontend/src/utils/font-loader.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as opentype from 'opentype.js'
|
||||
import type { Font } from 'opentype.js'
|
||||
|
||||
/**
|
||||
* 从文件加载字体
|
||||
*/
|
||||
export async function loadFontFromFile(file: File): Promise<Font> {
|
||||
const buffer = await file.arrayBuffer()
|
||||
return opentype.parse(buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 加载字体
|
||||
*/
|
||||
export async function loadFontFromUrl(url: string): Promise<Font> {
|
||||
return new Promise((resolve, reject) => {
|
||||
opentype.load(url, (err, font) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else if (font) {
|
||||
resolve(font)
|
||||
} else {
|
||||
reject(new Error('Failed to load font'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 加载字体(带进度)
|
||||
*/
|
||||
export async function loadFontWithProgress(
|
||||
url: string,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<Font> {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length')
|
||||
const total = contentLength ? parseInt(contentLength) : 0
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let loaded = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
chunks.push(value)
|
||||
loaded += value.length
|
||||
|
||||
if (total > 0 && onProgress) {
|
||||
onProgress(Math.round((loaded / total) * 100))
|
||||
}
|
||||
}
|
||||
|
||||
// 合并所有 chunks
|
||||
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
|
||||
const buffer = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
buffer.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return opentype.parse(buffer.buffer)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load font from ${url}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 ArrayBuffer 加载字体
|
||||
*/
|
||||
export async function loadFontFromBuffer(buffer: ArrayBuffer): Promise<Font> {
|
||||
return opentype.parse(buffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字体信息
|
||||
*/
|
||||
export function getFontInfo(font: Font) {
|
||||
return {
|
||||
familyName: font.names.fontFamily?.en || 'Unknown',
|
||||
styleName: font.names.fontSubfamily?.en || 'Regular',
|
||||
fullName: font.names.fullName?.en || 'Unknown',
|
||||
postScriptName: font.names.postScriptName?.en || 'Unknown',
|
||||
version: font.names.version?.en || 'Unknown',
|
||||
unitsPerEm: font.unitsPerEm,
|
||||
ascender: font.ascender,
|
||||
descender: font.descender,
|
||||
numGlyphs: font.numGlyphs,
|
||||
}
|
||||
}
|
||||
86
frontend/src/utils/harfbuzz.ts
Normal file
86
frontend/src/utils/harfbuzz.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ShapedGlyph } from '../types/font'
|
||||
|
||||
let hb: any = null
|
||||
|
||||
/**
|
||||
* 初始化 HarfBuzz WASM
|
||||
*/
|
||||
export async function initHarfbuzz() {
|
||||
if (!hb) {
|
||||
// 使用动态 import 避免打包时的问题
|
||||
const hbModule = await import('harfbuzzjs/hb.js')
|
||||
const createHB = (hbModule as any).default || hbModule
|
||||
const instance = await createHB()
|
||||
|
||||
const hbjsModule = await import('harfbuzzjs/hbjs.js')
|
||||
const hbjsFunc = (hbjsModule as any).default || hbjsModule
|
||||
hb = hbjsFunc(instance)
|
||||
}
|
||||
return hb
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 HarfBuzz 进行 text shaping
|
||||
* @param fontBuffer 字体文件的 ArrayBuffer
|
||||
* @param text 要处理的文本
|
||||
* @param features OpenType 特性(可选)
|
||||
* @returns Shaped glyphs 数组
|
||||
*/
|
||||
export async function shapeText(
|
||||
fontBuffer: ArrayBuffer,
|
||||
text: string,
|
||||
features?: string[]
|
||||
): Promise<ShapedGlyph[]> {
|
||||
const hb = await initHarfbuzz()
|
||||
|
||||
// 创建 blob
|
||||
const blob = hb.createBlob(fontBuffer)
|
||||
const face = hb.createFace(blob, 0)
|
||||
const font = hb.createFont(face)
|
||||
|
||||
// 创建 buffer 并添加文本
|
||||
const buffer = hb.createBuffer()
|
||||
buffer.addText(text)
|
||||
buffer.guessSegmentProperties()
|
||||
|
||||
// 如果有特性,设置特性
|
||||
if (features && features.length > 0) {
|
||||
// HarfBuzz features format: "+liga", "-kern", etc.
|
||||
// 这里简化处理,实际使用时可能需要更复杂的特性解析
|
||||
}
|
||||
|
||||
// 进行 shaping
|
||||
hb.shape(font, buffer)
|
||||
|
||||
// 获取结果
|
||||
const result = buffer.json(font)
|
||||
|
||||
// 清理资源
|
||||
buffer.destroy()
|
||||
font.destroy()
|
||||
face.destroy()
|
||||
blob.destroy()
|
||||
|
||||
// 转换为我们的格式
|
||||
return result.map((item: any) => ({
|
||||
glyphIndex: item.g,
|
||||
xAdvance: item.ax || 0,
|
||||
yAdvance: item.ay || 0,
|
||||
xOffset: item.dx || 0,
|
||||
yOffset: item.dy || 0,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 HarfBuzz 是否已初始化
|
||||
*/
|
||||
export function isHarfbuzzInitialized(): boolean {
|
||||
return hb !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 HarfBuzz 实例(如果已初始化)
|
||||
*/
|
||||
export function getHarfbuzz() {
|
||||
return hb
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
34
frontend/src/utils/text-layout.ts
Normal file
34
frontend/src/utils/text-layout.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const MAX_CHARS_PER_LINE = 45
|
||||
|
||||
/**
|
||||
* 统一换行符为 \n
|
||||
*/
|
||||
export function normalizeLineBreaks(text: string): string {
|
||||
return text.replace(/\r\n?/g, '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 按字符数自动换行,保留用户手动换行
|
||||
*/
|
||||
export function wrapTextByChars(text: string, maxCharsPerLine = MAX_CHARS_PER_LINE): string {
|
||||
if (maxCharsPerLine <= 0) return normalizeLineBreaks(text)
|
||||
|
||||
const normalized = normalizeLineBreaks(text)
|
||||
const lines = normalized.split('\n')
|
||||
const wrappedLines: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const chars = Array.from(line)
|
||||
|
||||
if (chars.length === 0) {
|
||||
wrappedLines.push('')
|
||||
continue
|
||||
}
|
||||
|
||||
for (let i = 0; i < chars.length; i += maxCharsPerLine) {
|
||||
wrappedLines.push(chars.slice(i, i + maxCharsPerLine).join(''))
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedLines.join('\n')
|
||||
}
|
||||
Reference in New Issue
Block a user