Files
afilmory/scripts/extract-font-glyphs.ts
Innei 0fb4cd4c8e feat: enhance font glyph extraction and SVG text rendering
- Added additional characters ('\', '~', '`', "'", '{', '}') to the CHARACTERS array in extract-font-glyphs.ts.
- Updated the font path in extract-font-glyphs.ts to use SF-Pro-Display-Medium.ttf.
- Made adjustments to the character paths and dimensions in svg-text-renderer.ts for improved rendering accuracy.

Signed-off-by: Innei <tukon479@gmail.com>
2025-06-05 18:11:06 +08:00

405 lines
8.3 KiB
TypeScript

import { existsSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import opentype from 'opentype.js'
interface GlyphData {
path: string
width: number
height: number
advanceWidth: number
}
// 常用字符集
const CHARACTERS = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
' ',
'.',
',',
'!',
'?',
':',
';',
'-',
'(',
')',
'[',
']',
'/',
'&',
'@',
'#',
'$',
'%',
'*',
'+',
'=',
'<',
'>',
'|',
'\\',
'~',
'`',
"'",
'{',
'}',
]
const __dirname = fileURLToPath(new URL('.', import.meta.url))
// 常见的无衬线字体路径(优先使用 TTF 格式)
const HELVETICA_FONT_PATHS = [
join(__dirname, './SF-Pro-Display-Medium.ttf'),
'/System/Library/Fonts/SFCompact.ttf',
'/System/Library/Fonts/Geneva.ttf',
'/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/System/Library/Fonts/Helvetica.ttf',
'/Library/Fonts/Helvetica.ttf',
]
function findHelveticaFont(): string | null {
for (const fontPath of HELVETICA_FONT_PATHS) {
if (existsSync(fontPath)) {
console.info('Found font:', fontPath)
return fontPath
}
}
return null
}
function normalizeGlyphPath(path: opentype.Path, unitsPerEm: number): string {
const scale = 100 / unitsPerEm
const commands: string[] = []
path.commands.forEach((cmd) => {
switch (cmd.type) {
case 'M': {
commands.push(
`M ${(cmd.x * scale).toFixed(1)} ${(100 - cmd.y * scale).toFixed(1)}`,
)
break
}
case 'L': {
commands.push(
`L ${(cmd.x * scale).toFixed(1)} ${(100 - cmd.y * scale).toFixed(1)}`,
)
break
}
case 'C': {
commands.push(
`C ${(cmd.x1 * scale).toFixed(1)} ${(100 - cmd.y1 * scale).toFixed(1)} ${(
cmd.x2 * scale
).toFixed(1)} ${(100 - cmd.y2 * scale).toFixed(1)} ${(
cmd.x * scale
).toFixed(1)} ${(100 - cmd.y * scale).toFixed(1)}`,
)
break
}
case 'Q': {
commands.push(
`Q ${(cmd.x1 * scale).toFixed(1)} ${(100 - cmd.y1 * scale).toFixed(1)} ${(
cmd.x * scale
).toFixed(1)} ${(100 - cmd.y * scale).toFixed(1)}`,
)
break
}
case 'Z': {
commands.push('Z')
break
}
}
})
return commands.join(' ')
}
async function extractGlyphs(): Promise<Record<string, GlyphData>> {
const fontPath = findHelveticaFont()
if (!fontPath) {
throw new Error('Helvetica font not found.')
}
console.info('Loading font from:', fontPath)
const font = await opentype.load(fontPath)
console.info('Font loaded:', font.names.fontFamily?.en || 'Unknown')
const glyphs: Record<string, GlyphData> = {}
const { unitsPerEm } = font
for (const char of CHARACTERS) {
const glyph = font.charToGlyph(char)
if (glyph && glyph.path) {
const path = normalizeGlyphPath(glyph.path, unitsPerEm)
const advanceWidth = (glyph.advanceWidth / unitsPerEm) * 100
glyphs[char] = {
path,
width: advanceWidth,
height: 100,
advanceWidth,
}
console.info(
'Extracted glyph for:',
char,
'width:',
advanceWidth.toFixed(1),
)
} else {
console.warn('No glyph found for character:', char)
if (char === ' ') {
glyphs[char] = {
path: '',
width: 25,
height: 100,
advanceWidth: 25,
}
}
}
}
return glyphs
}
function generateSVGTextRenderer(glyphs: Record<string, GlyphData>): string {
const glyphEntries = Object.entries(glyphs)
.map(([char, data]) => {
const escapedChar = char === "'" ? "\\'" : char === '\\' ? '\\\\' : char
return ` '${escapedChar}': {
path: '${data.path}',
width: ${data.width.toFixed(1)},
height: ${data.height},
advanceWidth: ${data.advanceWidth.toFixed(1)}
}`
})
.join(',\n')
return `// SVG 文本渲染器 - 基于真实 Helvetica 字体提取的字形
// 自动生成,请勿手动编辑
interface CharacterPath {
path: string
width: number
height: number
advanceWidth: number
}
const HELVETICA_CHARACTERS: Record<string, CharacterPath> = {
${glyphEntries}
}
interface SVGTextOptions {
fontSize?: number
fontWeight?: 'normal' | 'bold'
color?: string
letterSpacing?: number
lineHeight?: number
}
export function renderSVGText(
text: string,
x: number,
y: number,
options: SVGTextOptions = {}
): string {
const {
fontSize = 48,
fontWeight = 'normal',
color = 'white',
letterSpacing = 0,
lineHeight = 1.2
} = options
const scale = fontSize / 100
const lines = text.split('\\n')
let svgPaths = ''
lines.forEach((line, lineIndex) => {
let currentX = x
const currentY = y + (lineIndex * fontSize * lineHeight)
for (let i = 0; i < line.length; i++) {
const char = line[i]
const charData = HELVETICA_CHARACTERS[char] || HELVETICA_CHARACTERS[' ']
if (charData.path) {
const scaledPath = charData.path.replace(/([\\d.-]+)/g, (match) => {
const num = parseFloat(match)
return (num * scale).toFixed(1)
})
svgPaths += '<g transform="translate(' + currentX + ', ' + currentY + ')">'
svgPaths += '<path d="' + scaledPath + '" fill="' + color + '"'
if (fontWeight === 'bold') {
svgPaths += ' stroke="' + color + '" stroke-width="' + (1.5 * scale) + '"'
}
svgPaths += ' stroke-linejoin="round" />'
svgPaths += '</g>'
}
currentX += (charData.advanceWidth * scale) + letterSpacing
}
})
return svgPaths
}
export function measureSVGText(
text: string,
options: SVGTextOptions = {}
): { width: number; height: number } {
const {
fontSize = 48,
letterSpacing = 0,
lineHeight = 1.2
} = options
const scale = fontSize / 100
const lines = text.split('\\n')
let maxWidth = 0
const height = lines.length * fontSize * lineHeight
lines.forEach(line => {
let lineWidth = 0
for (let i = 0; i < line.length; i++) {
const char = line[i]
const charData = HELVETICA_CHARACTERS[char] || HELVETICA_CHARACTERS[' ']
lineWidth += (charData.advanceWidth * scale) + letterSpacing
}
maxWidth = Math.max(maxWidth, lineWidth - letterSpacing)
})
return { width: maxWidth, height }
}
export function wrapSVGText(
text: string,
maxWidth: number,
options: SVGTextOptions = {}
): string {
const words = text.split(' ')
const lines: string[] = []
let currentLine = ''
for (const word of words) {
const testLine = currentLine ? currentLine + ' ' + word : word
const { width } = measureSVGText(testLine, options)
if (width <= maxWidth) {
currentLine = testLine
} else {
if (currentLine) {
lines.push(currentLine)
currentLine = word
} else {
lines.push(word)
}
}
}
if (currentLine) {
lines.push(currentLine)
}
return lines.join('\\n')
}
`
}
async function main() {
try {
console.info('Searching for Helvetica font...')
const glyphs = await extractGlyphs()
console.info(
'Generating SVG text renderer with',
Object.keys(glyphs).length,
'glyphs...',
)
const rendererCode = generateSVGTextRenderer(glyphs)
const originalPath = join(process.cwd(), 'scripts', 'svg-text-renderer.ts')
writeFileSync(originalPath, rendererCode)
console.info('New SVG text renderer generated:', originalPath)
console.info('Font extraction completed successfully!')
} catch (error) {
console.error('Font extraction failed:', error)
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1)
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(console.error)
}
export { extractGlyphs, generateSVGTextRenderer }