feat(ttf): Implementar rasterización TTF con antialiasing

- Añadir GlyphPoint, Contour, GlyphOutline para representar contornos
- Implementar getGlyphOutline() para parsear tabla glyf
- Implementar rasterizeGlyph() con supersampling 2x
- Scanline fill con non-zero winding rule
- Subdivisión de curvas Bezier cuadráticas
- Cache de glyphs por codepoint+size
- Alpha blending para antialiasing
- Reemplazar drawGlyphPlaceholder con renderizado real

El código parsea contornos TTF y los rasteriza con antialiasing.
Próximo paso: test visual con AdwaitaSans.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-16 00:42:02 +01:00
parent 54626c8edf
commit 69745ba857

View file

@ -23,6 +23,250 @@ const Rect = Layout.Rect;
// TTF Data Types // 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 /// TTF table directory entry
const TableEntry = struct { const TableEntry = struct {
tag: [4]u8, 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 /// Get horizontal metrics for glyph
pub fn getHMetrics(self: Self, glyph_index: u16) struct { advance: u16, lsb: i16 } { pub fn getHMetrics(self: Self, glyph_index: u16) struct { advance: u16, lsb: i16 } {
if (self.hmtx_offset == 0 or self.hhea_offset == 0) { 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); 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( pub fn drawText(
self: *Self, self: *Self,
fb: *Framebuffer, fb: *Framebuffer,
@ -453,90 +851,119 @@ pub const TtfFont = struct {
for (text) |c| { for (text) |c| {
if (c == '\n') continue; 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 if (self.glyph_cache.get(cache_key)) |cached| {
// Full glyph rasterization would require bezier curve rendering // Draw cached glyph
self.drawGlyphPlaceholder(fb, cx + metrics.bearing_x, baseline_y, c, color, clip); 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) /// Draw a cached glyph bitmap with alpha blending
fn drawGlyphPlaceholder( fn drawGlyphBitmap(
self: Self, self: Self,
fb: *Framebuffer, fb: *Framebuffer,
x: i32, x: i32,
baseline_y: i32, baseline_y: i32,
char: u8, glyph: CachedGlyph,
color: Color, color: Color,
clip: Rect, clip: Rect,
) void { ) void {
// Simple placeholder rendering - draw a rectangle for each character _ = self;
// In a full implementation, this would rasterize the actual glyph outline
const char_height = self.render_size; // Calculate position: bearing_y is distance from baseline to top
const char_width: u16 = @intFromFloat(@as(f32, @floatFromInt(char_height)) * 0.6); 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 // Draw each pixel with alpha blending
switch (char) { for (0..height) |py| {
' ' => {}, // Space - nothing to draw for (0..width) |px| {
'.' => { const alpha = glyph.bitmap[py * width + px];
// Dot at baseline if (alpha == 0) continue;
const dot_size: i32 = @max(1, @as(i32, char_height / 8));
const dot_x = x + @as(i32, char_width / 2) - dot_size / 2; const screen_x = glyph_x + @as(i32, @intCast(px));
const dot_y = baseline_y - dot_size; const screen_y = glyph_y + @as(i32, @intCast(py));
fb.fillRect(dot_x, dot_y, @intCast(dot_size), @intCast(dot_size), color, clip);
}, // Clip check
'-' => { if (screen_x < clip.x or screen_x >= clip.x + @as(i32, @intCast(clip.w))) continue;
// Horizontal line in middle if (screen_y < clip.y or screen_y >= clip.y + @as(i32, @intCast(clip.h))) continue;
const line_y = baseline_y - @as(i32, char_height / 3); if (screen_x < 0 or screen_y < 0) continue;
const line_h: u32 = @max(1, char_height / 8); if (screen_x >= @as(i32, @intCast(fb.width)) or screen_y >= @as(i32, @intCast(fb.height))) continue;
fb.fillRect(x + 1, line_y, char_width - 2, line_h, color, clip);
}, // Alpha blend
'_' => { if (alpha == 255) {
// Underline at baseline fb.setPixel(@intCast(screen_x), @intCast(screen_y), color);
const line_h: u32 = @max(1, char_height / 8); } else {
fb.fillRect(x, baseline_y, char_width, line_h, color, clip); 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);
// 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);
} }
}, }
'\\' => {
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 // Helper functions
// ============================================================================= // =============================================================================