remove png support

This commit is contained in:
Emil Lerch 2026-08-04 16:19:37 -07:00
parent f9919d9521
commit 95f677eb7f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
10 changed files with 21 additions and 627 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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",

Binary file not shown.

View file

@ -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)

View file

@ -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;

View file

@ -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:
\\

View file

@ -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);
}