update at 2026-02-11 19:13:31
This commit is contained in:
@@ -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,
|
||||||
@@ -88,22 +88,27 @@ async function handleExport(format: ExportFormat) {
|
|||||||
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)
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
await fontStore.loadFont(fontInfo)
|
const base = await getOrRenderPreviewBase(fontInfo)
|
||||||
} catch (error) {
|
if (!base || isStaleGeneration(generationToken)) {
|
||||||
console.error(`Failed to load font ${fontInfo.name}:`, error)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStaleGeneration(generationToken) || !fontInfo.font) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const item: PreviewRenderItem = {
|
||||||
const svgResult = await generateSvgWithPreviewCache(fontInfo)
|
|
||||||
if (!svgResult) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStaleGeneration(generationToken)) {
|
|
||||||
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 () => {
|
||||||
|
|||||||
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