docs: add TTF font implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
421
docs/superpowers/plans/2026-03-26-ttf-font.md
Normal file
421
docs/superpowers/plans/2026-03-26-ttf-font.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# TTF Font Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Self-implemented TTF parser + rasterizer with on-demand glyph cache atlas for variable-width Unicode text rendering in the editor.
|
||||
|
||||
**Architecture:** 4 modules: TtfParser (binary parsing) → Rasterizer (outline→bitmap) → GlyphCache (atlas management) → TtfFont (unified interface). All pure CPU logic, no GPU dependency for core. GPU integration only in editor_demo.
|
||||
|
||||
**Tech Stack:** Rust, std only (no external crates). wgpu for atlas texture upload in editor integration.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-26-ttf-font-design.md`
|
||||
|
||||
**Test font:** Tests need a real TTF file. Use a system font or download a small open-source font. For tests, embed a minimal subset or use `C:/Windows/Fonts/arial.ttf` which is available on Windows.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: TtfParser — table directory + head/hhea/maxp
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/voltex_editor/src/ttf_parser.rs`
|
||||
- Modify: `crates/voltex_editor/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Write tests and implement table directory parsing**
|
||||
|
||||
Create `ttf_parser.rs` with byte-reading helpers and table directory parsing:
|
||||
|
||||
```rust
|
||||
// Byte reading helpers (big-endian, TTF standard)
|
||||
fn read_u16(data: &[u8], offset: usize) -> u16
|
||||
fn read_i16(data: &[u8], offset: usize) -> i16
|
||||
fn read_u32(data: &[u8], offset: usize) -> u32
|
||||
|
||||
// Table directory entry
|
||||
struct TableRecord {
|
||||
tag: [u8; 4],
|
||||
offset: u32,
|
||||
length: u32,
|
||||
}
|
||||
|
||||
pub struct TtfParser {
|
||||
data: Vec<u8>,
|
||||
tables: HashMap<[u8; 4], TableRecord>,
|
||||
pub units_per_em: u16,
|
||||
pub num_glyphs: u16,
|
||||
pub ascender: i16,
|
||||
pub descender: i16,
|
||||
pub line_gap: i16,
|
||||
pub num_h_metrics: u16,
|
||||
pub loca_format: i16,
|
||||
}
|
||||
```
|
||||
|
||||
Parse: offset table → table directory → head (units_per_em, indexToLocFormat) → hhea (ascender, descender, lineGap, numberOfHMetrics) → maxp (numGlyphs).
|
||||
|
||||
Tests:
|
||||
- `test_parse_loads_tables`: Load a TTF file, verify tables exist (head, cmap, glyf, etc.)
|
||||
- `test_head_values`: units_per_em > 0, loca_format is 0 or 1
|
||||
- `test_hhea_values`: ascender > 0, descender < 0 (typically)
|
||||
- `test_maxp_values`: num_glyphs > 0
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
Run: `cargo test -p voltex_editor --lib ttf_parser -- --nocapture`
|
||||
|
||||
- [ ] **Step 3: Add module to lib.rs**
|
||||
|
||||
```rust
|
||||
pub mod ttf_parser;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(editor): add TTF parser with table directory and header parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: TtfParser — cmap (Unicode → glyph index)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/voltex_editor/src/ttf_parser.rs`
|
||||
|
||||
- [ ] **Step 1: Implement cmap Format 4 parsing**
|
||||
|
||||
Add `glyph_index(codepoint: u32) -> u16` method:
|
||||
- Find cmap table → find Format 4 subtable (platformID=3, encodingID=1 or platformID=0)
|
||||
- Parse segCount, endCode[], startCode[], idDelta[], idRangeOffset[]
|
||||
- Binary search for the segment containing the codepoint
|
||||
- Calculate glyph index
|
||||
|
||||
Tests:
|
||||
- `test_cmap_ascii`: 'A' (0x41) → non-zero glyph index
|
||||
- `test_cmap_space`: ' ' (0x20) → valid glyph index
|
||||
- `test_cmap_unmapped`: very high codepoint → 0 (missing glyph)
|
||||
- `test_cmap_multiple_chars`: several chars return distinct indices
|
||||
|
||||
- [ ] **Step 2: Run tests, commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(editor): add cmap Format 4 parsing for Unicode glyph mapping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: TtfParser — glyf (glyph outlines) + hmtx (metrics)
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/voltex_editor/src/ttf_parser.rs`
|
||||
|
||||
- [ ] **Step 1: Implement loca + glyf parsing**
|
||||
|
||||
Add:
|
||||
```rust
|
||||
pub struct OutlinePoint {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub on_curve: bool,
|
||||
}
|
||||
|
||||
pub struct GlyphOutline {
|
||||
pub contours: Vec<Vec<OutlinePoint>>,
|
||||
pub x_min: i16,
|
||||
pub y_min: i16,
|
||||
pub x_max: i16,
|
||||
pub y_max: i16,
|
||||
}
|
||||
|
||||
pub struct GlyphMetrics {
|
||||
pub advance_width: u16,
|
||||
pub left_side_bearing: i16,
|
||||
}
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `glyph_offset(glyph_id: u16) -> usize` — loca table lookup
|
||||
- `glyph_outline(glyph_id: u16) -> Option<GlyphOutline>` — glyf table parse
|
||||
- Simple glyph: endPtsOfContours, flags, x/y deltas
|
||||
- Handle on_curve/off_curve: insert implicit on-curve points between consecutive off-curve
|
||||
- Compound glyph: skip for now (return None)
|
||||
- `glyph_metrics(glyph_id: u16) -> GlyphMetrics` — hmtx table
|
||||
|
||||
Tests:
|
||||
- `test_glyph_outline_has_contours`: 'A' outline has >= 1 contour
|
||||
- `test_glyph_outline_points`: contour points count > 0
|
||||
- `test_glyph_metrics_advance`: 'A' advance > 0
|
||||
- `test_space_glyph_no_contours`: ' ' has 0 contours (or None)
|
||||
|
||||
- [ ] **Step 2: Run tests, commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(editor): add glyf outline and hmtx metrics parsing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Rasterizer
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/voltex_editor/src/rasterizer.rs`
|
||||
- Modify: `crates/voltex_editor/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Implement bezier flattening + scanline rasterizer**
|
||||
|
||||
```rust
|
||||
pub struct RasterResult {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub bitmap: Vec<u8>, // R8 alpha
|
||||
pub offset_x: f32,
|
||||
pub offset_y: f32,
|
||||
}
|
||||
|
||||
/// Flatten quadratic bezier to line segments.
|
||||
fn flatten_quadratic(p0: (f32,f32), p1: (f32,f32), p2: (f32,f32), segments: &mut Vec<(f32,f32)>);
|
||||
|
||||
/// Rasterize outline at given scale.
|
||||
pub fn rasterize(outline: &GlyphOutline, scale: f32, units_per_em: u16) -> RasterResult;
|
||||
```
|
||||
|
||||
Rasterize steps:
|
||||
1. Scale outline points: `px = (x - x_min) * scale`, `py = (y_max - y) * scale` (Y flip)
|
||||
2. Walk contours: on_curve→on_curve = line, on_curve→off_curve→on_curve = quadratic bezier
|
||||
3. Flatten all curves into edges (line segments)
|
||||
4. For each scanline y: find all edge intersections, sort by x
|
||||
5. Fill using non-zero winding rule
|
||||
6. Output alpha bitmap
|
||||
|
||||
Tests:
|
||||
- `test_rasterize_produces_bitmap`: rasterize 'A' at 32px → width>0, height>0, bitmap non-empty
|
||||
- `test_rasterize_has_filled_pixels`: bitmap has at least some non-zero pixels
|
||||
- `test_rasterize_space`: space glyph → 0-width or empty bitmap
|
||||
- `test_flatten_quadratic`: known bezier → correct number of segments
|
||||
|
||||
- [ ] **Step 2: Add module to lib.rs, run tests, commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(editor): add glyph rasterizer with bezier flattening and scanline fill"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: GlyphCache (on-demand atlas)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/voltex_editor/src/glyph_cache.rs`
|
||||
- Modify: `crates/voltex_editor/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Implement GlyphCache**
|
||||
|
||||
```rust
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub const ATLAS_SIZE: u32 = 1024;
|
||||
|
||||
pub struct GlyphInfo {
|
||||
pub uv: [f32; 4], // u0, v0, u1, v1
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub advance: f32,
|
||||
pub bearing_x: f32,
|
||||
pub bearing_y: f32,
|
||||
}
|
||||
|
||||
pub struct GlyphCache {
|
||||
pub atlas_data: Vec<u8>,
|
||||
pub atlas_width: u32,
|
||||
pub atlas_height: u32,
|
||||
glyphs: HashMap<char, GlyphInfo>,
|
||||
cursor_x: u32,
|
||||
cursor_y: u32,
|
||||
row_height: u32,
|
||||
pub dirty: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `new(width, height)` → zeroed atlas_data
|
||||
- `get(ch) -> Option<&GlyphInfo>`
|
||||
- `insert(ch, bitmap, width, height, advance, bearing_x, bearing_y) -> &GlyphInfo`
|
||||
- Copy bitmap into atlas at (cursor_x, cursor_y)
|
||||
- Advance cursor, handle row overflow
|
||||
- Set dirty = true
|
||||
- `clear_dirty()`
|
||||
|
||||
Tests:
|
||||
- `test_insert_and_get`: insert a 10x10 glyph → get returns correct UV
|
||||
- `test_cache_hit`: insert then get same char → same UV
|
||||
- `test_row_wrap`: insert many wide glyphs → cursor_y advances to next row
|
||||
- `test_uv_coordinates`: UV values are in 0.0-1.0 range and match position
|
||||
|
||||
- [ ] **Step 2: Add module, run tests, commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(editor): add GlyphCache on-demand atlas manager"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: TtfFont (unified interface)
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/voltex_editor/src/ttf_font.rs`
|
||||
- Modify: `crates/voltex_editor/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: Implement TtfFont**
|
||||
|
||||
```rust
|
||||
pub struct TtfFont {
|
||||
parser: TtfParser,
|
||||
cache: GlyphCache,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub ascender: f32,
|
||||
pub descender: f32,
|
||||
scale: f32,
|
||||
}
|
||||
|
||||
impl TtfFont {
|
||||
pub fn new(data: &[u8], font_size: f32) -> Result<Self, String>;
|
||||
pub fn glyph(&mut self, ch: char) -> &GlyphInfo; // cache lookup or rasterize+insert
|
||||
pub fn text_width(&mut self, text: &str) -> f32;
|
||||
pub fn atlas_data(&self) -> &[u8];
|
||||
pub fn atlas_size(&self) -> (u32, u32);
|
||||
pub fn is_dirty(&self) -> bool;
|
||||
pub fn clear_dirty(&mut self);
|
||||
}
|
||||
```
|
||||
|
||||
The `glyph` method:
|
||||
1. Check cache.get(ch)
|
||||
2. If miss: parser.glyph_index → parser.glyph_outline → rasterize → cache.insert
|
||||
3. Return &GlyphInfo
|
||||
|
||||
Tests:
|
||||
- `test_ttf_font_new`: loads TTF file, font_size = 24
|
||||
- `test_glyph_caches`: call glyph('A') twice → second is cache hit (dirty resets)
|
||||
- `test_text_width`: "Hello" width > 0, "Hello World" width > "Hello" width
|
||||
- `test_korean_glyph`: '가' glyph loads without panic (if font supports it)
|
||||
|
||||
- [ ] **Step 2: Add module, run tests, commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(editor): add TtfFont unified interface with glyph caching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: UI integration + editor_demo
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/voltex_editor/src/ui_context.rs`
|
||||
- Modify: `crates/voltex_editor/src/renderer.rs`
|
||||
- Modify: `examples/editor_demo/src/main.rs`
|
||||
|
||||
- [ ] **Step 1: Add ttf_font to UiContext + draw_text helper**
|
||||
|
||||
In `ui_context.rs`, add:
|
||||
```rust
|
||||
pub ttf_font: Option<crate::ttf_font::TtfFont>,
|
||||
```
|
||||
|
||||
Add helper method:
|
||||
```rust
|
||||
pub fn draw_text(&mut self, text: &str, x: f32, y: f32, color: [u8; 4]) {
|
||||
if let Some(ref mut ttf) = self.ttf_font {
|
||||
let mut cx = x;
|
||||
for ch in text.chars() {
|
||||
let glyph = ttf.glyph(ch);
|
||||
let gx = cx + glyph.bearing_x;
|
||||
let gy = y + self.font.glyph_height as f32 - glyph.bearing_y; // baseline align
|
||||
let (u0, v0, u1, v1) = (glyph.uv[0], glyph.uv[1], glyph.uv[2], glyph.uv[3]);
|
||||
if glyph.width > 0.0 && glyph.height > 0.0 {
|
||||
self.draw_list.add_rect_uv(gx, gy, glyph.width, glyph.height, u0, v0, u1, v1, color);
|
||||
}
|
||||
cx += glyph.advance;
|
||||
}
|
||||
} else {
|
||||
// Fallback to bitmap font
|
||||
let gw = self.font.glyph_width as f32;
|
||||
let gh = self.font.glyph_height as f32;
|
||||
let mut cx = x;
|
||||
for ch in text.chars() {
|
||||
let (u0, v0, u1, v1) = self.font.glyph_uv(ch);
|
||||
self.draw_list.add_rect_uv(cx, y, gw, gh, u0, v0, u1, v1, color);
|
||||
cx += gw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ttf_text_width(&mut self, text: &str) -> f32 {
|
||||
if let Some(ref mut ttf) = self.ttf_font {
|
||||
ttf.text_width(text)
|
||||
} else {
|
||||
text.len() as f32 * self.font.glyph_width as f32
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update UiRenderer for TTF atlas**
|
||||
|
||||
In `renderer.rs`, add support for a second texture (TTF atlas):
|
||||
- When TtfFont is active, create a TTF atlas texture (R8Unorm, 1024x1024)
|
||||
- Each frame: check dirty → upload atlas data
|
||||
- Create a second bind group for TTF texture
|
||||
- DrawCommands need to know which texture to use
|
||||
|
||||
Simplest approach: add a `ttf_atlas_texture` and `ttf_bind_group` to UiRenderer. When rendering, if TTF is active, use the TTF bind group for all draws (since TTF atlas includes all rendered glyphs).
|
||||
|
||||
Actually, simpler: just replace the font atlas texture with the TTF atlas when TTF is active. The shader is the same (R8 alpha sampling).
|
||||
|
||||
- [ ] **Step 3: Update editor_demo**
|
||||
|
||||
In `resumed`:
|
||||
```rust
|
||||
// Load TTF font
|
||||
let ttf_data = std::fs::read("C:/Windows/Fonts/arial.ttf")
|
||||
.or_else(|_| std::fs::read("C:/Windows/Fonts/malgun.ttf"))
|
||||
.ok();
|
||||
if let Some(data) = ttf_data {
|
||||
if let Ok(font) = TtfFont::new(&data, 16.0) {
|
||||
state.ui.ttf_font = Some(font);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and test**
|
||||
|
||||
Run: `cargo build -p editor_demo`
|
||||
Run: `cargo test -p voltex_editor`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "feat(editor): integrate TTF font rendering into UI system and editor_demo"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Update docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/STATUS.md`
|
||||
- Modify: `docs/DEFERRED.md`
|
||||
|
||||
- [ ] **Step 1: Update STATUS.md**
|
||||
|
||||
Add: `- voltex_editor: TtfParser, Rasterizer, GlyphCache, TtfFont (TTF rendering, Unicode)`
|
||||
Update test count.
|
||||
|
||||
- [ ] **Step 2: Update DEFERRED.md**
|
||||
|
||||
```
|
||||
- ~~**TTF 폰트**~~ ✅ 자체 TTF 파서 + 래스터라이저 + 온디맨드 캐시 완료. 한글/유니코드 지원.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git commit -m "docs: update STATUS.md and DEFERRED.md with TTF font support"
|
||||
```
|
||||
Reference in New Issue
Block a user