Compare commits
10 commits
148bd862b5
...
315eb73bfa
| Author | SHA1 | Date | |
|---|---|---|---|
| 315eb73bfa | |||
| 102d5c09ea | |||
| 6e84aa815e | |||
| 58c88f2320 | |||
| 153afe5b72 | |||
| c1e864be61 | |||
| f1c85205f9 | |||
| 4c678c1f7e | |||
| 5c00c36802 | |||
| d0f08aacfa |
15 changed files with 791 additions and 106 deletions
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Zig
|
||||
uses: https://codeberg.org/mlugg/setup-zig@v2.1.0
|
||||
uses: https://codeberg.org/mlugg/setup-zig@v2.2.1
|
||||
- name: Build project
|
||||
run: zig build --summary all
|
||||
- name: Run tests
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[tools]
|
||||
pre-commit = "4.2.0"
|
||||
"ubi:DonIsaac/zlint" = "0.7.6"
|
||||
prek = "0.3.1"
|
||||
"ubi:DonIsaac/zlint" = "0.7.9"
|
||||
zig = "0.15.2"
|
||||
zls = "0.15.0"
|
||||
zls = "0.15.1"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v3.2.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
|
|
|||
58
build.zig
58
build.zig
|
|
@ -6,6 +6,14 @@ pub fn build(b: *std.Build) void {
|
|||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
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,
|
||||
.optimize = optimize,
|
||||
|
|
@ -16,6 +24,22 @@ 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", .{});
|
||||
|
|
@ -100,12 +124,6 @@ pub fn build(b: *std.Build) void {
|
|||
|
||||
maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{});
|
||||
|
||||
const version = GitVersion.getVersion(b, .{});
|
||||
const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false;
|
||||
const build_options = b.addOptions();
|
||||
build_options.addOption([]const u8, "version", version);
|
||||
build_options.addOption(bool, "download_geoip", download_geoip);
|
||||
|
||||
const root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
|
|
@ -113,19 +131,37 @@ 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);
|
||||
const libs = &[_]*std.Build.Step.Compile{
|
||||
maxminddb,
|
||||
phoon,
|
||||
sunriset,
|
||||
};
|
||||
|
||||
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 exe = b.addExecutable(.{
|
||||
.name = "wttr",
|
||||
.root_module = root_module,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,22 @@
|
|||
},
|
||||
.phoon = .{ .path = "libs/phoon_14Aug2014" },
|
||||
.sunriset = .{ .path = "libs/sunriset" },
|
||||
.ghostty = .{
|
||||
.url = "git+https://github.com/ghostty-org/ghostty#ec2912dbafe50cc32b786d2327dcd0213c83ecc6",
|
||||
.hash = "ghostty-1.3.0-dev-5UdBC_y2RASwYWn5fjn71WsP-arlg8wSICLc0rYiozdf",
|
||||
},
|
||||
.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",
|
||||
},
|
||||
},
|
||||
.fingerprint = 0x710c2b57e81aa678,
|
||||
.minimum_zig_version = "0.15.2",
|
||||
|
|
|
|||
BIN
libs/2914-LexiGulim090423.ttf
Normal file
BIN
libs/2914-LexiGulim090423.ttf
Normal file
Binary file not shown.
|
|
@ -26,6 +26,8 @@ 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
|
||||
|
|
@ -60,6 +62,7 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam
|
|||
'A' => params.ansi = true,
|
||||
'T' => params.text_only = true,
|
||||
't' => params.transparency = 150,
|
||||
'p' => params.add_frame = true,
|
||||
else => continue,
|
||||
}
|
||||
}
|
||||
|
|
@ -77,6 +80,8 @@ pub fn parse(allocator: std.mem.Allocator, query_string: []const u8) !QueryParam
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ allocator: std.mem.Allocator,
|
|||
httpz_server: httpz.Server(*Context),
|
||||
context: Context,
|
||||
|
||||
const Context = struct {
|
||||
pub const Context = struct {
|
||||
options: handler.HandleWeatherOptions,
|
||||
rate_limiter: *RateLimiter,
|
||||
|
||||
|
|
@ -45,6 +45,7 @@ pub fn init(
|
|||
rate_limiter: *RateLimiter,
|
||||
) !Server {
|
||||
const ctx = try allocator.create(Context);
|
||||
errdefer allocator.destroy(ctx);
|
||||
ctx.* = .{
|
||||
.options = options,
|
||||
.rate_limiter = rate_limiter,
|
||||
|
|
@ -241,27 +242,14 @@ test "handleWeather: default endpoint uses IP address" {
|
|||
try handler.handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody(
|
||||
\\<!DOCTYPE html>
|
||||
\\<html>
|
||||
\\<head>
|
||||
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
\\<link rel="stylesheet" href="https://adobe-fonts.github.io/source-code-pro/source-code-pro.css">
|
||||
\\<style>
|
||||
\\body{background:#000;color:#bbb}
|
||||
\\pre{font-family:"Source Code Pro","DejaVu Sans Mono",Menlo,"Lucida Sans Typewriter","Lucida Console",monaco,"Bitstream Vera Sans Mono",monospace;font-size:75%}
|
||||
\\</style>
|
||||
\\</head>
|
||||
\\<body><pre>
|
||||
\\Weather report: Union City, California, United States
|
||||
\\
|
||||
\\<span style="color:#ffff00"> \ / </span> Clear
|
||||
\\<span style="color:#ffff00"> .-. </span> <span style="color:#d7ff00">+68(+68)</span> °F
|
||||
\\<span style="color:#ffff00"> ― ( ) ― </span> ↓ <span style="color:#6c6c6c">3</span> mph
|
||||
\\<span style="color:#ffff00"> `-' </span> 6 mi
|
||||
\\<span style="color:#ffff00"> / \ </span> 0.0 in
|
||||
\\</pre></body></html>
|
||||
);
|
||||
// Don't assert exact location name since the GeoLite2 database updates
|
||||
// upstream and city mappings change over time. Verify structural properties:
|
||||
// response is HTML with a weather report containing expected weather data.
|
||||
const pr = try ht.parseResponse();
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "Weather report:") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "California, United States") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "°F") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "<!DOCTYPE html>") != null);
|
||||
}
|
||||
|
||||
test "handleWeather: x-forwarded-for with multiple IPs" {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ 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);
|
||||
|
||||
|
|
@ -19,6 +22,9 @@ pub const HandleWeatherOptions = struct {
|
|||
geoip: *@import("../location/GeoIp.zig"),
|
||||
};
|
||||
|
||||
/// Only used for shutdown route (/stop) in debug mode
|
||||
pub var server_instance: ?*httpz.Server(*@import("Server.zig").Context) = null;
|
||||
|
||||
pub fn handleWeather(
|
||||
opts: *HandleWeatherOptions,
|
||||
req: *httpz.Request,
|
||||
|
|
@ -33,17 +39,34 @@ 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| {
|
||||
break :blk loc;
|
||||
} else break :blk client_ip; // no location, just use client ip instead
|
||||
};
|
||||
if (server_instance) |s|
|
||||
if (std.mem.eql(u8, location, "stop")) {
|
||||
s.stop();
|
||||
return;
|
||||
};
|
||||
|
||||
if (std.mem.eql(u8, "favicon.ico", location)) {
|
||||
res.header("Content-Type", "image/x-icon");
|
||||
res.body = @embedFile("favicon.ico");
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, "robots.txt", location)) {
|
||||
res.content_type = .TEXT;
|
||||
res.body = help.robots_txt;
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, "sitemap.xml", location)) {
|
||||
res.header("Content-Type", "application/xml");
|
||||
res.body = help.sitemap_xml;
|
||||
return;
|
||||
}
|
||||
log.debug("location = {s}, client_ip = {s}", .{ location, client_ip });
|
||||
if (location.len == 0) {
|
||||
res.content_type = .TEXT;
|
||||
|
|
@ -75,16 +98,23 @@ fn handleWeatherInternal(
|
|||
) !void {
|
||||
const req_alloc = req.arena;
|
||||
|
||||
// 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_query.len == 0) {
|
||||
if (location_str.len == 0) {
|
||||
res.status = 404;
|
||||
res.body = "Location not found\n";
|
||||
return;
|
||||
}
|
||||
const location = opts.resolver.resolve(location_query) catch |err| {
|
||||
const location = opts.resolver.resolve(location_str) catch |err| {
|
||||
switch (err) {
|
||||
error.LocationNotFound => {
|
||||
log.debug("Location not found for query {s}", .{location_query});
|
||||
|
|
@ -118,6 +148,7 @@ 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;
|
||||
|
|
@ -140,50 +171,72 @@ fn handleWeatherInternal(
|
|||
const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude });
|
||||
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);
|
||||
|
||||
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| {
|
||||
// Anything except the json will be plain text
|
||||
res.content_type = .TEXT;
|
||||
if (std.mem.eql(u8, fmt, "1")) {
|
||||
try Line.render(res.writer(), weather, .@"1", render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "2")) {
|
||||
try Line.render(res.writer(), weather, .@"2", render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "3")) {
|
||||
try Line.render(res.writer(), weather, .@"3", render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "4")) {
|
||||
try Line.render(res.writer(), weather, .@"4", render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "j1")) {
|
||||
res.content_type = .JSON; // reset to json
|
||||
try Json.render(res.writer(), weather);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "p1")) {
|
||||
try Prometheus.render(res.writer(), weather);
|
||||
return;
|
||||
}
|
||||
if (std.mem.eql(u8, fmt, "v2")) {
|
||||
try V2.render(res.writer(), weather, render_options.use_imperial);
|
||||
return;
|
||||
}
|
||||
// Everything else goes to Custom renderer
|
||||
try Custom.render(res.writer(), weather, fmt, render_options.use_imperial);
|
||||
res.content_type = if (std.mem.eql(u8, fmt, "j1")) .JSON else .TEXT;
|
||||
} else {
|
||||
// No specific format selected, we'll provide Formatted output in either
|
||||
// text (ansi/plain) or html
|
||||
render_options.format = determineFormat(params, req.headers.get("user-agent"));
|
||||
log.debug(
|
||||
"Format: {}. params.ansi {}, params.text {}, user agent: {?s}",
|
||||
.{ render_options.format, params.ansi, params.text_only, req.headers.get("user-agent") },
|
||||
);
|
||||
if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML;
|
||||
try Formatted.render(res.writer(), weather, render_options);
|
||||
res.content_type = if (render_options.format == .html) .HTML else .TEXT;
|
||||
}
|
||||
try renderWeatherData(res.writer(), weather, params, render_options);
|
||||
}
|
||||
|
||||
fn renderWeatherData(
|
||||
writer: *std.Io.Writer,
|
||||
weather: types.WeatherData,
|
||||
params: QueryParams,
|
||||
render_options: Formatted.RenderOptions,
|
||||
) !void {
|
||||
if (params.format) |fmt| {
|
||||
if (std.mem.eql(u8, fmt, "1")) {
|
||||
try Line.render(writer, weather, .@"1", render_options.use_imperial);
|
||||
} else if (std.mem.eql(u8, fmt, "2")) {
|
||||
try Line.render(writer, weather, .@"2", render_options.use_imperial);
|
||||
} else if (std.mem.eql(u8, fmt, "3")) {
|
||||
try Line.render(writer, weather, .@"3", render_options.use_imperial);
|
||||
} else if (std.mem.eql(u8, fmt, "4")) {
|
||||
try Line.render(writer, weather, .@"4", render_options.use_imperial);
|
||||
} else if (std.mem.eql(u8, fmt, "j1")) {
|
||||
try Json.render(writer, weather);
|
||||
} else if (std.mem.eql(u8, fmt, "p1")) {
|
||||
try Prometheus.render(writer, weather);
|
||||
} else if (std.mem.eql(u8, fmt, "v2")) {
|
||||
try V2.render(writer, weather, render_options.use_imperial);
|
||||
} else {
|
||||
try Custom.render(writer, weather, fmt, render_options.use_imperial);
|
||||
}
|
||||
} else {
|
||||
try Formatted.render(writer, weather, render_options);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +324,44 @@ test "handler: favicon" {
|
|||
try ht.expectStatus(200);
|
||||
}
|
||||
|
||||
test "handler: robots.txt" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/robots.txt");
|
||||
ht.param("location", "robots.txt");
|
||||
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody(help.robots_txt);
|
||||
}
|
||||
|
||||
test "handler: sitemap.xml" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
||||
var harness = try MockHarness.init(allocator);
|
||||
defer harness.deinit();
|
||||
|
||||
var ht = httpz.testing.init(.{});
|
||||
defer ht.deinit();
|
||||
|
||||
ht.url("/sitemap.xml");
|
||||
ht.param("location", "sitemap.xml");
|
||||
|
||||
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody(help.sitemap_xml);
|
||||
}
|
||||
|
||||
test "handler: format j1 (json)" {
|
||||
const allocator = std.testing.allocator;
|
||||
const MockHarness = @import("Server.zig").MockHarness;
|
||||
|
|
@ -370,20 +461,12 @@ test "handler: format v2" {
|
|||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
// Should we have 2 empty lines?
|
||||
try ht.expectBody(
|
||||
\\Weather report: Union City, California, United States
|
||||
\\
|
||||
\\ Current conditions
|
||||
\\ Clear
|
||||
\\ 🌡️ 20.0°C (68.0°F)
|
||||
\\ 💧 50%
|
||||
\\ 🌬️ 5.0 km/h N
|
||||
\\ 🔽 1013.0 hPa
|
||||
\\ 💦 0.0 mm
|
||||
\\
|
||||
\\
|
||||
);
|
||||
// Don't assert exact location name since the GeoLite2 database updates
|
||||
// upstream and city mappings change over time. Verify structural properties.
|
||||
const pr = try ht.parseResponse();
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "Weather report:") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "California, United States") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "Current conditions") != null);
|
||||
}
|
||||
|
||||
test "handler: format custom (%c)" {
|
||||
|
|
@ -467,5 +550,9 @@ test "handler: format line 3" {
|
|||
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
|
||||
|
||||
try ht.expectStatus(200);
|
||||
try ht.expectBody("Union City, California, United States: ☀️ +20°C\n");
|
||||
// Don't assert exact location name since the GeoLite2 database updates
|
||||
// upstream and city mappings change over time. Verify structural properties.
|
||||
const pr = try ht.parseResponse();
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "California, United States:") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, pr.body, "°C") != null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,33 @@ pub const translation_page =
|
|||
\\
|
||||
;
|
||||
|
||||
pub const robots_txt =
|
||||
\\User-agent: *
|
||||
\\Disallow: /
|
||||
\\Allow: /:help
|
||||
\\Allow: /:translation
|
||||
\\Allow: /robots.txt
|
||||
\\Allow: /sitemap.xml
|
||||
\\
|
||||
;
|
||||
|
||||
pub const sitemap_xml =
|
||||
\\<?xml version="1.0" encoding="UTF-8"?>
|
||||
\\<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
\\ <url>
|
||||
\\ <loc>/:help</loc>
|
||||
\\ <changefreq>monthly</changefreq>
|
||||
\\ <priority>1.0</priority>
|
||||
\\ </url>
|
||||
\\ <url>
|
||||
\\ <loc>/:translation</loc>
|
||||
\\ <changefreq>monthly</changefreq>
|
||||
\\ <priority>0.8</priority>
|
||||
\\ </url>
|
||||
\\</urlset>
|
||||
\\
|
||||
;
|
||||
|
||||
test "help page exists" {
|
||||
try std.testing.expect(help_page.len > 0);
|
||||
}
|
||||
|
|
@ -133,3 +160,11 @@ test "help page exists" {
|
|||
test "translation page exists" {
|
||||
try std.testing.expect(translation_page.len > 0);
|
||||
}
|
||||
|
||||
test "robots.txt exists" {
|
||||
try std.testing.expect(robots_txt.len > 0);
|
||||
}
|
||||
|
||||
test "sitemap.xml exists" {
|
||||
try std.testing.expect(sitemap_xml.len > 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ pub const Airport = struct {
|
|||
|
||||
const Airports = @This();
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
arena: std.heap.ArenaAllocator,
|
||||
airports: std.StringHashMap(Airport),
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator) !Airports {
|
||||
|
|
@ -18,31 +18,28 @@ pub fn init(allocator: std.mem.Allocator) !Airports {
|
|||
}
|
||||
|
||||
pub fn initFromData(allocator: std.mem.Allocator, csv_data: []const u8) !Airports {
|
||||
var airports = std.StringHashMap(Airport).init(allocator);
|
||||
var arena = std.heap.ArenaAllocator.init(allocator);
|
||||
const alloc = arena.allocator();
|
||||
var airports = std.StringHashMap(Airport).init(alloc);
|
||||
|
||||
var lines = std.mem.splitScalar(u8, csv_data, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len == 0) continue;
|
||||
|
||||
const airport = parseAirportLine(allocator, line) catch continue;
|
||||
if (airport.iata.len == 3) {
|
||||
const airport = parseAirportLine(alloc, line) catch continue;
|
||||
if (airport.iata.len == 3 and airports.get(airport.iata) == null) {
|
||||
try airports.put(airport.iata, airport);
|
||||
}
|
||||
}
|
||||
|
||||
return Airports{
|
||||
.allocator = allocator,
|
||||
.arena = arena,
|
||||
.airports = airports,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Airports) void {
|
||||
var it = self.airports.iterator();
|
||||
while (it.next()) |entry| {
|
||||
self.allocator.free(entry.key_ptr.*);
|
||||
self.allocator.free(entry.value_ptr.name);
|
||||
}
|
||||
self.airports.deinit();
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
pub fn lookup(self: *Airports, iata_code: []const u8) ?Airport {
|
||||
|
|
|
|||
|
|
@ -96,11 +96,31 @@ pub fn isUSIp(self: *GeoIP, ip: []const u8) bool {
|
|||
return std.mem.eql(u8, country_code, "US");
|
||||
}
|
||||
|
||||
/// Maximum accuracy radius (in km) to trust from GeoLite2. Entries with a
|
||||
/// radius above this are too coarse for weather lookups (e.g. backbone/transit
|
||||
/// IPs that MaxMind maps to the wrong city) and should fall back to IP2Location.
|
||||
const max_accuracy_radius_km = 200;
|
||||
|
||||
fn extractCoordinates(self: *GeoIP, ip: []const u8, result: c.MMDB_lookup_result_s) ?Location {
|
||||
if (!result.found_entry) return null;
|
||||
|
||||
var entry_copy = result.entry;
|
||||
|
||||
// Check accuracy_radius first -- reject low-confidence entries so we
|
||||
// fall back to the IP2Location online lookup instead.
|
||||
// SAFETY: accuracy_data set by MMDB_get_value
|
||||
var accuracy_data: c.MMDB_entry_data_s = undefined;
|
||||
const acc_status = c.MMDB_get_value(&entry_copy, &accuracy_data, "location", "accuracy_radius", @as([*c]const u8, null));
|
||||
if (acc_status == c.MMDB_SUCCESS and accuracy_data.has_data) {
|
||||
const radius = accuracy_data.unnamed_0.uint16;
|
||||
if (radius > max_accuracy_radius_km) {
|
||||
log.info("GeoLite2 accuracy_radius for ip {s} is {d} km (>{d} km threshold), falling back to IP2Location", .{ ip, radius, max_accuracy_radius_km });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
entry_copy = result.entry;
|
||||
|
||||
// SAFETY: latitude_data set by MMDB_get_value
|
||||
var latitude_data: c.MMDB_entry_data_s = undefined;
|
||||
const lat_status = c.MMDB_get_value(&entry_copy, &latitude_data, "location", "latitude", @as([*c]const u8, null));
|
||||
|
|
@ -221,12 +241,16 @@ test "lookup works" {
|
|||
return error.SkipZigTest;
|
||||
defer geoip.deinit();
|
||||
|
||||
// Test that the function doesn't crash with various IPs
|
||||
// Test that lookup returns a valid location for a well-known residential IP.
|
||||
// We don't assert exact values since the GeoLite2 database is fetched from
|
||||
// the latest upstream release and city/coordinate mappings change over time.
|
||||
const maybe_result = geoip.lookup("73.158.64.1");
|
||||
|
||||
try std.testing.expect(maybe_result != null);
|
||||
|
||||
const result = maybe_result.?;
|
||||
defer result.deinit();
|
||||
try std.testing.expectEqual(@as(f64, 37.5958), result.coords.latitude);
|
||||
try std.testing.expect(result.coords.latitude > 37.0 and result.coords.latitude < 38.0);
|
||||
try std.testing.expect(result.coords.longitude < -121.0 and result.coords.longitude > -123.0);
|
||||
try std.testing.expect(result.name.len > 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -319,7 +319,11 @@ test "resolve IP address with GeoIP" {
|
|||
const location = try resolver.resolve(test_ip);
|
||||
defer location.deinit();
|
||||
|
||||
try std.testing.expectEqualStrings("Union City, California, United States", location.name);
|
||||
// Don't assert exact name/coords since the GeoLite2 database updates
|
||||
// upstream and city mappings shift over time. Just verify structural
|
||||
// properties: non-empty name containing "California", valid SF Bay Area coords.
|
||||
try std.testing.expect(location.name.len > 0);
|
||||
try std.testing.expect(std.mem.indexOf(u8, location.name, "California") != null);
|
||||
try std.testing.expect(location.coords.latitude != 0 or location.coords.longitude != 0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,11 @@ pub fn main() !u8 {
|
|||
.geoip = &geoip,
|
||||
}, &rate_limiter);
|
||||
|
||||
// Only set up the server instance in debug mode
|
||||
if (@import("builtin").mode == .Debug) @import("http/handler.zig").server_instance = &server.httpz_server;
|
||||
|
||||
try server.listen();
|
||||
std.debug.print("shutting down\n", .{});
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
489
src/render/Png.zig
Normal file
489
src/render/Png.zig
Normal file
|
|
@ -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, 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue