update at 2026-02-11 19:13:31
This commit is contained in:
@@ -78,7 +78,7 @@ async function handleExport(format: ExportFormat) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { generateSvg } = await import('./utils/svg-builder')
|
||||
const { renderSvgByApi } = await import('./utils/render-api')
|
||||
const {
|
||||
convertSvgToPngBlob,
|
||||
downloadSvg,
|
||||
@@ -88,22 +88,27 @@ async function handleExport(format: ExportFormat) {
|
||||
generateSvgFilename,
|
||||
} = 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) {
|
||||
// 单个字体,直接下载 SVG
|
||||
const item = selectedItems[0]
|
||||
if (!item?.fontInfo.font) {
|
||||
alert('选中字体未加载完成,请稍后重试')
|
||||
const fontId = item?.fontInfo?.id
|
||||
if (!fontId) {
|
||||
alert('选中字体信息无效,请重新选择后重试')
|
||||
return
|
||||
}
|
||||
|
||||
const font = item.fontInfo.font
|
||||
const svgResult = await generateSvg({
|
||||
text: inputText,
|
||||
font,
|
||||
fontSize: uiStore.fontSize,
|
||||
fillColor: uiStore.textColor,
|
||||
letterSpacing: 0
|
||||
})
|
||||
const svgResult = await renderByApi(fontId)
|
||||
|
||||
if (format === 'svg') {
|
||||
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
||||
@@ -122,19 +127,13 @@ async function handleExport(format: ExportFormat) {
|
||||
|
||||
for (const item of selectedItems) {
|
||||
try {
|
||||
const font = item.fontInfo.font
|
||||
if (!font) {
|
||||
console.warn(`字体 ${item.fontInfo.name} 尚未加载,已跳过导出`)
|
||||
const fontId = item?.fontInfo?.id
|
||||
if (!fontId) {
|
||||
console.warn('发现无效字体项,已跳过')
|
||||
continue
|
||||
}
|
||||
|
||||
const svgResult = await generateSvg({
|
||||
text: inputText,
|
||||
font,
|
||||
fontSize: uiStore.fontSize,
|
||||
fillColor: uiStore.textColor,
|
||||
letterSpacing: 0
|
||||
})
|
||||
const svgResult = await renderByApi(fontId)
|
||||
|
||||
if (format === 'svg') {
|
||||
const filename = generateSvgFilename(inputText, svgResult.fontName)
|
||||
|
||||
@@ -2,14 +2,30 @@
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useFontStore } from '../stores/fontStore'
|
||||
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 { SvgGenerateResult, FontInfo } from '../types/font'
|
||||
|
||||
const fontStore = useFontStore()
|
||||
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 isBatchGenerating = ref(false)
|
||||
const activePreviewFonts = ref<FontInfo[]>([])
|
||||
@@ -24,17 +40,17 @@ const fillColor = computed(() => uiStore.textColor)
|
||||
|
||||
const PREVIEW_DEBOUNCE_MS = 240
|
||||
const PREVIEW_CONCURRENCY = 4
|
||||
const PREVIEW_GEOMETRY_CACHE_LIMIT = 600
|
||||
const PREVIEW_COLOR_TOKEN = '__FONT2SVG_FILL__'
|
||||
const PREVIEW_API_CACHE_LIMIT = 600
|
||||
const PREVIEW_BATCH_SIZE = 20
|
||||
const PREVIEW_PREFETCH_OFFSET = 10
|
||||
const PREVIEW_RENDER_FONT_SIZE = 120
|
||||
|
||||
let previewGenerateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let previewGenerationToken = 0
|
||||
let hasTriggeredInitialGenerate = false
|
||||
let previewLazyLoadObserver: IntersectionObserver | null = null
|
||||
|
||||
const previewGeometryCache = new Map<string, Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string }>()
|
||||
const previewApiCache = new Map<string, PreviewApiCacheItem>()
|
||||
|
||||
const isAllPreviewSelected = computed(() => {
|
||||
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
|
||||
}
|
||||
|
||||
function getPreviewGeometryCacheKey(fontInfo: FontInfo): string {
|
||||
return [fontInfo.id, fontInfo.path, inputText.value, fontSize.value].join('::')
|
||||
function normalizeHexColor(input: string, fallback = '#000000'): string {
|
||||
const value = String(input || '').trim()
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function readPreviewGeometryFromCache(key: string) {
|
||||
const cached = previewGeometryCache.get(key)
|
||||
function clampFontSize(value: number, fallback = PREVIEW_RENDER_FONT_SIZE): number {
|
||||
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) {
|
||||
return null
|
||||
}
|
||||
|
||||
previewGeometryCache.delete(key)
|
||||
previewGeometryCache.set(key, cached)
|
||||
previewApiCache.delete(key)
|
||||
previewApiCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
function writePreviewGeometryToCache(
|
||||
key: string,
|
||||
geometry: Omit<SvgGenerateResult, 'svg'> & { svgTemplate: string },
|
||||
) {
|
||||
if (previewGeometryCache.has(key)) {
|
||||
previewGeometryCache.delete(key)
|
||||
function writePreviewToCache(key: string, value: PreviewApiCacheItem) {
|
||||
if (previewApiCache.has(key)) {
|
||||
previewApiCache.delete(key)
|
||||
}
|
||||
previewGeometryCache.set(key, geometry)
|
||||
previewApiCache.set(key, value)
|
||||
|
||||
while (previewGeometryCache.size > PREVIEW_GEOMETRY_CACHE_LIMIT) {
|
||||
const oldestKey = previewGeometryCache.keys().next().value
|
||||
while (previewApiCache.size > PREVIEW_API_CACHE_LIMIT) {
|
||||
const oldestKey = previewApiCache.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
previewGeometryCache.delete(oldestKey)
|
||||
previewApiCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSvgWithPreviewCache(fontInfo: FontInfo): Promise<SvgGenerateResult | null> {
|
||||
if (!fontInfo.font) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheKey = getPreviewGeometryCacheKey(fontInfo)
|
||||
let geometry = readPreviewGeometryFromCache(cacheKey)
|
||||
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)
|
||||
}
|
||||
function toStyledSvgResult(item: PreviewRenderItem): SvgGenerateResult {
|
||||
const targetSize = clampFontSize(Number(fontSize.value), PREVIEW_RENDER_FONT_SIZE)
|
||||
const renderSize = Number(item.renderFontSize) > 0 ? Number(item.renderFontSize) : PREVIEW_RENDER_FONT_SIZE
|
||||
const scale = targetSize / renderSize
|
||||
const styledSvg = scaleSvgDimensions(
|
||||
replaceSvgFillColor(item.baseSvg, fillColor.value),
|
||||
scale,
|
||||
)
|
||||
|
||||
return {
|
||||
width: geometry.width,
|
||||
height: geometry.height,
|
||||
fontName: geometry.fontName,
|
||||
svg: geometry.svgTemplate.split(PREVIEW_COLOR_TOKEN).join(fillColor.value),
|
||||
svg: styledSvg,
|
||||
width: Number(item.baseWidth) > 0 ? Number(item.baseWidth) * scale : 0,
|
||||
height: Number(item.baseHeight) > 0 ? Number(item.baseHeight) * scale : 0,
|
||||
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(
|
||||
fonts: FontInfo[],
|
||||
startIndex: number,
|
||||
batchSize: number,
|
||||
generationToken: number,
|
||||
): Promise<PreviewItemType[]> {
|
||||
): Promise<PreviewRenderItem[]> {
|
||||
const endIndex = Math.min(startIndex + batchSize, fonts.length)
|
||||
const batchFonts = fonts.slice(startIndex, endIndex)
|
||||
if (batchFonts.length === 0) {
|
||||
@@ -210,7 +310,7 @@ async function generatePreviewBatch(
|
||||
}
|
||||
|
||||
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)
|
||||
let nextIndex = 0
|
||||
|
||||
@@ -231,36 +331,29 @@ async function generatePreviewBatch(
|
||||
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) {
|
||||
const base = await getOrRenderPreviewBase(fontInfo)
|
||||
if (!base || isStaleGeneration(generationToken)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const svgResult = await generateSvgWithPreviewCache(fontInfo)
|
||||
if (!svgResult) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isStaleGeneration(generationToken)) {
|
||||
return
|
||||
}
|
||||
|
||||
items[localIndex] = {
|
||||
const item: PreviewRenderItem = {
|
||||
fontInfo,
|
||||
svgResult,
|
||||
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) {
|
||||
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(
|
||||
[previewFonts, inputText, fontSize, fillColor],
|
||||
[previewFonts, inputText, () => uiStore.letterSpacing],
|
||||
() => {
|
||||
scheduleGeneratePreviews(hasTriggeredInitialGenerate)
|
||||
hasTriggeredInitialGenerate = true
|
||||
@@ -375,6 +468,13 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[fontSize, fillColor],
|
||||
() => {
|
||||
applyLocalPreviewStyles()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
[previewTriggerIndex, hasMorePreviewItems],
|
||||
async () => {
|
||||
|
||||
140
frontend/src/utils/render-api.ts
Normal file
140
frontend/src/utils/render-api.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user