update at 2026-02-11 19:13:31

This commit is contained in:
douboer
2026-02-11 19:13:31 +08:00
parent 74eebc19db
commit b3add14421
3 changed files with 334 additions and 95 deletions

View File

@@ -78,7 +78,7 @@ async function handleExport(format: ExportFormat) {
} }
try { try {
const { generateSvg } = await import('./utils/svg-builder') const { renderSvgByApi } = await import('./utils/render-api')
const { const {
convertSvgToPngBlob, convertSvgToPngBlob,
downloadSvg, downloadSvg,
@@ -87,23 +87,28 @@ async function handleExport(format: ExportFormat) {
generatePngFilename, generatePngFilename,
generateSvgFilename, generateSvgFilename,
} = await import('./utils/download') } = await import('./utils/download')
const renderByApi = async (fontId: string) => {
return renderSvgByApi({
fontId,
text: inputText,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: Number(uiStore.letterSpacing) || 0,
maxCharsPerLine: MAX_CHARS_PER_LINE,
})
}
if (selectedItems.length === 1) { if (selectedItems.length === 1) {
// 单个字体,直接下载 SVG // 单个字体,直接下载 SVG
const item = selectedItems[0] const item = selectedItems[0]
if (!item?.fontInfo.font) { const fontId = item?.fontInfo?.id
alert('选中字体未加载完成,请稍后重试') if (!fontId) {
alert('选中字体信息无效,请重新选择后重试')
return return
} }
const font = item.fontInfo.font const svgResult = await renderByApi(fontId)
const svgResult = await generateSvg({
text: inputText,
font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
if (format === 'svg') { if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName) const filename = generateSvgFilename(inputText, svgResult.fontName)
@@ -122,19 +127,13 @@ async function handleExport(format: ExportFormat) {
for (const item of selectedItems) { for (const item of selectedItems) {
try { try {
const font = item.fontInfo.font const fontId = item?.fontInfo?.id
if (!font) { if (!fontId) {
console.warn(`字体 ${item.fontInfo.name} 尚未加载,已跳过导出`) console.warn('发现无效字体项,已跳过')
continue continue
} }
const svgResult = await generateSvg({ const svgResult = await renderByApi(fontId)
text: inputText,
font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
if (format === 'svg') { if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName) const filename = generateSvgFilename(inputText, svgResult.fontName)

View File

@@ -2,14 +2,30 @@
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue' import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
import { useFontStore } from '../stores/fontStore' import { useFontStore } from '../stores/fontStore'
import { useUiStore } from '../stores/uiStore' import { useUiStore } from '../stores/uiStore'
import { generateSvg } from '../utils/svg-builder' import { renderSvgByApi } from '../utils/render-api'
import { MAX_CHARS_PER_LINE } from '../utils/text-layout'
import type { PreviewItem as PreviewItemType } from '../types/font' import type { PreviewItem as PreviewItemType } from '../types/font'
import type { SvgGenerateResult, FontInfo } from '../types/font' import type { SvgGenerateResult, FontInfo } from '../types/font'
const fontStore = useFontStore() const fontStore = useFontStore()
const uiStore = useUiStore() const uiStore = useUiStore()
const previewItems = ref<PreviewItemType[]>([]) interface PreviewApiCacheItem {
svg: string
width: number
height: number
fontName: string
renderFontSize: number
}
interface PreviewRenderItem extends PreviewItemType {
baseSvg: string
baseWidth: number
baseHeight: number
renderFontSize: number
}
const previewItems = ref<PreviewRenderItem[]>([])
const isGenerating = ref(false) const isGenerating = ref(false)
const isBatchGenerating = ref(false) const isBatchGenerating = ref(false)
const activePreviewFonts = ref<FontInfo[]>([]) const activePreviewFonts = ref<FontInfo[]>([])
@@ -24,17 +40,17 @@ const fillColor = computed(() => uiStore.textColor)
const PREVIEW_DEBOUNCE_MS = 240 const PREVIEW_DEBOUNCE_MS = 240
const PREVIEW_CONCURRENCY = 4 const PREVIEW_CONCURRENCY = 4
const PREVIEW_GEOMETRY_CACHE_LIMIT = 600 const PREVIEW_API_CACHE_LIMIT = 600
const PREVIEW_COLOR_TOKEN = '__FONT2SVG_FILL__'
const PREVIEW_BATCH_SIZE = 20 const PREVIEW_BATCH_SIZE = 20
const PREVIEW_PREFETCH_OFFSET = 10 const PREVIEW_PREFETCH_OFFSET = 10
const PREVIEW_RENDER_FONT_SIZE = 120
let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null
let previewGenerationToken = 0 let previewGenerationToken = 0
let hasTriggeredInitialGenerate = false let hasTriggeredInitialGenerate = false
let previewLazyLoadObserver: IntersectionObserver | null = null let previewLazyLoadObserver: IntersectionObserver | null = null
const previewGeometryCache = new Map<string, Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string }>() const previewApiCache = new Map<string, PreviewApiCacheItem>()
const isAllPreviewSelected = computed(() => { const isAllPreviewSelected = computed(() => {
return previewItems.value.length > 0 && previewItems.value.every(item => item.selected) return previewItems.value.length > 0 && previewItems.value.every(item => item.selected)
@@ -131,78 +147,162 @@ function setPreviewItemRef(el: unknown, index: number) {
previewTriggerItemEl.value = el instanceof HTMLElement ? el : null previewTriggerItemEl.value = el instanceof HTMLElement ? el : null
} }
function getPreviewGeometryCacheKey(fontInfo: FontInfo): string { function normalizeHexColor(input: string, fallback = '#000000'): string {
return [fontInfo.id, fontInfo.path, inputText.value, fontSize.value].join('::') const value = String(input || '').trim()
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
return value
}
return fallback
} }
function readPreviewGeometryFromCache(key: string) { function clampFontSize(value: number, fallback = PREVIEW_RENDER_FONT_SIZE): number {
const cached = previewGeometryCache.get(key) if (!Number.isFinite(value)) {
return fallback
}
return Math.max(1, Math.min(2048, Math.round(value)))
}
function formatSvgNumber(value: number): string {
const text = Number(value).toFixed(2)
return text.replace(/\.?0+$/, '')
}
function replaceSvgFillColor(svg: string, color: string): string {
const normalizedColor = normalizeHexColor(color)
if (!svg) return ''
if (/<g\b[^>]*\sfill="[^"]*"/.test(svg)) {
return svg.replace(/(<g\b[^>]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`)
}
return svg.replace(/<g\b([^>]*)>/, `<g$1 fill="${normalizedColor}">`)
}
function scaleSvgDimensions(svg: string, scale: number): string {
if (!svg || !Number.isFinite(scale) || scale <= 0) {
return svg
}
return svg
.replace(/width="([0-9]+(?:\.[0-9]+)?)"/, (_, width) => {
const scaledWidth = Number(width) * scale
return `width="${formatSvgNumber(scaledWidth)}"`
})
.replace(/height="([0-9]+(?:\.[0-9]+)?)"/, (_, height) => {
const scaledHeight = Number(height) * scale
return `height="${formatSvgNumber(scaledHeight)}"`
})
}
function buildPreviewCacheKey(
fontId: string,
text: string,
letterSpacing: number,
maxCharsPerLine: number,
): string {
const safeSpacing = Number.isFinite(letterSpacing) ? letterSpacing.toFixed(4) : '0.0000'
return [fontId, safeSpacing, String(maxCharsPerLine), text].join('::')
}
function getPreviewApiCacheKey(fontInfo: FontInfo): string {
return buildPreviewCacheKey(
fontInfo.id,
String(inputText.value || ''),
Number(uiStore.letterSpacing) || 0,
MAX_CHARS_PER_LINE,
)
}
function readPreviewFromCache(key: string): PreviewApiCacheItem | null {
const cached = previewApiCache.get(key)
if (!cached) { if (!cached) {
return null return null
} }
previewGeometryCache.delete(key) previewApiCache.delete(key)
previewGeometryCache.set(key, cached) previewApiCache.set(key, cached)
return cached return cached
} }
function writePreviewGeometryToCache( function writePreviewToCache(key: string, value: PreviewApiCacheItem) {
key: string, if (previewApiCache.has(key)) {
geometry: Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string }, previewApiCache.delete(key)
) {
if (previewGeometryCache.has(key)) {
previewGeometryCache.delete(key)
} }
previewGeometryCache.set(key, geometry) previewApiCache.set(key, value)
while (previewGeometryCache.size > PREVIEW_GEOMETRY_CACHE_LIMIT) { while (previewApiCache.size > PREVIEW_API_CACHE_LIMIT) {
const oldestKey = previewGeometryCache.keys().next().value const oldestKey = previewApiCache.keys().next().value
if (oldestKey === undefined) { if (oldestKey === undefined) {
break break
} }
previewGeometryCache.delete(oldestKey) previewApiCache.delete(oldestKey)
} }
} }
async function generateSvgWithPreviewCache(fontInfo: FontInfo): Promise<SvgGenerateResult | null> { function toStyledSvgResult(item: PreviewRenderItem): SvgGenerateResult {
if (!fontInfo.font) { const targetSize = clampFontSize(Number(fontSize.value), PREVIEW_RENDER_FONT_SIZE)
return null const renderSize = Number(item.renderFontSize) > 0 ? Number(item.renderFontSize) : PREVIEW_RENDER_FONT_SIZE
} const scale = targetSize / renderSize
const styledSvg = scaleSvgDimensions(
const cacheKey = getPreviewGeometryCacheKey(fontInfo) replaceSvgFillColor(item.baseSvg, fillColor.value),
let geometry = readPreviewGeometryFromCache(cacheKey) scale,
if (!geometry) { )
const baseResult = await generateSvg({
text: inputText.value,
font: fontInfo.font,
fontSize: fontSize.value,
fillColor: PREVIEW_COLOR_TOKEN,
})
geometry = {
width: baseResult.width,
height: baseResult.height,
fontName: baseResult.fontName,
svgTemplate: baseResult.svg,
}
writePreviewGeometryToCache(cacheKey, geometry)
}
return { return {
width: geometry.width, svg: styledSvg,
height: geometry.height, width: Number(item.baseWidth) > 0 ? Number(item.baseWidth) * scale : 0,
fontName: geometry.fontName, height: Number(item.baseHeight) > 0 ? Number(item.baseHeight) * scale : 0,
svg: geometry.svgTemplate.split(PREVIEW_COLOR_TOKEN).join(fillColor.value), fontName: item.svgResult.fontName || item.fontInfo.name,
} }
} }
function applyLocalStyleToPreviewItem(item: PreviewRenderItem): PreviewRenderItem {
return {
...item,
svgResult: toStyledSvgResult(item),
}
}
function applyLocalPreviewStyles() {
if (previewItems.value.length === 0) {
return
}
previewItems.value = previewItems.value.map(item => applyLocalStyleToPreviewItem(item))
}
async function getOrRenderPreviewBase(fontInfo: FontInfo): Promise<PreviewApiCacheItem | null> {
const cacheKey = getPreviewApiCacheKey(fontInfo)
const cached = readPreviewFromCache(cacheKey)
if (cached) {
return cached
}
const result = await renderSvgByApi({
fontId: fontInfo.id,
text: inputText.value,
fontSize: PREVIEW_RENDER_FONT_SIZE,
fillColor: '#000000',
letterSpacing: Number(uiStore.letterSpacing) || 0,
maxCharsPerLine: MAX_CHARS_PER_LINE,
})
const base: PreviewApiCacheItem = {
svg: result.svg,
width: result.width,
height: result.height,
fontName: result.fontName || fontInfo.name,
renderFontSize: PREVIEW_RENDER_FONT_SIZE,
}
writePreviewToCache(cacheKey, base)
return base
}
async function generatePreviewBatch( async function generatePreviewBatch(
fonts: FontInfo[], fonts: FontInfo[],
startIndex: number, startIndex: number,
batchSize: number, batchSize: number,
generationToken: number, generationToken: number,
): Promise<PreviewItemType[]> { ): Promise<PreviewRenderItem[]> {
const endIndex = Math.min(startIndex + batchSize, fonts.length) const endIndex = Math.min(startIndex + batchSize, fonts.length)
const batchFonts = fonts.slice(startIndex, endIndex) const batchFonts = fonts.slice(startIndex, endIndex)
if (batchFonts.length === 0) { if (batchFonts.length === 0) {
@@ -210,7 +310,7 @@ async function generatePreviewBatch(
} }
const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id)) const selectedFontIdSet = new Set(uiStore.selectedExportItems.map(item => item.fontInfo.id))
const items = new Array<PreviewItemType | null>(batchFonts.length).fill(null) const items = new Array<PreviewRenderItem | null>(batchFonts.length).fill(null)
const workerCount = Math.min(PREVIEW_CONCURRENCY, batchFonts.length) const workerCount = Math.min(PREVIEW_CONCURRENCY, batchFonts.length)
let nextIndex = 0 let nextIndex = 0
@@ -231,36 +331,29 @@ async function generatePreviewBatch(
continue continue
} }
if (!fontInfo.loaded) {
try {
await fontStore.loadFont(fontInfo)
} catch (error) {
console.error(`Failed to load font ${fontInfo.name}:`, error)
continue
}
}
if (isStaleGeneration(generationToken) || !fontInfo.font) {
continue
}
try { try {
const svgResult = await generateSvgWithPreviewCache(fontInfo) const base = await getOrRenderPreviewBase(fontInfo)
if (!svgResult) { if (!base || isStaleGeneration(generationToken)) {
continue continue
} }
if (isStaleGeneration(generationToken)) { const item: PreviewRenderItem = {
return
}
items[localIndex] = {
fontInfo, fontInfo,
svgResult,
selected: selectedFontIdSet.has(fontInfo.id), selected: selectedFontIdSet.has(fontInfo.id),
baseSvg: base.svg,
baseWidth: base.width,
baseHeight: base.height,
renderFontSize: base.renderFontSize,
svgResult: {
svg: '',
width: 0,
height: 0,
fontName: base.fontName || fontInfo.name,
},
} }
items[localIndex] = applyLocalStyleToPreviewItem(item)
} catch (error) { } catch (error) {
console.error(`Failed to generate SVG for ${fontInfo.name}:`, error) console.error(`Failed to render preview for ${fontInfo.name}:`, error)
} }
} }
} }
@@ -367,7 +460,7 @@ async function regeneratePreviews() {
} }
watch( watch(
[previewFonts, inputText, fontSize, fillColor], [previewFonts, inputText, () => uiStore.letterSpacing],
() => { () => {
scheduleGeneratePreviews(hasTriggeredInitialGenerate) scheduleGeneratePreviews(hasTriggeredInitialGenerate)
hasTriggeredInitialGenerate = true hasTriggeredInitialGenerate = true
@@ -375,6 +468,13 @@ watch(
{ immediate: true }, { immediate: true },
) )
watch(
[fontSize, fillColor],
() => {
applyLocalPreviewStyles()
},
)
watch( watch(
[previewTriggerIndex, hasMorePreviewItems], [previewTriggerIndex, hasMorePreviewItems],
async () => { async () => {

View File

@@ -0,0 +1,140 @@
import { MAX_CHARS_PER_LINE } from './text-layout'
const DEFAULT_RENDER_API_URL = '/api/render-svg'
const REQUEST_TIMEOUT_MS = 30000
interface RenderApiResponseData {
svg: string
width: number
height: number
fontName: string
fontId?: string
}
interface RenderApiResponseBody {
ok?: boolean
data?: RenderApiResponseData
error?: string
}
type BrowserWithRenderApiConfig = Window & {
__FONT2SVG_API_URL__?: string
}
export interface RenderSvgPayload {
fontId: string
text: string
fontSize?: number
fillColor?: string
letterSpacing?: number
maxCharsPerLine?: number
}
export interface RenderSvgResult {
svg: string
width: number
height: number
fontName: string
fontId: string
}
function resolveRenderApiUrl(): string {
const envUrl = (import.meta.env.VITE_RENDER_API_URL as string | undefined)?.trim()
if (envUrl) {
return envUrl
}
if (typeof window !== 'undefined') {
const globalUrl = (window as BrowserWithRenderApiConfig).__FONT2SVG_API_URL__
if (typeof globalUrl === 'string' && globalUrl.trim()) {
return globalUrl.trim()
}
}
return DEFAULT_RENDER_API_URL
}
function normalizeRenderResult(data: RenderApiResponseData | undefined): RenderSvgResult {
if (!data || typeof data !== 'object') {
throw new Error('渲染服务返回格式无效')
}
const svg = typeof data.svg === 'string' ? data.svg : ''
if (!svg.trim()) {
throw new Error('渲染服务未返回有效 SVG')
}
return {
svg,
width: Number(data.width) || 0,
height: Number(data.height) || 0,
fontName: data.fontName || 'Unknown',
fontId: data.fontId || '',
}
}
function normalizePayload(payload: RenderSvgPayload) {
const fontId = String(payload.fontId || '').trim()
const text = String(payload.text || '')
if (!fontId) {
throw new Error('缺少字体 ID')
}
if (!text.trim()) {
throw new Error('文本内容不能为空')
}
return {
fontId,
text,
fontSize: Number(payload.fontSize) || 120,
fillColor: payload.fillColor || '#000000',
letterSpacing: Number(payload.letterSpacing) || 0,
maxCharsPerLine: Number(payload.maxCharsPerLine) || MAX_CHARS_PER_LINE,
}
}
export async function renderSvgByApi(payload: RenderSvgPayload): Promise<RenderSvgResult> {
const requestBody = normalizePayload(payload)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
let response: Response
try {
response = await fetch(resolveRenderApiUrl(), {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(requestBody),
signal: controller.signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('渲染服务请求超时')
}
throw new Error(`渲染服务请求失败:${error instanceof Error ? error.message : String(error)}`)
} finally {
clearTimeout(timer)
}
let body: RenderApiResponseBody | null = null
try {
body = (await response.json()) as RenderApiResponseBody
} catch {
body = null
}
if (!response.ok) {
const errMsg = body && typeof body.error === 'string' && body.error.trim()
? body.error
: `渲染服务请求失败HTTP ${response.status}`
throw new Error(errMsg)
}
if (!body || !body.ok) {
throw new Error((body && body.error) || '渲染服务返回错误')
}
return normalizeRenderResult(body.data)
}