update at 2026-02-07 11:14:09

This commit is contained in:
douboer
2026-02-07 11:14:09 +08:00
parent 2d18aa5137
commit 591bd9ba05
67 changed files with 4885 additions and 0 deletions

10
frontend/src/App.test.vue Normal file
View File

@@ -0,0 +1,10 @@
<template>
<div style="padding: 20px; background: lightblue;">
<h1>测试页面</h1>
<p>如果你能看到这个说明 Vue 应用已经挂载成功</p>
</div>
</template>
<script setup lang="ts">
console.log('App.test.vue is loading...')
</script>

332
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,332 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useFontLoader } from './composables/useFontLoader'
import { useUiStore } from './stores/uiStore'
import { useFontStore } from './stores/fontStore'
import { MAX_CHARS_PER_LINE, wrapTextByChars } from './utils/text-layout'
import FontSelector from './components/FontSelector.vue'
import FavoritesList from './components/FavoritesList.vue'
import SvgPreview from './components/SvgPreview.vue'
console.log('App.vue: script setup running...')
const uiStore = useUiStore()
const fontStore = useFontStore()
const fontSizePercent = computed(() => {
const raw = ((uiStore.fontSize - 10) / (500 - 10)) * 100
return Math.max(0, Math.min(100, raw))
})
// 加载字体列表
try {
useFontLoader()
console.log('App.vue: useFontLoader called successfully')
} catch (error) {
console.error('App.vue: Error in useFontLoader:', error)
}
const normalizedInitialInput = wrapTextByChars(uiStore.inputText, MAX_CHARS_PER_LINE)
if (normalizedInitialInput !== uiStore.inputText) {
uiStore.setInputText(normalizedInitialInput)
}
type ExportFormat = 'svg' | 'png'
async function handleExport(format: ExportFormat) {
uiStore.retainExportItemsByFontIds(fontStore.previewFontIds)
const selectedItems = uiStore.selectedExportItems
const inputText = uiStore.inputText.trim()
if (selectedItems.length === 0) {
alert('请选择需要导出的效果')
return
}
if (!inputText) {
alert('请输入要导出的文字')
return
}
try {
const { generateSvg } = await import('./utils/svg-builder')
const {
convertSvgToPngBlob,
downloadSvg,
downloadMultipleFiles,
downloadPngFromSvg,
generatePngFilename,
generateSvgFilename,
} = await import('./utils/download')
if (selectedItems.length === 1) {
// 单个字体,直接下载 SVG
const item = selectedItems[0]
const svgResult = await generateSvg({
text: inputText,
font: item.fontInfo.font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName)
downloadSvg(svgResult.svg, filename)
} else {
const filename = generatePngFilename(inputText, svgResult.fontName)
await downloadPngFromSvg(svgResult.svg, filename, {
width: svgResult.width,
height: svgResult.height,
})
}
} else {
// 多个字体,打包下载
const files: Array<{ name: string; content: string | Blob }> = []
for (const item of selectedItems) {
try {
const svgResult = await generateSvg({
text: inputText,
font: item.fontInfo.font,
fontSize: uiStore.fontSize,
fillColor: uiStore.textColor,
letterSpacing: 0
})
if (format === 'svg') {
const filename = generateSvgFilename(inputText, svgResult.fontName)
files.push({
name: filename,
content: svgResult.svg
})
} else {
const filename = generatePngFilename(inputText, svgResult.fontName)
const pngBlob = await convertSvgToPngBlob(svgResult.svg, {
width: svgResult.width,
height: svgResult.height,
})
files.push({
name: filename,
content: pngBlob,
})
}
} catch (error) {
console.warn(`字体 ${item.fontInfo.name} 导出失败:`, error)
}
}
if (files.length > 0) {
const zipFilename = format === 'svg' ? 'font2svg-svg-export.zip' : 'font2svg-png-export.zip'
await downloadMultipleFiles(files, zipFilename)
} else {
alert(`所有字体${format.toUpperCase()}导出都失败了`)
}
}
} catch (error) {
console.error('导出失败:', error)
alert(`导出失败: ${error instanceof Error ? error.message : '未知错误'}`)
}
}
function handleFontSizeChange(size: number) {
uiStore.setFontSize(size)
}
function handleSliderInput(event: Event) {
const target = event.target as HTMLInputElement
const value = Number(target.value)
if (!Number.isNaN(value)) {
uiStore.setFontSize(value)
}
}
function handleTextInput(event: Event) {
const target = event.target as HTMLTextAreaElement
const wrappedText = wrapTextByChars(target.value, MAX_CHARS_PER_LINE)
if (wrappedText !== target.value) {
target.value = wrappedText
}
uiStore.setInputText(wrappedText)
}
console.log('App.vue: script setup completed')
</script>
<template>
<div class="w-screen h-screen box-border p-8px bg-white flex flex-col overflow-hidden">
<!-- Frame 7: 顶部工具栏 -->
<div class="flex gap-2 items-center shrink-0 h-24 px-2 py-1">
<!-- webicon - 48x48 -->
<div class="w-12 h-12 rounded-xl overflow-hidden shrink-0">
<img src="/assets/webicon.svg" alt="logo" class="w-full h-full object-cover" />
</div>
<!-- 星程字体转换 - 弹性宽度 -->
<div class="shrink-0 max-w-[225px] min-w-[120px]" style="height: 72px;">
<img src="/assets/icons/星程字体转换.svg" alt="星程SVG文字生成 TEXT to SVG" class="w-full h-full object-contain" />
</div>
<!-- slider - 增加宽度 -->
<div class="flex items-center gap-3 px-2 shrink-0 relative" style="width: 280px; height: 32px;">
<button
@click="handleFontSizeChange(uiStore.fontSize - 10)"
class="w-4 h-4 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="减小字体"
>
<img src="/assets/icons/icons_idx%20_38.svg" alt="A-" class="w-4 h-4 object-contain" />
</button>
<div class="flex-1 h-6 flex items-center relative">
<input
type="range"
min="10"
max="500"
step="1"
:value="uiStore.fontSize"
@input="handleSliderInput"
class="font-size-range w-full h-6 cursor-pointer"
:style="{ background: `linear-gradient(to right, #9b6bc2 0%, #9b6bc2 ${fontSizePercent}%, #e5e6eb ${fontSizePercent}%, #e5e6eb 100%)` }"
/>
<!-- 字体大小数字显示 -->
<div
class="absolute pointer-events-none -top-4"
:style="{ left: `calc(${fontSizePercent}% - 7px)` }"
>
<div class="text-[#8552A1] text-[12px] font-medium text-center w-6">
{{ uiStore.fontSize }}
</div>
</div>
</div>
<button
@click="handleFontSizeChange(uiStore.fontSize + 10)"
class="w-6 h-6 shrink-0 cursor-pointer hover:opacity-70 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="增大字体"
>
<img src="/assets/icons/icons_idx%20_33.svg" alt="A+" class="w-6 h-6 object-contain" />
</button>
</div>
<!-- 文字颜色选择 -->
<div class="shrink-0 relative w-9 h-9">
<label class="w-full h-full flex items-center justify-center cursor-pointer">
<img src="/assets/icons/choose-color.svg" alt="颜色" class="w-9 h-9 object-contain" />
<input
type="color"
:value="uiStore.textColor"
@input="uiStore.setTextColor(($event.target as HTMLInputElement).value)"
class="absolute inset-0 opacity-0 cursor-pointer"
aria-label="选择文字颜色"
/>
</label>
</div>
<!-- Frame 14: 输入框 - 弹性宽度 -->
<div class="flex-1 min-w-[80px] bg-[#f7f8fa] rounded-lg px-2 py-1 h-12">
<textarea
:value="uiStore.inputText"
@input="handleTextInput"
placeholder="此处输入内容"
class="w-full h-full bg-transparent border-none outline-none text-base text-[#4e5969] placeholder-[#4e5969] resize-none leading-5 overflow-y-auto"
/>
</div>
<!-- Export Group -->
<div class="flex items-center gap-1 shrink-0 border border-[#8552A1] rounded-lg px-1 py-1 bg-[#f7f8fa] shadow-sm">
<div class="w-[18px] h-[42px] shrink-0 pointer-events-none">
<img src="/assets/icons/export.svg" alt="导出" class="w-full h-full object-contain" />
</div>
<button
@click="handleExport('svg')"
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="导出 SVG"
>
<img src="/assets/icons/export-svg.svg" alt="导出SVG" class="w-12 h-12 object-contain" />
</button>
<button
@click="handleExport('png')"
class="w-12 h-12 shrink-0 cursor-pointer hover:opacity-85 transition-opacity flex items-center justify-center p-0 border-0 bg-transparent"
title="导出 PNG"
>
<img src="/assets/icons/export-png.svg" alt="导出PNG" class="w-12 h-12 object-contain" />
</button>
</div>
</div>
<!-- Frame 9: 主内容区 -->
<div class="flex-1 flex gap-2 min-h-0 overflow-hidden px-2">
<!-- Frame 15: 左侧栏 - 弹性宽度 -->
<div class="flex flex-col gap-2 shrink-0 overflow-hidden" style="flex-basis: 400px; max-width: 480px; min-width: 320px;">
<!-- Frame 5: 字体选择 - 弹性高度 -->
<div class="flex-[2] border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-h-0">
<h2 class="text-base text-black shrink-0 leading-none">字体选择</h2>
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
<FontSelector />
</div>
</div>
<!-- Frame 5: 已收藏字体 - 弹性高度 -->
<div class="border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 flex-1 overflow-hidden min-h-[120px]">
<h2 class="text-base text-black shrink-0 leading-none">已收藏字体</h2>
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-y-auto overflow-x-hidden pr-2">
<FavoritesList />
</div>
</div>
</div>
<!-- Frame 8: 右侧预览区 - 弹性宽度 -->
<div class="flex-1 border border-solid border-[#f7e0e0] rounded-lg p-1.5 flex flex-col gap-2 overflow-hidden min-w-0">
<h2 class="text-base text-black shrink-0 leading-none">效果预览</h2>
<div v-overflow-aware class="scrollbar-hover flex-1 min-h-0 py-2 overflow-y-auto overflow-x-hidden">
<SvgPreview />
</div>
</div>
</div>
<!-- 底部版权 -->
<div class="text-[#86909c] text-xs text-center shrink-0 h-6 pt-4 flex items-center justify-center px-2">
@版权说明所有字体来源互联网分享仅供效果预览不做下载传播如有侵权请告知douboer@gmail.com
</div>
</div>
</template>
<style scoped>
.font-size-range {
-webkit-appearance: none;
appearance: none;
height: 3px;
border-radius: 10px;
background: #e5e6eb;
}
.font-size-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e6eb;
cursor: pointer;
}
.font-size-range::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e6eb;
cursor: pointer;
}
.font-size-range::-moz-range-track {
height: 3px;
border-radius: 10px;
background: transparent;
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import { downloadSvg, downloadMultipleFiles, generateSvgFilename } from '../utils/download'
import { useUiStore } from '../stores/uiStore'
const uiStore = useUiStore()
const selectedItems = computed(() => uiStore.selectedExportItems)
async function handleExport() {
if (selectedItems.value.length === 0) {
alert('请先选择要导出的预览项')
return
}
uiStore.isExporting = true
try {
if (selectedItems.value.length === 1) {
// 单个导出
const item = selectedItems.value[0]
if (!item) return
const filename = generateSvgFilename(uiStore.inputText, item.fontInfo.name)
downloadSvg(item.svgResult.svg, filename)
} else {
// 批量导出
const files = selectedItems.value.map((item) => ({
name: generateSvgFilename(uiStore.inputText, item.fontInfo.name),
content: item.svgResult.svg,
}))
await downloadMultipleFiles(files)
}
} catch (error) {
console.error('Export failed:', error)
alert('导出失败,请重试')
} finally {
uiStore.isExporting = false
}
}
</script>
<template>
<div class="h-full flex flex-col p-4">
<h2 class="text-lg font-semibold text-gray-800 mb-4">导出</h2>
<div v-overflow-aware class="scrollbar-hover flex-1 overflow-auto mb-4">
<div v-if="selectedItems.length === 0" class="text-sm text-gray-500 text-center py-8">
未选择任何预览项
</div>
<div v-else class="space-y-2">
<div
v-for="(item, index) in selectedItems"
:key="index"
class="text-sm text-gray-700 p-2 bg-gray-50 rounded"
>
{{ item.fontInfo.name }}
</div>
</div>
</div>
<button
class="w-full py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
:disabled="selectedItems.length === 0 || uiStore.isExporting"
@click="handleExport"
>
<span v-if="uiStore.isExporting">导出中...</span>
<span v-else>导出 SVG ({{ selectedItems.length }})</span>
</button>
<p class="mt-3 text-xs text-gray-500 text-center">
点击预览项选中然后点击导出按钮
</p>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useFontStore } from '../stores/fontStore'
const fontStore = useFontStore()
const favoriteFonts = computed(() => fontStore.favoriteFonts)
function handlePreviewClick(fontId: string, event: Event) {
event.stopPropagation()
fontStore.togglePreview(fontId)
}
function handleFavoriteClick(fontId: string, event: Event) {
event.stopPropagation()
fontStore.toggleFavorite(fontId)
}
function isFavorite(fontId: string): boolean {
return fontStore.favoriteFontIds.has(fontId)
}
function isInPreview(fontId: string): boolean {
return fontStore.previewFontIds.has(fontId)
}
</script>
<template>
<div class="space-y-2">
<div v-if="favoriteFonts.length === 0" class="text-sm text-gray-500 text-center py-8">
暂无收藏字体
</div>
<div v-else class="flex flex-col gap-3 favorite-indent">
<div
v-for="font in favoriteFonts"
:key="font.id"
class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2"
>
<!-- 字体图标 -->
<div class="w-4 h-4 shrink-0">
<img src="/assets/icons/icons_idx%20_18.svg" alt="font" class="w-full h-full" />
</div>
<!-- 字体名称 -->
<div class="flex-1 text-xs text-[#86909c]">
{{ font.name }}
</div>
<!-- 预览复选框 -->
<button
@click="handlePreviewClick(font.id, $event)"
class="w-[18px] h-[18px] shrink-0 border rounded-full flex items-center justify-center p-0 bg-transparent"
:class="isInPreview(font.id) ? 'bg-[#9b6bc2] border-[#9b6bc2]' : 'border-[#c9cdd4]'"
>
<img v-if="isInPreview(font.id)" src="/assets/icons/checkbox.svg" alt="选中" class="w-[11px] h-[9px]" />
</button>
<!-- 收藏按钮 -->
<button
@click="handleFavoriteClick(font.id, $event)"
class="w-[18px] h-[17px] shrink-0 p-0 border-0 bg-transparent"
>
<img
src="/assets/icons/icons_idx%20_19.svg"
alt="收藏"
class="w-full h-full"
:class="isFavorite(font.id) ? 'favorite-active' : ''"
/>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.favorite-indent {
padding-left: 2ch;
}
.favorite-active {
filter: brightness(0) saturate(100%) invert(16%) sepia(96%) saturate(7491%) hue-rotate(356deg) brightness(99%) contrast(119%);
}
</style>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useFontStore } from '../stores/fontStore'
import FontTree from './FontTree.vue'
const fontStore = useFontStore()
const fontTree = computed(() => fontStore.fontTree)
</script>
<template>
<div class="space-y-2">
<div v-if="fontTree.length === 0" class="text-sm text-gray-500 text-center py-8">
暂无字体
</div>
<FontTree v-else :nodes="fontTree" />
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { useUiStore } from '../stores/uiStore'
const uiStore = useUiStore()
function handleSizeChange(event: Event) {
const target = event.target as HTMLInputElement
uiStore.setFontSize(Number(target.value))
}
</script>
<template>
<div class="space-y-2">
<div class="flex justify-between items-center">
<label class="text-sm font-medium text-gray-700">字体大小</label>
<span class="text-sm text-gray-600">{{ uiStore.fontSize }}px</span>
</div>
<input
type="range"
min="10"
max="500"
:value="uiStore.fontSize"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600"
@input="handleSizeChange"
>
<div class="flex justify-between text-xs text-gray-500">
<span>10px</span>
<span>500px</span>
</div>
</div>
</template>
<style scoped>
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2563eb;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #2563eb;
cursor: pointer;
border: none;
}
</style>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { FontTreeNode } from '../types/font'
import { useFontStore } from '../stores/fontStore'
const props = defineProps<{
nodes: FontTreeNode[]
}>()
const fontStore = useFontStore()
function toggleExpand(node: FontTreeNode) {
const next = !node.expanded
node.expanded = next
fontStore.setCategoryExpanded(node.name, next)
}
function handlePreviewClick(node: FontTreeNode, event: Event) {
event.stopPropagation()
if (node.type === 'font' && node.fontInfo) {
fontStore.togglePreview(node.fontInfo.id)
}
}
function handleFavoriteClick(node: FontTreeNode, event: Event) {
event.stopPropagation()
if (node.type === 'font' && node.fontInfo) {
fontStore.toggleFavorite(node.fontInfo.id)
}
}
function isFavorite(node: FontTreeNode): boolean {
return node.type === 'font' && node.fontInfo ? fontStore.favoriteFontIds.has(node.fontInfo.id) : false
}
function isInPreview(node: FontTreeNode): boolean {
return node.type === 'font' && node.fontInfo ? fontStore.previewFontIds.has(node.fontInfo.id) : false
}
</script>
<template>
<div class="space-y-0">
<div v-for="node in nodes" :key="node.name">
<!-- 分类节点 -->
<div v-if="node.type === 'category'" class="relative mb-3">
<div class="flex items-center">
<!-- 左侧展开图标 -->
<div class="tree-icon-wrapper">
<button
@click="toggleExpand(node)"
class="tree-toggle"
>
<img
v-if="node.expanded"
src="/assets/icons/zhedie.svg"
alt="收起"
class="w-[15px] h-[15px]"
/>
<img
v-else
src="/assets/icons/icons_idx%20_12.svg"
alt="展开"
class="w-[15px] h-[15px]"
/>
</button>
</div>
<!-- 分类标题 -->
<div
@click="toggleExpand(node)"
class="text-base font-medium text-black cursor-pointer flex-1 ml-2"
>
{{ node.name }}
</div>
</div>
<!-- 竖直连接线 -->
<div v-if="node.expanded && node.children" class="tree-vertical-line"></div>
<!-- 字体列表 -->
<div v-if="node.expanded && node.children" class="flex flex-col gap-3 mt-3">
<div
v-for="(child, index) in node.children"
:key="child.name"
class="flex items-center gap-2 border-b border-[#c9cdd4] pb-2 relative"
>
<!-- 水平连接线 -->
<div class="tree-horizontal-line"></div>
<!-- 字体图标 -->
<div class="w-4 h-4 shrink-0 ml-[17px]">
<img src="/assets/icons/icons_idx%20_18.svg" alt="font" class="w-full h-full" />
</div>
<!-- 字体名称 -->
<div class="flex-1 text-xs text-[#86909c]">
{{ child.name }}
</div>
<!-- 预览复选框 -->
<button
@click="handlePreviewClick(child, $event)"
class="w-[18px] h-[18px] shrink-0 border rounded-full flex items-center justify-center p-0 bg-transparent"
:class="isInPreview(child) ? 'bg-[#9b6bc2] border-[#9b6bc2]' : 'border-[#c9cdd4]'"
>
<img v-if="isInPreview(child)" src="/assets/icons/checkbox.svg" alt="选中" class="w-[11px] h-[9px]" />
</button>
<!-- 收藏按钮 -->
<button
@click="handleFavoriteClick(child, $event)"
class="w-[18px] h-[17px] shrink-0 p-0 border-0 bg-transparent"
>
<img
src="/assets/icons/icons_idx%20_19.svg"
alt="收藏"
class="w-full h-full"
:class="isFavorite(child) ? 'favorite-active' : ''"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tree-icon-wrapper {
position: relative;
width: 17px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tree-toggle {
width: 15px;
height: 15px;
padding: 0;
border: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
}
.tree-vertical-line {
position: absolute;
left: 8px;
top: 20px;
bottom: 12px;
width: 1px;
background: #c9cdd4;
}
.tree-horizontal-line {
position: absolute;
left: 8px;
top: 12px;
width: 10px;
height: 1px;
background: #c9cdd4;
}
.favorite-active {
filter: brightness(0) saturate(100%) invert(16%) sepia(96%) saturate(7491%) hue-rotate(356deg) brightness(99%) contrast(119%);
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { PreviewItem } from '../types/font'
const props = defineProps<{
previewItem: PreviewItem
}>()
const emit = defineEmits<{
toggleSelect: []
}>()
function handleClick() {
emit('toggleSelect')
}
</script>
<template>
<div
class="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
:class="previewItem.selected ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'"
@click="handleClick"
>
<!-- 字体名称 -->
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-gray-800">{{ previewItem.fontInfo.name }}</h3>
<div
class="w-5 h-5 border-2 rounded flex items-center justify-center"
:class="previewItem.selected ? 'border-blue-500 bg-blue-500' : 'border-gray-300'"
>
<span v-if="previewItem.selected" class="text-white text-xs"></span>
</div>
</div>
<!-- SVG 预览 -->
<div class="bg-gray-50 rounded p-4 flex items-center justify-center min-h-32">
<div v-html="previewItem.svgResult.svg" class="max-w-full" />
</div>
<!-- 信息 -->
<div class="mt-3 text-xs text-gray-500 flex justify-between">
<span>{{ previewItem.svgResult.width.toFixed(0) }} × {{ previewItem.svgResult.height.toFixed(0) }}</span>
<span>{{ previewItem.svgResult.fontName }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useFontStore } from '../stores/fontStore'
import { useUiStore } from '../stores/uiStore'
import { generateSvg } from '../utils/svg-builder'
import type { PreviewItem as PreviewItemType } from '../types/font'
const fontStore = useFontStore()
const uiStore = useUiStore()
const previewItems = ref<PreviewItemType[]>([])
const isGenerating = ref(false)
const previewFonts = computed(() => fontStore.previewFonts)
const inputText = computed(() => uiStore.inputText)
const fontSize = computed(() => uiStore.fontSize)
const fillColor = computed(() => uiStore.textColor)
watch(
[previewFonts, inputText, fontSize, fillColor],
async () => {
await generatePreviews()
},
{ immediate: true }
)
async function generatePreviews() {
const validPreviewFontIds = new Set(previewFonts.value.map(font => font.id))
uiStore.retainExportItemsByFontIds(validPreviewFontIds)
if (!inputText.value || inputText.value.trim() === '') {
previewItems.value = []
return
}
const fonts = previewFonts.value
if (fonts.length === 0) {
previewItems.value = []
return
}
isGenerating.value = true
try {
const items: PreviewItemType[] = []
for (const fontInfo of fonts) {
if (!fontInfo.loaded) {
await fontStore.loadFont(fontInfo)
}
if (fontInfo.font) {
try {
const svgResult = await generateSvg({
text: inputText.value,
font: fontInfo.font,
fontSize: fontSize.value,
fillColor: fillColor.value,
})
items.push({
fontInfo,
svgResult,
selected: uiStore.selectedExportItems.some(item => item.fontInfo.id === fontInfo.id)
})
} catch (error) {
console.error(`Failed to generate SVG for ${fontInfo.name}:`, error)
}
}
}
previewItems.value = items
} catch (error) {
console.error('Failed to generate previews:', error)
} finally {
isGenerating.value = false
}
}
function toggleSelectItem(item: PreviewItemType) {
item.selected = !item.selected
uiStore.toggleExportItem(item)
}
</script>
<template>
<div class="flex flex-col gap-2">
<div v-if="previewItems.length === 0" class="text-[#86909c] text-center py-20">
{{ isGenerating ? '生成预览中...' : '请选择字体并输入内容' }}
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in previewItems"
:key="item.fontInfo.id"
class="flex flex-col gap-2"
>
<div class="flex items-center gap-[8px] border-b border-[#c9cdd4] pb-[8px] pr-[8px]">
<div class="w-[24px] h-[24px] shrink-0">
<img src="/assets/icons/icons_idx%20_32.svg" alt="字体" class="w-full h-full" />
</div>
<div class="flex-1 text-xs text-[#86909c]">
{{ item.fontInfo.name }}
</div>
<button
@click="toggleSelectItem(item)"
class="w-[18px] h-[18px] shrink-0 border rounded-full flex items-center justify-center p-0 bg-transparent"
:class="item.selected ? 'bg-[#9b6bc2] border-[#9b6bc2]' : 'border-[#c9cdd4]'"
>
<img v-if="item.selected" src="/assets/icons/checkbox.svg" alt="选中" class="w-[11px] h-[9px]" />
</button>
</div>
<div
@click="toggleSelectItem(item)"
class="bg-white px-[8px] py-[8px] cursor-pointer"
>
<div v-html="item.svgResult.svg" class="svg-preview-container"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.svg-preview-container {
width: fit-content;
max-width: 100%;
}
.svg-preview-container :deep(svg) {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: none;
}
</style>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useUiStore } from '../stores/uiStore'
const uiStore = useUiStore()
const localText = ref(uiStore.inputText)
function handlePreview() {
uiStore.setInputText(localText.value)
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handlePreview()
}
}
</script>
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-gray-700">输入文本</label>
<div class="flex gap-2">
<input
v-model="localText"
type="text"
placeholder="此处输入内容"
class="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
@keydown="handleKeydown"
>
<button
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
@click="handlePreview"
>
预览
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,68 @@
import { onMounted } from 'vue'
import { useFontStore } from '../stores/fontStore'
import type { FontInfo } from '../types/font'
interface FontListItem {
id: string
name: string
filename: string
category: string
path: string
}
export function useFontLoader() {
const fontStore = useFontStore()
async function loadFontList() {
console.log('Starting to load font list...')
fontStore.isLoadingFonts = true
try {
console.log('Fetching /fonts.json...')
const response = await fetch('/fonts.json')
console.log('Response status:', response.status, response.statusText)
if (!response.ok) {
throw new Error(`Failed to load fonts.json: ${response.statusText}`)
}
const fontList: FontListItem[] = await response.json()
console.log('Loaded font list:', fontList.length, 'fonts')
// 转换为 FontInfo
for (const item of fontList) {
const fontInfo: FontInfo = {
id: item.id,
name: item.name,
path: item.path,
category: item.category,
isFavorite: false,
loaded: false,
progress: 0,
}
fontStore.addFont(fontInfo)
}
// 更新字体树
fontStore.updateFontTree()
console.log(`Successfully loaded ${fontList.length} fonts`)
} catch (error) {
console.error('Failed to load font list:', error)
alert('加载字体列表失败,请刷新页面重试')
} finally {
fontStore.isLoadingFonts = false
console.log('Font loading finished')
}
}
onMounted(() => {
console.log('useFontLoader: onMounted called')
loadFontList()
})
return {
loadFontList,
}
}

