//! TTF Font Support //! //! TrueType font loading and rendering support. //! Uses zcatttf library for parsing and rasterization. //! //! Features: //! - Load TTF files from memory or file //! - Rasterize glyphs at any size with antialiasing //! - Glyph caching for performance const std = @import("std"); const Allocator = std.mem.Allocator; const Style = @import("../core/style.zig"); const Layout = @import("../core/layout.zig"); const Framebuffer = @import("framebuffer.zig").Framebuffer; // Use zcatttf library for TTF parsing and rasterization const zcatttf = @import("zcatttf"); const Color = Style.Color; const Rect = Layout.Rect; // ============================================================================= // TTF Data Types (compatibility layer) // ============================================================================= /// Glyph metrics pub const GlyphMetrics = struct { /// Width of the glyph bitmap width: u16 = 0, /// Height of the glyph bitmap height: u16 = 0, /// X bearing (left side bearing) bearing_x: i16 = 0, /// Y bearing (top side bearing from baseline) bearing_y: i16 = 0, /// Advance width to next character advance: u16 = 0, }; /// Cached glyph const CachedGlyph = struct { /// Bitmap data (alpha values 0-255) bitmap: []u8, /// Metrics metrics: GlyphMetrics, /// Character code codepoint: u32, }; /// Font metrics pub const FontMetrics = struct { /// Ascent (above baseline) ascent: i16 = 0, /// Descent (below baseline, typically negative) descent: i16 = 0, /// Line gap line_gap: i16 = 0, /// Units per em units_per_em: u16 = 2048, }; // ============================================================================= // TTF Font (wrapper around zcatttf) // ============================================================================= /// TrueType font pub const TtfFont = struct { allocator: Allocator, /// zcatttf font instance font: zcatttf.Font, /// Raw font data (kept for reference) data: []const u8, /// Whether we own the data owns_data: bool = false, /// Font metrics (cached) metrics: FontMetrics = .{}, /// Number of glyphs num_glyphs: u16 = 0, /// Glyph cache (for rendered glyphs) glyph_cache: std.AutoHashMap(u64, CachedGlyph), /// Current render size render_size: u16 = 16, /// Scale factor for current size scale: f32 = 1.0, // Legacy fields for compatibility (populated from zcatttf) cmap_offset: u32 = 0, glyf_offset: u32 = 0, loca_offset: u32 = 0, index_to_loc_format: i16 = 0, const Self = @This(); /// Load font from file pub fn loadFromFile(allocator: Allocator, path: []const u8) !Self { const file = try std.fs.cwd().openFile(path, .{}); defer file.close(); const stat = try file.stat(); const data = try allocator.alloc(u8, stat.size); const bytes_read = try file.readAll(data); if (bytes_read != stat.size) { allocator.free(data); return error.IncompleteRead; } var ttf = try initFromMemory(allocator, data); ttf.owns_data = true; return ttf; } /// Initialize font from memory pub fn initFromMemory(allocator: Allocator, data: []const u8) !Self { // Initialize zcatttf font var font = try zcatttf.Font.init(allocator, data); // Get vertical metrics const vmetrics = font.getVMetrics(); var self = Self{ .allocator = allocator, .font = font, .data = data, .glyph_cache = std.AutoHashMap(u64, CachedGlyph).init(allocator), .num_glyphs = font.getNumGlyphs(), .metrics = FontMetrics{ .ascent = vmetrics.ascender, .descent = vmetrics.descender, .line_gap = vmetrics.line_gap, .units_per_em = font.getUnitsPerEm(), }, }; self.setSize(16); return self; } /// Initialize from embedded font (DroidSans) pub fn initEmbedded(allocator: Allocator) !Self { const embedded = @import("embedded_font.zig"); return initFromMemory(allocator, embedded.font_data); } /// Deinitialize font pub fn deinit(self: *Self) void { // Free cached glyphs var it = self.glyph_cache.iterator(); while (it.next()) |entry| { self.allocator.free(entry.value_ptr.bitmap); } self.glyph_cache.deinit(); // Deinit zcatttf font self.font.deinit(); // Free data if we own it if (self.owns_data) { self.allocator.free(@constCast(self.data)); } } /// Set render size pub fn setSize(self: *Self, size: u16) void { self.render_size = size; self.scale = @as(f32, @floatFromInt(size)) / @as(f32, @floatFromInt(self.metrics.units_per_em)); self.font.setPixelHeight(@floatFromInt(size)); } /// Get glyph index for codepoint pub fn getGlyphIndex(self: Self, codepoint: u32) u16 { return self.font.getGlyphIndex(@intCast(codepoint)); } /// Get horizontal metrics for glyph pub fn getHMetrics(self: Self, glyph_index: u16) struct { advance: u16, lsb: i16 } { const hm = self.font.getScaledHMetrics(glyph_index); return .{ .advance = @intFromFloat(hm.advance_width), .lsb = @intFromFloat(hm.left_side_bearing), }; } /// Get glyph metrics (scaled) pub fn getGlyphMetrics(self: Self, codepoint: u32) GlyphMetrics { const glyph_index = self.getGlyphIndex(codepoint); const h_metrics = self.getHMetrics(glyph_index); return GlyphMetrics{ .advance = h_metrics.advance, .bearing_x = h_metrics.lsb, }; } /// Get text width pub fn textWidth(self: Self, text: []const u8) u32 { var width: u32 = 0; var i: usize = 0; while (i < text.len) { const codepoint = decodeUtf8(text[i..]) orelse { i += 1; continue; }; const metrics = self.getGlyphMetrics(codepoint.char); width += metrics.advance; i += codepoint.len; } return width; } /// Get line height pub fn lineHeight(self: Self) u32 { const vm = self.font.getScaledVMetrics(); return @intFromFloat(vm.ascender - vm.descender + vm.line_gap); } /// Get ascent (scaled) pub fn ascent(self: Self) i32 { const vm = self.font.getScaledVMetrics(); return @intFromFloat(vm.ascender); } /// Get descent (scaled) pub fn descent(self: Self) i32 { const vm = self.font.getScaledVMetrics(); return @intFromFloat(vm.descender); } /// Draw text using TTF font with real glyph rasterization pub fn drawText( self: *Self, fb: *Framebuffer, x: i32, y: i32, text: []const u8, color: Color, clip: Rect, ) void { var cx = x; const baseline_y = y + self.ascent(); var i: usize = 0; while (i < text.len) { // Decode UTF-8 const decoded = decodeUtf8(text[i..]) orelse { i += 1; continue; }; const codepoint = decoded.char; i += decoded.len; if (codepoint == '\n') continue; if (codepoint == ' ') { const metrics = self.getGlyphMetrics(codepoint); cx += @intCast(metrics.advance); continue; } // Try to get cached glyph or rasterize const cache_key = makeCacheKey(codepoint, self.render_size); if (self.glyph_cache.get(cache_key)) |cached| { // Draw cached glyph self.drawGlyphBitmap(fb, cx, baseline_y, cached, color, clip); cx += @intCast(cached.metrics.advance); } else { // Rasterize using zcatttf const glyph_index = self.getGlyphIndex(codepoint); if (glyph_index == 0) continue; if (self.font.rasterizeGlyph(glyph_index)) |bitmap| { var bitmap_mut = bitmap; defer bitmap_mut.deinit(); // Copy bitmap data for cache const bitmap_data = self.allocator.alloc(u8, bitmap.data.len) catch continue; @memcpy(bitmap_data, bitmap.data); const cached_glyph = CachedGlyph{ .bitmap = bitmap_data, .metrics = GlyphMetrics{ .width = @intCast(bitmap.width), .height = @intCast(bitmap.height), .bearing_x = @intCast(bitmap.x_offset), .bearing_y = @intCast(bitmap.y_offset), .advance = @intFromFloat(bitmap.advance_width), }, .codepoint = codepoint, }; self.glyph_cache.put(cache_key, cached_glyph) catch {}; self.drawGlyphBitmap(fb, cx, baseline_y, cached_glyph, color, clip); cx += @intCast(cached_glyph.metrics.advance); } else |_| { // Rasterization failed, skip character const metrics = self.getGlyphMetrics(codepoint); cx += @intCast(metrics.advance); } } } } /// Draw a cached glyph bitmap with alpha blending /// Optimized: pre-calculate visible region, direct pixel access fn drawGlyphBitmap( self: Self, fb: *Framebuffer, x: i32, baseline_y: i32, glyph: CachedGlyph, color: Color, clip: Rect, ) void { @setRuntimeSafety(false); // Hot path: bounds validated in visible region calculation _ = self; const width: u32 = glyph.metrics.width; const height: u32 = glyph.metrics.height; if (width == 0 or height == 0) return; // Calculate glyph position const glyph_x = x + glyph.metrics.bearing_x; const glyph_y = baseline_y - glyph.metrics.bearing_y; // Early exit: entire glyph outside clip or framebuffer const glyph_right = glyph_x + @as(i32, @intCast(width)); const glyph_bottom = glyph_y + @as(i32, @intCast(height)); const clip_right = clip.x + @as(i32, @intCast(clip.w)); const clip_bottom = clip.y + @as(i32, @intCast(clip.h)); if (glyph_right <= clip.x or glyph_x >= clip_right) return; if (glyph_bottom <= clip.y or glyph_y >= clip_bottom) return; if (glyph_right <= 0 or glyph_x >= @as(i32, @intCast(fb.width))) return; if (glyph_bottom <= 0 or glyph_y >= @as(i32, @intCast(fb.height))) return; // Calculate visible region (intersection of glyph, clip, and framebuffer) const vis_x0 = @max(0, @max(glyph_x, clip.x)); const vis_y0 = @max(0, @max(glyph_y, clip.y)); const vis_x1 = @min(@as(i32, @intCast(fb.width)), @min(glyph_right, clip_right)); const vis_y1 = @min(@as(i32, @intCast(fb.height)), @min(glyph_bottom, clip_bottom)); if (vis_x0 >= vis_x1 or vis_y0 >= vis_y1) return; // Precompute color for blending (u32 to avoid per-pixel struct creation) const color_r: u32 = color.r; const color_g: u32 = color.g; const color_b: u32 = color.b; const color_packed = color.toABGR(); // Draw only visible region with direct pixel access var screen_y = vis_y0; while (screen_y < vis_y1) : (screen_y += 1) { const glyph_py: u32 = @intCast(screen_y - glyph_y); const dst_row: u32 = @intCast(screen_y); const dst_row_start = dst_row * fb.width; const src_row_start = glyph_py * width; var screen_x = vis_x0; while (screen_x < vis_x1) : (screen_x += 1) { const glyph_px: u32 = @intCast(screen_x - glyph_x); const alpha = glyph.bitmap[src_row_start + glyph_px]; if (alpha == 0) continue; const dst_idx = dst_row_start + @as(u32, @intCast(screen_x)); if (alpha == 255) { // Fully opaque: direct write fb.pixels[dst_idx] = color_packed; } else { // Alpha blend with direct u32 math const bg = fb.pixels[dst_idx]; const bg_r: u32 = bg & 0xFF; const bg_g: u32 = (bg >> 8) & 0xFF; const bg_b: u32 = (bg >> 16) & 0xFF; const a: u32 = alpha; const inv_a: u32 = 255 - a; const out_r: u8 = @intCast((color_r * a + bg_r * inv_a) / 255); const out_g: u8 = @intCast((color_g * a + bg_g * inv_a) / 255); const out_b: u8 = @intCast((color_b * a + bg_b * inv_a) / 255); fb.pixels[dst_idx] = @as(u32, out_r) | (@as(u32, out_g) << 8) | (@as(u32, out_b) << 16) | (0xFF << 24); } } } } }; /// Create cache key from codepoint and size fn makeCacheKey(codepoint: u32, size: u16) u64 { return (@as(u64, codepoint) << 16) | @as(u64, size); } /// Blend foreground color with background using alpha fn blendColors(fg: Color, bg: Color, alpha: u8) Color { const a = @as(u16, alpha); const inv_a = 255 - a; return Color{ .r = @intCast((@as(u16, fg.r) * a + @as(u16, bg.r) * inv_a) / 255), .g = @intCast((@as(u16, fg.g) * a + @as(u16, bg.g) * inv_a) / 255), .b = @intCast((@as(u16, fg.b) * a + @as(u16, bg.b) * inv_a) / 255), .a = 255, }; } /// Decode UTF-8 codepoint from byte slice fn decodeUtf8(bytes: []const u8) ?struct { char: u32, len: usize } { if (bytes.len == 0) return null; const b0 = bytes[0]; // ASCII (0xxxxxxx) if (b0 < 0x80) { return .{ .char = b0, .len = 1 }; } // 2-byte sequence (110xxxxx 10xxxxxx) if (b0 >= 0xC0 and b0 < 0xE0) { if (bytes.len < 2) return null; const b1 = bytes[1]; if ((b1 & 0xC0) != 0x80) return null; const char = (@as(u32, b0 & 0x1F) << 6) | @as(u32, b1 & 0x3F); return .{ .char = char, .len = 2 }; } // 3-byte sequence (1110xxxx 10xxxxxx 10xxxxxx) if (b0 >= 0xE0 and b0 < 0xF0) { if (bytes.len < 3) return null; const b1 = bytes[1]; const b2 = bytes[2]; if ((b1 & 0xC0) != 0x80 or (b2 & 0xC0) != 0x80) return null; const char = (@as(u32, b0 & 0x0F) << 12) | (@as(u32, b1 & 0x3F) << 6) | @as(u32, b2 & 0x3F); return .{ .char = char, .len = 3 }; } // 4-byte sequence (11110xxx 10xxxxxx 10xxxxxx 10xxxxxx) if (b0 >= 0xF0 and b0 < 0xF8) { if (bytes.len < 4) return null; const b1 = bytes[1]; const b2 = bytes[2]; const b3 = bytes[3]; if ((b1 & 0xC0) != 0x80 or (b2 & 0xC0) != 0x80 or (b3 & 0xC0) != 0x80) return null; const char = (@as(u32, b0 & 0x07) << 18) | (@as(u32, b1 & 0x3F) << 12) | (@as(u32, b2 & 0x3F) << 6) | @as(u32, b3 & 0x3F); return .{ .char = char, .len = 4 }; } return null; } // ============================================================================= // Font Interface - Unified API for both bitmap and TTF fonts // ============================================================================= /// Font type tag pub const FontType = enum { bitmap, ttf, }; /// Unified font reference pub const FontRef = union(FontType) { bitmap: *const @import("font.zig").Font, ttf: *TtfFont, pub fn textWidth(self: FontRef, text: []const u8) u32 { return switch (self) { .bitmap => |f| f.textWidth(text), .ttf => |f| f.textWidth(text), }; } pub fn charHeight(self: FontRef) u32 { return switch (self) { .bitmap => |f| f.charHeight(), .ttf => |f| f.lineHeight(), }; } pub fn drawText( self: FontRef, fb: *Framebuffer, x: i32, y: i32, text: []const u8, color: Color, clip: Rect, ) void { switch (self) { .bitmap => |f| f.drawText(fb, x, y, text, color, clip), .ttf => |f| @constCast(f).drawText(fb, x, y, text, color, clip), } } }; // ============================================================================= // Legacy exports for compatibility // ============================================================================= /// Rasterized glyph bitmap (legacy, for code that uses this directly) pub const GlyphBitmap = struct { data: []u8, width: u32, height: u32, bearing_x: i32, bearing_y: i32, allocator: Allocator, pub fn deinit(self: *GlyphBitmap) void { self.allocator.free(self.data); } }; // ============================================================================= // Tests // ============================================================================= test "TTF types" { const metrics = GlyphMetrics{ .width = 10, .height = 12, .bearing_x = 1, .bearing_y = 10, .advance = 8, }; try std.testing.expectEqual(@as(u16, 10), metrics.width); try std.testing.expectEqual(@as(u16, 8), metrics.advance); } test "FontRef bitmap" { const bitmap_font = @import("font.zig"); const font_ref = FontRef{ .bitmap = &bitmap_font.default_font }; try std.testing.expectEqual(@as(u32, 40), font_ref.textWidth("Hello")); try std.testing.expectEqual(@as(u32, 8), font_ref.charHeight()); } test "decodeUtf8" { // ASCII const ascii = decodeUtf8("A").?; try std.testing.expectEqual(@as(u32, 'A'), ascii.char); try std.testing.expectEqual(@as(usize, 1), ascii.len); // 2-byte (é = 0xC3 0xA9) const e_acute = decodeUtf8("\xC3\xA9").?; try std.testing.expectEqual(@as(u32, 0xE9), e_acute.char); try std.testing.expectEqual(@as(usize, 2), e_acute.len); // 3-byte (€ = 0xE2 0x82 0xAC) const euro = decodeUtf8("\xE2\x82\xAC").?; try std.testing.expectEqual(@as(u32, 0x20AC), euro.char); try std.testing.expectEqual(@as(usize, 3), euro.len); } test "TtfFont with zcatttf" { const allocator = std.testing.allocator; // Try to load DroidSans from system var font = TtfFont.loadFromFile(allocator, "/usr/share/fonts/google-droid-sans-fonts/DroidSans.ttf") catch { // Font not available on this system, skip test return; }; defer font.deinit(); // Verify font was loaded try std.testing.expect(font.num_glyphs > 0); try std.testing.expect(font.metrics.units_per_em > 0); // Get glyph index for 'A' const glyph_index = font.getGlyphIndex('A'); try std.testing.expect(glyph_index > 0); // Get metrics const metrics = font.getGlyphMetrics('A'); try std.testing.expect(metrics.advance > 0); // Test text width const width = font.textWidth("Hello"); try std.testing.expect(width > 0); }