Files
font2pic/PLAN.md
2026-02-07 13:32:31 +08:00

39 KiB
Raw Blame History

Font2SVG Web 应用开发计划

项目概述

基于 Figma 设计实现一个交互式字体转 SVG 工具的 Web 应用。采用纯前端架构,使用 TypeScript + Vue3 + Vite 技术栈,在浏览器中通过 opentype.js 和 harfbuzz.js (WASM) 实现字体到 SVG 的实时转换。

设计来源: Figma 设计稿

🔄 最新机制更新2026-02-07

  • 字体唯一来源目录调整为 frontend/public/fonts/
  • 删除独立 font/ 源目录流程
  • pnpm run prepare-fonts 仅执行字体扫描并重建 frontend/public/fonts.json
  • 如本文后续历史段落仍出现 font/ 或复制流程,请以上述机制为准

📋 Figma Annotations 功能需求清单

以下所有功能需求来自 Figma 设计中各组件的 annotation 标注:

1. 文本输入与预览控制 (节点 8:94, 18:146)

  • 输入框: "此处输入内容"
  • 触发方式: 输入内容后,回车或者点击**"预览"按钮**生效
  • 预览按钮 (节点 18:157): 点击预览

2. 字体大小调整 (节点 7:2720)

  • 滑块控件: 文字预览大小调整
  • 实时更新: 调整时,效果预览窗口文字大小动态变化

3. 导出功能 (节点 23:77)

  • 导出按钮: 导出选中的预览文字的 svg 图
  • 保存位置: 弹出保存框,默认保存在 ~/Download
  • 批量导出: 选中多个字体时批量导出

4. 字体选择区 (节点 21:185)

  • 数据源: 从 frontend/public/fonts/ 目录读取所有字体
  • 树状结构: 字体按目录树状分组
  • 展开/收拢: 支持展开和收拢
  • 单选: 字体支持单个选择
  • 批量框选: 支持鼠标批量框选Shift/Ctrl 多选)

5. 收藏功能

  • 未收藏图标 (节点 21:200): 点击收藏该字体。无填充表示当前未收藏
  • 已收藏图标 (节点 21:206, 21:241): 点击取消收藏该字体。红色填充表示当前已收藏
  • 预览勾选 (节点 21:205, I21:205): 勾选表示在效果预览窗口中展示

6. 已收藏字体区 (节点 8:75)

  • 展示方式: 已收藏字体展示,字体按目录树状分组,支持展开和收拢
  • 操作方式: 字体支持单个选择,或者鼠标批量框选
  • 取消收藏: 点击可取消收藏
  • 动态分类 (节点 21:258): 如该分类下没有选中字体,该分类删除

7. 效果预览区 (节点 8:95)

  • 预览内容: 预览窗口,内容、大小依据设置
  • 字体依据: 字体依据字体选择勾选
  • 多字体对比: 如勾选 2 个字体,显示内容的 2 个字体预览

8. 预览项细节 (节点 8:130-8:135)

  • 字体名称 (节点 8:134): 预览字体名,取文件名
  • 预览窗口 (节点 8:135): 某一字体预览窗口
  • 选中交互: 点击任意区域选中或取消选中
  • 导出标记: 选中的可以导出
  • 选择框 (节点 I23:55): 选中/未选中

9. 版权说明 (节点 5:15)

  • 底部显示:@版权说明:所有字体来源互联网分享,仅供效果预览,不做下载传播,如有侵权,请告知douboer@gmail.com

⚠️ 核心技术决策

字体到 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 的功能

技术对比

// 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 不变,仅作为参考
  • 纯前端实现,无需后端服务器
  • 字体文件放在 frontend/public/fonts/ 目录
  • SVG 图标放在 src/assets/icons/
  • 应用图标为 src/assets/webicon.png
  • 使用 fonttools.subset 预处理字体(构建时)
  • 暂不实现运行时缓存策略
  • 暂不需要许可证管理

项目结构

font2svg/
├── frontend/
│   ├── public/
│   │   ├── fonts/                 # 字体唯一来源目录(支持分类子目录)
│   │   └── fonts.json             # 由脚本重建的字体清单
│   └── src/
│       ├── assets/
│       │   ├── icons/             # SVG UI 图标
│       │   └── webicon.png        # 应用图标
│       ├── components/
│       ├── composables/
│       ├── stores/
│       ├── utils/
│       ├── types/
│       ├── App.vue
│       └── main.ts
├── scripts/
│   └── generate-font-list.py      # 扫描 frontend/public/fonts 并重建 fonts.json
├── frontend/vite.config.ts
├── frontend/tsconfig.json
├── frontend/uno.config.ts
└── package.json

开发阶段

阶段 1: 项目初始化与环境配置 (1-2 天)

目标: 搭建开发环境和基础架构

任务:

  1. 初始化 Vite + Vue3 + TypeScript 项目

    pnpm create vite font2svg-web --template vue-ts
    
  2. 安装核心依赖

    pnpm add opentype.js harfbuzzjs pinia
    pnpm add -D unocss @unocss/preset-wind vite-plugin-wasm
    
  3. 配置 Vite 支持 WASM

    // vite.config.ts
    import wasm from 'vite-plugin-wasm';
    export default defineConfig({
      plugins: [vue(), UnoCSS(), wasm()],
      optimizeDeps: { exclude: ['harfbuzzjs'] }
    });
    
  4. 配置 UnoCSS

    // 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 封装

// 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 字体加载模块

// 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 模板拼接 字符串拼接
// 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 下载工具

// 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 状态管理