75
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,75 @@
import { createApp, type Directive } from 'vue'
import { createPinia } from 'pinia'
import 'virtual:uno.css'
import './style.css'
import App from './App.vue'
console.log('main.ts is loading...')
console.log('App component:', App)
const app = createApp(App)
app.use(createPinia())
type OverflowAwareCleanup = {
resizeObserver: ResizeObserver
mutationObserver: MutationObserver
onMouseEnter: () => void
onWindowResize: () => void
}
const overflowAwareMap = new WeakMap<HTMLElement, OverflowAwareCleanup>()
function updateOverflowState(el: HTMLElement) {
const isOverflowing = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth
el.dataset.overflowing = isOverflowing ? 'true' : 'false'
}
const overflowAwareDirective: Directive<HTMLElement> = {
mounted(el) {
const refresh = () => updateOverflowState(el)
refresh()
const resizeObserver = new ResizeObserver(refresh)
resizeObserver.observe(el)
const mutationObserver = new MutationObserver(refresh)
mutationObserver.observe(el, { childList: true, subtree: true, characterData: true })
const onMouseEnter = () => refresh()
const onWindowResize = () => refresh()
el.addEventListener('mouseenter', onMouseEnter)
window.addEventListener('resize', onWindowResize)
overflowAwareMap.set(el, {
resizeObserver,
mutationObserver,
onMouseEnter,
onWindowResize,
})
},
updated(el) {
updateOverflowState(el)
},
unmounted(el) {
const cleanup = overflowAwareMap.get(el)
if (!cleanup) return
cleanup.resizeObserver.disconnect()
cleanup.mutationObserver.disconnect()
el.removeEventListener('mouseenter', cleanup.onMouseEnter)
window.removeEventListener('resize', cleanup.onWindowResize)
overflowAwareMap.delete(el)
},
}
app.directive('overflow-aware', overflowAwareDirective)
app.config.errorHandler = (err, instance, info) => {
console.error('Vue Error:', err)
console.error('Error info:', info)
console.error('Component instance:', instance)
}
console.log('Mounting app to #app...')
app.mount('#app')
console.log('App mounted successfully!')

