Something went wrong. Try again.
Draws glyphs from zig-font onto z2d surfaces from positioned glyph runs, with fontconfig-based font loading and fallback.
Something went wrong. Try again.
10 kB · 261 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Renders a line of text to a PNG, for looking at what the library draws.//!//! fontrender [options] -o out.png TEXT//!//! -f PATTERN a fontconfig pattern, such as "Noto Sans:bold"//! (default "sans-serif"), with fallback per character//! --font FILE[:N] a font file instead, face N of a collection//! --size PX pixels per em (default 48)//! --var TAG=V,... axis settings, such as wght=700,wdth=75//! --fg RRGGBB[AA] text color (default 000000)//! --bg RRGGBB[AA] background (default ffffff)//! --palette N CPAL palette for color glyphs (default 0)//! --no-color draw color glyphs as plain outlines//! --hinting MODE none, light or full; for a pattern, the default is//! what fontconfig says, and for a file, none//!//! The layout is the library's stand-in, with no shaping.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;const font = @import("font");const fc = @import("font_config");const font_loader = @import("font_loader");const renderer = @import("font_renderer");const z2d = @import("z2d");
const Args = struct { pattern: []const u8 = "sans-serif", file: ?[]const u8 = null, size: f32 = 48, variations: []const u8 = "", fg: z2d.pixel.RGBA = .{ .r = 0, .g = 0, .b = 0, .a = 255 }, bg: z2d.pixel.RGBA = .{ .r = 255, .g = 255, .b = 255, .a = 255 }, palette: u16 = 0, color: bool = true, hinting: ?renderer.Face.Hinting = null, output: ?[]const u8 = null, text: ?[]const u8 = null,};
pub fn main(init: std.process.Init) !void { const gpa = init.gpa; const io = init.io; const argv = try init.minimal.args.toSlice(init.arena.allocator());
var stderr_buffer: [4096]u8 = undefined; var stderr_writer: Io.File.Writer = .init(.stderr(), io, &stderr_buffer); const err_out = &stderr_writer.interface; defer err_out.flush() catch {};
const args = parseArgs(argv) catch |err| { try err_out.print("fontrender: {t}\n", .{err}); try err_out.writeAll(usage); std.process.exit(2); }; const output = args.output orelse { try err_out.writeAll(usage); std.process.exit(2); }; const text = args.text orelse { try err_out.writeAll(usage); std.process.exit(2); };
var vars: std.ArrayList(renderer.Face.Variation) = .empty; defer vars.deinit(gpa); try font_loader.Loader.parseVariations(gpa, args.variations, &vars);
var runs: []renderer.Run = &.{}; var end_x: f32 = 0; var primary: *const renderer.Face = undefined;
// Two ways to a face: a file named outright, or a pattern resolved // through the system's fontconfig configuration. Both keep what they // load until the image is written. var direct: ?*Direct = null; defer if (direct) |d| d.destroy(gpa); var config: ?*fc.Config = null; defer if (config) |c| c.deinit(); var loader: ?font_loader.Loader = null; defer if (loader) |*l| l.deinit(); var list: ?font_loader.FontList = null; defer if (list) |*l| l.deinit(); var laid: ?font_loader.fallback_layout.Layout = null; defer if (laid) |*l| l.deinit(gpa); var single: [1]renderer.Run = undefined;
if (args.file) |spec| { var path = spec; var index: u32 = 0; if (std.mem.findScalarLast(u8, spec, ':')) |colon| { if (std.fmt.parseInt(u32, spec[colon + 1 ..], 10)) |n| { path = spec[0..colon]; index = n; } else |_| {} } const d = try Direct.create(gpa, io, path, index, .{ .variations = vars.items, .hinting = args.hinting orelse .none, }, args.size, text); direct = d; single[0] = .{ .face = &d.face, .size = args.size, .glyphs = d.placed.glyphs }; runs = &single; end_x = d.placed.end_x; primary = &d.face; } else { config = try fc.Config.load(gpa, io, .fromMap(init.environ_map), .{ .prgname = "fontrender" }); try config.?.scan(io); loader = .init(gpa, io, config.?); var pattern: std.ArrayList(u8) = .empty; defer pattern.deinit(gpa); try pattern.appendSlice(gpa, args.pattern); if (args.hinting) |h| try pattern.appendSlice(gpa, switch (h) { .none => ":hintstyle=hintnone", .light => ":hintstyle=hintslight", .full => ":hintstyle=hintfull", }); list = try loader.?.resolveName(pattern.items); primary = try list.?.primary() orelse { try err_out.print("fontrender: no font matches \"{s}\"\n", .{args.pattern}); std.process.exit(1); }; laid = try font_loader.fallback_layout.layout(gpa, &list.?, args.size, text); runs = laid.?.runs; end_x = laid.?.end_x; for (0..list.?.fonts.len) |i| switch (list.?.states[i]) { .loaded => try err_out.print("loaded: {s}\n", .{list.?.path(i).?}), else => {}, }; }
// A margin of a quarter em all round, and the line's height from the // primary face's metrics. const m = primary.metrics().scaled(primary.scale(args.size)); const margin = args.size / 4; const width: i32 = @intFromFloat(@ceil(@max(1, end_x) + 2 * margin)); const height: i32 = @intFromFloat(@ceil(m.ascender - m.descender + 2 * margin)); var surface = try z2d.Surface.initPixel(args.bg.multiply().asPixel(), gpa, width, height); defer surface.deinit(gpa);
var cache: renderer.GlyphCache = .init(gpa, renderer.GlyphCache.default_budget); defer cache.deinit(); for (runs) |r| { try renderer.drawRun(gpa, &surface, r, margin, margin + m.ascender, .{ .color = args.fg.multiply().asPixel(), .palette = args.palette, .color_glyphs = args.color, .cache = &cache, }); } try z2d.png_exporter.writeToPNGFile(io, surface, output, .{});}
/// A font file loaded outright, its one face, and the text laid out in it.const Direct = struct { data: []u8, font: font.Font, face: renderer.Face, placed: renderer.simple_layout.Layout,
fn create( gpa: Allocator, io: Io, path: []const u8, index: u32, options: renderer.Face.Options, size: f32, text: []const u8, ) !*Direct { const d = try gpa.create(Direct); errdefer gpa.destroy(d); d.data = try Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(font_loader.Loader.max_file_size)); errdefer gpa.free(d.data); d.font = try font.Font.parseIndex(gpa, d.data, index, .{ .copy = false }); errdefer d.font.deinit(); d.face = try renderer.Face.init(gpa, &d.font, options); errdefer d.face.deinit(gpa); d.placed = try renderer.simple_layout.layout(gpa, &d.face, size, text); return d; }
fn destroy(d: *Direct, gpa: Allocator) void { d.placed.deinit(gpa); d.face.deinit(gpa); d.font.deinit(); gpa.free(d.data); gpa.destroy(d); }};
const usage = \\usage: fontrender [-f PATTERN | --font FILE[:N]] [--size PX] [--var TAG=V,...] \\ [--fg RRGGBB[AA]] [--bg RRGGBB[AA]] [--palette N] [--no-color] \\ [--hinting none|light|full] \\ -o OUT.png TEXT \\;
fn parseArgs(argv: []const []const u8) !Args { var a: Args = .{}; var i: usize = 1; while (i < argv.len) : (i += 1) { const arg = argv[i]; if (std.mem.eql(u8, arg, "--no-color")) { a.color = false; continue; } const takes_value = for ([_][]const u8{ "-f", "--font", "--size", "--var", "--fg", "--bg", "--palette", "--hinting", "-o" }) |o| { if (std.mem.eql(u8, arg, o)) break true; } else false; if (!takes_value) { if (arg.len > 1 and arg[0] == '-') return error.UnknownOption; if (a.text != null) return error.TooManyArguments; a.text = arg; continue; } i += 1; if (i >= argv.len) return error.MissingValue; const v = argv[i]; if (std.mem.eql(u8, arg, "-f")) { a.pattern = v; } else if (std.mem.eql(u8, arg, "--font")) { a.file = v; } else if (std.mem.eql(u8, arg, "--size")) { a.size = try std.fmt.parseFloat(f32, v); if (!(a.size > 0 and a.size <= 2048)) return error.BadSize; } else if (std.mem.eql(u8, arg, "--var")) { a.variations = v; } else if (std.mem.eql(u8, arg, "--fg")) { a.fg = try parseColor(v); } else if (std.mem.eql(u8, arg, "--bg")) { a.bg = try parseColor(v); } else if (std.mem.eql(u8, arg, "--palette")) { a.palette = try std.fmt.parseInt(u16, v, 10); } else if (std.mem.eql(u8, arg, "--hinting")) { a.hinting = std.meta.stringToEnum(renderer.Face.Hinting, v) orelse return error.BadHinting; } else if (std.mem.eql(u8, arg, "-o")) { a.output = v; } } return a;}
/// `RRGGBB` or `RRGGBBAA`, straight alpha.fn parseColor(s: []const u8) !z2d.pixel.RGBA { const hex = if (s.len > 0 and s[0] == '#') s[1..] else s; if (hex.len != 6 and hex.len != 8) return error.BadColor; var b: [4]u8 = .{ 0, 0, 0, 255 }; for (0..hex.len / 2) |k| b[k] = try std.fmt.parseInt(u8, hex[k * 2 ..][0..2], 16); return .{ .r = b[0], .g = b[1], .b = b[2], .a = b[3] };}
test "colors parse with and without alpha" { try std.testing.expectEqual(z2d.pixel.RGBA{ .r = 0x12, .g = 0x34, .b = 0x56, .a = 255 }, try parseColor("123456")); try std.testing.expectEqual(z2d.pixel.RGBA{ .r = 0x12, .g = 0x34, .b = 0x56, .a = 0x78 }, try parseColor("#12345678")); try std.testing.expectError(error.BadColor, parseColor("12345"));}