mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 14:44:48 +00:00
- 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>
405 lines
8.3 KiB
TypeScript
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 }
|