zcatgui/src/render/ttf.zig
R.Eugenio 0342d5c145 perf(render): Shadow Baking + Glyph Blitting optimizado (v0.27.0-v0.27.1)
Shadow Baking:
- ShadowCache prerenderiza sombras blur (key: w,h,blur,radius,spread)
- initWithCache() habilita cache, deinit() lo libera
- 4.2x más rápido en Debug, 2.5x en ReleaseSafe

Glyph Blitting:
- Early exit si glifo fuera de clip
- Pre-cálculo región visible
- Acceso directo fb.pixels[]
- Aritmética u32 (sin structs Color)
- Fast path alpha=255

Correcciones:
- Integer overflow: saturating arithmetic (+|, -|, *|)
- u16→u32 en blitShadowCache

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 01:49:54 +01:00

590 lines
19 KiB
Zig

//! 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 {
_ = 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);
}