From b3add144213214f9476a9cf88b4f12875d869cf1 Mon Sep 17 00:00:00 2001 From: douboer Date: Wed, 11 Feb 2026 19:13:31 +0800 Subject: [PATCH] update at 2026-02-11 19:13:31 --- frontend/src/App.vue | 41 ++-- frontend/src/components/SvgPreview.vue | 248 +++++++++++++++++-------- frontend/src/utils/render-api.ts | 140 ++++++++++++++ 3 files changed, 334 insertions(+), 95 deletions(-) create mode 100644 frontend/src/utils/render-api.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a8d0056..90e7ebe 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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, @@ -87,23 +87,28 @@ async function handleExport(format: ExportFormat) { generatePngFilename, 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) diff --git a/frontend/src/components/SvgPreview.vue b/frontend/src/components/SvgPreview.vue index e2662d1..5315023 100644 --- a/frontend/src/components/SvgPreview.vue +++ b/frontend/src/components/SvgPreview.vue @@ -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([]) +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([]) const isGenerating = ref(false) const isBatchGenerating = ref(false) const activePreviewFonts = ref([]) @@ -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 | null = null let previewGenerationToken = 0 let hasTriggeredInitialGenerate = false let previewLazyLoadObserver: IntersectionObserver | null = null -const previewGeometryCache = new Map & { svgTemplate: string }>() +const previewApiCache = new Map() 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 (/]*\sfill="[^"]*"/.test(svg)) { + return svg.replace(/(]*\sfill=")[^"]*(")/, `$1${normalizedColor}$2`) + } + + return svg.replace(/]*)>/, ``) +} + +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 & { 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 { - 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 { + 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 { +): Promise { 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(batchFonts.length).fill(null) + const items = new Array(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) { - continue - } - try { - const svgResult = await generateSvgWithPreviewCache(fontInfo) - if (!svgResult) { + const base = await getOrRenderPreviewBase(fontInfo) + if (!base || isStaleGeneration(generationToken)) { 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 () => { diff --git a/frontend/src/utils/render-api.ts b/frontend/src/utils/render-api.ts new file mode 100644 index 0000000..c9a318a --- /dev/null +++ b/frontend/src/utils/render-api.ts @@ -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 { + 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) +}