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>
1161 lines
39 KiB
Zig
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);
|
|
}
|
|
}
|
|
}
|
|
}
|