View File

@@ -0,0 +1,248 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { FontInfo, FontTreeNode } from '../types/font'
import { loadFontWithProgress } from '../utils/font-loader'
export const useFontStore = defineStore('font', () => {
function readSet(key: string): Set<string> {
try {
const raw = localStorage.getItem(key)
if (!raw) return new Set()
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? new Set(parsed.map(String)) : new Set()
} catch {
return new Set()
}
}
function writeSet(key: string, value: Set<string>) {
try {
localStorage.setItem(key, JSON.stringify(Array.from(value)))
} catch {
// ignore storage errors
}
}
// 状态
const fonts = ref<FontInfo[]>([])
const selectedFontIds = ref<Set<string>>(new Set())
const favoriteFontIds = ref<Set<string>>(readSet('font.favoriteFontIds'))
const previewFontIds = ref<Set<string>>(readSet('font.previewFontIds'))
const expandedCategoryNames = ref<Set<string>>(readSet('font.expandedCategories'))
const fontTree = ref<FontTreeNode[]>([])
const isLoadingFonts = ref(false)
// 计算属性
const selectedFonts = computed(() => {
return fonts.value.filter(f => selectedFontIds.value.has(f.id))
})
const favoriteFonts = computed(() => {
return fonts.value.filter(f => favoriteFontIds.value.has(f.id))
})
const previewFonts = computed(() => {
return fonts.value.filter(f => previewFontIds.value.has(f.id))
})
const favoriteTree = computed(() => {
return buildFontTree(favoriteFonts.value)
})
// 方法
function addFont(fontInfo: FontInfo) {
fonts.value.push(fontInfo)
}
function removeFont(fontId: string) {
const index = fonts.value.findIndex(f => f.id === fontId)
if (index !== -1) {
fonts.value.splice(index, 1)
}
selectedFontIds.value.delete(fontId)
favoriteFontIds.value.delete(fontId)
writeSet('font.favoriteFontIds', favoriteFontIds.value)
previewFontIds.value.delete(fontId)
writeSet('font.previewFontIds', previewFontIds.value)
}
function selectFont(fontId: string) {
selectedFontIds.value.add(fontId)
}
function unselectFont(fontId: string) {
selectedFontIds.value.delete(fontId)
}
function toggleSelectFont(fontId: string) {
if (selectedFontIds.value.has(fontId)) {
unselectFont(fontId)
} else {
selectFont(fontId)
}
}
function clearSelection() {
selectedFontIds.value.clear()
}
function favoriteFont(fontId: string) {
const font = fonts.value.find(f => f.id === fontId)
if (font) {
font.isFavorite = true
favoriteFontIds.value.add(fontId)
writeSet('font.favoriteFontIds', favoriteFontIds.value)
}
}
function unfavoriteFont(fontId: string) {
const font = fonts.value.find(f => f.id === fontId)
if (font) {
font.isFavorite = false
favoriteFontIds.value.delete(fontId)
writeSet('font.favoriteFontIds', favoriteFontIds.value)
}
}
function toggleFavorite(fontId: string) {
if (favoriteFontIds.value.has(fontId)) {
unfavoriteFont(fontId)
} else {
favoriteFont(fontId)
}
}
function addToPreview(fontId: string) {
previewFontIds.value.add(fontId)
writeSet('font.previewFontIds', previewFontIds.value)
}
function removeFromPreview(fontId: string) {
previewFontIds.value.delete(fontId)
writeSet('font.previewFontIds', previewFontIds.value)
}
function togglePreview(fontId: string) {
if (previewFontIds.value.has(fontId)) {
removeFromPreview(fontId)
} else {
addToPreview(fontId)
}
}
function clearPreview() {
previewFontIds.value.clear()
writeSet('font.previewFontIds', previewFontIds.value)
}
function setCategoryExpanded(categoryName: string, expanded: boolean) {
const next = new Set(expandedCategoryNames.value)
if (expanded) {
next.add(categoryName)
} else {
next.delete(categoryName)
}
expandedCategoryNames.value = next
writeSet('font.expandedCategories', expandedCategoryNames.value)
}
async function loadFont(fontInfo: FontInfo) {
if (fontInfo.loaded || !fontInfo.path) {
return
}
try {
const font = await loadFontWithProgress(fontInfo.path, (progress) => {
fontInfo.progress = progress
})
fontInfo.font = font
fontInfo.loaded = true
fontInfo.progress = 100
} catch (error) {
console.error(`Failed to load font ${fontInfo.name}:`, error)
throw error
}
}
function buildFontTree(fontList: FontInfo[]): FontTreeNode[] {
const tree: FontTreeNode[] = []
const categoryMap = new Map<string, FontTreeNode>()
for (const font of fontList) {
let categoryNode = categoryMap.get(font.category)
if (!categoryNode) {
categoryNode = {
name: font.category,
type: 'category',
children: [],
expanded: expandedCategoryNames.value.has(font.category),
selected: false,
}
categoryMap.set(font.category, categoryNode)
tree.push(categoryNode)
}
const fontNode: FontTreeNode = {
name: font.name,
type: 'font',
fontInfo: font,
expanded: false,
selected: selectedFontIds.value.has(font.id),
}
categoryNode.children!.push(fontNode)
}
// 按类别名称排序
tree.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
// 每个类别内的字体按名称排序
for (const category of tree) {
if (category.children) {
category.children.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
}
}
return tree
}
function updateFontTree() {
fontTree.value = buildFontTree(fonts.value)
}
return {
// 状态
fonts,
selectedFontIds,
favoriteFontIds,
previewFontIds,
expandedCategoryNames,
fontTree,
isLoadingFonts,
// 计算属性
selectedFonts,
favoriteFonts,
previewFonts,
favoriteTree,
// 方法
addFont,
removeFont,
selectFont,
unselectFont,
toggleSelectFont,
clearSelection,
favoriteFont,
unfavoriteFont,
toggleFavorite,
addToPreview,
removeFromPreview,
togglePreview,
clearPreview,
setCategoryExpanded,
loadFont,
updateFontTree,
}
})

