diff --git a/build.zig b/build.zig index 7151001..8e0f6f3 100644 --- a/build.zig +++ b/build.zig @@ -137,6 +137,9 @@ pub fn build(b: *std.Build) void { root_module.addAnonymousImport("JetBrainsMono-Regular.ttf", .{ .root_source_file = jetbrains_mono.path("fonts/ttf/JetBrainsMono-Regular.ttf"), }); + root_module.addAnonymousImport("LexiGulim.ttf", .{ + .root_source_file = b.path("libs/2914-LexiGulim090423.ttf"), + }); root_module.addAnonymousImport("SymbolsNerdFont-Regular.ttf", .{ .root_source_file = nerd_fonts.path("SymbolsNerdFont-Regular.ttf"), }); diff --git a/libs/2914-LexiGulim090423.ttf b/libs/2914-LexiGulim090423.ttf new file mode 100644 index 0000000..bcfadd8 Binary files /dev/null and b/libs/2914-LexiGulim090423.ttf differ diff --git a/src/render/Png.zig b/src/render/Png.zig new file mode 100644 index 0000000..b939973 --- /dev/null +++ b/src/render/Png.zig @@ -0,0 +1,489 @@ +const std = @import("std"); +const zigimg = @import("zigimg"); +const freetype = @import("freetype"); + +const Png = @This(); + +allocator: std.mem.Allocator, +buffer: std.ArrayList(u8), + +pub const PngOptions = struct { + transparency: u8 = 150, + background: ?[]const u8 = null, + add_frame: bool = false, +}; + +const Color = struct { + r: u8, + g: u8, + b: u8, + a: u8, +}; + +const CHAR_WIDTH = 8; +const CHAR_HEIGHT = 14; +const FRAME_PADDING = 10; +const FRAME_BORDER = 2; + +pub fn init(allocator: std.mem.Allocator) Png { + return .{ + .allocator = allocator, + .buffer = .{}, + }; +} + +pub fn deinit(self: *Png) void { + self.buffer.deinit(self.allocator); +} + +pub fn writer(self: *Png) std.ArrayList(u8).Writer { + return self.buffer.writer(self.allocator); +} + +pub fn render(self: *Png, output: *std.Io.Writer, options: PngOptions) !void { + // Parse ANSI text to get dimensions and content + var parsed = try parseAnsiText(self.allocator, self.buffer.items); + defer parsed.deinit(); + + if (parsed.lines.items.len == 0) { + return error.NoTextToRender; + } + + // Calculate image dimensions + const content_width: u32 = @intCast(parsed.max_width * CHAR_WIDTH); + const content_height: u32 = @intCast(parsed.lines.items.len * CHAR_HEIGHT); + + std.debug.print("PNG: max_width={}, lines={}, content={}x{}\n", .{ parsed.max_width, parsed.lines.items.len, content_width, content_height }); + + const padding: u32 = if (options.add_frame) FRAME_PADDING else 0; + const border: u32 = if (options.add_frame) FRAME_BORDER else 0; + const total_padding = padding + border; + + const img_width = content_width + (total_padding * 2); + const img_height = content_height + (total_padding * 2); + + // Parse background color - default to black (matching legacy wttr.in) + const bg_color = if (options.background) |bg| + try parseColor(bg, 255) // Opaque if background color specified + else + Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; // Black background by default + + // Initialize FreeType + var ft_lib = try freetype.Library.init(); + defer ft_lib.deinit(); + + // Load fonts + const mono_font_data = @embedFile("LexiGulim.ttf"); + const symbol_font_data = @embedFile("SymbolsNerdFont-Regular.ttf"); + + var mono_face = try ft_lib.initMemoryFace(mono_font_data, 0); + defer mono_face.deinit(); + + var symbol_face = try ft_lib.initMemoryFace(symbol_font_data, 0); + defer symbol_face.deinit(); + + try mono_face.setCharSize(0, 13 * 64, 0, 0); + try symbol_face.setCharSize(0, 13 * 64, 0, 0); + + // Create image buffer + var image = try zigimg.Image.create(self.allocator, img_width, img_height, .rgba32); + defer image.deinit(self.allocator); + + // Fill background + fillBackground(&image, bg_color); + + // Draw frame if requested + if (options.add_frame) { + drawFrame(&image, border); + } + + // Render text + const x_offset = total_padding; + const y_offset = total_padding; + + for (parsed.lines.items, 0..) |line, row| { + for (line.chars.items, 0..) |char_info, col| { + const x = x_offset + @as(u32, @intCast(col)) * CHAR_WIDTH; + const y = y_offset + @as(u32, @intCast(row)) * CHAR_HEIGHT; + + const face = if (isSymbol(char_info.codepoint)) &symbol_face else &mono_face; + try renderChar( + &image, + face, + char_info.codepoint, + x, + y, + char_info.fg_color, + char_info.bg_color, + ); + } + } + + // Encode to PNG + var write_buffer: [1024 * 1024]u8 = undefined; + const png_data = try image.writeToMemory(self.allocator, &write_buffer, .{ .png = .{} }); + try output.writeAll(png_data); +} + +const ParsedText = struct { + allocator: std.mem.Allocator, + lines: std.ArrayList(Line), + max_width: usize, + + const Line = struct { + chars: std.ArrayList(CharInfo), + }; + + const CharInfo = struct { + codepoint: u21, + fg_color: Color, + bg_color: Color, + }; + + fn deinit(self: *ParsedText) void { + for (self.lines.items) |*line| { + line.chars.deinit(self.allocator); + } + self.lines.deinit(self.allocator); + } +}; + +fn parseAnsiText(allocator: std.mem.Allocator, text: []const u8) !ParsedText { + var result = ParsedText{ + .allocator = allocator, + .lines = .{}, + .max_width = 0, + }; + + var current_line = ParsedText.Line{ + .chars = .{}, + }; + + var fg_color = Color{ .r = 255, .g = 255, .b = 255, .a = 255 }; // white for dark bg + var bg_color = Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; // black background + + var i: usize = 0; + while (i < text.len) { + if (text[i] == '\x1b' and i + 1 < text.len and text[i + 1] == '[') { + // ANSI escape sequence + const seq_end = std.mem.indexOfScalarPos(u8, text, i, 'm') orelse text.len; + const seq = text[i + 2 .. seq_end]; + + // Parse color codes + var iter = std.mem.splitScalar(u8, seq, ';'); + var codes: std.ArrayList(u8) = .{}; + defer codes.deinit(allocator); + + while (iter.next()) |code_str| { + const code = std.fmt.parseInt(u8, code_str, 10) catch continue; + try codes.append(allocator, code); + } + + // Handle 256-color codes: ESC[38;5;Nm or ESC[48;5;Nm + if (codes.items.len >= 3 and codes.items[0] == 38 and codes.items[1] == 5) { + fg_color = ansi256ToRgb(codes.items[2]); + } else if (codes.items.len >= 3 and codes.items[0] == 48 and codes.items[1] == 5) { + bg_color = ansi256ToRgb(codes.items[2]); + } else { + // Basic 16-color codes + for (codes.items) |code| { + fg_color = parseAnsiColor(code, fg_color); + } + } + + i = seq_end + 1; + } else if (text[i] == '\n') { + if (current_line.chars.items.len > result.max_width) { + result.max_width = current_line.chars.items.len; + } + try result.lines.append(allocator, current_line); + current_line = ParsedText.Line{ + .chars = .{}, + }; + i += 1; + } else { + // Regular character + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1; + const codepoint = std.unicode.utf8Decode(text[i .. i + len]) catch '?'; + + try current_line.chars.append(allocator, .{ + .codepoint = codepoint, + .fg_color = fg_color, + .bg_color = bg_color, + }); + + i += len; + } + } + + // Add last line + if (current_line.chars.items.len > 0) { + if (current_line.chars.items.len > result.max_width) { + result.max_width = current_line.chars.items.len; + } + try result.lines.append(allocator, current_line); + } + + return result; +} + +fn parseAnsiColor(code: u8, current: Color) Color { + return switch (code) { + 0 => Color{ .r = 255, .g = 255, .b = 255, .a = 255 }, // reset to white + 30 => Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, // black + 31 => Color{ .r = 205, .g = 49, .b = 49, .a = 255 }, // red + 32 => Color{ .r = 13, .g = 188, .b = 121, .a = 255 }, // green + 33 => Color{ .r = 229, .g = 229, .b = 16, .a = 255 }, // yellow + 34 => Color{ .r = 36, .g = 114, .b = 200, .a = 255 }, // blue + 35 => Color{ .r = 188, .g = 63, .b = 188, .a = 255 }, // magenta + 36 => Color{ .r = 17, .g = 168, .b = 205, .a = 255 }, // cyan + 37 => Color{ .r = 229, .g = 229, .b = 229, .a = 255 }, // white + else => current, + }; +} + +fn ansi256ToRgb(code: u8) Color { + // ANSI 256 color palette + if (code < 16) { + // Basic 16 colors + return switch (code) { + 0 => Color{ .r = 0, .g = 0, .b = 0, .a = 255 }, + 1 => Color{ .r = 205, .g = 49, .b = 49, .a = 255 }, + 2 => Color{ .r = 13, .g = 188, .b = 121, .a = 255 }, + 3 => Color{ .r = 229, .g = 229, .b = 16, .a = 255 }, + 4 => Color{ .r = 36, .g = 114, .b = 200, .a = 255 }, + 5 => Color{ .r = 188, .g = 63, .b = 188, .a = 255 }, + 6 => Color{ .r = 17, .g = 168, .b = 205, .a = 255 }, + 7 => Color{ .r = 229, .g = 229, .b = 229, .a = 255 }, + 8 => Color{ .r = 102, .g = 102, .b = 102, .a = 255 }, + 9 => Color{ .r = 241, .g = 76, .b = 76, .a = 255 }, + 10 => Color{ .r = 35, .g = 209, .b = 139, .a = 255 }, + 11 => Color{ .r = 245, .g = 245, .b = 67, .a = 255 }, + 12 => Color{ .r = 59, .g = 142, .b = 234, .a = 255 }, + 13 => Color{ .r = 214, .g = 112, .b = 214, .a = 255 }, + 14 => Color{ .r = 41, .g = 184, .b = 219, .a = 255 }, + 15 => Color{ .r = 255, .g = 255, .b = 255, .a = 255 }, + else => Color{ .r = 255, .g = 255, .b = 255, .a = 255 }, + }; + } else if (code < 232) { + // 216 color cube (6x6x6) + const idx = code - 16; + const r = (idx / 36) % 6; + const g = (idx / 6) % 6; + const b = idx % 6; + return Color{ + .r = if (r > 0) @as(u8, @intCast(55 + r * 40)) else 0, + .g = if (g > 0) @as(u8, @intCast(55 + g * 40)) else 0, + .b = if (b > 0) @as(u8, @intCast(55 + b * 40)) else 0, + .a = 255, + }; + } else { + // Grayscale (24 shades) + const gray: u8 = @intCast(8 + (code - 232) * 10); + return Color{ .r = gray, .g = gray, .b = gray, .a = 255 }; + } +} + +fn parseColor(hex: []const u8, alpha: u8) !Color { + if (hex.len != 6) return error.InvalidColor; + + const r = try std.fmt.parseInt(u8, hex[0..2], 16); + const g = try std.fmt.parseInt(u8, hex[2..4], 16); + const b = try std.fmt.parseInt(u8, hex[4..6], 16); + + return Color{ .r = r, .g = g, .b = b, .a = alpha }; +} + +fn fillBackground(image: *zigimg.Image, color: Color) void { + const pixels = image.pixels.rgba32; + for (pixels) |*pixel| { + pixel.* = .{ .r = color.r, .g = color.g, .b = color.b, .a = color.a }; + } +} + +fn drawFrame(image: *zigimg.Image, border_width: u32) void { + const pixels = image.pixels.rgba32; + const width = image.width; + const height = image.height; + + const frame_color = zigimg.color.Rgba32{ .r = 255, .g = 255, .b = 255, .a = 255 }; + + // Draw border + for (0..height) |y| { + for (0..width) |x| { + if (x < border_width or x >= width - border_width or + y < border_width or y >= height - border_width) + { + pixels[y * width + x] = frame_color; + } + } + } +} + +fn isSymbol(codepoint: u21) bool { + // Nerd Fonts symbol ranges + return (codepoint >= 0xe000 and codepoint <= 0xf8ff) or // Private Use Area + (codepoint >= 0xf0000 and codepoint <= 0xffffd) or // Supplementary Private Use Area-A + (codepoint >= 0x100000 and codepoint <= 0x10fffd); // Supplementary Private Use Area-B +} + +fn renderChar( + image: *zigimg.Image, + face: *freetype.Face, + codepoint: u21, + x: u32, + y: u32, + fg_color: Color, + bg_color: Color, +) !void { + // Draw background for this character cell first + const pixels = image.pixels.rgba32; + const width = image.width; + + var dy: u32 = 0; + while (dy < CHAR_HEIGHT) : (dy += 1) { + var dx: u32 = 0; + while (dx < CHAR_WIDTH) : (dx += 1) { + const px = x + dx; + const py = y + dy; + if (px < width and py < image.height) { + const idx = py * width + px; + pixels[idx] = .{ .r = bg_color.r, .g = bg_color.g, .b = bg_color.b, .a = bg_color.a }; + } + } + } + + const glyph_index = face.getCharIndex(codepoint) orelse return; + try face.loadGlyph(glyph_index, .{ .render = true }); + + const glyph_slot = face.handle.*.glyph; + const bitmap = glyph_slot.*.bitmap; + + if (bitmap.width == 0 or bitmap.rows == 0) return; + + const buffer = bitmap.buffer orelse return; + + // Match original wttr.in: y + runeHeight - 3 + const base_x: i32 = @as(i32, @intCast(x)) + glyph_slot.*.bitmap_left; + const base_y: i32 = @as(i32, @intCast(y + CHAR_HEIGHT - 3)) - glyph_slot.*.bitmap_top; + + for (0..bitmap.rows) |row| { + for (0..bitmap.width) |col| { + const alpha = buffer[row * @as(usize, @intCast(bitmap.pitch)) + col]; + if (alpha == 0) continue; + + const px: i32 = base_x + @as(i32, @intCast(col)); + const py: i32 = base_y + @as(i32, @intCast(row)); + + if (px < 0 or py < 0 or px >= width or py >= image.height) continue; + + const idx = @as(u32, @intCast(py)) * width + @as(u32, @intCast(px)); + const bg = pixels[idx]; + + // Proper alpha blending: blend foreground with background + const alpha_f: u16 = alpha; + const alpha_inv = 255 - alpha_f; + + pixels[idx] = .{ + .r = @intCast((alpha_f * fg_color.r + alpha_inv * bg.r) / 255), + .g = @intCast((alpha_f * fg_color.g + alpha_inv * bg.g) / 255), + .b = @intCast((alpha_f * fg_color.b + alpha_inv * bg.b) / 255), + .a = 255, + }; + } + } +} + +test "parseColor valid hex" { + const color = try parseColor("ff0000", 255); + try std.testing.expectEqual(@as(u8, 255), color.r); + try std.testing.expectEqual(@as(u8, 0), color.g); + try std.testing.expectEqual(@as(u8, 0), color.b); + try std.testing.expectEqual(@as(u8, 255), color.a); +} + +test "parseColor with transparency" { + const color = try parseColor("00ff00", 128); + try std.testing.expectEqual(@as(u8, 0), color.r); + try std.testing.expectEqual(@as(u8, 255), color.g); + try std.testing.expectEqual(@as(u8, 0), color.b); + try std.testing.expectEqual(@as(u8, 128), color.a); +} + +test "parseColor invalid length" { + try std.testing.expectError(error.InvalidColor, parseColor("fff", 255)); + try std.testing.expectError(error.InvalidColor, parseColor("fffffff", 255)); +} + +test "parseAnsiText simple text" { + const allocator = std.testing.allocator; + const text = "Hello"; + var parsed = try parseAnsiText(allocator, text); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(usize, 1), parsed.lines.items.len); + try std.testing.expectEqual(@as(usize, 5), parsed.lines.items[0].chars.items.len); + try std.testing.expectEqual(@as(u21, 'H'), parsed.lines.items[0].chars.items[0].codepoint); +} + +test "parseAnsiText with newlines" { + const allocator = std.testing.allocator; + const text = "Line1\nLine2\nLine3"; + var parsed = try parseAnsiText(allocator, text); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(usize, 3), parsed.lines.items.len); + try std.testing.expectEqual(@as(usize, 5), parsed.max_width); +} + +test "parseAnsiText with color codes" { + const allocator = std.testing.allocator; + const text = "\x1b[31mRed\x1b[0m"; + var parsed = try parseAnsiText(allocator, text); + defer parsed.deinit(); + + try std.testing.expectEqual(@as(usize, 1), parsed.lines.items.len); + try std.testing.expectEqual(@as(usize, 3), parsed.lines.items[0].chars.items.len); + + const first_char = parsed.lines.items[0].chars.items[0]; + try std.testing.expectEqual(@as(u8, 205), first_char.fg_color.r); +} + +test "parseAnsiColor codes" { + const white = Color{ .r = 255, .g = 255, .b = 255, .a = 255 }; + + const red = parseAnsiColor(31, white); + try std.testing.expectEqual(@as(u8, 205), red.r); + + const green = parseAnsiColor(32, white); + try std.testing.expectEqual(@as(u8, 188), green.g); + + const reset = parseAnsiColor(0, red); + try std.testing.expectEqual(@as(u8, 0), reset.r); // Reset to black +} + +test "isSymbol detects nerd font ranges" { + try std.testing.expect(isSymbol(0xe000)); + try std.testing.expect(isSymbol(0xf8ff)); + try std.testing.expect(!isSymbol(0x0041)); // 'A' + try std.testing.expect(!isSymbol(0x263a)); // ☺ +} + +test "init and deinit" { + const allocator = std.testing.allocator; + var png = init(allocator); + defer png.deinit(); + + try std.testing.expectEqual(@as(usize, 0), png.buffer.items.len); +} + +test "writer captures data" { + const allocator = std.testing.allocator; + var png = init(allocator); + defer png.deinit(); + + const w = png.writer(); + try w.writeAll("test data"); + + try std.testing.expectEqualStrings("test data", png.buffer.items); +}