diff --git a/build.zig b/build.zig index ec59822..95831ef 100644 --- a/build.zig +++ b/build.zig @@ -7,6 +7,15 @@ pub fn build(b: *std.Build) void { // Check if building for WASM const is_wasm = target.result.cpu.arch == .wasm32 or target.result.cpu.arch == .wasm64; + // =========================================== + // Dependencies + // =========================================== + const zcatttf_dep = b.dependency("zcatttf", .{ + .target = target, + .optimize = optimize, + }); + const zcatttf_mod = zcatttf_dep.module("zcatttf"); + // =========================================== // Main library module // =========================================== @@ -15,6 +24,9 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .link_libc = !is_wasm, + .imports = &.{ + .{ .name = "zcatttf", .module = zcatttf_mod }, + }, }); // Link SDL2 to the module (only for native builds) @@ -31,6 +43,9 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .link_libc = true, + .imports = &.{ + .{ .name = "zcatttf", .module = zcatttf_mod }, + }, }), }); lib_unit_tests.root_module.linkSystemLibrary("SDL2", .{}); @@ -127,31 +142,6 @@ pub fn build(b: *std.Build) void { const table_step = b.step("table-demo", "Run table demo with split panels"); table_step.dependOn(&run_table.step); - // =========================================== - // Debug tools - // =========================================== - - // cmap debug - TTF font table diagnostics - const cmap_debug_exe = b.addExecutable(.{ - .name = "cmap-debug", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/render/cmap_debug.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .imports = &.{ - .{ .name = "zcatgui", .module = zcatgui_mod }, - }, - }), - }); - cmap_debug_exe.root_module.linkSystemLibrary("SDL2", .{}); - b.installArtifact(cmap_debug_exe); - - const run_cmap_debug = b.addRunArtifact(cmap_debug_exe); - run_cmap_debug.step.dependOn(b.getInstallStep()); - const cmap_debug_step = b.step("cmap-debug", "Run TTF cmap diagnostics"); - cmap_debug_step.dependOn(&run_cmap_debug.step); - // =========================================== // WASM Build // =========================================== @@ -162,10 +152,20 @@ pub fn build(b: *std.Build) void { .os_tag = .freestanding, }); + // zcatttf for WASM + const zcatttf_wasm_dep = b.dependency("zcatttf", .{ + .target = wasm_target, + .optimize = .ReleaseSmall, + }); + const zcatttf_wasm_mod = zcatttf_wasm_dep.module("zcatttf"); + const zcatgui_wasm_mod = b.createModule(.{ .root_source_file = b.path("src/zcatgui.zig"), .target = wasm_target, .optimize = .ReleaseSmall, + .imports = &.{ + .{ .name = "zcatttf", .module = zcatttf_wasm_mod }, + }, }); // WASM demo executable @@ -205,11 +205,21 @@ pub fn build(b: *std.Build) void { .abi = .android, }); + // zcatttf for Android ARM64 + const zcatttf_android_dep = b.dependency("zcatttf", .{ + .target = android_target, + .optimize = .ReleaseSafe, + }); + const zcatttf_android_mod = zcatttf_android_dep.module("zcatttf"); + const zcatgui_android_mod = b.createModule(.{ .root_source_file = b.path("src/zcatgui.zig"), .target = android_target, .optimize = .ReleaseSafe, .link_libc = true, + .imports = &.{ + .{ .name = "zcatttf", .module = zcatttf_android_mod }, + }, }); // Android demo shared library @@ -249,11 +259,21 @@ pub fn build(b: *std.Build) void { .abi = .android, }); + // zcatttf for Android x86_64 + const zcatttf_android_x86_dep = b.dependency("zcatttf", .{ + .target = android_x86_target, + .optimize = .ReleaseSafe, + }); + const zcatttf_android_x86_mod = zcatttf_android_x86_dep.module("zcatttf"); + const zcatgui_android_x86_mod = b.createModule(.{ .root_source_file = b.path("src/zcatgui.zig"), .target = android_x86_target, .optimize = .ReleaseSafe, .link_libc = true, + .imports = &.{ + .{ .name = "zcatttf", .module = zcatttf_android_x86_mod }, + }, }); const android_demo_x86_mod = b.createModule(.{ @@ -293,11 +313,21 @@ pub fn build(b: *std.Build) void { .os_tag = .ios, }); + // zcatttf for iOS + const zcatttf_ios_dep = b.dependency("zcatttf", .{ + .target = ios_target, + .optimize = .ReleaseSafe, + }); + const zcatttf_ios_mod = zcatttf_ios_dep.module("zcatttf"); + const zcatgui_ios_mod = b.createModule(.{ .root_source_file = b.path("src/zcatgui.zig"), .target = ios_target, .optimize = .ReleaseSafe, .link_libc = true, + .imports = &.{ + .{ .name = "zcatttf", .module = zcatttf_ios_mod }, + }, }); // iOS static library (object file that can be linked into iOS app) @@ -323,11 +353,21 @@ pub fn build(b: *std.Build) void { .abi = .simulator, }); + // zcatttf for iOS Simulator + const zcatttf_ios_sim_dep = b.dependency("zcatttf", .{ + .target = ios_sim_target, + .optimize = .ReleaseSafe, + }); + const zcatttf_ios_sim_mod = zcatttf_ios_sim_dep.module("zcatttf"); + const zcatgui_ios_sim_mod = b.createModule(.{ .root_source_file = b.path("src/zcatgui.zig"), .target = ios_sim_target, .optimize = .ReleaseSafe, .link_libc = true, + .imports = &.{ + .{ .name = "zcatttf", .module = zcatttf_ios_sim_mod }, + }, }); const ios_sim_lib = b.addExecutable(.{ diff --git a/build.zig.zon b/build.zig.zon index a358aa2..25ef7f9 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -4,7 +4,11 @@ .version = "0.1.0", .minimum_zig_version = "0.15.0", - .dependencies = .{}, + .dependencies = .{ + .zcatttf = .{ + .path = "../zcatttf", + }, + }, .paths = .{ "build.zig", diff --git a/src/render/ttf.zig b/src/render/ttf.zig index f053ffb..646a573 100644 --- a/src/render/ttf.zig +++ b/src/render/ttf.zig @@ -1,13 +1,12 @@ //! TTF Font Support //! //! TrueType font loading and rendering support. -//! Uses a simplified Zig implementation for basic TTF parsing. +//! Uses zcatttf library for parsing and rasterization. //! //! Features: //! - Load TTF files from memory or file -//! - Rasterize glyphs at any size +//! - Rasterize glyphs at any size with antialiasing //! - Glyph caching for performance -//! - Kerning support (basic) const std = @import("std"); const Allocator = std.mem.Allocator; @@ -16,268 +15,16 @@ 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 +// TTF Data Types (compatibility layer) // ============================================================================= -/// 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 @@ -315,36 +62,27 @@ pub const FontMetrics = struct { }; // ============================================================================= -// TTF Font +// TTF Font (wrapper around zcatttf) // ============================================================================= /// TrueType font pub const TtfFont = struct { allocator: Allocator, - /// Raw font data + /// zcatttf font instance + font: zcatttf.Font, + + /// Raw font data (kept for reference) 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 + /// Font metrics (cached) 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), @@ -354,6 +92,12 @@ pub const TtfFont = struct { /// 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 @@ -370,30 +114,41 @@ pub const TtfFont = struct { return error.IncompleteRead; } - var font = try initFromMemory(allocator, data); - font.owns_data = true; - return font; + 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(), + }, }; - try self.parseHeader(); self.setSize(16); - return self; } - /// Initialize from embedded font (AdwaitaSans) - /// Convenience function for zero external dependencies + /// Initialize from embedded font (DroidSans) pub fn initEmbedded(allocator: Allocator) !Self { const embedded = @import("embedded_font.zig"); - return initFromMemory(allocator, embedded.adwaita_sans_data); + return initFromMemory(allocator, embedded.font_data); } /// Deinitialize font @@ -405,406 +160,34 @@ pub const TtfFont = struct { } 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)); } } - /// 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)); + self.font.setPixelHeight(@floatFromInt(size)); } /// 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, - }; + return self.font.getGlyphIndex(@intCast(codepoint)); } /// 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)), - }; - } + const hm = self.font.getScaledHMetrics(glyph_index); + return .{ + .advance = @intFromFloat(hm.advance_width), + .lsb = @intFromFloat(hm.left_side_bearing), + }; } /// Get glyph metrics (scaled) @@ -813,37 +196,43 @@ pub const TtfFont = struct { 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), + .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; - for (text) |c| { - const metrics = self.getGlyphMetrics(c); + 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 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); + const vm = self.font.getScaledVMetrics(); + return @intFromFloat(vm.ascender - vm.descender + vm.line_gap); } /// Get ascent (scaled) pub fn ascent(self: Self) i32 { - return @intFromFloat(@as(f32, @floatFromInt(self.metrics.ascent)) * self.scale); + const vm = self.font.getScaledVMetrics(); + return @intFromFloat(vm.ascender); } /// Get descent (scaled) pub fn descent(self: Self) i32 { - return @intFromFloat(@as(f32, @floatFromInt(self.metrics.descent)) * self.scale); + const vm = self.font.getScaledVMetrics(); + return @intFromFloat(vm.descender); } /// Draw text using TTF font with real glyph rasterization @@ -859,52 +248,63 @@ pub const TtfFont = struct { var cx = x; const baseline_y = y + self.ascent(); - for (text) |c| { - if (c == '\n') continue; - if (c == ' ') { - const metrics = self.getGlyphMetrics(c); + 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(c, self.render_size); + 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); - const metrics = self.getGlyphMetrics(c); - cx += @intCast(metrics.advance); + cx += @intCast(cached.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 using zcatttf + const glyph_index = self.getGlyphIndex(codepoint); + if (glyph_index == 0) continue; - // 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, - }; + if (self.font.rasterizeGlyph(glyph_index)) |bitmap| { + var bitmap_mut = bitmap; + defer bitmap_mut.deinit(); - self.glyph_cache.put(cache_key, cached_glyph) catch {}; - self.drawGlyphBitmap(fb, cx, baseline_y, cached_glyph, color, clip); - } + // 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); } - - const metrics = self.getGlyphMetrics(c); - cx += @intCast(metrics.advance); } } } @@ -921,13 +321,15 @@ pub const TtfFont = struct { ) void { _ = self; - // Calculate position: bearing_y is distance from baseline to top + // Calculate position: bearing_y is distance from baseline to top of glyph 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; + if (width == 0 or height == 0) return; + // Draw each pixel with alpha blending for (0..height) |py| { for (0..width) |px| { @@ -981,21 +383,48 @@ fn blendColors(fg: Color, bg: Color, alpha: u8) Color { }; } -// ============================================================================= -// Helper functions -// ============================================================================= +/// Decode UTF-8 codepoint from byte slice +fn decodeUtf8(bytes: []const u8) ?struct { char: u32, len: usize } { + if (bytes.len == 0) return null; -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]); -} + const b0 = bytes[0]; -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]); + // 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; } // ============================================================================= @@ -1043,12 +472,29 @@ pub const FontRef = union(FontType) { } }; +// ============================================================================= +// 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" { - // Basic type tests const metrics = GlyphMetrics{ .width = 10, .height = 12, @@ -1069,28 +515,34 @@ test "FontRef bitmap" { 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 "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 "readU32Big" { - const data = [_]u8{ 0x12, 0x34, 0x56, 0x78 }; - try std.testing.expectEqual(@as(u32, 0x12345678), readU32Big(&data, 0)); -} - -test "TTF load and rasterize AdwaitaSans" { +test "TtfFont with zcatttf" { 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 { + // 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 parsed correctly + // Verify font was loaded try std.testing.expect(font.num_glyphs > 0); try std.testing.expect(font.metrics.units_per_em > 0); @@ -1098,64 +550,11 @@ test "TTF load and rasterize AdwaitaSans" { 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(); + // Get metrics + const metrics = font.getGlyphMetrics('A'); + try std.testing.expect(metrics.advance > 0); - // '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); - } - } - } + // Test text width + const width = font.textWidth("Hello"); + try std.testing.expect(width > 0); }