View File

@@ -0,0 +1,177 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { PreviewItem } from '../types/font'
export const useUiStore = defineStore('ui', () => {
function clampFontSize(size: number) {
return Math.max(10, Math.min(500, size))
}
const initialFontSize = (() => {
try {
const stored = localStorage.getItem('ui.fontSize')
const parsed = stored ? Number(stored) : NaN
return Number.isFinite(parsed) ? clampFontSize(parsed) : 100
} catch {
return 100
}
})()
const initialInputText = (() => {
try {
return localStorage.getItem('ui.inputText') || ''
} catch {
return ''
}
})()
const initialTextColor = (() => {
try {
return localStorage.getItem('ui.textColor') || '#000000'
} catch {
return '#000000'
}
})()
const initialSelectedExportItems = (() => {
try {
const stored = localStorage.getItem('ui.selectedExportItems')
return stored ? JSON.parse(stored) : []
} catch {
return []
}
})()
// 状态
const inputText = ref(initialInputText)
const fontSize = ref(initialFontSize)
const textColor = ref(initialTextColor)
const letterSpacing = ref(0)
const enableLigatures = ref(true)
// 导出相关
const selectedExportItems = ref<PreviewItem[]>(initialSelectedExportItems)
const isExporting = ref(false)
// 侧边栏状态
const isFontSelectorExpanded = ref(true)
const isFavoritesExpanded = ref(true)
// 方法
function setInputText(text: string) {
inputText.value = text
try {
localStorage.setItem('ui.inputText', text)
} catch {
// ignore storage errors
}
}
function setFontSize(size: number) {
const clamped = clampFontSize(size)
fontSize.value = clamped
try {
localStorage.setItem('ui.fontSize', String(clamped))
} catch {
// ignore storage errors
}
}
function setTextColor(color: string) {
textColor.value = color
try {
localStorage.setItem('ui.textColor', color)
} catch {
// ignore storage errors
}
}
function setLetterSpacing(spacing: number) {
letterSpacing.value = spacing
}
function toggleLigatures() {
enableLigatures.value = !enableLigatures.value
}
function toggleExportItem(item: PreviewItem) {
const index = selectedExportItems.value.findIndex(i => i.fontInfo.id === item.fontInfo.id)
if (index >= 0) {
selectedExportItems.value.splice(index, 1)
} else {
selectedExportItems.value.push(item)
}
try {
localStorage.setItem('ui.selectedExportItems', JSON.stringify(selectedExportItems.value))
} catch {
// ignore storage errors
}
}
function retainExportItemsByFontIds(validFontIds: Set<string>) {
const nextItems = selectedExportItems.value.filter(item => validFontIds.has(item.fontInfo.id))
if (nextItems.length === selectedExportItems.value.length) {
return
}
selectedExportItems.value = nextItems
try {
localStorage.setItem('ui.selectedExportItems', JSON.stringify(selectedExportItems.value))
} catch {
// ignore storage errors
}
}
function clearExportSelection() {
selectedExportItems.value = []
try {
localStorage.setItem('ui.selectedExportItems', JSON.stringify([]))
} catch {
// ignore storage errors
}
}
function selectAllExportItems(items: PreviewItem[]) {
selectedExportItems.value = [...items]
try {
localStorage.setItem('ui.selectedExportItems', JSON.stringify(selectedExportItems.value))
} catch {
// ignore storage errors
}
}
function toggleFontSelectorExpanded() {
isFontSelectorExpanded.value = !isFontSelectorExpanded.value
}
function toggleFavoritesExpanded() {
isFavoritesExpanded.value = !isFavoritesExpanded.value
}
return {
// 状态
inputText,
fontSize,
textColor,
letterSpacing,
enableLigatures,
selectedExportItems,
isExporting,
isFontSelectorExpanded,
isFavoritesExpanded,
// 方法
setInputText,
setFontSize,
setTextColor,
setLetterSpacing,
toggleLigatures,
toggleExportItem,
retainExportItemsByFontIds,
clearExportSelection,
selectAllExportItems,
toggleFontSelectorExpanded,
toggleFavoritesExpanded,
}
})