⚠️ 重要性能策略:按需加载字体

如果 font/ 目录有 100+ 个字体文件(总计 1-2GB绝对不能在初始化时全部加载到内存。

正确的加载策略

  1. 初始化时只加载字体元数据JSON~10KB
    • 字体名称、路径、分类
    • 不加载字体文件本身
  2. 按需加载:用户选择字体时才加载该字体文件
    • 点击预览 → 加载字体 → 生成 SVG
  3. 内存缓存:已加载的字体保存在内存中
    • 避免重复加载同一字体
  4. 可选优化LRU 策略限制缓存数量
    • 最多缓存 20 个字体(~200-400MB
    • 超出后清除最久未使用的字体
// 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;  // ⚠️ 注意:初始时为 undefined按需加载
}

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() {
      // ⚠️ 只加载元数据 JSON不加载字体文件
      // 这个 JSON 只有 ~10KB包含 100+ 字体的基本信息
      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');
        // 这里才真正加载 10-20MB 的字体文件
        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);
      }
    }
  }
});
// 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 主布局组件

参考: Figma 节点 3:5 主界面布局 + annotations

<!-- 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>
      
      <!-- 字号调整滑块 -->
      <!-- Figma 节点 7:2720: 调整时效果预览窗口文字大小动态变化 -->
      <LetterSpacingSlider v-model="fontSize" />
      
      <!-- 文本输入 -->
      <!-- Figma 节点 18:146: 输入内容后回车或点击"预览"按钮生效 -->
      <TextInput v-model="text" @preview="handlePreview" @keyup.enter="handlePreview" />
      
      <!-- 预览按钮 -->
      <!-- Figma 节点 18:157: 点击预览 -->
      <button 
        @click="handlePreview"
        class="bg-purple-600 text-white px-4 py-2 rounded-lg h-22"
      >
        <br>
      </button>
      
      <!-- 导出按钮 -->
      <!-- Figma 节点 23:77: 导出选中的预览文字的svg图弹出保存框默认保存在~/Download -->
      <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">
        <!-- Figma 节点 21:185: 从font目录读取所有字体字体按目录树状分组支持展开和收拢字体支持单个选择或者鼠标批量框选 -->
        <FontSelector />
        
        <!-- Figma 节点 8:75: 已收藏字体展示字体按目录树状分组支持展开和收拢字体支持单个选择或者鼠标批量框选点击可取消收藏 -->
        <FavoritesList />
      </aside>
      
      <!-- 右侧预览区 -->
      <!-- Figma 节点 8:95: 预览窗口内容大小依据设置字体依据字体选择勾选如勾选2个字体显示内容的2个字体预览 -->
      <section class="flex-1 border border-red-100 rounded-4 p-2">
        <SvgPreview />
      </section>
    </main>
    
    <!-- 底部版权 -->
    <!-- Figma 节点 5:15 -->
    <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 字体选择器组件

<!-- 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 字体树组件(支持展开/收拢)

参考: Figma 节点 21:188-21:226支持批量框选Shift/Ctrl

<!-- src/components/FontTree.vue -->
<template>
  <div class="mb-4">
    <!-- 分类标题 -->
    <!-- Figma: 支持展开和收拢 -->
    <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>
        
        <!-- 选择框 -->
        <!-- Figma 节点 21:205: 勾选表示在效果预览窗口中展示 -->
        <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>
        
        <!-- 收藏按钮 -->
        <!-- Figma 节点 21:200/21:206: 无填充=未收藏红色填充=已收藏 -->
        <button @click="$emit('toggle-favorite', font.id)">
          <img
            :src="isFavorite(font.id) ? iconHeartFilled : iconHeart"
            class="w-4 h-4"
            :title="isFavorite(font.id) ? '取消收藏' : '收藏字体'"
          />
        </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 预览组件

<!-- 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>
<!-- src/components/PreviewItem.vue -->
<template>
  <div class="border-b border-gray-200 pb-2">
    <!-- 字体名称和选择框 -->
    <!-- Figma 节点 8:134: 预览字体名取文件名 -->
    <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>
      <!-- Figma 节点 I23:55: 选中/未选中选中的可以导出 -->
      <label>
        <input
          v-model="isSelectedForExport"
          type="checkbox"
          class="form-checkbox rounded text-purple-600"
        />
      </label>
    </div>
    
    <!-- SVG 预览 -->
    <!-- Figma 节点 8:135: 某一字体预览窗口点击任意区域选中或取消选中 -->
    <div 
      class="bg-white p-2 min-h-40 flex items-center justify-center cursor-pointer"
      @click="isSelectedForExport = !isSelectedForExport"
    >
      <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;
  
  // ⚠️ 按需加载:用户点击预览时才加载字体文件
  // 如果字体已加载fontInfo.font 存在),直接使用缓存
  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
  • 响应式布局适配不同屏幕
  • 关键100+ 字体初始化时不卡顿(只加载元数据)
  • 关键:字体按需加载,已加载字体缓存生效

阶段 4: 字体资源管理 (2-3 天)

目标: 配置字体文件的加载和优化

4.1 字体元数据生成

⚠️ 重要:这个脚本生成的 JSON 文件是唯一在初始化时加载的数据,必须保持轻量(~10KB

# 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 字体子集化脚本

# 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 脚本

{
  "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 导出功能

// 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 优化

// 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 部署配置

# 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 精确输出
  • 字形路径缓存

依赖包清单

{
  "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 在构建时优化字体
  • 暂不实现运行时缓存策略
  • 暂不需要许可证管理功能