diff --git a/docs/superpowers/plans/2026-03-26-ttf-font.md b/docs/superpowers/plans/2026-03-26-ttf-font.md new file mode 100644 index 0000000..ea0d2b9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-ttf-font.md @@ -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, + 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>, + 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` — 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, // 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, + pub atlas_width: u32, + pub atlas_height: u32, + glyphs: HashMap, + 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; + 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, +``` + +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" +```