101
frontend/src/style.css Normal file
View File

@@ -0,0 +1,101 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light;
color: #213547;
background-color: #ffffff;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
width: 100%;
height: 100vh;
}
/* 默认隐藏滚动条,鼠标悬停到具体滚动容器时再显示 */
.scrollbar-hover {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hover::-webkit-scrollbar {
width: 0;
height: 0;
}
.scrollbar-hover[data-overflowing='true']:hover {
scrollbar-width: thin;
scrollbar-color: #c9cdd4 transparent;
}
.scrollbar-hover[data-overflowing='true']:hover::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-hover[data-overflowing='true']:hover::-webkit-scrollbar-thumb {
background-color: #c9cdd4;
border-radius: 9999px;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

101
frontend/src/types/font.d.ts vendored Normal file
View File

@@ -0,0 +1,101 @@
import type { Font } from 'opentype.js'
/**
* 字体文件信息
*/
export interface FontInfo {
/** 字体唯一标识 */
id: string
/** 字体文件名(不含扩展名) */
name: string
/** 字体文件路径 */
path: string
/** 字体分类(目录名) */
category: string
/** 是否已收藏 */
isFavorite: boolean
/** opentype.js Font 对象 */
font?: Font
/** 加载状态 */
loaded: boolean
/** 加载进度 0-100 */
progress: number
}
/**
* 字体树节点
*/
export interface FontTreeNode {
/** 节点名称 */
name: string
/** 节点类型 */
type: 'category' | 'font'
/** 子节点 */
children?: FontTreeNode[]
/** 字体信息type为font时 */
fontInfo?: FontInfo
/** 是否展开 */
expanded: boolean
/** 是否选中 */
selected: boolean
}
/**
* HarfBuzz shaped glyph
*/
export interface ShapedGlyph {
/** 字形索引 */
glyphIndex: number
/** X 方向前进距离 */
xAdvance: number
/** Y 方向前进距离 */
yAdvance: number
/** X 方向偏移 */
xOffset: number
/** Y 方向偏移 */
yOffset: number
}
/**
* SVG 生成选项
*/
export interface SvgGenerateOptions {
/** 输入文本 */
text: string
/** 字体对象 */
font: Font
/** 字号(单位:像素) */
fontSize?: number
/** 文本颜色(默认#000000 */
fillColor?: string
/** 字间距单位em默认0 */
letterSpacing?: number
/** 是否启用连字默认true */
enableLigatures?: boolean
}
/**
* SVG 生成结果
*/
export interface SvgGenerateResult {
/** SVG 字符串 */
svg: string
/** 宽度 */
width: number
/** 高度 */
height: number
/** 字体名称 */
fontName: string
}
/**
* 预览项数据
*/
export interface PreviewItem {
/** 字体信息 */
fontInfo: FontInfo
/** SVG 结果 */
svgResult: SvgGenerateResult
/** 是否选中(用于导出) */
selected: boolean
}

51
frontend/src/types/modules.d.ts vendored Normal file
View File

@@ -0,0 +1,51 @@
declare module 'opentype.js' {
export interface Font {
names: {
fontFamily?: { en?: string }
fontSubfamily?: { en?: string }
fullName?: { en?: string }
postScriptName?: { en?: string }
version?: { en?: string }
}
unitsPerEm: number
ascender: number
descender: number
numGlyphs: number
glyphs: {
get(index: number): Glyph
}
charToGlyph(char: string): Glyph
}
export interface Glyph {
name: string
unicode?: number
unicodes?: number[]
index: number
advanceWidth?: number
leftSideBearing?: number
path: Path
getPath(x: number, y: number, fontSize: number): Path
}
export interface Path {
commands: PathCommand[]
}
export type PathCommand =
| { type: 'M'; x: number; y: number }
| { type: 'L'; x: number; y: number }
| { type: 'Q'; x: number; y: number; x1: number; y1: number }
| { type: 'C'; x: number; y: number; x1: number; y1: number; x2: number; y2: number }
| { type: 'Z' }
export function parse(buffer: ArrayBuffer): Font
export function load(
url: string,
callback: (err: Error | null, font?: Font) => void
): void
}
declare module 'harfbuzzjs' {
export default function (): Promise<any>
}

View File

@@ -0,0 +1,170 @@
/**
* 下载文本内容为文件
*/
export function downloadText(content: string, filename: string, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType })
downloadBlob(blob, filename)
}
export interface DownloadFileItem {
name: string
content: string | Blob | ArrayBuffer | Uint8Array
}
/**
* 下载 Blob 为文件
*/
export function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* 下载 SVG 文件
*/
export function downloadSvg(svgContent: string, filename: string) {
downloadText(svgContent, filename, 'image/svg+xml')
}
/**
* 批量下载文件(使用 JSZip
*/
export async function downloadMultipleFiles(files: DownloadFileItem[], zipFilename = 'font2svg-export.zip') {
const JSZip = (await import('jszip')).default
const zip = new JSZip()
for (const file of files) {
zip.file(file.name, file.content)
}
const blob = await zip.generateAsync({ type: 'blob' })
downloadBlob(blob, zipFilename)
}
function parseLengthValue(value: string | null): number | null {
if (!value) return null
const match = value.match(/-?\d+(\.\d+)?/)
if (!match) return null
const parsed = Number(match[0])
return Number.isFinite(parsed) ? parsed : null
}
function getSvgSize(svgContent: string): { width: number; height: number } {
const doc = new DOMParser().parseFromString(svgContent, 'image/svg+xml')
const svg = doc.documentElement
const width = parseLengthValue(svg.getAttribute('width'))
const height = parseLengthValue(svg.getAttribute('height'))
if (width && height) {
return { width, height }
}
const viewBox = svg.getAttribute('viewBox')
if (viewBox) {
const values = viewBox.trim().split(/[\s,]+/).map(Number)
if (values.length === 4 && Number.isFinite(values[2]) && Number.isFinite(values[3])) {
return { width: Math.max(1, values[2]!), height: Math.max(1, values[3]!) }
}
}
return { width: 1024, height: 1024 }
}
export async function convertSvgToPngBlob(
svgContent: string,
options?: { width?: number; height?: number; scale?: number; backgroundColor?: string }
): Promise<Blob> {
const size = getSvgSize(svgContent)
const scale = options?.scale ?? 1
const width = Math.max(1, Math.round((options?.width ?? size.width) * scale))
const height = Math.max(1, Math.round((options?.height ?? size.height) * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) {
throw new Error('无法创建 PNG 画布')
}
if (options?.backgroundColor) {
context.fillStyle = options.backgroundColor
context.fillRect(0, 0, width, height)
} else {
context.clearRect(0, 0, width, height)
}
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
try {
const image = new Image()
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve()
image.onerror = () => reject(new Error('SVG 转 PNG 失败'))
image.src = url
})
context.drawImage(image, 0, 0, width, height)
} finally {
URL.revokeObjectURL(url)
}
const pngBlob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png')
})
if (!pngBlob) {
throw new Error('PNG 编码失败')
}
return pngBlob
}
export async function downloadPngFromSvg(
svgContent: string,
filename: string,
options?: { width?: number; height?: number; scale?: number; backgroundColor?: string }
) {
const blob = await convertSvgToPngBlob(svgContent, options)
downloadBlob(blob, filename)
}
/**
* 清理文件名(移除非法字符)
*/
export function sanitizeFilename(filename: string): string {
// 移除或替换非法字符
return filename
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
.replace(/\s+/g, '_')
.substring(0, 200) // 限制长度
}
function generateBaseFilename(text: string, fontName: string): string {
const textPart = sanitizeFilename(Array.from(text).slice(0, 8).join(''))
const fontPart = sanitizeFilename(fontName.substring(0, 20))
return `${fontPart}_${textPart}`
}
/**
* 生成默认的 SVG 文件名
*/
export function generateSvgFilename(text: string, fontName: string): string {
return `${generateBaseFilename(text, fontName)}.svg`
}
/**
* 生成默认的 PNG 文件名
*/
export function generatePngFilename(text: string, fontName: string): string {
return `${generateBaseFilename(text, fontName)}.png`
}

