1250 lines
34 KiB
Markdown
1250 lines
34 KiB
Markdown
# Font2SVG Web 应用开发计划
|
||
|
||
## 项目概述
|
||
|
||
基于 Figma 设计实现一个交互式字体转 SVG 工具的 Web 应用。采用纯前端架构,使用 TypeScript + Vue3 + Vite 技术栈,在浏览器中通过 opentype.js 和 harfbuzz.js (WASM) 实现字体到 SVG 的实时转换。
|
||
|
||
## ⚠️ 核心技术决策
|
||
|
||
**字体到 SVG 转换实现方式:TypeScript 重新实现**
|
||
|
||
**决策内容**:
|
||
- ✅ 使用 TypeScript + opentype.js + harfbuzz.js 重新实现核心转换逻辑
|
||
- ✅ `font2svg.py` 保持不变,仅作为算法参考
|
||
- ❌ 不调用 Python 代码(不使用 FastAPI 后端、Pyodide 或 Electron)
|
||
|
||
**决策理由**:
|
||
1. **纯前端架构** - 无需后端服务器,部署成本为零
|
||
2. **性能更优** - 浏览器直接处理,无网络延迟
|
||
3. **用户体验佳** - 实时预览响应快,交互流畅
|
||
4. **可离线使用** - 可打包为 PWA,完全离线工作
|
||
5. **技术可行** - opentype.js + harfbuzz.js 完全覆盖 font2svg.py 的功能
|
||
|
||
**技术对比**:
|
||
```typescript
|
||
// font2svg.py 核心逻辑(~100 行)
|
||
// 1. 加载字体 → opentype.parse(buffer)
|
||
// 2. Text shaping → harfbuzz.js WASM
|
||
// 3. 获取字形 → glyph.getPath()
|
||
// 4. 坐标变换 → 简单数学计算
|
||
// 5. 生成 SVG → 字符串拼接
|
||
```
|
||
|
||
## 技术架构
|
||
|
||
### 核心技术栈
|
||
- **前端框架**: Vue 3 + TypeScript
|
||
- **构建工具**: Vite
|
||
- **样式方案**: UnoCSS (原子化 CSS,与 Tailwind 兼容)
|
||
- **状态管理**: Pinia
|
||
- **字体处理**: opentype.js + harfbuzz.js (WASM)
|
||
|
||
### 关键约束
|
||
- ✅ 保持 `font2svg.py` 不变,仅作为参考
|
||
- ✅ 纯前端实现,无需后端服务器
|
||
- ✅ 字体文件放在 `font/` 目录
|
||
- ✅ SVG 图标放在 `src/assets/icons/`
|
||
- ✅ 应用图标为 `src/assets/webicon.png`
|
||
- ✅ 使用 fonttools.subset 预处理字体(构建时)
|
||
- ❌ 暂不实现运行时缓存策略
|
||
- ❌ 暂不需要许可证管理
|
||
|
||
## 项目结构
|
||
|
||
```
|
||
font2svg/
|
||
├── font/ # 源字体文件(不打包)
|
||
│ ├── 庞门正道/
|
||
│ ├── 王漢宗/
|
||
│ └── 其他字体/
|
||
├── public/
|
||
│ └── fonts/ # 构建时生成的字体子集
|
||
├── scripts/
|
||
│ └── prepare-fonts.py # 字体预处理脚本(fonttools.subset)
|
||
├── src/
|
||
│ ├── assets/
|
||
│ │ ├── icons/ # SVG UI 图标
|
||
│ │ └── webicon.png # 应用图标
|
||
│ ├── components/
|
||
│ │ ├── FontSelector.vue # 字体选择器(树状结构)
|
||
│ │ ├── FontTree.vue # 字体树组件(展开/收拢)
|
||
│ │ ├── FavoritesList.vue # 收藏字体列表
|
||
│ │ ├── TextInput.vue # 文本输入框
|
||
│ │ ├── LetterSpacingSlider.vue# 字间距调整滑块
|
||
│ │ ├── SvgPreview.vue # SVG 预览区
|
||
│ │ ├── PreviewItem.vue # 单个字体预览项
|
||
│ │ └── ExportPanel.vue # 导出面板
|
||
│ ├── composables/
|
||
│ │ ├── useFont.ts # 字体加载和管理
|
||
│ │ ├── useSvgGenerate.ts # SVG 生成核心逻辑
|
||
│ │ ├── useTextShaping.ts # HarfBuzz text shaping
|
||
│ │ └── useFavorites.ts # 收藏功能
|
||
│ ├── stores/
|
||
│ │ ├── fontStore.ts # 字体状态管理
|
||
│ │ └── uiStore.ts # UI 状态(预览大小等)
|
||
│ ├── utils/
|
||
│ │ ├── harfbuzz.ts # HarfBuzz WASM 封装
|
||
│ │ ├── font-loader.ts # 字体文件加载
|
||
│ │ ├── svg-builder.ts # SVG 文档构建
|
||
│ │ └── download.ts # 文件下载工具
|
||
│ ├── types/
|
||
│ │ └── font.d.ts # TypeScript 类型定义
|
||
│ ├── App.vue # 根组件
|
||
│ └── main.ts # 应用入口
|
||
├── vite.config.ts
|
||
├── tsconfig.json
|
||
├── uno.config.ts # UnoCSS 配置
|
||
└── package.json
|
||
```
|
||
|
||
## 开发阶段
|
||
|
||
### 阶段 1: 项目初始化与环境配置 (1-2 天)
|
||
|
||
**目标**: 搭建开发环境和基础架构
|
||
|
||
**任务**:
|
||
1. 初始化 Vite + Vue3 + TypeScript 项目
|
||
```bash
|
||
pnpm create vite font2svg-web --template vue-ts
|
||
```
|
||
|
||
2. 安装核心依赖
|
||
```bash
|
||
pnpm add opentype.js harfbuzzjs pinia
|
||
pnpm add -D unocss @unocss/preset-wind vite-plugin-wasm
|
||
```
|
||
|
||
3. 配置 Vite 支持 WASM
|
||
```typescript
|
||
// vite.config.ts
|
||
import wasm from 'vite-plugin-wasm';
|
||
export default defineConfig({
|
||
plugins: [vue(), UnoCSS(), wasm()],
|
||
optimizeDeps: { exclude: ['harfbuzzjs'] }
|
||
});
|
||
```
|
||
|
||
4. 配置 UnoCSS
|
||
```typescript
|
||
// uno.config.ts
|
||
import { defineConfig, presetWind } from 'unocss';
|
||
export default defineConfig({
|
||
presets: [presetWind()]
|
||
});
|
||
```
|
||
|
||
5. 创建目录结构
|
||
- 创建 `src/components/`、`src/composables/`、`src/stores/`、`src/utils/`、`src/types/`
|
||
- 复制 UI 图标到 `src/assets/icons/`
|
||
- 复制应用图标到 `src/assets/webicon.png`
|
||
|
||
**验收标准**:
|
||
- ✅ 项目可成功启动并显示 Hello World
|
||
- ✅ UnoCSS 样式正常工作
|
||
- ✅ TypeScript 类型检查无错误
|
||
|
||
---
|
||
|
||
### 阶段 2: 核心转换模块开发 (3-5 天)
|
||
|
||
**目标**: 实现字体到 SVG 的核心转换逻辑
|
||
|
||
**参考**: `font2svg.py` 的实现算法
|
||
|
||
**任务**:
|
||
|
||
#### 2.1 HarfBuzz WASM 封装
|
||
```typescript
|
||
// src/utils/harfbuzz.ts
|
||
import hbjs from 'harfbuzzjs';
|
||
|
||
let hb: any = null;
|
||
|
||
export async function initHarfbuzz() {
|
||
if (!hb) {
|
||
hb = await hbjs();
|
||
}
|
||
return hb;
|
||
}
|
||
|
||
export interface ShapedGlyph {
|
||
glyphIndex: number;
|
||
xAdvance: number;
|
||
yAdvance: number;
|
||
xOffset: number;
|
||
yOffset: number;
|
||
}
|
||
|
||
export async function shapeText(
|
||
fontBuffer: ArrayBuffer,
|
||
text: string
|
||
): Promise<ShapedGlyph[]> {
|
||
const hb = await initHarfbuzz();
|
||
|
||
const blob = hb.createBlob(fontBuffer);
|
||
const face = hb.createFace(blob, 0);
|
||
const font = hb.createFont(face);
|
||
|
||
const buffer = hb.createBuffer();
|
||
buffer.addText(text);
|
||
buffer.guessSegmentProperties();
|
||
hb.shape(font, buffer);
|
||
|
||
const result = buffer.json();
|
||
|
||
// 清理资源
|
||
buffer.destroy();
|
||
font.destroy();
|
||
face.destroy();
|
||
blob.destroy();
|
||
|
||
return result.map((item: any) => ({
|
||
glyphIndex: item.g,
|
||
xAdvance: item.ax,
|
||
yAdvance: item.ay,
|
||
xOffset: item.dx,
|
||
yOffset: item.dy
|
||
}));
|
||
}
|
||
```
|
||
|
||
#### 2.2 字体加载模块
|
||
```typescript
|
||
// src/utils/font-loader.ts
|
||
import opentype from 'opentype.js';
|
||
|
||
export async function loadFont(
|
||
source: File | string | ArrayBuffer
|
||
): Promise<opentype.Font> {
|
||
if (source instanceof File) {
|
||
const buffer = await source.arrayBuffer();
|
||
return opentype.parse(buffer);
|
||
} else if (typeof source === 'string') {
|
||
return opentype.load(source);
|
||
} else {
|
||
return opentype.parse(source);
|
||
}
|
||
}
|
||
|
||
export async function loadFontWithProgress(
|
||
url: string,
|
||
onProgress?: (percent: number) => void
|
||
): Promise<opentype.Font> {
|
||
const response = await fetch(url);
|
||
const contentLength = response.headers.get('content-length');
|
||
const total = contentLength ? parseInt(contentLength) : 0;
|
||
|
||
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 (onProgress && total > 0) {
|
||
onProgress(Math.round((loaded / total) * 100));
|
||
}
|
||
}
|
||
|
||
const buffer = new Uint8Array(loaded);
|
||
let offset = 0;
|
||
for (const chunk of chunks) {
|
||
buffer.set(chunk, offset);
|
||
offset += chunk.length;
|
||
}
|
||
|
||
return opentype.parse(buffer.buffer);
|
||
}
|
||
```
|
||
|
||
#### 2.3 SVG 生成核心逻辑
|
||
```typescript
|
||
// src/composables/useSvgGenerate.ts
|
||
import { ref } from 'vue';
|
||
import type { Font } from 'opentype.js';
|
||
import { shapeText, initHarfbuzz } from '@/utils/harfbuzz';
|
||
|
||
export function useSvgGenerate() {
|
||
const svgContent = ref('');
|
||
const isGenerating = ref(false);
|
||
const error = ref<string | null>(null);
|
||
|
||
async function generate(
|
||
font: Font,
|
||
text: string,
|
||
letterSpacing: number = 0,
|
||
fontSize: number = 72
|
||
) {
|
||
isGenerating.value = true;
|
||
error.value = null;
|
||
|
||
try {
|
||
// 1. 初始化 HarfBuzz
|
||
await initHarfbuzz();
|
||
|
||
// 2. 获取字体 buffer
|
||
const fontBuffer = font.toArrayBuffer();
|
||
|
||
// 3. Text shaping
|
||
const shaped = await shapeText(fontBuffer, text);
|
||
|
||
// 4. 提取字形路径和位置
|
||
const scale = fontSize / font.unitsPerEm;
|
||
let currentX = 0;
|
||
const paths: Array<{ d: string; x: number; y: number }> = [];
|
||
|
||
for (const item of shaped) {
|
||
const glyph = font.glyphs.get(item.glyphIndex);
|
||
const glyphPath = glyph.getPath(0, 0, fontSize);
|
||
|
||
const x = currentX + (item.xOffset / 64) * scale;
|
||
const y = (item.yOffset / 64) * scale;
|
||
|
||
paths.push({
|
||
d: glyphPath.toPathData(),
|
||
x,
|
||
y
|
||
});
|
||
|
||
// HarfBuzz 使用 26.6 定点数(除以 64)
|
||
currentX += (item.xAdvance / 64) * scale + letterSpacing;
|
||
}
|
||
|
||
// 5. 计算边界框
|
||
const fullPath = font.getPath(text, 0, 0, fontSize);
|
||
const bbox = fullPath.getBoundingBox();
|
||
|
||
const width = Math.ceil(bbox.x2 - bbox.x1 + letterSpacing * (text.length - 1));
|
||
const height = Math.ceil(bbox.y2 - bbox.y1);
|
||
const offsetX = -bbox.x1;
|
||
const offsetY = -bbox.y1;
|
||
|
||
// 6. 生成 SVG 文档
|
||
const pathElements = paths.map(({ d, x, y }) => {
|
||
return ` <path d="${d}" transform="translate(${x + offsetX} ${y + offsetY}) scale(1 -1)" />`;
|
||
}).join('\n');
|
||
|
||
svgContent.value = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg xmlns="http://www.w3.org/2000/svg"
|
||
width="${width}"
|
||
height="${height}"
|
||
viewBox="0 0 ${width} ${height}">
|
||
<g fill="#000000">
|
||
${pathElements}
|
||
</g>
|
||
</svg>`;
|
||
|
||
} catch (err) {
|
||
error.value = err instanceof Error ? err.message : '生成失败';
|
||
console.error('SVG 生成错误:', err);
|
||
} finally {
|
||
isGenerating.value = false;
|
||
}
|
||
}
|
||
|
||
return { svgContent, isGenerating, error, generate };
|
||
}
|
||
```
|
||
|
||
#### 2.4 下载工具
|
||
```typescript
|
||
// src/utils/download.ts
|
||
export function downloadSvg(content: string, filename: string) {
|
||
const blob = new Blob([content], { type: 'image/svg+xml;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename.endsWith('.svg') ? filename : `${filename}.svg`;
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
export async function downloadMultipleSvgs(
|
||
svgList: Array<{ content: string; filename: string }>
|
||
) {
|
||
// 使用 JSZip 打包多个 SVG
|
||
const JSZip = (await import('jszip')).default;
|
||
const zip = new JSZip();
|
||
|
||
svgList.forEach(({ content, filename }) => {
|
||
zip.file(filename, content);
|
||
});
|
||
|
||
const blob = await zip.generateAsync({ type: 'blob' });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = 'font-svgs.zip';
|
||
link.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
```
|
||
|
||
**验收标准**:
|
||
- ✅ 成功加载 TTF/OTF 字体文件
|
||
- ✅ HarfBuzz WASM 正常初始化和工作
|
||
- ✅ 能够将文本转换为 SVG 格式
|
||
- ✅ SVG 输出与 `font2svg.py` 结果一致
|
||
- ✅ 支持中文字符和复杂排版
|
||
|
||
---
|
||
|
||
### 阶段 3: UI 组件开发 (5-7 天)
|
||
|
||
**目标**: 实现 Figma 设计的所有界面组件
|
||
|
||
**Figma 设计参考**: 节点 3:5
|
||
|
||
#### 3.1 状态管理
|
||
```typescript
|
||
// src/stores/fontStore.ts
|
||
import { defineStore } from 'pinia';
|
||
import type { Font } from 'opentype.js';
|
||
|
||
export interface FontInfo {
|
||
id: string;
|
||
name: string;
|
||
category: string;
|
||
path: string;
|
||
font?: Font;
|
||
}
|
||
|
||
export const useFontStore = defineStore('font', {
|
||
state: () => ({
|
||
fontList: [] as FontInfo[],
|
||
selectedFonts: [] as string[], // font IDs for preview
|
||
currentFont: null as Font | null,
|
||
text: '星程紫微',
|
||
letterSpacing: 0,
|
||
fontSize: 72
|
||
}),
|
||
|
||
actions: {
|
||
async loadFontList() {
|
||
// 从 fonts.json 加载字体元数据
|
||
const response = await fetch('/fonts.json');
|
||
this.fontList = await response.json();
|
||
},
|
||
|
||
async loadFont(fontId: string) {
|
||
const fontInfo = this.fontList.find(f => f.id === fontId);
|
||
if (!fontInfo) return;
|
||
|
||
if (!fontInfo.font) {
|
||
const { loadFont } = await import('@/utils/font-loader');
|
||
fontInfo.font = await loadFont(fontInfo.path);
|
||
}
|
||
|
||
this.currentFont = fontInfo.font;
|
||
},
|
||
|
||
toggleFontSelection(fontId: string) {
|
||
const index = this.selectedFonts.indexOf(fontId);
|
||
if (index === -1) {
|
||
this.selectedFonts.push(fontId);
|
||
} else {
|
||
this.selectedFonts.splice(index, 1);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
```typescript
|
||
// src/composables/useFavorites.ts
|
||
import { ref, computed } from 'vue';
|
||
|
||
const STORAGE_KEY = 'font2svg-favorites';
|
||
|
||
export function useFavorites() {
|
||
const favorites = ref<string[]>([]);
|
||
|
||
// 从 localStorage 加载
|
||
function load() {
|
||
const stored = localStorage.getItem(STORAGE_KEY);
|
||
if (stored) {
|
||
favorites.value = JSON.parse(stored);
|
||
}
|
||
}
|
||
|
||
// 保存到 localStorage
|
||
function save() {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value));
|
||
}
|
||
|
||
function toggle(fontId: string) {
|
||
const index = favorites.value.indexOf(fontId);
|
||
if (index === -1) {
|
||
favorites.value.push(fontId);
|
||
} else {
|
||
favorites.value.splice(index, 1);
|
||
}
|
||
save();
|
||
}
|
||
|
||
function isFavorite(fontId: string) {
|
||
return favorites.value.includes(fontId);
|
||
}
|
||
|
||
// 初始化时加载
|
||
load();
|
||
|
||
return {
|
||
favorites: computed(() => favorites.value),
|
||
toggle,
|
||
isFavorite
|
||
};
|
||
}
|
||
```
|
||
|
||
#### 3.2 主布局组件
|
||
```vue
|
||
<!-- src/App.vue -->
|
||
<template>
|
||
<div class="min-h-screen bg-white p-6 flex flex-col gap-4">
|
||
<!-- 顶部工具栏 -->
|
||
<header class="flex items-center gap-2">
|
||
<img src="@/assets/webicon.png" class="w-24 h-24 rounded-8" />
|
||
<div class="flex-1">
|
||
<img src="@/assets/title.svg" class="h-8" alt="星程字体转换" />
|
||
</div>
|
||
|
||
<!-- 字号调整滑块 -->
|
||
<LetterSpacingSlider v-model="fontSize" />
|
||
|
||
<!-- 文本输入 -->
|
||
<TextInput v-model="text" @preview="handlePreview" />
|
||
|
||
<!-- 预览按钮 -->
|
||
<button
|
||
@click="handlePreview"
|
||
class="bg-purple-600 text-white px-4 py-2 rounded-lg h-22"
|
||
>
|
||
预<br>览
|
||
</button>
|
||
|
||
<!-- 导出按钮 -->
|
||
<button
|
||
@click="handleExport"
|
||
class="bg-blue-800 text-white px-4 py-2 rounded-lg h-22"
|
||
>
|
||
导<br>出
|
||
</button>
|
||
</header>
|
||
|
||
<!-- 主内容区 -->
|
||
<main class="flex-1 flex gap-2 min-h-0">
|
||
<!-- 左侧:字体选择和收藏 -->
|
||
<aside class="flex flex-col gap-2 w-92">
|
||
<FontSelector />
|
||
<FavoritesList />
|
||
</aside>
|
||
|
||
<!-- 右侧:预览区 -->
|
||
<section class="flex-1 border border-red-100 rounded-4 p-2">
|
||
<SvgPreview />
|
||
</section>
|
||
</main>
|
||
|
||
<!-- 底部版权 -->
|
||
<footer class="text-gray-400 text-sm">
|
||
@版权说明:所有字体来源互联网分享,仅供效果预览,不做下载传播,如有侵权,请告知douboer@gmail.com
|
||
</footer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted } from 'vue';
|
||
import { useFontStore } from '@/stores/fontStore';
|
||
import TextInput from '@/components/TextInput.vue';
|
||
import LetterSpacingSlider from '@/components/LetterSpacingSlider.vue';
|
||
import FontSelector from '@/components/FontSelector.vue';
|
||
import FavoritesList from '@/components/FavoritesList.vue';
|
||
import SvgPreview from '@/components/SvgPreview.vue';
|
||
|
||
const fontStore = useFontStore();
|
||
const text = ref('星程紫微');
|
||
const fontSize = ref(72);
|
||
|
||
onMounted(async () => {
|
||
await fontStore.loadFontList();
|
||
});
|
||
|
||
function handlePreview() {
|
||
// 触发预览更新
|
||
fontStore.text = text.value;
|
||
fontStore.fontSize = fontSize.value;
|
||
}
|
||
|
||
function handleExport() {
|
||
// 导出选中的 SVG
|
||
}
|
||
</script>
|
||
```
|
||
|
||
#### 3.3 字体选择器组件
|
||
```vue
|
||
<!-- src/components/FontSelector.vue -->
|
||
<template>
|
||
<div class="border border-red-100 rounded-4 p-2 flex-1 overflow-auto">
|
||
<h3 class="text-6 font-medium mb-4">字体选择</h3>
|
||
|
||
<div v-for="category in categories" :key="category.name">
|
||
<FontTree
|
||
:category="category"
|
||
:favorites="favorites"
|
||
@toggle-favorite="handleToggleFavorite"
|
||
@toggle-selection="handleToggleSelection"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue';
|
||
import { useFontStore } from '@/stores/fontStore';
|
||
import { useFavorites } from '@/composables/useFavorites';
|
||
import FontTree from '@/components/FontTree.vue';
|
||
|
||
const fontStore = useFontStore();
|
||
const { favorites, toggle: toggleFavorite } = useFavorites();
|
||
|
||
const categories = computed(() => {
|
||
const map = new Map<string, any>();
|
||
|
||
fontStore.fontList.forEach(font => {
|
||
if (!map.has(font.category)) {
|
||
map.set(font.category, {
|
||
name: font.category,
|
||
fonts: []
|
||
});
|
||
}
|
||
map.get(font.category).fonts.push(font);
|
||
});
|
||
|
||
return Array.from(map.values());
|
||
});
|
||
|
||
function handleToggleFavorite(fontId: string) {
|
||
toggleFavorite(fontId);
|
||
}
|
||
|
||
function handleToggleSelection(fontId: string) {
|
||
fontStore.toggleFontSelection(fontId);
|
||
}
|
||
</script>
|
||
```
|
||
|
||
#### 3.4 字体树组件(支持展开/收拢)
|
||
```vue
|
||
<!-- src/components/FontTree.vue -->
|
||
<template>
|
||
<div class="mb-4">
|
||
<!-- 分类标题 -->
|
||
<div class="flex items-center gap-2 mb-2 cursor-pointer" @click="toggleExpand">
|
||
<img
|
||
:src="isExpanded ? iconExpanded : iconCollapsed"
|
||
class="w-4 h-4"
|
||
/>
|
||
<span class="text-4.5 font-medium">{{ category.name }}</span>
|
||
</div>
|
||
|
||
<!-- 字体列表 -->
|
||
<div v-if="isExpanded" class="ml-4 space-y-2">
|
||
<div
|
||
v-for="font in category.fonts"
|
||
:key="font.id"
|
||
class="flex items-center gap-2 pb-2 border-b border-gray-200"
|
||
>
|
||
<!-- 展开图标 -->
|
||
<img src="@/assets/icons/file.svg" class="w-4 h-4" />
|
||
|
||
<!-- 字体名称 -->
|
||
<span class="flex-1 text-gray-400 text-3">{{ font.name }}</span>
|
||
|
||
<!-- 选择框 -->
|
||
<label class="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
:checked="isSelected(font.id)"
|
||
@change="$emit('toggle-selection', font.id)"
|
||
class="form-checkbox rounded text-purple-600"
|
||
/>
|
||
</label>
|
||
|
||
<!-- 收藏按钮 -->
|
||
<button @click="$emit('toggle-favorite', font.id)">
|
||
<img
|
||
:src="isFavorite(font.id) ? iconHeartFilled : iconHeart"
|
||
class="w-4 h-4"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed } from 'vue';
|
||
import { useFontStore } from '@/stores/fontStore';
|
||
import iconExpanded from '@/assets/icons/expanded.svg';
|
||
import iconCollapsed from '@/assets/icons/collapsed.svg';
|
||
import iconHeart from '@/assets/icons/heart.svg';
|
||
import iconHeartFilled from '@/assets/icons/heart-filled.svg';
|
||
|
||
interface Props {
|
||
category: { name: string; fonts: any[] };
|
||
favorites: string[];
|
||
}
|
||
|
||
const props = defineProps<Props>();
|
||
const emit = defineEmits(['toggle-favorite', 'toggle-selection']);
|
||
|
||
const fontStore = useFontStore();
|
||
const isExpanded = ref(true);
|
||
|
||
function toggleExpand() {
|
||
isExpanded.value = !isExpanded.value;
|
||
}
|
||
|
||
function isFavorite(fontId: string) {
|
||
return props.favorites.includes(fontId);
|
||
}
|
||
|
||
function isSelected(fontId: string) {
|
||
return fontStore.selectedFonts.includes(fontId);
|
||
}
|
||
</script>
|
||
```
|
||
|
||
#### 3.5 SVG 预览组件
|
||
```vue
|
||
<!-- src/components/SvgPreview.vue -->
|
||
<template>
|
||
<div class="h-full flex flex-col gap-2">
|
||
<h3 class="text-6">效果预览</h3>
|
||
|
||
<div class="flex-1 overflow-auto space-y-2">
|
||
<PreviewItem
|
||
v-for="fontId in fontStore.selectedFonts"
|
||
:key="fontId"
|
||
:font-id="fontId"
|
||
:text="fontStore.text"
|
||
:letter-spacing="fontStore.letterSpacing"
|
||
:font-size="fontStore.fontSize"
|
||
/>
|
||
|
||
<div v-if="fontStore.selectedFonts.length === 0" class="text-center text-gray-400 py-10">
|
||
请从左侧选择字体进行预览
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { useFontStore } from '@/stores/fontStore';
|
||
import PreviewItem from '@/components/PreviewItem.vue';
|
||
|
||
const fontStore = useFontStore();
|
||
</script>
|
||
```
|
||
|
||
```vue
|
||
<!-- src/components/PreviewItem.vue -->
|
||
<template>
|
||
<div class="border-b border-gray-200 pb-2">
|
||
<!-- 字体名称和选择框 -->
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<img src="@/assets/icons/file.svg" class="w-3 h-3" />
|
||
<span class="flex-1 text-gray-400 text-3">{{ fontInfo?.name }}</span>
|
||
<label>
|
||
<input
|
||
v-model="isSelectedForExport"
|
||
type="checkbox"
|
||
class="form-checkbox rounded text-purple-600"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- SVG 预览 -->
|
||
<div class="bg-white p-2 min-h-40 flex items-center justify-center">
|
||
<div v-if="isLoading" class="text-gray-400">加载中...</div>
|
||
<div v-else-if="error" class="text-red-500">{{ error }}</div>
|
||
<div v-else-if="svgContent" v-html="svgContent" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from 'vue';
|
||
import { useFontStore } from '@/stores/fontStore';
|
||
import { useSvgGenerate } from '@/composables/useSvgGenerate';
|
||
import { debounce } from 'lodash-es';
|
||
|
||
interface Props {
|
||
fontId: string;
|
||
text: string;
|
||
letterSpacing: number;
|
||
fontSize: number;
|
||
}
|
||
|
||
const props = defineProps<Props>();
|
||
const fontStore = useFontStore();
|
||
const { svgContent, isGenerating: isLoading, error, generate } = useSvgGenerate();
|
||
const isSelectedForExport = ref(true);
|
||
|
||
const fontInfo = computed(() => {
|
||
return fontStore.fontList.find(f => f.id === props.fontId);
|
||
});
|
||
|
||
// 防抖生成 SVG
|
||
const debouncedGenerate = debounce(async () => {
|
||
if (!fontInfo.value) return;
|
||
|
||
// 加载字体
|
||
await fontStore.loadFont(props.fontId);
|
||
|
||
if (fontStore.currentFont) {
|
||
await generate(
|
||
fontStore.currentFont,
|
||
props.text,
|
||
props.letterSpacing,
|
||
props.fontSize
|
||
);
|
||
}
|
||
}, 300);
|
||
|
||
watch(
|
||
() => [props.text, props.letterSpacing, props.fontSize],
|
||
() => debouncedGenerate(),
|
||
{ immediate: true }
|
||
);
|
||
</script>
|
||
```
|
||
|
||
**验收标准**:
|
||
- ✅ 界面布局与 Figma 设计一致
|
||
- ✅ 字体树支持展开/收拢
|
||
- ✅ 收藏功能正常(LocalStorage 持久化)
|
||
- ✅ 多字体预览正常显示
|
||
- ✅ 输入防抖生效(300ms)
|
||
- ✅ 响应式布局适配不同屏幕
|
||
|
||
---
|
||
|
||
### 阶段 4: 字体资源管理 (2-3 天)
|
||
|
||
**目标**: 配置字体文件的加载和优化
|
||
|
||
#### 4.1 字体元数据生成
|
||
```python
|
||
# scripts/scan-fonts.py
|
||
import os
|
||
import json
|
||
from pathlib import Path
|
||
|
||
def scan_fonts(font_dir: str):
|
||
fonts = []
|
||
|
||
for root, dirs, files in os.walk(font_dir):
|
||
category = Path(root).name
|
||
if category == Path(font_dir).name:
|
||
category = "其他字体"
|
||
|
||
for file in files:
|
||
if file.endswith(('.ttf', '.otf')):
|
||
font_path = os.path.join(root, file)
|
||
rel_path = os.path.relpath(font_path, start=os.path.dirname(font_dir))
|
||
|
||
fonts.append({
|
||
'id': file.replace('.', '_').replace(' ', '_'),
|
||
'name': Path(file).stem,
|
||
'category': category,
|
||
'path': f'/fonts/{rel_path}'
|
||
})
|
||
|
||
return fonts
|
||
|
||
if __name__ == '__main__':
|
||
fonts = scan_fonts('./font')
|
||
|
||
with open('public/fonts.json', 'w', encoding='utf-8') as f:
|
||
json.dump(fonts, f, ensure_ascii=False, indent=2)
|
||
|
||
print(f'扫描完成,共找到 {len(fonts)} 个字体')
|
||
```
|
||
|
||
#### 4.2 字体子集化脚本
|
||
```python
|
||
# scripts/prepare-fonts.py
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
from fontTools.subset import Subsetter, Options
|
||
from fontTools.ttLib import TTFont
|
||
|
||
# 常用汉字(3500 字)
|
||
COMMON_CHARS = """
|
||
的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严
|
||
"""
|
||
|
||
def subset_font(input_path: str, output_path: str, text: str):
|
||
"""对字体进行子集化"""
|
||
try:
|
||
font = TTFont(input_path)
|
||
|
||
# 配置选项
|
||
options = Options()
|
||
options.drop_tables.add('GSUB') # 移除连字表
|
||
options.drop_tables.add('GPOS') # 移除位置表
|
||
|
||
# 创建子集器
|
||
subsetter = Subsetter(options=options)
|
||
subsetter.populate(text=text)
|
||
subsetter.subset(font)
|
||
|
||
# 保存
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
font.save(output_path)
|
||
|
||
# 计算压缩率
|
||
original_size = os.path.getsize(input_path)
|
||
subset_size = os.path.getsize(output_path)
|
||
ratio = (1 - subset_size / original_size) * 100
|
||
|
||
print(f'✓ {Path(input_path).name}')
|
||
print(f' 原始: {original_size / 1024 / 1024:.2f} MB')
|
||
print(f' 子集: {subset_size / 1024 / 1024:.2f} MB')
|
||
print(f' 压缩: {ratio:.1f}%')
|
||
|
||
return True
|
||
except Exception as e:
|
||
print(f'✗ {Path(input_path).name}: {e}')
|
||
return False
|
||
|
||
def main():
|
||
font_dir = Path('./font')
|
||
output_dir = Path('./public/fonts')
|
||
|
||
if not font_dir.exists():
|
||
print('错误: font/ 目录不存在')
|
||
sys.exit(1)
|
||
|
||
# 清空输出目录
|
||
if output_dir.exists():
|
||
import shutil
|
||
shutil.rmtree(output_dir)
|
||
|
||
# 扫描并处理所有字体
|
||
font_files = list(font_dir.rglob('*.ttf')) + list(font_dir.rglob('*.otf'))
|
||
|
||
print(f'找到 {len(font_files)} 个字体文件')
|
||
print(f'开始子集化(保留 {len(COMMON_CHARS)} 个常用汉字)...\n')
|
||
|
||
success = 0
|
||
for font_path in font_files:
|
||
rel_path = font_path.relative_to(font_dir)
|
||
output_path = output_dir / rel_path
|
||
|
||
if subset_font(str(font_path), str(output_path), COMMON_CHARS):
|
||
success += 1
|
||
print()
|
||
|
||
print(f'完成!成功处理 {success}/{len(font_files)} 个字体')
|
||
|
||
if __name__ == '__main__':
|
||
main()
|
||
```
|
||
|
||
#### 4.3 package.json 脚本
|
||
```json
|
||
{
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vite build",
|
||
"preview": "vite preview",
|
||
"prepare-fonts": "python scripts/prepare-fonts.py && python scripts/scan-fonts.py"
|
||
}
|
||
}
|
||
```
|
||
|
||
**验收标准**:
|
||
- ✅ 字体元数据 JSON 正确生成
|
||
- ✅ 字体子集化成功,文件大小减少 60-80%
|
||
- ✅ 前端可正常加载优化后的字体
|
||
- ✅ 子集字体包含所有测试文本字符
|
||
|
||
---
|
||
|
||
### 阶段 5: 导出功能与优化 (2-3 天)
|
||
|
||
**目标**: 完成 SVG 导出和性能优化
|
||
|
||
#### 5.1 导出功能
|
||
```typescript
|
||
// src/composables/useExport.ts
|
||
import { downloadSvg, downloadMultipleSvgs } from '@/utils/download';
|
||
import { useFontStore } from '@/stores/fontStore';
|
||
import { useSvgGenerate } from './useSvgGenerate';
|
||
|
||
export function useExport() {
|
||
const fontStore = useFontStore();
|
||
const { generate } = useSvgGenerate();
|
||
|
||
async function exportSelected() {
|
||
const selectedFonts = fontStore.fontList.filter(f =>
|
||
fontStore.selectedFonts.includes(f.id)
|
||
);
|
||
|
||
if (selectedFonts.length === 0) {
|
||
alert('请先选择要导出的字体');
|
||
return;
|
||
}
|
||
|
||
if (selectedFonts.length === 1) {
|
||
// 单个导出
|
||
const font = selectedFonts[0];
|
||
await fontStore.loadFont(font.id);
|
||
|
||
if (fontStore.currentFont) {
|
||
const { svgContent } = await generate(
|
||
fontStore.currentFont,
|
||
fontStore.text,
|
||
fontStore.letterSpacing,
|
||
fontStore.fontSize
|
||
);
|
||
|
||
const filename = `${font.name}_${fontStore.text}.svg`;
|
||
downloadSvg(svgContent, filename);
|
||
}
|
||
} else {
|
||
// 批量导出为 ZIP
|
||
const svgList = [];
|
||
|
||
for (const font of selectedFonts) {
|
||
await fontStore.loadFont(font.id);
|
||
|
||
if (fontStore.currentFont) {
|
||
const { svgContent } = await generate(
|
||
fontStore.currentFont,
|
||
fontStore.text,
|
||
fontStore.letterSpacing,
|
||
fontStore.fontSize
|
||
);
|
||
|
||
svgList.push({
|
||
content: svgContent,
|
||
filename: `${font.name}_${fontStore.text}.svg`
|
||
});
|
||
}
|
||
}
|
||
|
||
await downloadMultipleSvgs(svgList);
|
||
}
|
||
}
|
||
|
||
return { exportSelected };
|
||
}
|
||
```
|
||
|
||
#### 5.2 Web Worker 优化
|
||
```typescript
|
||
// src/workers/font-processor.worker.ts
|
||
import opentype from 'opentype.js';
|
||
import { shapeText } from '@/utils/harfbuzz';
|
||
|
||
self.onmessage = async (e) => {
|
||
const { type, data } = e.data;
|
||
|
||
try {
|
||
switch (type) {
|
||
case 'load-font': {
|
||
const font = opentype.parse(data.buffer);
|
||
self.postMessage({
|
||
type: 'font-loaded',
|
||
data: { fontName: font.names.fullName.en }
|
||
});
|
||
break;
|
||
}
|
||
|
||
case 'generate-svg': {
|
||
const { fontBuffer, text, letterSpacing, fontSize } = data;
|
||
const font = opentype.parse(fontBuffer);
|
||
|
||
// ... SVG 生成逻辑 ...
|
||
|
||
self.postMessage({
|
||
type: 'svg-generated',
|
||
data: { svgContent }
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
self.postMessage({
|
||
type: 'error',
|
||
error: error.message
|
||
});
|
||
}
|
||
};
|
||
```
|
||
|
||
#### 5.3 性能优化
|
||
- 添加 Service Worker 缓存 harfbuzz.js WASM 文件
|
||
- 实现虚拟滚动优化字体列表渲染
|
||
- 添加图片懒加载
|
||
- 优化防抖时间和逻辑
|
||
|
||
**验收标准**:
|
||
- ✅ 单个 SVG 导出正常
|
||
- ✅ 批量导出 ZIP 正常
|
||
- ✅ 文件命名格式正确
|
||
- ✅ 大字体文件加载不卡顿
|
||
- ✅ 输入防抖生效,性能流畅
|
||
|
||
---
|
||
|
||
### 阶段 6: 测试与部署 (1-2 天)
|
||
|
||
**目标**: 全面测试和准备部署
|
||
|
||
#### 6.1 功能测试清单
|
||
- [ ] 字体上传功能
|
||
- [ ] 预置字体加载
|
||
- [ ] 字体树展开/收拢
|
||
- [ ] 收藏功能(刷新后保持)
|
||
- [ ] 文本输入和实时预览
|
||
- [ ] 字号调整滑块
|
||
- [ ] 字间距调整
|
||
- [ ] 多字体对比预览
|
||
- [ ] 单个 SVG 导出
|
||
- [ ] 批量 ZIP 导出
|
||
- [ ] 错误提示
|
||
- [ ] 响应式布局
|
||
|
||
#### 6.2 浏览器兼容性测试
|
||
- [ ] Chrome 最新版
|
||
- [ ] Firefox 最新版
|
||
- [ ] Safari 最新版
|
||
- [ ] Edge 最新版
|
||
|
||
#### 6.3 部署配置
|
||
```yaml
|
||
# vercel.json
|
||
{
|
||
"headers": [
|
||
{
|
||
"source": "/fonts/(.*)",
|
||
"headers": [
|
||
{
|
||
"key": "Cache-Control",
|
||
"value": "public, max-age=31536000, immutable"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**验收标准**:
|
||
- ✅ 所有功能测试通过
|
||
- ✅ 主流浏览器兼容
|
||
- ✅ 成功部署到 Vercel
|
||
- ✅ 文档齐全
|
||
|
||
---
|
||
|
||
## 技术难点与解决方案
|
||
|
||
### 1. HarfBuzz WASM 体积大(~1-2MB)
|
||
**解决方案**:
|
||
- 代码分割,动态 import
|
||
- CDN 加速
|
||
- Service Worker 缓存
|
||
|
||
### 2. 中文字体文件大(10-20MB)
|
||
**解决方案**:
|
||
- 构建时子集化(保留常用 3500 字)
|
||
- 流式加载 + 进度条
|
||
- Web Worker 处理避免 UI 阻塞
|
||
|
||
### 3. opentype.js + harfbuzz.js 集成复杂
|
||
**解决方案**:
|
||
- 先用 opentype.js 实现简单版本
|
||
- 逐步集成 harfbuzz.js
|
||
- 参考 font2svg.py 的算法
|
||
|
||
### 4. 性能优化
|
||
**解决方案**:
|
||
- 输入防抖(300ms)
|
||
- 虚拟滚动(字体列表)
|
||
- Canvas 快速预览 + SVG 精确输出
|
||
- 字形路径缓存
|
||
|
||
---
|
||
|
||
## 依赖包清单
|
||
|
||
```json
|
||
{
|
||
"dependencies": {
|
||
"vue": "^3.4.0",
|
||
"pinia": "^2.1.7",
|
||
"opentype.js": "^1.3.4",
|
||
"harfbuzzjs": "^0.3.3",
|
||
"lodash-es": "^4.17.21",
|
||
"jszip": "^3.10.1"
|
||
},
|
||
"devDependencies": {
|
||
"@vitejs/plugin-vue": "^5.0.0",
|
||
"typescript": "^5.3.0",
|
||
"vite": "^5.0.0",
|
||
"unocss": "^0.58.0",
|
||
"@unocss/preset-wind": "^0.58.0",
|
||
"vite-plugin-wasm": "^3.3.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 开发时间估算
|
||
|
||
| 阶段 | 预计时间 | 累计时间 |
|
||
|------|---------|---------|
|
||
| 阶段 1: 项目初始化 | 1-2 天 | 1-2 天 |
|
||
| 阶段 2: 核心转换模块 | 3-5 天 | 4-7 天 |
|
||
| 阶段 3: UI 组件开发 | 5-7 天 | 9-14 天 |
|
||
| 阶段 4: 字体资源管理 | 2-3 天 | 11-17 天 |
|
||
| 阶段 5: 导出与优化 | 2-3 天 | 13-20 天 |
|
||
| 阶段 6: 测试与部署 | 1-2 天 | 14-22 天 |
|
||
|
||
**总计: 14-22 天**
|
||
|
||
---
|
||
|
||
## 成功标准
|
||
|
||
1. ✅ 所有 Figma 设计功能完整实现
|
||
2. ✅ SVG 输出与 `font2svg.py` 一致
|
||
3. ✅ 支持中文字体和复杂排版
|
||
4. ✅ 性能流畅,无明显卡顿
|
||
5. ✅ 主流浏览器兼容
|
||
6. ✅ 代码规范,类型安全
|
||
7. ✅ 成功部署并可访问
|
||
|
||
---
|
||
|
||
## 备注
|
||
|
||
- 本计划基于纯前端实现,无需后端服务器
|
||
- `font2svg.py` 保持不变,仅作为参考
|
||
- 使用 fonttools.subset 在构建时优化字体
|
||
- 暂不实现运行时缓存策略
|
||
- 暂不需要许可证管理功能
|