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

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

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

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

View 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')
}