diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6649f5b..17abbfa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=80"] + args: ["build", "coverage", "-Ddownload-geoip", "-Dcoverage-threshold=80"] language: system types: [file] pass_filenames: false diff --git a/MISSING_FEATURES.md b/MISSING_FEATURES.md index 7f199f3..351125f 100644 --- a/MISSING_FEATURES.md +++ b/MISSING_FEATURES.md @@ -2,23 +2,28 @@ Features not yet implemented in the Zig version: -## 1. PNG Generation -- Render weather reports as PNG images -- Support transparency and custom styling -- Requires image rendering library integration - -## 2. Language/Localization +## 1. Language/Localization - Accept-Language header parsing - lang query parameter support - Translation of weather conditions and text (54 languages) -## 3. Json output +## 2. Json output - Does not match wttr.in format -## 4. Moon endpoint +## 3. Moon endpoint - `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented - Moon phase calculation is implemented and available in custom format (%m, %M) +## ~~PNG Generation~~ +- Render weather reports as PNG images +- Support transparency and custom styling + +This was implemented behind a `-Denable-png` build flag and has been removed. It +was slow and memory-intensive (a full font stack plus megabyte-scale buffers per +request), carried its own set of bugs, and saw effectively no use — it did not +justify its maintenance cost or the dependency surface it pulled in (zigimg, +freetype via ghostty, and two embedded font packages). + ## ~~Multiple Locations Support~~ - Handle colon-separated locations (e.g., `London:Paris:Berlin`) - Process and display weather for multiple cities in one request diff --git a/README.md b/README.md index 1ad8205..54c188b 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ *wttr* is a console-oriented weather forecast service written in Zig, based on [wttr.in](https://wttr.in). wttr supports various information representation methods like terminal-oriented -ANSI-sequences for console HTTP clients (curl, httpie, or wget), HTML for web -browsers, or PNG for graphical viewers. +ANSI-sequences for console HTTP clients (curl, httpie, or wget), and HTML for +web browsers. ## Usage diff --git a/build.zig b/build.zig index 9f91a9c..149eac8 100644 --- a/build.zig +++ b/build.zig @@ -8,11 +8,9 @@ pub fn build(b: *std.Build) void { const version = GitVersion.getVersion(b, .{}); const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false; - const enable_png = b.option(bool, "enable-png", "Enable PNG image generation (adds zigimg, freetype, and embedded fonts)") orelse false; const build_options = b.addOptions(); build_options.addOption([]const u8, "version", version); build_options.addOption(bool, "download_geoip", download_geoip); - build_options.addOption(bool, "enable_png", enable_png); const httpz = b.dependency("httpz", .{ .target = target, @@ -24,22 +22,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); - const zigimg = if (enable_png) b.dependency("zigimg", .{ - .target = target, - .optimize = optimize, - }) else null; - - const freetype = if (enable_png) b.dependency("ghostty", .{ - .target = target, - .optimize = optimize, - }).builder.dependency("freetype", .{ - .target = target, - .optimize = optimize, - }) else null; - - const jetbrains_mono = if (enable_png) b.dependency("jetbrains_mono", .{}) else null; - const nerd_fonts = if (enable_png) b.dependency("nerd_fonts_symbols_only", .{}) else null; - const openflights = b.dependency("openflights", .{}); const maxminddb_upstream = b.dependency("maxminddb", .{}); @@ -143,36 +125,16 @@ pub fn build(b: *std.Build) void { }); root_module.addImport("httpz", httpz.module("httpz")); root_module.addImport("zeit", zeit.module("zeit")); - if (zigimg) |dep| root_module.addImport("zigimg", dep.module("zigimg")); - if (freetype) |dep| root_module.addImport("freetype", dep.module("freetype")); root_module.addAnonymousImport("airports.dat", .{ .root_source_file = openflights.path("data/airports.dat"), }); - if (jetbrains_mono) |dep| root_module.addAnonymousImport("JetBrainsMono-Regular.ttf", .{ - .root_source_file = dep.path("fonts/ttf/JetBrainsMono-Regular.ttf"), - }); - if (enable_png) root_module.addAnonymousImport("LexiGulim.ttf", .{ - .root_source_file = b.path("libs/2914-LexiGulim090423.ttf"), - }); - if (nerd_fonts) |dep| root_module.addAnonymousImport("SymbolsNerdFont-Regular.ttf", .{ - .root_source_file = dep.path("SymbolsNerdFont-Regular.ttf"), - }); root_module.addOptions("build_options", build_options); root_module.addIncludePath(maxminddb_upstream.path("include")); root_module.addIncludePath(b.path("libs/phoon_14Aug2014")); root_module.addIncludePath(b.path("libs/sunriset")); root_module.addConfigHeader(maxminddb_config); - var libs_buf: [4]*std.Build.Step.Compile = undefined; - libs_buf[0] = maxminddb; - libs_buf[1] = phoon; - libs_buf[2] = sunriset; - var libs_len: usize = 3; - if (freetype) |dep| { - libs_buf[libs_len] = dep.artifact("freetype"); - libs_len += 1; - } - const libs = libs_buf[0..libs_len]; + const libs: []const *std.Build.Step.Compile = &.{ maxminddb, phoon, sunriset }; const exe = b.addExecutable(.{ .name = "wttr", diff --git a/build.zig.zon b/build.zig.zon index bec3e11..02c1d1c 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -20,23 +20,6 @@ }, .phoon = .{ .path = "libs/phoon_14Aug2014" }, .sunriset = .{ .path = "libs/sunriset" }, - .ghostty = .{ - .url = "git+https://github.com/ghostty-org/ghostty#ccb08f35f683d6087786dda8e793e911ef1a2f8a", - .hash = "ghostty-1.3.2-dev-5UdBC9KHOwUAvF5QKNA6pbo-dV0WE-F9l5NtaT0mhpmu", - }, - .zigimg = .{ - .url = "git+https://github.com/zigimg/zigimg#9714df09f76891323c7fdbbbf23a17b79024fffb", - .hash = "zigimg-0.1.0-8_eo2j4mFwCU7tWnqvkYtzqe-OPRn_bxEql_IJhW85LT", - }, - .jetbrains_mono = .{ - .url = "https://deps.files.ghostty.org/JetBrainsMono-2.304.tar.gz", - .hash = "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x", - }, - .nerd_fonts_symbols_only = .{ - .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", - .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", - .lazy = true, - }, }, .fingerprint = 0x710c2b57e81aa678, .minimum_zig_version = "0.16.0", diff --git a/libs/2914-LexiGulim090423.ttf b/libs/2914-LexiGulim090423.ttf deleted file mode 100644 index bcfadd8..0000000 Binary files a/libs/2914-LexiGulim090423.ttf and /dev/null differ diff --git a/src/http/QueryParams.zig b/src/http/QueryParams.zig index bf9c02e..5d13a06 100644 --- a/src/http/QueryParams.zig +++ b/src/http/QueryParams.zig @@ -25,9 +25,6 @@ const QueryParams = @This(); format: ?[]const u8 = null, lang: ?[]const u8 = null, location: ?[]const u8 = null, -transparency: ?u8 = null, -background: ?[]const u8 = null, -add_frame: bool = false, /// A: Ignore user agent and force ansi mode ansi: bool = false, /// T: Avoid terminal sequences and just output plain text @@ -61,8 +58,6 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam 'Q' => render_options.super_quiet = true, 'A' => params.ansi = true, 'T' => params.text_only = true, - 't' => params.transparency = 150, - 'p' => params.add_frame = true, else => continue, } } @@ -76,12 +71,6 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam params.use_imperial = true; } else if (std.mem.eql(u8, key, "use_metric")) { params.use_imperial = false; - } else if (std.mem.eql(u8, key, "transparency")) { - if (value) |v| { - params.transparency = try std.fmt.parseInt(u8, v, 10); - } - } else if (std.mem.eql(u8, key, "background")) { - params.background = if (value) |v| try allocator.dupe(u8, v) else null; } } @@ -145,16 +134,6 @@ test "parse multiple parameters" { try std.testing.expect(!params.use_imperial.?); } -test "parse transparency" { - const allocator = std.testing.allocator; - const params_t = try QueryParams.parse(allocator, "t"); - try std.testing.expect(params_t.transparency != null); - try std.testing.expectEqual(@as(u8, 150), params_t.transparency.?); - - const params_custom = try QueryParams.parse(allocator, "transparency=200"); - try std.testing.expectEqual(@as(u8, 200), params_custom.transparency.?); -} - test "imperial units selection logic" { // This test documents the priority order for unit selection: // 1. Explicit ?u or ?m parameter (highest priority) diff --git a/src/http/handler.zig b/src/http/handler.zig index a2996c3..d9fa42c 100644 --- a/src/http/handler.zig +++ b/src/http/handler.zig @@ -9,10 +9,8 @@ const Json = @import("../render/Json.zig"); const V2 = @import("../render/V2.zig"); const Custom = @import("../render/Custom.zig"); const Prometheus = @import("../render/Prometheus.zig"); -const Png = if (build_options.enable_png) @import("../render/Png.zig") else struct {}; const help = @import("help.zig"); const types = @import("../weather/types.zig"); -const build_options = @import("build_options"); const log = std.log.scoped(.handler); @@ -41,7 +39,6 @@ pub fn handleWeather( defer { if (params.format) |f| req.arena.free(f); if (params.lang) |l| req.arena.free(l); - if (params.background) |b| req.arena.free(b); } if (params.location) |loc| { @@ -103,23 +100,16 @@ fn handleWeatherInternal( // stays free of I/O. const now_unix_s = std.Io.Timestamp.now(opts.io, .real).toSeconds(); - // Check for PNG request - const is_png = if (comptime build_options.enable_png) - std.mem.endsWith(u8, location_query, ".png") - else - false; - const location_str = if (is_png) location_query[0 .. location_query.len - 4] else location_query; - // Resolve location. By the time we get here, we really // should have a location from the path, query string, or // client IP lookup. So if we have an empty location parameter, it // is better to 404 than to fake it with a London response - if (location_str.len == 0) { + if (location_query.len == 0) { res.status = 404; res.body = "Location not found\n"; return; } - const location = opts.resolver.resolve(location_str) catch |err| { + const location = opts.resolver.resolve(location_query) catch |err| { switch (err) { error.LocationNotFound => { log.debug("Location not found for query {s}", .{location_query}); @@ -153,7 +143,6 @@ fn handleWeatherInternal( defer { if (params.format) |f| req_alloc.free(f); if (params.lang) |l| req_alloc.free(l); - if (params.background) |b| req_alloc.free(b); } var render_options = params.render_options; @@ -177,31 +166,6 @@ fn handleWeatherInternal( res.headers.add("X-Location-Coordinates", coords_header); // Render weather data - if (comptime build_options.enable_png) { - if (is_png) { - res.content_type = .PNG; - var png_renderer = Png.init(req_alloc); - defer png_renderer.deinit(); - - var png_buffer: [1024 * 1024]u8 = undefined; - var png_writer_impl = std.Io.Writer.fixed(&png_buffer); - const png_writer = &png_writer_impl; - - render_options.format = .ansi; // Force ANSI for PNG - try renderWeatherData(png_writer, weather, params, render_options, now_unix_s); - - const text_output = png_buffer[0..png_writer_impl.end]; - try png_renderer.buffer.appendSlice(req_alloc, text_output); - - const png_options = Png.PngOptions{ - .transparency = params.transparency orelse 150, - .background = params.background, - .add_frame = params.add_frame, - }; - try png_renderer.render(res.writer(), png_options); - return; - } - } // Set content type based on format if (params.format) |fmt| { res.content_type = if (std.mem.eql(u8, fmt, "j1")) .JSON else .TEXT; diff --git a/src/http/help.zig b/src/http/help.zig index bae0947..1eabbb3 100644 --- a/src/http/help.zig +++ b/src/http/help.zig @@ -75,20 +75,10 @@ pub const help_page = \\ %s # sunset time \\ %d # dusk time \\ - \\PNG options: - \\ - \\ /paris.png # generate a PNG file - \\ p # add frame around the output - \\ t # transparency 150 - \\ transparency=... # transparency from 0 to 255 (255 = not transparent) - \\ background=... # background color in form RRGGBB, e.g. 00aaaa - \\ \\Options can be combined: \\ - \\ /Paris?0pq - \\ /Paris?0pq&lang=fr - \\ /Paris_0pq.png # in PNG the file mode are specified after _ - \\ /Rome_0pq_lang=it.png # long options are separated with underscore + \\ /Paris?0q + \\ /Paris?0q&lang=fr \\ \\* Localization: \\ diff --git a/src/render/Png.zig b/src/render/Png.zig deleted file mode 100644 index f500b48..0000000 --- a/src/render/Png.zig +++ /dev/null @@ -1,489 +0,0 @@ -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, 255), reset.r); // Reset to white -} - -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); -}