zcatgui/src/render/ttf.zig
reugenio 0cdd44b8a0 fix: TTF ABGR format + herramienta diagnóstico cmap
Cambios:
- ttf.zig: Fix formato pixel ABGR (era RGBA invertido)
- cmap_debug.zig: Herramienta diagnóstico tabla cmap
- build.zig: Target cmap-debug para ejecutar diagnóstico
- docs/research/TTF_DEBUG_SESSION: Documentación sesión debug

Nota: El código base TTF funciona (cmap_debug lo confirma).
El bug de rendering sigue sin resolver en integración.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:00:54 +01:00

1161 lines
39 KiB
Zig

//! TTF Font Support
//!
//! TrueType font loading and rendering support.
//! Uses a simplified Zig implementation for basic TTF parsing.
//!
//! Features:
//! - Load TTF files from memory or file
//! - Rasterize glyphs at any size
//! - Glyph caching for performance
//! - Kerning support (basic)
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;
const Color = Style.Color;
const Rect = Layout.Rect;
// =============================================================================
// TTF Data Types
// =============================================================================
/// Point on a glyph contour
pub const GlyphPoint = struct {
x: i16,
y: i16,
on_curve: bool, // true = on curve, false = control point (quadratic bezier)
};
/// A single contour (closed path)
pub const Contour = struct {
points: []GlyphPoint,
};
/// Glyph outline (all contours)
pub const GlyphOutline = struct {
contours: []Contour,
x_min: i16,
y_min: i16,
x_max: i16,
y_max: i16,
allocator: Allocator,
pub fn deinit(self: *GlyphOutline) void {
for (self.contours) |contour| {
self.allocator.free(contour.points);
}
self.allocator.free(self.contours);
}
};
/// Rasterized glyph bitmap
pub const GlyphBitmap = struct {
data: []u8, // Alpha values 0-255
width: u32,
height: u32,
bearing_x: i32,
bearing_y: i32,
allocator: Allocator,
pub fn deinit(self: *GlyphBitmap) void {
self.allocator.free(self.data);
}
};
// =============================================================================
// Rasterization
// =============================================================================
/// Edge for scanline rasterization
const Edge = struct {
x0: f32,
y0: f32,
x1: f32,
y1: f32,
direction: i8, // +1 going up, -1 going down
};
/// Rasterize a glyph outline to bitmap with antialiasing
pub fn rasterizeGlyph(
allocator: Allocator,
outline: GlyphOutline,
scale: f32,
supersample: u8, // 1 = no AA, 2-4 = supersampling level
) ?GlyphBitmap {
// Calculate scaled bounding box
const x_min_f = @as(f32, @floatFromInt(outline.x_min)) * scale;
const y_min_f = @as(f32, @floatFromInt(outline.y_min)) * scale;
const x_max_f = @as(f32, @floatFromInt(outline.x_max)) * scale;
const y_max_f = @as(f32, @floatFromInt(outline.y_max)) * scale;
const width: u32 = @intFromFloat(@ceil(x_max_f - x_min_f) + 2);
const height: u32 = @intFromFloat(@ceil(y_max_f - y_min_f) + 2);
if (width == 0 or height == 0 or width > 1000 or height > 1000) return null;
// Allocate bitmap
const bitmap_size = width * height;
var data = allocator.alloc(u8, bitmap_size) catch return null;
@memset(data, 0);
// Collect all edges from contours
var edges_list: std.ArrayListUnmanaged(Edge) = .{};
defer edges_list.deinit(allocator);
for (outline.contours) |contour| {
collectEdgesFromContour(allocator, &edges_list, contour.points, scale, x_min_f, y_min_f, supersample) catch return null;
}
const ss = @as(f32, @floatFromInt(supersample));
const ss_sq = @as(f32, @floatFromInt(@as(u32, supersample) * @as(u32, supersample)));
// Scanline fill with supersampling
for (0..height) |py| {
for (0..width) |px| {
var coverage: u32 = 0;
// Subsample
for (0..supersample) |sy| {
for (0..supersample) |sx| {
const sample_x = @as(f32, @floatFromInt(px)) + (@as(f32, @floatFromInt(sx)) + 0.5) / ss;
const sample_y = @as(f32, @floatFromInt(py)) + (@as(f32, @floatFromInt(sy)) + 0.5) / ss;
// Count winding number
var winding: i32 = 0;
for (edges_list.items) |edge| {
if (edgeCrossesRay(edge, sample_x, sample_y)) {
winding += edge.direction;
}
}
if (winding != 0) coverage += 1;
}
}
// Convert coverage to alpha
// Flip Y: TTF has Y-up, screen has Y-down
const alpha: u8 = @intFromFloat((@as(f32, @floatFromInt(coverage)) / ss_sq) * 255.0);
data[(height - 1 - py) * width + px] = alpha;
}
}
return GlyphBitmap{
.data = data,
.width = width,
.height = height,
.bearing_x = 0, // Bitmap is normalized to (0,0), no offset needed
.bearing_y = @intFromFloat(y_max_f), // Distance from baseline to top of glyph
.allocator = allocator,
};
}
/// Collect edges from a contour, handling bezier curves
fn collectEdgesFromContour(
allocator: Allocator,
edges: *std.ArrayListUnmanaged(Edge),
points: []const GlyphPoint,
scale: f32,
x_off: f32,
y_off: f32,
subdivisions: u8,
) !void {
if (points.len < 2) return;
const n = points.len;
var i: usize = 0;
while (i < n) {
const p0 = points[i];
const p1 = points[(i + 1) % n];
const x0 = @as(f32, @floatFromInt(p0.x)) * scale - x_off;
const y0 = @as(f32, @floatFromInt(p0.y)) * scale - y_off;
const x1 = @as(f32, @floatFromInt(p1.x)) * scale - x_off;
const y1 = @as(f32, @floatFromInt(p1.y)) * scale - y_off;
if (p0.on_curve and p1.on_curve) {
// Straight line
try addEdge(allocator, edges, x0, y0, x1, y1);
i += 1;
} else if (p0.on_curve and !p1.on_curve) {
// Bezier curve: p0 is on, p1 is control
const p2 = points[(i + 2) % n];
var x2: f32 = undefined;
var y2: f32 = undefined;
if (p2.on_curve) {
x2 = @as(f32, @floatFromInt(p2.x)) * scale - x_off;
y2 = @as(f32, @floatFromInt(p2.y)) * scale - y_off;
i += 2;
} else {
// Two off-curve points: interpolate midpoint
x2 = (x1 + @as(f32, @floatFromInt(p2.x)) * scale - x_off) / 2.0;
y2 = (y1 + @as(f32, @floatFromInt(p2.y)) * scale - y_off) / 2.0;
i += 1;
}
// Subdivide bezier curve
try subdivideBezier(allocator, edges, x0, y0, x1, y1, x2, y2, subdivisions * 2);
} else {
// Off-curve start: should have been handled, skip
i += 1;
}
}
}
/// Subdivide quadratic bezier curve into line segments
fn subdivideBezier(
allocator: Allocator,
edges: *std.ArrayListUnmanaged(Edge),
x0: f32,
y0: f32,
cx: f32,
cy: f32,
x1: f32,
y1: f32,
steps: u8,
) !void {
var prev_x = x0;
var prev_y = y0;
const step_f = @as(f32, @floatFromInt(steps));
for (1..steps + 1) |s| {
const t = @as(f32, @floatFromInt(s)) / step_f;
const t1 = 1.0 - t;
// Quadratic bezier: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
const curr_x = t1 * t1 * x0 + 2.0 * t1 * t * cx + t * t * x1;
const curr_y = t1 * t1 * y0 + 2.0 * t1 * t * cy + t * t * y1;
try addEdge(allocator, edges, prev_x, prev_y, curr_x, curr_y);
prev_x = curr_x;
prev_y = curr_y;
}
}
/// Add edge if it's not horizontal
fn addEdge(allocator: Allocator, edges: *std.ArrayListUnmanaged(Edge), x0: f32, y0: f32, x1: f32, y1: f32) !void {
// Skip horizontal edges
if (@abs(y1 - y0) < 0.001) return;
// Direction: +1 if going up (y increasing), -1 if going down
const direction: i8 = if (y1 > y0) 1 else -1;
// Always store with y0 < y1
if (y0 < y1) {
try edges.append(allocator, Edge{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1, .direction = direction });
} else {
try edges.append(allocator, Edge{ .x0 = x1, .y0 = y1, .x1 = x0, .y1 = y0, .direction = direction });
}
}
/// Check if edge crosses horizontal ray from point to the right
fn edgeCrossesRay(edge: Edge, px: f32, py: f32) bool {
// Ray goes from (px, py) to (+infinity, py)
// Edge goes from (x0, y0) to (x1, y1) where y0 < y1
// Check if ray is within edge's y range
if (py < edge.y0 or py >= edge.y1) return false;
// Calculate x intersection
const t = (py - edge.y0) / (edge.y1 - edge.y0);
const x_intersect = edge.x0 + t * (edge.x1 - edge.x0);
// Ray crosses if intersection is to the right of point
return x_intersect > px;
}
/// TTF table directory entry
const TableEntry = struct {
tag: [4]u8,
checksum: u32,
offset: u32,
length: u32,
};
/// 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
// =============================================================================
/// TrueType font
pub const TtfFont = struct {
allocator: Allocator,
/// Raw font data
data: []const u8,
/// Whether we own the data
owns_data: bool = false,
/// Table offsets
cmap_offset: u32 = 0,
glyf_offset: u32 = 0,
head_offset: u32 = 0,
hhea_offset: u32 = 0,
hmtx_offset: u32 = 0,
loca_offset: u32 = 0,
maxp_offset: u32 = 0,
/// Font metrics
metrics: FontMetrics = .{},
/// Number of glyphs
num_glyphs: u16 = 0,
/// Index to loc format (0 = short, 1 = long)
index_to_loc_format: i16 = 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,
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 font = try initFromMemory(allocator, data);
font.owns_data = true;
return font;
}
/// Initialize font from memory
pub fn initFromMemory(allocator: Allocator, data: []const u8) !Self {
var self = Self{
.allocator = allocator,
.data = data,
.glyph_cache = std.AutoHashMap(u64, CachedGlyph).init(allocator),
};
try self.parseHeader();
self.setSize(16);
return self;
}
/// Initialize from embedded font (AdwaitaSans)
/// Convenience function for zero external dependencies
pub fn initEmbedded(allocator: Allocator) !Self {
const embedded = @import("embedded_font.zig");
return initFromMemory(allocator, embedded.adwaita_sans_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();
// Free data if we own it
if (self.owns_data) {
self.allocator.free(@constCast(self.data));
}
}
/// Parse TTF header and locate tables
fn parseHeader(self: *Self) !void {
if (self.data.len < 12) return error.InvalidFont;
// Check magic number (0x00010000 for TTF, 'true' for some Mac fonts)
const magic = readU32Big(self.data, 0);
if (magic != 0x00010000 and magic != 0x74727565) {
return error.InvalidFont;
}
const num_tables = readU16Big(self.data, 4);
// Parse table directory
var offset: u32 = 12;
var i: u16 = 0;
while (i < num_tables) : (i += 1) {
if (offset + 16 > self.data.len) return error.InvalidFont;
const entry = TableEntry{
.tag = self.data[offset..][0..4].*,
.checksum = readU32Big(self.data, offset + 4),
.offset = readU32Big(self.data, offset + 8),
.length = readU32Big(self.data, offset + 12),
};
// Store table offsets
if (std.mem.eql(u8, &entry.tag, "cmap")) self.cmap_offset = entry.offset;
if (std.mem.eql(u8, &entry.tag, "glyf")) self.glyf_offset = entry.offset;
if (std.mem.eql(u8, &entry.tag, "head")) self.head_offset = entry.offset;
if (std.mem.eql(u8, &entry.tag, "hhea")) self.hhea_offset = entry.offset;
if (std.mem.eql(u8, &entry.tag, "hmtx")) self.hmtx_offset = entry.offset;
if (std.mem.eql(u8, &entry.tag, "loca")) self.loca_offset = entry.offset;
if (std.mem.eql(u8, &entry.tag, "maxp")) self.maxp_offset = entry.offset;
offset += 16;
}
// Parse head table
if (self.head_offset > 0) {
self.metrics.units_per_em = readU16Big(self.data, self.head_offset + 18);
self.index_to_loc_format = @bitCast(readU16Big(self.data, self.head_offset + 50));
}
// Parse hhea table
if (self.hhea_offset > 0) {
self.metrics.ascent = @bitCast(readU16Big(self.data, self.hhea_offset + 4));
self.metrics.descent = @bitCast(readU16Big(self.data, self.hhea_offset + 6));
self.metrics.line_gap = @bitCast(readU16Big(self.data, self.hhea_offset + 8));
}
// Parse maxp table
if (self.maxp_offset > 0) {
self.num_glyphs = readU16Big(self.data, self.maxp_offset + 4);
}
}
/// 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));
}
/// Get glyph index for codepoint
pub fn getGlyphIndex(self: Self, codepoint: u32) u16 {
if (self.cmap_offset == 0) return 0;
// Parse cmap table to find glyph index
const cmap_data = self.data[self.cmap_offset..];
if (cmap_data.len < 4) return 0;
const num_subtables = readU16Big(cmap_data, 2);
// Look for format 4 (Unicode BMP) or format 12 (Unicode full)
var subtable_offset: u32 = 4;
var i: u16 = 0;
while (i < num_subtables) : (i += 1) {
if (subtable_offset + 8 > cmap_data.len) break;
const platform_id = readU16Big(cmap_data, subtable_offset);
const encoding_id = readU16Big(cmap_data, subtable_offset + 2);
const offset = readU32Big(cmap_data, subtable_offset + 4);
// Unicode platform (0) or Windows platform (3) with Unicode encoding
if ((platform_id == 0 or (platform_id == 3 and encoding_id == 1)) and offset < cmap_data.len) {
const subtable = cmap_data[offset..];
const format = readU16Big(subtable, 0);
if (format == 4 and codepoint < 0x10000) {
return self.lookupFormat4(subtable, @intCast(codepoint));
} else if (format == 12) {
return self.lookupFormat12(subtable, codepoint);
}
}
subtable_offset += 8;
}
return 0;
}
/// Lookup glyph in format 4 subtable
fn lookupFormat4(self: Self, subtable: []const u8, codepoint: u16) u16 {
_ = self;
if (subtable.len < 14) return 0;
const seg_count_x2 = readU16Big(subtable, 6);
const seg_count = seg_count_x2 / 2;
const end_codes_offset: usize = 14;
const start_codes_offset = end_codes_offset + seg_count_x2 + 2; // +2 for reserved pad
const id_delta_offset = start_codes_offset + seg_count_x2;
const id_range_offset_offset = id_delta_offset + seg_count_x2;
// Binary search for segment
var lo: u16 = 0;
var hi = seg_count;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
const end_code = readU16Big(subtable, end_codes_offset + @as(usize, mid) * 2);
if (codepoint > end_code) {
lo = mid + 1;
} else {
hi = mid;
}
}
if (lo >= seg_count) return 0;
const seg_idx: usize = lo;
const end_code = readU16Big(subtable, end_codes_offset + seg_idx * 2);
const start_code = readU16Big(subtable, start_codes_offset + seg_idx * 2);
if (codepoint < start_code or codepoint > end_code) return 0;
const id_delta: i16 = @bitCast(readU16Big(subtable, id_delta_offset + seg_idx * 2));
const id_range_offset = readU16Big(subtable, id_range_offset_offset + seg_idx * 2);
if (id_range_offset == 0) {
const result = @as(i32, codepoint) + @as(i32, id_delta);
return @intCast(@as(u32, @bitCast(result)) & 0xFFFF);
} else {
const glyph_offset = id_range_offset_offset + seg_idx * 2 + id_range_offset + (@as(usize, codepoint) - @as(usize, start_code)) * 2;
if (glyph_offset + 2 > subtable.len) return 0;
const glyph_id = readU16Big(subtable, glyph_offset);
if (glyph_id == 0) return 0;
const result = @as(i32, glyph_id) + @as(i32, id_delta);
return @intCast(@as(u32, @bitCast(result)) & 0xFFFF);
}
}
/// Lookup glyph in format 12 subtable
fn lookupFormat12(self: Self, subtable: []const u8, codepoint: u32) u16 {
_ = self;
if (subtable.len < 16) return 0;
const num_groups = readU32Big(subtable, 12);
var group_offset: usize = 16;
var i: u32 = 0;
while (i < num_groups) : (i += 1) {
if (group_offset + 12 > subtable.len) break;
const start_char = readU32Big(subtable, group_offset);
const end_char = readU32Big(subtable, group_offset + 4);
const start_glyph = readU32Big(subtable, group_offset + 8);
if (codepoint >= start_char and codepoint <= end_char) {
return @intCast(start_glyph + (codepoint - start_char));
}
group_offset += 12;
}
return 0;
}
/// Get glyph location in glyf table
fn getGlyphLocation(self: Self, glyph_index: u16) ?struct { offset: u32, length: u32 } {
if (self.loca_offset == 0 or self.glyf_offset == 0) return null;
if (glyph_index >= self.num_glyphs) return null;
const loca_data = self.data[self.loca_offset..];
var offset1: u32 = undefined;
var offset2: u32 = undefined;
if (self.index_to_loc_format == 0) {
// Short format (offsets divided by 2)
if (@as(usize, glyph_index + 1) * 2 + 2 > loca_data.len) return null;
offset1 = @as(u32, readU16Big(loca_data, @as(usize, glyph_index) * 2)) * 2;
offset2 = @as(u32, readU16Big(loca_data, @as(usize, glyph_index + 1) * 2)) * 2;
} else {
// Long format
if (@as(usize, glyph_index + 1) * 4 + 4 > loca_data.len) return null;
offset1 = readU32Big(loca_data, @as(usize, glyph_index) * 4);
offset2 = readU32Big(loca_data, @as(usize, glyph_index + 1) * 4);
}
if (offset1 == offset2) return null; // Empty glyph
return .{
.offset = offset1,
.length = offset2 - offset1,
};
}
/// Get glyph outline (contours with points)
pub fn getGlyphOutline(self: Self, glyph_index: u16) ?GlyphOutline {
const loc = self.getGlyphLocation(glyph_index) orelse return null;
if (loc.length < 10) return null;
const glyph_data = self.data[self.glyf_offset + loc.offset ..];
if (glyph_data.len < loc.length) return null;
const num_contours: i16 = @bitCast(readU16Big(glyph_data, 0));
// Compound glyph (negative num_contours) - not supported yet
if (num_contours < 0) return null;
if (num_contours == 0) return null;
const x_min: i16 = @bitCast(readU16Big(glyph_data, 2));
const y_min: i16 = @bitCast(readU16Big(glyph_data, 4));
const x_max: i16 = @bitCast(readU16Big(glyph_data, 6));
const y_max: i16 = @bitCast(readU16Big(glyph_data, 8));
const n_contours: usize = @intCast(num_contours);
// Read endPtsOfContours
var end_pts = self.allocator.alloc(u16, n_contours) catch return null;
defer self.allocator.free(end_pts);
var offset: usize = 10;
for (0..n_contours) |i| {
if (offset + 2 > glyph_data.len) return null;
end_pts[i] = readU16Big(glyph_data, offset);
offset += 2;
}
const n_points: usize = @as(usize, end_pts[n_contours - 1]) + 1;
// Skip instructions
if (offset + 2 > glyph_data.len) return null;
const instruction_length = readU16Big(glyph_data, offset);
offset += 2 + instruction_length;
// Parse flags (with repeat encoding)
var flags = self.allocator.alloc(u8, n_points) catch return null;
defer self.allocator.free(flags);
var point_idx: usize = 0;
while (point_idx < n_points) {
if (offset >= glyph_data.len) return null;
const flag = glyph_data[offset];
offset += 1;
flags[point_idx] = flag;
point_idx += 1;
// Repeat flag
if ((flag & 0x08) != 0 and point_idx < n_points) {
if (offset >= glyph_data.len) return null;
const repeat_count = glyph_data[offset];
offset += 1;
var r: usize = 0;
while (r < repeat_count and point_idx < n_points) : (r += 1) {
flags[point_idx] = flag;
point_idx += 1;
}
}
}
// Parse X coordinates
var x_coords = self.allocator.alloc(i16, n_points) catch return null;
defer self.allocator.free(x_coords);
var x: i16 = 0;
for (0..n_points) |i| {
const flag = flags[i];
const x_short = (flag & 0x02) != 0;
const x_same_or_positive = (flag & 0x10) != 0;
if (x_short) {
if (offset >= glyph_data.len) return null;
const dx: i16 = @intCast(glyph_data[offset]);
offset += 1;
x += if (x_same_or_positive) dx else -dx;
} else if (!x_same_or_positive) {
if (offset + 2 > glyph_data.len) return null;
const dx: i16 = @bitCast(readU16Big(glyph_data, offset));
offset += 2;
x += dx;
}
// else: x_same_or_positive and !x_short means same as previous
x_coords[i] = x;
}
// Parse Y coordinates
var y_coords = self.allocator.alloc(i16, n_points) catch return null;
defer self.allocator.free(y_coords);
var y: i16 = 0;
for (0..n_points) |i| {
const flag = flags[i];
const y_short = (flag & 0x04) != 0;
const y_same_or_positive = (flag & 0x20) != 0;
if (y_short) {
if (offset >= glyph_data.len) return null;
const dy: i16 = @intCast(glyph_data[offset]);
offset += 1;
y += if (y_same_or_positive) dy else -dy;
} else if (!y_same_or_positive) {
if (offset + 2 > glyph_data.len) return null;
const dy: i16 = @bitCast(readU16Big(glyph_data, offset));
offset += 2;
y += dy;
}
y_coords[i] = y;
}
// Build contours
var contours = self.allocator.alloc(Contour, n_contours) catch return null;
errdefer self.allocator.free(contours);
var start_pt: usize = 0;
for (0..n_contours) |c| {
const end_pt: usize = @as(usize, end_pts[c]) + 1;
const contour_len = end_pt - start_pt;
var points = self.allocator.alloc(GlyphPoint, contour_len) catch {
// Free already allocated contours
for (0..c) |j| {
self.allocator.free(contours[j].points);
}
self.allocator.free(contours);
return null;
};
for (0..contour_len) |i| {
const pt_idx = start_pt + i;
points[i] = GlyphPoint{
.x = x_coords[pt_idx],
.y = y_coords[pt_idx],
.on_curve = (flags[pt_idx] & 0x01) != 0,
};
}
contours[c] = Contour{ .points = points };
start_pt = end_pt;
}
return GlyphOutline{
.contours = contours,
.x_min = x_min,
.y_min = y_min,
.x_max = x_max,
.y_max = y_max,
.allocator = self.allocator,
};
}
/// Get horizontal metrics for glyph
pub fn getHMetrics(self: Self, glyph_index: u16) struct { advance: u16, lsb: i16 } {
if (self.hmtx_offset == 0 or self.hhea_offset == 0) {
return .{ .advance = @intFromFloat(@as(f32, @floatFromInt(self.render_size)) * 0.6), .lsb = 0 };
}
const num_h_metrics = readU16Big(self.data, self.hhea_offset + 34);
const hmtx_data = self.data[self.hmtx_offset..];
if (glyph_index < num_h_metrics) {
const offset = @as(usize, glyph_index) * 4;
if (offset + 4 > hmtx_data.len) {
return .{ .advance = @intFromFloat(@as(f32, @floatFromInt(self.render_size)) * 0.6), .lsb = 0 };
}
return .{
.advance = readU16Big(hmtx_data, offset),
.lsb = @bitCast(readU16Big(hmtx_data, offset + 2)),
};
} else {
// Use last advance width
const last_offset = @as(usize, num_h_metrics - 1) * 4;
const lsb_offset = @as(usize, num_h_metrics) * 4 + (@as(usize, glyph_index) - num_h_metrics) * 2;
if (last_offset + 4 > hmtx_data.len or lsb_offset + 2 > hmtx_data.len) {
return .{ .advance = @intFromFloat(@as(f32, @floatFromInt(self.render_size)) * 0.6), .lsb = 0 };
}
return .{
.advance = readU16Big(hmtx_data, last_offset),
.lsb = @bitCast(readU16Big(hmtx_data, lsb_offset)),
};
}
}
/// 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 = @intFromFloat(@as(f32, @floatFromInt(h_metrics.advance)) * self.scale),
.bearing_x = @intFromFloat(@as(f32, @floatFromInt(h_metrics.lsb)) * self.scale),
};
}
/// Get text width
pub fn textWidth(self: Self, text: []const u8) u32 {
var width: u32 = 0;
for (text) |c| {
const metrics = self.getGlyphMetrics(c);
width += metrics.advance;
}
return width;
}
/// Get line height
pub fn lineHeight(self: Self) u32 {
const asc: f32 = @floatFromInt(self.metrics.ascent);
const desc: f32 = @floatFromInt(self.metrics.descent);
const gap: f32 = @floatFromInt(self.metrics.line_gap);
return @intFromFloat((asc - desc + gap) * self.scale);
}
/// Get ascent (scaled)
pub fn ascent(self: Self) i32 {
return @intFromFloat(@as(f32, @floatFromInt(self.metrics.ascent)) * self.scale);
}
/// Get descent (scaled)
pub fn descent(self: Self) i32 {
return @intFromFloat(@as(f32, @floatFromInt(self.metrics.descent)) * self.scale);
}
/// 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();
for (text) |c| {
if (c == '\n') continue;
if (c == ' ') {
const metrics = self.getGlyphMetrics(c);
cx += @intCast(metrics.advance);
continue;
}
// Try to get cached glyph or rasterize
const cache_key = makeCacheKey(c, self.render_size);
if (self.glyph_cache.get(cache_key)) |cached| {
// Draw cached glyph
self.drawGlyphBitmap(fb, cx, baseline_y, cached, color, clip);
const metrics = self.getGlyphMetrics(c);
cx += @intCast(metrics.advance);
} else {
// Rasterize and cache
const glyph_index = self.getGlyphIndex(c);
if (self.getGlyphOutline(glyph_index)) |outline| {
defer {
var outline_copy = outline;
outline_copy.deinit();
}
// Rasterize with 2x supersampling for antialiasing
if (rasterizeGlyph(self.allocator, outline, self.scale, 2)) |bitmap| {
const cached_glyph = CachedGlyph{
.bitmap = bitmap.data,
.metrics = GlyphMetrics{
.width = @intCast(bitmap.width),
.height = @intCast(bitmap.height),
.bearing_x = @intCast(bitmap.bearing_x),
.bearing_y = @intCast(bitmap.bearing_y),
.advance = self.getGlyphMetrics(c).advance,
},
.codepoint = c,
};
self.glyph_cache.put(cache_key, cached_glyph) catch {};
self.drawGlyphBitmap(fb, cx, baseline_y, cached_glyph, color, clip);
}
}
const metrics = self.getGlyphMetrics(c);
cx += @intCast(metrics.advance);
}
}
}
/// Draw a cached glyph bitmap with alpha blending
fn drawGlyphBitmap(
self: Self,
fb: *Framebuffer,
x: i32,
baseline_y: i32,
glyph: CachedGlyph,
color: Color,
clip: Rect,
) void {
_ = self;
// Calculate position: bearing_y is distance from baseline to top
const glyph_x = x + glyph.metrics.bearing_x;
const glyph_y = baseline_y - glyph.metrics.bearing_y;
const width = glyph.metrics.width;
const height = glyph.metrics.height;
// Draw each pixel with alpha blending
for (0..height) |py| {
for (0..width) |px| {
const alpha = glyph.bitmap[py * width + px];
if (alpha == 0) continue;
const screen_x = glyph_x + @as(i32, @intCast(px));
const screen_y = glyph_y + @as(i32, @intCast(py));
// Clip check
if (screen_x < clip.x or screen_x >= clip.x + @as(i32, @intCast(clip.w))) continue;
if (screen_y < clip.y or screen_y >= clip.y + @as(i32, @intCast(clip.h))) continue;
if (screen_x < 0 or screen_y < 0) continue;
if (screen_x >= @as(i32, @intCast(fb.width)) or screen_y >= @as(i32, @intCast(fb.height))) continue;
// Alpha blend
if (alpha == 255) {
fb.setPixel(@intCast(screen_x), @intCast(screen_y), color);
} else {
// Get background pixel and convert u32 to Color (ABGR format)
const bg_u32 = fb.getPixel(@intCast(screen_x), @intCast(screen_y)) orelse 0;
const bg = Color{
.r = @truncate(bg_u32),
.g = @truncate(bg_u32 >> 8),
.b = @truncate(bg_u32 >> 16),
.a = @truncate(bg_u32 >> 24),
};
const blended = blendColors(color, bg, alpha);
fb.setPixel(@intCast(screen_x), @intCast(screen_y), blended);
}
}
}
}
};
/// 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,
};
}
// =============================================================================
// Helper functions
// =============================================================================
fn readU16Big(data: []const u8, offset: usize) u16 {
if (offset + 2 > data.len) return 0;
return (@as(u16, data[offset]) << 8) | @as(u16, data[offset + 1]);
}
fn readU32Big(data: []const u8, offset: usize) u32 {
if (offset + 4 > data.len) return 0;
return (@as(u32, data[offset]) << 24) |
(@as(u32, data[offset + 1]) << 16) |
(@as(u32, data[offset + 2]) << 8) |
@as(u32, data[offset + 3]);
}
// =============================================================================
// 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),
}
}
};
// =============================================================================
// Tests
// =============================================================================
test "TTF types" {
// Basic type tests
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 "readU16Big" {
const data = [_]u8{ 0x12, 0x34, 0x56, 0x78 };
try std.testing.expectEqual(@as(u16, 0x1234), readU16Big(&data, 0));
try std.testing.expectEqual(@as(u16, 0x3456), readU16Big(&data, 1));
}
test "readU32Big" {
const data = [_]u8{ 0x12, 0x34, 0x56, 0x78 };
try std.testing.expectEqual(@as(u32, 0x12345678), readU32Big(&data, 0));
}
test "TTF load and rasterize AdwaitaSans" {
const allocator = std.testing.allocator;
// Try to load AdwaitaSans from system
var font = TtfFont.loadFromFile(allocator, "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Regular.ttf") catch {
// Font not available on this system, skip test
return;
};
defer font.deinit();
// Verify font was parsed correctly
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 glyph outline
if (font.getGlyphOutline(glyph_index)) |outline| {
var outline_mut = outline;
defer outline_mut.deinit();
// 'A' should have contours (typically 2: outer and inner)
try std.testing.expect(outline.contours.len > 0);
// Rasterize
font.setSize(24);
if (rasterizeGlyph(allocator, outline, font.scale, 2)) |bitmap| {
var bitmap_mut = bitmap;
defer bitmap_mut.deinit();
// Should produce a bitmap with non-zero dimensions
try std.testing.expect(bitmap.width > 0);
try std.testing.expect(bitmap.height > 0);
// Should have some non-zero alpha values (actual glyph data)
var has_content = false;
for (bitmap.data) |alpha| {
if (alpha > 0) {
has_content = true;
break;
}
}
try std.testing.expect(has_content);
}
}
}
test "TTF rasterize multiple characters" {
const allocator = std.testing.allocator;
var font = TtfFont.loadFromFile(allocator, "/usr/share/fonts/adwaita-sans-fonts/AdwaitaSans-Regular.ttf") catch {
return; // Skip if font not available
};
defer font.deinit();
font.setSize(16);
// Test several characters
const test_chars = "AaBb0123";
for (test_chars) |c| {
const glyph_index = font.getGlyphIndex(c);
if (glyph_index == 0) continue;
if (font.getGlyphOutline(glyph_index)) |outline| {
var outline_mut = outline;
defer outline_mut.deinit();
if (rasterizeGlyph(allocator, outline, font.scale, 2)) |bitmap| {
var bitmap_mut = bitmap;
defer bitmap_mut.deinit();
try std.testing.expect(bitmap.width > 0);
try std.testing.expect(bitmap.height > 0);
}
}
}
}