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.
7.6 kB · 232 lines
Zig
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Rendered glyphs, kept so that drawing the same glyph again composites an//! image instead of filling an outline.//!//! An entry is one glyph of one face at one size, at one of sixteen//! positions within a pixel — a quarter pixel each way — and, for a color//! glyph, in one palette with one foreground color. Glyphs with nothing to//! draw are cached too, as the absence of an image, so that a space costs a//! lookup and not an outline decode.//!//! The cache holds at most `budget` bytes of pixels and drops the least//! recently used entries to stay under it. Not thread-safe.
const std = @import("std");const Allocator = std.mem.Allocator;const Face = @import("Face.zig");const raster = @import("raster.zig");
const GlyphCache = @This();
gpa: Allocator,budget: usize,used: usize = 0,map: std.AutoHashMapUnmanaged(Key, *Node) = .empty,/// Most recently used first.head: ?*Node = null,tail: ?*Node = null,
/// What makes two renderings of a glyph the same.pub const Key = struct { face: usize, glyph_id: u16, /// The bits of the `f32` size, so that equal sizes are equal keys. size: u32, /// Quarter pixels, 0 to 3. sub_x: u2, sub_y: u2, color: bool, anti_aliasing: u8, /// Zero for an outline glyph, whose image does not depend on them. palette: u16, foreground: u32,};
const Node = struct { key: Key, image: ?raster.Image, prev: ?*Node = null, next: ?*Node = null,
fn bytes(n: *const Node) usize { // Something for an empty entry too, so that a flood of them is // bounded like anything else. const overhead = @sizeOf(Node) + @sizeOf(Key) + @sizeOf(*Node); return overhead + if (n.image) |img| img.byteSize() else 0; }};
/// The default budget: room for a few thousand glyphs at text sizes.pub const default_budget = 16 * 1024 * 1024;
pub fn init(gpa: Allocator, budget: usize) GlyphCache { return .{ .gpa = gpa, .budget = budget };}
pub fn deinit(cache: *GlyphCache) void { cache.clear(); cache.map.deinit(cache.gpa); cache.* = undefined;}
/// Drop every entry.pub fn clear(cache: *GlyphCache) void { var n = cache.head; while (n) |node| { n = node.next; cache.destroy(node); } cache.map.clearRetainingCapacity(); cache.head = null; cache.tail = null; cache.used = 0;}
/// Drop every entry rendered from `face`. Must be called before a face that/// has been drawn through this cache is freed or moved: entries are keyed/// by its address, and a new face at the same address would otherwise be/// drawn with the old one's glyphs.pub fn evictFace(cache: *GlyphCache, face: *const Face) void { const addr = @intFromPtr(face); var n = cache.head; while (n) |node| { n = node.next; if (node.key.face == addr) cache.remove(node); }}
/// How many entries there are.pub fn count(cache: *const GlyphCache) usize { return cache.map.count();}
/// The entry for `key`, marked as most recently used. The outer null is a/// miss; the inner one an entry for a glyph with nothing to draw. The image/// is valid until the next `put`, `evictFace` or `clear`.pub fn get(cache: *GlyphCache, key: Key) ??*const raster.Image { const node = cache.map.get(key) orelse return null; cache.unlink(node); cache.pushFront(node); return if (node.image) |*img| img else @as(?*const raster.Image, null);}
/// Take ownership of `image` as the entry for `key`, dropping older entries/// to stay within the budget. An image larger than the whole budget is not/// kept and is freed at once. On failure `image` is freed.pub fn put(cache: *GlyphCache, key: Key, image: ?raster.Image) Allocator.Error!void { var probe: Node = .{ .key = key, .image = image }; const size = probe.bytes(); if (size > cache.budget) { if (probe.image) |*img| img.deinit(cache.gpa); return; } const node = cache.gpa.create(Node) catch |err| { if (probe.image) |*img| img.deinit(cache.gpa); return err; }; node.* = probe; const gop = cache.map.getOrPut(cache.gpa, key) catch |err| { cache.destroy(node); return err; }; if (gop.found_existing) { const old = gop.value_ptr.*; cache.unlink(old); cache.used -= old.bytes(); cache.destroy(old); } gop.value_ptr.* = node; cache.pushFront(node); cache.used += size;
while (cache.used > cache.budget) { const last = cache.tail orelse break; if (last == node) break; cache.remove(last); }}
fn remove(cache: *GlyphCache, node: *Node) void { _ = cache.map.remove(node.key); cache.unlink(node); cache.used -= node.bytes(); cache.destroy(node);}
fn destroy(cache: *GlyphCache, node: *Node) void { if (node.image) |*img| img.deinit(cache.gpa); cache.gpa.destroy(node);}
fn unlink(cache: *GlyphCache, node: *Node) void { if (node.prev) |p| p.next = node.next else cache.head = node.next; if (node.next) |n| n.prev = node.prev else cache.tail = node.prev; node.prev = null; node.next = null;}
fn pushFront(cache: *GlyphCache, node: *Node) void { node.prev = null; node.next = cache.head; if (cache.head) |h| h.prev = node else cache.tail = node; cache.head = node;}
fn testImage(gpa: Allocator, w: i32, h: i32) !raster.Image { const z2d = @import("z2d"); return .{ .surface = try z2d.Surface.init(.image_surface_alpha8, gpa, w, h), .left = 0, .top = 0 };}
fn testKey(glyph_id: u16) Key { return .{ .face = 1, .glyph_id = glyph_id, .size = 0, .sub_x = 0, .sub_y = 0, .color = false, .anti_aliasing = 0, .palette = 0, .foreground = 0, };}
test "least recently used entries go first" { const gpa = std.testing.allocator; const entry = @sizeOf(Node) + @sizeOf(Key) + @sizeOf(*Node); var cache: GlyphCache = .init(gpa, 3 * (entry + 100)); defer cache.deinit();
try cache.put(testKey(1), try testImage(gpa, 10, 10)); try cache.put(testKey(2), try testImage(gpa, 10, 10)); try cache.put(testKey(3), null); try std.testing.expectEqual(@as(usize, 3), cache.count()); // Touch 1, so that 2 is now the oldest. try std.testing.expect(cache.get(testKey(1)) != null); try cache.put(testKey(4), try testImage(gpa, 10, 10)); try std.testing.expect(cache.get(testKey(2)) == null); try std.testing.expect(cache.get(testKey(1)) != null); // An empty entry is a hit with no image. try std.testing.expectEqual(@as(?*const raster.Image, null), cache.get(testKey(3)).?); try std.testing.expect(cache.used <= cache.budget);}
test "an image larger than the budget is not kept" { const gpa = std.testing.allocator; var cache: GlyphCache = .init(gpa, 64); defer cache.deinit(); try cache.put(testKey(1), try testImage(gpa, 100, 100)); try std.testing.expectEqual(@as(usize, 0), cache.count());}
test "replacing an entry frees the old one" { const gpa = std.testing.allocator; var cache: GlyphCache = .init(gpa, default_budget); defer cache.deinit(); try cache.put(testKey(1), try testImage(gpa, 10, 10)); try cache.put(testKey(1), try testImage(gpa, 20, 20)); try std.testing.expectEqual(@as(usize, 1), cache.count()); try std.testing.expectEqual(@as(i32, 20), cache.get(testKey(1)).?.?.surface.getWidth());}