View File

@@ -0,0 +1,102 @@
import * as opentype from 'opentype.js'
import type { Font } from 'opentype.js'
/**
* 从文件加载字体
*/
export async function loadFontFromFile(file: File): Promise<Font> {
const buffer = await file.arrayBuffer()
return opentype.parse(buffer)
}
/**
* 从 URL 加载字体
*/
export async function loadFontFromUrl(url: string): Promise<Font> {
return new Promise((resolve, reject) => {
opentype.load(url, (err, font) => {
if (err) {
reject(err)
} else if (font) {
resolve(font)
} else {
reject(new Error('Failed to load font'))
}
})
})
}
/**
* 从 URL 加载字体(带进度)
*/
export async function loadFontWithProgress(
url: string,
onProgress?: (percent: number) => void
): Promise<Font> {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const contentLength = response.headers.get('content-length')
const total = contentLength ? parseInt(contentLength) : 0
if (!response.body) {
throw new Error('Response body is null')
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let loaded = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.length
if (total > 0 && onProgress) {
onProgress(Math.round((loaded / total) * 100))
}
}
// 合并所有 chunks
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
const buffer = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
buffer.set(chunk, offset)
offset += chunk.length
}
return opentype.parse(buffer.buffer)
} catch (error) {
throw new Error(`Failed to load font from ${url}: ${error}`)
}
}
/**
* 从 ArrayBuffer 加载字体
*/
export async function loadFontFromBuffer(buffer: ArrayBuffer): Promise<Font> {
return opentype.parse(buffer)
}
/**
* 获取字体信息
*/
export function getFontInfo(font: Font) {
return {
familyName: font.names.fontFamily?.en || 'Unknown',
styleName: font.names.fontSubfamily?.en || 'Regular',
fullName: font.names.fullName?.en || 'Unknown',
postScriptName: font.names.postScriptName?.en || 'Unknown',
version: font.names.version?.en || 'Unknown',
unitsPerEm: font.unitsPerEm,
ascender: font.ascender,
descender: font.descender,
numGlyphs: font.numGlyphs,
}
}

