Files
font2pic/PLAN.md
2026-02-06 16:18:57 +08:00

1264 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 生成核心逻辑
**⚠️ 关键实现:参考 `font2svg.py` 用 TypeScript 重写**
这个模块是核心转换逻辑,完全对应 `font2svg.py` 的算法流程:
| Python 实现 | TypeScript 等价实现 |
|------------|-------------------|
| `TTFont(font_path)` | `opentype.parse(buffer)` |
| `hb.shape()` | `harfbuzz.js WASM shapeText()` |
| `font.getGlyphSet()[gid]` | `font.glyphs.get(glyphIndex)` |
| `glyph.draw(pen)` | `glyph.getPath().toPathData()` |
| 坐标变换26.6 定点数) | `item.xAdvance / 64` |
| 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 在构建时优化字体
- 暂不实现运行时缓存策略
- 暂不需要许可证管理功能