terminal/apc: glyph glossary registration business logic

This commit is contained in:
Mitchell Hashimoto
2026-06-04 18:34:36 -07:00
parent 59d2ad9b6a
commit cf548a3aad

View File

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