From cf548a3aadf53f55865e270abc27db152f0002d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jun 2026 18:34:36 -0700 Subject: [PATCH] terminal/apc: glyph glossary registration business logic --- src/terminal/apc/glyph/Glossary.zig | 193 ++++++++++++++++++++++------ 1 file changed, 154 insertions(+), 39 deletions(-) diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig index f3ef420f2..1af290127 100644 --- a/src/terminal/apc/glyph/Glossary.zig +++ b/src/terminal/apc/glyph/Glossary.zig @@ -4,6 +4,7 @@ const Glossary = @This(); const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const CircBuf = @import("../../../datastruct/circ_buf.zig").CircBuf; const face = @import("../../../font/face.zig"); @@ -13,6 +14,13 @@ const glyf_rasterize = @import("../../../font/glyf_rasterize.zig"); const request = @import("request.zig"); const RegisterReq = request.Request.Register; +/// Maximum entries allowed in the glossary before eviction. +/// Defined by the specification. +pub const max_entries = 1024; + +/// An empty glossary with no registered glyphs. +pub const empty: Glossary = .{ .entries = .empty }; + /// The set of entries in the glossary keyed by the codepoint. /// /// The array hash map preserves insertion order and has O(N) @@ -24,7 +32,51 @@ const RegisterReq = request.Request.Register; /// I'm also operating under the assumption that full glossaries /// for a session will be rare, so the eviction cost shouldn't /// happen regularly. -entries: std.AutoArrayHashMap(u21, Entry), +entries: std.AutoArrayHashMapUnmanaged(u21, Entry), + +/// Release all glyph entries and hash map storage owned by the glossary. +pub fn deinit(self: *Glossary, alloc: Allocator) void { + for (self.entries.values()) |*entry| entry.deinit(alloc); + self.entries.deinit(alloc); + self.* = undefined; +} + +/// Register the given glyph entry. +/// +/// This will act according to the glyph specification +pub fn register( + self: *Glossary, + alloc: Allocator, + cp: u21, + entry: Entry, +) (Allocator.Error || error{OutOfNamespace})!void { + // Validate codepoint according to spec. + if (!isPrivateUse(cp)) return error.OutOfNamespace; + + const gop = try self.entries.getOrPut(alloc, cp); + if (gop.found_existing) { + // Found an existing entry, we need to shift the FIFO so + // that this is now the most recent (at the end). This is + // O(N) but N is usually small and max N is bounded by the spec. + gop.value_ptr.*.deinit(alloc); + assert(self.entries.orderedRemove(cp)); + + // We already had enough capacity for this key before removing it, so + // reinserting the replacement cannot require another allocation. + self.entries.putAssumeCapacity(cp, entry); + return; + } + + // Array hash maps preserve insertion order so always immediately insert. + gop.value_ptr.* = entry; + + // Fast, typical path: we fit within the glossary, just return. + if (self.entries.count() <= max_entries) return; + + // Slow path: we need to evict. + self.entries.values()[0].deinit(alloc); + self.entries.orderedRemoveAt(0); +} /// A single glyph registration entry. pub const Entry = struct { @@ -55,9 +107,6 @@ pub const Entry = struct { /// explicitly-provided option value. InvalidOptions, - /// `cp` is not in any PUA range. - OutOfNamespace, - /// The requested payload format is not supported by this glossary. UnsupportedFormat, }; @@ -68,30 +117,26 @@ pub const Entry = struct { /// decodes the base64 glyph payload, and stores the decoded outline. The /// returned entry owns decoded glyph memory and must be released with /// `deinit`. - pub fn init(alloc: Allocator, register: RegisterReq) InitError!Entry { - // Validate codepoint - const cp = register.get(.cp) orelse return error.InvalidOptions; - if (!isPrivateUse(cp)) return error.OutOfNamespace; - + pub fn init(alloc: Allocator, req: RegisterReq) InitError!Entry { // Validate format - const fmt = register.get(.fmt) orelse return error.InvalidOptions; + const fmt = req.get(.fmt) orelse return error.InvalidOptions; const design: glyf_rasterize.DesignMetrics = .{ - .units_per_em = register.get(.upm) orelse return error.InvalidOptions, - .advance_width = register.get(.aw) orelse return error.InvalidOptions, - .line_height = register.get(.lh) orelse return error.InvalidOptions, + .units_per_em = req.get(.upm) orelse return error.InvalidOptions, + .advance_width = req.get(.aw) orelse return error.InvalidOptions, + .line_height = req.get(.lh) orelse return error.InvalidOptions, }; if (design.units_per_em == 0 or design.advance_width == 0 or design.line_height == 0) return error.InvalidOptions; - const width = register.get(.width) orelse return error.InvalidOptions; + const width = req.get(.width) orelse return error.InvalidOptions; // Get our constraints - const constraint = try constraintFromRegister(register); + const constraint = try constraintFromRegister(req); // Decode the payload into some usable glyph format for // future rasterization. const glyph: Glyph = switch (fmt) { - .glyf => .{ .glyf = try register.decodeGlyfPayload(alloc) }, + .glyf => .{ .glyf = try req.decodeGlyfPayload(alloc) }, .colrv0, .colrv1 => return error.UnsupportedFormat, }; @@ -122,13 +167,13 @@ pub const Entry = struct { /// it does not have exact equivalents for every protocol size mode, so this /// function is the single normalization point for those policy choices. fn constraintFromRegister( - register: RegisterReq, + req: RegisterReq, ) error{InvalidOptions}!face.RenderOptions.Constraint { // Register.get applies the Glyph Protocol ยง6.1 defaults when options // are omitted: size=height, align=center,center, and pad=0,0,0,0. - const size = register.get(.size) orelse return error.InvalidOptions; - const alignment = register.get(.@"align") orelse return error.InvalidOptions; - const pad = register.get(.pad) orelse return error.InvalidOptions; + const size = req.get(.size) orelse return error.InvalidOptions; + const alignment = req.get(.@"align") orelse return error.InvalidOptions; + const pad = req.get(.pad) orelse return error.InvalidOptions; return .{ .size = switch (size) { @@ -170,37 +215,60 @@ pub const Entry = struct { .pad_left = pad.left, }; } - - /// Return true if `cp` is in one of the Unicode Private Use Areas. - fn isPrivateUse(cp: u21) bool { - return (cp >= 0xE000 and cp <= 0xF8FF) or - (cp >= 0xF0000 and cp <= 0xFFFFD) or - (cp >= 0x100000 and cp <= 0x10FFFD); - } }; +/// Return true if `cp` is in one of the Unicode Private Use Areas. +fn isPrivateUse(cp: u21) bool { + return (cp >= 0xE000 and cp <= 0xF8FF) or + (cp >= 0xF0000 and cp <= 0xFFFFD) or + (cp >= 0x100000 and cp <= 0x10FFFD); +} + fn testParseRegister(alloc: Allocator, data: []const u8) !RegisterReq { const raw = try alloc.dupe(u8, data); errdefer alloc.free(raw); const req = try request.Request.parse(alloc, raw); switch (req) { - .register => |register| return register, + .register => |reg| return reg, else => unreachable, } } +// Base64-encoded glyf payload from the "glyf: decode triangle" test in +// font/opentype/glyf.zig. This is a real simple-glyph record with one contour +// and three on-curve points. +const test_triangle_glyf_payload = "AAEAZABkA4QDhAACAAABAQEB9P5wAyADhPzgAAA="; + +fn testRegisterReq(alloc: Allocator, cp: u21) !RegisterReq { + const data = try std.fmt.allocPrint( + alloc, + "r;cp={x};upm=2048;aw=1024;lh=1536;width=2;size=stretch;align=end,start;pad=0.1,0.2,0.3,0.4;{s}", + .{ cp, test_triangle_glyf_payload }, + ); + errdefer alloc.free(data); + + const req = try request.Request.parse(alloc, data); + switch (req) { + .register => |reg| return reg, + else => unreachable, + } +} + +fn testRegisterEntry(alloc: Allocator, cp: u21) !Entry { + const req = try testRegisterReq(alloc, cp); + defer alloc.free(req.raw); + return try Entry.init(alloc, req); +} + test "Entry init decodes glyf payload and applies register fields" { const testing = std.testing; const alloc = testing.allocator; - const register = try testParseRegister( - alloc, - "r;cp=e000;upm=2048;aw=1024;lh=1536;width=2;size=stretch;align=end,start;pad=0.1,0.2,0.3,0.4;AAAAAAAAAAAAAA==", - ); - defer alloc.free(register.raw); + const req = try testRegisterReq(alloc, 0xE000); + defer alloc.free(req.raw); - var entry = try Entry.init(alloc, register); + var entry = try Entry.init(alloc, req); defer entry.deinit(alloc); try testing.expectEqual(@as(u32, 2048), entry.design.units_per_em); @@ -215,16 +283,63 @@ test "Entry init decodes glyf payload and applies register fields" { try testing.expectEqual(@as(f64, 0.3), entry.constraint.pad_bottom); try testing.expectEqual(@as(f64, 0.4), entry.constraint.pad_left); - try testing.expectEqual(@as(usize, 0), entry.glyph.glyf.points.len); - try testing.expectEqual(@as(usize, 0), entry.glyph.glyf.contours.len); + try testing.expectEqual(@as(usize, 3), entry.glyph.glyf.points.len); + try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len); } test "Entry init rejects invalid register payload" { const testing = std.testing; const alloc = testing.allocator; - const register = try testParseRegister(alloc, "r;cp=e000;%%%not-base64%%%"); - defer alloc.free(register.raw); + const req = try testParseRegister(alloc, "r;cp=e000;%%%not-base64%%%"); + defer alloc.free(req.raw); - try testing.expectError(error.MalformedPayload, Entry.init(alloc, register)); + try testing.expectError(error.MalformedPayload, Entry.init(alloc, req)); +} + +test "Glossary register overwrites and moves entry to newest position" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001)); + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + + try testing.expectEqual(@as(usize, 2), glossary.entries.count()); + try testing.expectEqual(@as(u21, 0xE001), glossary.entries.keys()[0]); + try testing.expectEqual(@as(u21, 0xE000), glossary.entries.keys()[1]); +} + +test "Glossary register evicts oldest entry" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + for (0..max_entries + 1) |i| { + const cp: u21 = @intCast(0xE000 + i); + try glossary.register(alloc, cp, try testRegisterEntry(alloc, cp)); + } + + try testing.expectEqual(@as(usize, max_entries), glossary.entries.count()); + try testing.expect(!glossary.entries.contains(0xE000)); + try testing.expect(glossary.entries.contains(0xE001)); + try testing.expect(glossary.entries.contains(0xE000 + max_entries)); +} + +test "Glossary register rejects non-PUA codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var entry = try testRegisterEntry(alloc, 0xE000); + errdefer entry.deinit(alloc); + + try testing.expectError(error.OutOfNamespace, glossary.register(alloc, 'A', entry)); }