View File

@@ -0,0 +1,86 @@
import type { ShapedGlyph } from '../types/font'
let hb: any = null
/**
* 初始化 HarfBuzz WASM
*/
export async function initHarfbuzz() {
if (!hb) {
// 使用动态 import 避免打包时的问题
const hbModule = await import('harfbuzzjs/hb.js')
const createHB = (hbModule as any).default || hbModule
const instance = await createHB()
const hbjsModule = await import('harfbuzzjs/hbjs.js')
const hbjsFunc = (hbjsModule as any).default || hbjsModule
hb = hbjsFunc(instance)
}
return hb
}
/**
* 使用 HarfBuzz 进行 text shaping
* @param fontBuffer 字体文件的 ArrayBuffer
* @param text 要处理的文本
* @param features OpenType 特性(可选)
* @returns Shaped glyphs 数组
*/
export async function shapeText(
fontBuffer: ArrayBuffer,
text: string,
features?: string[]
): Promise<ShapedGlyph[]> {
const hb = await initHarfbuzz()
// 创建 blob
const blob = hb.createBlob(fontBuffer)
const face = hb.createFace(blob, 0)
const font = hb.createFont(face)
// 创建 buffer 并添加文本
const buffer = hb.createBuffer()
buffer.addText(text)
buffer.guessSegmentProperties()
// 如果有特性,设置特性
if (features && features.length > 0) {
// HarfBuzz features format: "+liga", "-kern", etc.
// 这里简化处理,实际使用时可能需要更复杂的特性解析
}
// 进行 shaping
hb.shape(font, buffer)
// 获取结果
const result = buffer.json(font)
// 清理资源
buffer.destroy()
font.destroy()
face.destroy()
blob.destroy()
// 转换为我们的格式
return result.map((item: any) => ({
glyphIndex: item.g,
xAdvance: item.ax || 0,
yAdvance: item.ay || 0,
xOffset: item.dx || 0,
yOffset: item.dy || 0,
}))
}
/**
* 检查 HarfBuzz 是否已初始化
*/
export function isHarfbuzzInitialized(): boolean {
return hb !== null
}
/**
* 获取 HarfBuzz 实例(如果已初始化)
*/
export function getHarfbuzz() {
return hb
}

View File

