diff --git a/src/render/ttf.zig b/src/render/ttf.zig index 7b692ed..5d22c81 100644 --- a/src/render/ttf.zig +++ b/src/render/ttf.zig @@ -23,6 +23,250 @@ 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.ArrayList(Edge).init(allocator); + defer edges_list.deinit(); + + for (outline.contours) |contour| { + collectEdgesFromContour(&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 + const alpha: u8 = @intFromFloat((@as(f32, @floatFromInt(coverage)) / ss_sq) * 255.0); + data[py * width + px] = alpha; + } + } + + return GlyphBitmap{ + .data = data, + .width = width, + .height = height, + .bearing_x = @intFromFloat(x_min_f), + .bearing_y = @intFromFloat(y_max_f), // TTF Y is up, we flip + .allocator = allocator, + }; +} + +/// Collect edges from a contour, handling bezier curves +fn collectEdgesFromContour( + edges: *std.ArrayList(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(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(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( + edges: *std.ArrayList(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(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(edges: *std.ArrayList(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(Edge{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1, .direction = direction }); + } else { + try edges.append(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, @@ -365,6 +609,160 @@ pub const TtfFont = struct { }; } + /// 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) { @@ -438,7 +836,7 @@ pub const TtfFont = struct { return @intFromFloat(@as(f32, @floatFromInt(self.metrics.descent)) * self.scale); } - /// Draw text using TTF font + /// Draw text using TTF font with real glyph rasterization pub fn drawText( self: *Self, fb: *Framebuffer, @@ -453,90 +851,119 @@ pub const TtfFont = struct { for (text) |c| { if (c == '\n') continue; + if (c == ' ') { + const metrics = self.getGlyphMetrics(c); + cx += @intCast(metrics.advance); + continue; + } - const metrics = self.getGlyphMetrics(c); + // Try to get cached glyph or rasterize + const cache_key = makeCacheKey(c, self.render_size); - // For now, draw a simple placeholder rectangle - // Full glyph rasterization would require bezier curve rendering - self.drawGlyphPlaceholder(fb, cx + metrics.bearing_x, baseline_y, c, color, clip); + 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(); + } - cx += @intCast(metrics.advance); + // 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 simple placeholder for glyph (rectangle-based) - fn drawGlyphPlaceholder( + /// Draw a cached glyph bitmap with alpha blending + fn drawGlyphBitmap( self: Self, fb: *Framebuffer, x: i32, baseline_y: i32, - char: u8, + glyph: CachedGlyph, color: Color, clip: Rect, ) void { - // Simple placeholder rendering - draw a rectangle for each character - // In a full implementation, this would rasterize the actual glyph outline + _ = self; - const char_height = self.render_size; - const char_width: u16 = @intFromFloat(@as(f32, @floatFromInt(char_height)) * 0.6); + // 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 top_y = baseline_y - @as(i32, @intCast(char_height * 3 / 4)); + const width = glyph.metrics.width; + const height = glyph.metrics.height; - // Draw character based on simple patterns - switch (char) { - ' ' => {}, // Space - nothing to draw - '.' => { - // Dot at baseline - const dot_size: i32 = @max(1, @as(i32, char_height / 8)); - const dot_x = x + @as(i32, char_width / 2) - dot_size / 2; - const dot_y = baseline_y - dot_size; - fb.fillRect(dot_x, dot_y, @intCast(dot_size), @intCast(dot_size), color, clip); - }, - '-' => { - // Horizontal line in middle - const line_y = baseline_y - @as(i32, char_height / 3); - const line_h: u32 = @max(1, char_height / 8); - fb.fillRect(x + 1, line_y, char_width - 2, line_h, color, clip); - }, - '_' => { - // Underline at baseline - const line_h: u32 = @max(1, char_height / 8); - fb.fillRect(x, baseline_y, char_width, line_h, color, clip); - }, - '|' => { - // Vertical line - const line_w: u32 = @max(1, char_height / 8); - const line_x = x + @as(i32, char_width / 2) - @as(i32, @intCast(line_w / 2)); - fb.fillRect(line_x, top_y, line_w, char_height, color, clip); - }, - '/' => { - // Diagonal (approximate with vertical shifted) - const line_w: u32 = @max(1, char_height / 8); - var py: i32 = 0; - while (py < char_height) : (py += 1) { - const px = x + @as(i32, char_width) - (py * @as(i32, char_width)) / @as(i32, char_height); - fb.fillRect(px, top_y + py, line_w, 1, color, clip); + // 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 { + const bg = fb.getPixel(@intCast(screen_x), @intCast(screen_y)); + const blended = blendColors(color, bg, alpha); + fb.setPixel(@intCast(screen_x), @intCast(screen_y), blended); } - }, - '\\' => { - const line_w: u32 = @max(1, char_height / 8); - var py: i32 = 0; - while (py < char_height) : (py += 1) { - const px = x + (py * @as(i32, char_width)) / @as(i32, char_height); - fb.fillRect(px, top_y + py, line_w, 1, color, clip); - } - }, - else => { - // Default: draw a simple block for visibility - const inset: i32 = 1; - const block_w = if (char_width > 2) char_width - 2 else char_width; - const block_h = if (char_height > 2) char_height - 2 else char_height; - fb.fillRect(x + inset, top_y + inset, block_w, block_h, color, clip); - }, + } } } }; +/// 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 // =============================================================================