@@ -0,0 +1,344 @@
import type { Glyph } from 'opentype.js'
import type { SvgGenerateOptions, SvgGenerateResult } from '../types/font'
import { shapeText } from './harfbuzz'
import { wrapTextByChars } from './text-layout'
/**
* 格式化数字(移除尾随零)
*/
function formatNumber(value: number): string {
const text = value.toFixed(2).replace(/\.?0+$/, '')
return text || '0'
}
/**
* 获取字形路径的 SVG path d 属性
*/
function getGlyphPath(glyph: Glyph): string {
const path = glyph.path
if (!path || !path.commands || path.commands.length === 0) {
return ''
}
const commands: string[] = []
for (const cmd of path.commands) {
switch (cmd.type) {
case 'M':
commands.push(`M${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
break
case 'L':
commands.push(`L${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
break
case 'Q':
commands.push(`Q${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
break
case 'C':
commands.push(`C${formatNumber(cmd.x1)} ${formatNumber(cmd.y1)} ${formatNumber(cmd.x2)} ${formatNumber(cmd.y2)} ${formatNumber(cmd.x)} ${formatNumber(cmd.y)}`)
break
case 'Z':
commands.push('Z')
break
}
}
return commands.join(' ')
}
/**
* 计算字形的边界框
*/
function getGlyphBounds(glyph: Glyph): { xMin: number; yMin: number; xMax: number; yMax: number } | null {
const path = glyph.path
if (!path || !path.commands || path.commands.length === 0) {
return null
}
let xMin = Infinity
let yMin = Infinity
let xMax = -Infinity
let yMax = -Infinity
for (const cmd of path.commands) {
if ('x' in cmd) {
xMin = Math.min(xMin, cmd.x)
xMax = Math.max(xMax, cmd.x)
yMin = Math.min(yMin, cmd.y)
yMax = Math.max(yMax, cmd.y)
}
if ('x1' in cmd) {
xMin = Math.min(xMin, cmd.x1)
xMax = Math.max(xMax, cmd.x1)
yMin = Math.min(yMin, cmd.y1)
yMax = Math.max(yMax, cmd.y1)
}
if ('x2' in cmd) {
xMin = Math.min(xMin, cmd.x2)
xMax = Math.max(xMax, cmd.x2)
yMin = Math.min(yMin, cmd.y2)
yMax = Math.max(yMax, cmd.y2)
}
}
if (xMin === Infinity) {
return null
}
return { xMin, yMin, xMax, yMax }
}
interface GlyphRun {
glyph: Glyph
xPos: number
yPos: number
}
/**
* 生成 SVG使用 HarfBuzz 进行 text shaping
*/
export async function generateSvg(options: SvgGenerateOptions): Promise<SvgGenerateResult> {
const {
text,
font,
fontSize = 100,
fillColor = '#000000',
letterSpacing = 0,
// enableLigatures = true, // 暂时不使用
} = options
if (!text || text.trim() === '') {
throw new Error('文本内容不能为空')
}
const normalizedText = wrapTextByChars(text)
// 获取字体的 ArrayBuffer用于 HarfBuzz
// 注意opentype.js 的 Font 对象没有直接访问 ArrayBuffer 的方法
// 我们需要从原始数据获取,这里假设 font 对象有 outlinesFormat 属性
// 实际使用时,需要保存原始的 ArrayBuffer
// 这里先使用简化版本,不使用 HarfBuzz直接使用 opentype.js 的基本功能
// 后续可以优化为使用 HarfBuzz
const scale = fontSize / font.unitsPerEm
const letterSpacingEm = letterSpacing * font.unitsPerEm
// 获取字形(支持手动换行 + 按 45 字自动换行)
const glyphRuns: GlyphRun[] = []
let minX: number | null = null
let minY: number | null = null
let maxX: number | null = null
let maxY: number | null = null
let maxLineAdvance = 0
const ascender = Number.isFinite(font.ascender) ? font.ascender : font.unitsPerEm * 0.8
const descender = Number.isFinite(font.descender) ? font.descender : -font.unitsPerEm * 0.2
const lineAdvance = Math.max(font.unitsPerEm * 1.2, ascender - descender)
const lines = normalizedText.split('\n')
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex] ?? ''
const yPos = -lineIndex * lineAdvance
let xPos = 0
for (const char of Array.from(line)) {
const glyph = font.charToGlyph(char)
glyphRuns.push({
glyph,
xPos,
yPos,
})
const bounds = getGlyphBounds(glyph)
if (bounds) {
const adjustedXMin = bounds.xMin + xPos
const adjustedYMin = bounds.yMin + yPos
const adjustedXMax = bounds.xMax + xPos
const adjustedYMax = bounds.yMax + yPos
minX = minX === null ? adjustedXMin : Math.min(minX, adjustedXMin)
minY = minY === null ? adjustedYMin : Math.min(minY, adjustedYMin)
maxX = maxX === null ? adjustedXMax : Math.max(maxX, adjustedXMax)
maxY = maxY === null ? adjustedYMax : Math.max(maxY, adjustedYMax)
}
xPos += (glyph.advanceWidth || 0) + letterSpacingEm
}
maxLineAdvance = Math.max(maxLineAdvance, xPos)
}
if (minX === null || maxX === null) {
minX = 0
maxX = maxLineAdvance
}
if (minX === null || minY === null || maxX === null || maxY === null) {
throw new Error('未生成有效字形轮廓')
}
const width = (maxX - minX) * scale
const height = (maxY - minY) * scale
if (width <= 0 || height <= 0) {
throw new Error('计算得到的 SVG 尺寸无效')
}
// 生成路径
const paths: string[] = []
for (const run of glyphRuns) {
const d = getGlyphPath(run.glyph)
if (!d) continue
const transform = `translate(${formatNumber(run.xPos)} ${formatNumber(run.yPos)})`
paths.push(` <path d="${d}" transform="${transform}"/>`)
}
if (paths.length === 0) {
throw new Error('未生成任何路径')
}
// 构建 SVG
const viewBox = `${formatNumber(minX)} 0 ${formatNumber(maxX - minX)} ${formatNumber(maxY - minY)}`
const groupTransform = `translate(0 ${formatNumber(maxY)}) scale(1 -1)`
const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${formatNumber(width)}" height="${formatNumber(height)}" preserveAspectRatio="xMidYMid meet">
<g transform="${groupTransform}" fill="${fillColor}" stroke="none">
${paths.join('\n')}
</g>
</svg>`
const fontName = font.names.fontFamily?.en || font.names.fullName?.en || 'Unknown'
return {
svg,
width,
height,
fontName,
}
}
/**
* 生成 SVG使用 HarfBuzz高级版本
* 此版本需要原始字体的 ArrayBuffer
*/
export async function generateSvgWithHarfbuzz(
options: SvgGenerateOptions,
fontBuffer: ArrayBuffer
): Promise<SvgGenerateResult> {
const {
text,
font,
fontSize = 100,
fillColor = '#000000',
letterSpacing = 0,
} = options
if (!text || text.trim() === '') {
throw new Error('文本内容不能为空')
}
// 使用 HarfBuzz 进行 text shaping
const shapedGlyphs = await shapeText(fontBuffer, text)
const scale = fontSize / font.unitsPerEm
const letterSpacingRaw = letterSpacing * font.unitsPerEm
// 计算字形位置和路径
const glyphRuns: GlyphRun[] = []
let x = 0
let y = 0
// 确定 position scale参考 Python 版本的 _positions_scale 函数)
let posScale = 1.0
const sampleAdvance = shapedGlyphs.find(g => g.xAdvance)?.xAdvance || 0
if (Math.abs(sampleAdvance) > font.unitsPerEm * 4) {
posScale = 1 / 64.0
}
const spacingRaw = letterSpacingRaw / posScale
for (const shaped of shapedGlyphs) {
const glyph = font.glyphs.get(shaped.glyphIndex)
const xPos = (x + shaped.xOffset) * posScale
const yPos = (y + shaped.yOffset) * posScale
glyphRuns.push({
glyph,
xPos,
yPos,
})
x += shaped.xAdvance + spacingRaw
y += shaped.yAdvance
}
// 计算总边界框
let minX: number | null = null
let minY: number | null = null
let maxX: number | null = null
let maxY: number | null = null
for (const run of glyphRuns) {
const bounds = getGlyphBounds(run.glyph)
if (!bounds) continue
const { xMin, yMin, xMax, yMax } = bounds
const adjustedXMin = xMin + run.xPos
const adjustedYMin = yMin + run.yPos
const adjustedXMax = xMax + run.xPos
const adjustedYMax = yMax + run.yPos
minX = minX === null ? adjustedXMin : Math.min(minX, adjustedXMin)
minY = minY === null ? adjustedYMin : Math.min(minY, adjustedYMin)
maxX = maxX === null ? adjustedXMax : Math.max(maxX, adjustedXMax)
maxY = maxY === null ? adjustedYMax : Math.max(maxY, adjustedYMax)
}
if (minX === null || minY === null || maxX === null || maxY === null) {
throw new Error('未生成有效字形轮廓')
}
const width = (maxX - minX) * scale
const height = (maxY - minY) * scale
if (width <= 0 || height <= 0) {
throw new Error('计算得到的 SVG 尺寸无效')
}
// 生成路径
const paths: string[] = []
for (const run of glyphRuns) {
const d = getGlyphPath(run.glyph)
if (!d) continue
const transform = `translate(${formatNumber(run.xPos)} ${formatNumber(run.yPos)})`
paths.push(` <path d="${d}" transform="${transform}"/>`)
}
if (paths.length === 0) {
throw new Error('未生成任何路径')
}
// 构建 SVG
const viewBox = `${formatNumber(minX)} 0 ${formatNumber(maxX - minX)} ${formatNumber(maxY - minY)}`
const groupTransform = `translate(0 ${formatNumber(maxY)}) scale(1 -1)`
const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet">
<g transform="${groupTransform}" fill="${fillColor}" stroke="none">
${paths.join('\n')}
</g>
</svg>`
const fontName = font.names.fontFamily?.en || font.names.fullName?.en || 'Unknown'
return {
svg,
width,
height,
fontName,
}
}

View File

@@ -0,0 +1,34 @@
export const MAX_CHARS_PER_LINE = 45
/**
* 统一换行符为 \n
*/
export function normalizeLineBreaks(text: string): string {
return text.replace(/\r\n?/g, '\n')
}
/**
* 按字符数自动换行,保留用户手动换行
*/
export function wrapTextByChars(text: string, maxCharsPerLine = MAX_CHARS_PER_LINE): string {
if (maxCharsPerLine <= 0) return normalizeLineBreaks(text)
const normalized = normalizeLineBreaks(text)
const lines = normalized.split('\n')
const wrappedLines: string[] = []
for (const line of lines) {
const chars = Array.from(line)
if (chars.length === 0) {
wrappedLines.push('')
continue
}
for (let i = 0; i < chars.length; i += maxCharsPerLine) {
wrappedLines.push(chars.slice(i, i + maxCharsPerLine).join(''))
}
}
return wrappedLines.join('\n')
}