From 2055bb6dd6a039ffd7251e644b637cac6383e4f2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 13:13:33 -0700 Subject: [PATCH 01/13] terminal: glyph request glyf decode --- src/terminal/apc/glyph/AGENTS.md | 9 ++++ src/terminal/apc/glyph/request.zig | 86 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 src/terminal/apc/glyph/AGENTS.md diff --git a/src/terminal/apc/glyph/AGENTS.md b/src/terminal/apc/glyph/AGENTS.md new file mode 100644 index 000000000..c81029c0f --- /dev/null +++ b/src/terminal/apc/glyph/AGENTS.md @@ -0,0 +1,9 @@ +# Glyph Protocol + +- The specification source of truth is: + +- A summary of the specification is available in + `src/terminal/apc/glyph.zig` at the top. +- Reference the specification whenever any changes are made to + this folder. Prefer the local specification over fetching the + latest, unless it is lacking information. diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig index 4c50525fc..6d63673df 100644 --- a/src/terminal/apc/glyph/request.zig +++ b/src/terminal/apc/glyph/request.zig @@ -1,6 +1,11 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; + +/// Maximum decoded glyph payload size accepted by the protocol. +/// This is documented in the spec. +const max_payload_size = 64 * 1024; // 64 KiB /// Stateful parser for a single glyph APC payload after the `25a1;` prefix. pub const CommandParser = struct { @@ -242,6 +247,65 @@ pub const Request = union(enum) { self.raw[self.payload_idx + 1 ..]; } + /// Errors that can occur while decoding a register glyph payload. + pub const DecodeError = Allocator.Error || error{ + /// The decoded payload exceeds the protocol limit. + PayloadTooLarge, + + /// The payload could not be decoded or parsed as the declared format. + MalformedPayload, + + /// The glyf payload is composite, which the protocol forbids. + CompositeUnsupported, + + /// The glyf payload contains hinting instructions, which the + /// protocol forbids. + HintingUnsupported, + }; + + /// Decode this request's base64 glyf payload into an owned outline. + pub fn decodeGlyfPayload(self: Register, alloc: Allocator) DecodeError!Glyf.Outline { + // Prep base64 decoding, initial validation. + const Decoder = std.base64.standard.Decoder; + const payload_bytes = self.payload(); + const size = Decoder.calcSizeForSlice(payload_bytes) catch + return error.MalformedPayload; + if (size > max_payload_size) return error.PayloadTooLarge; + + // Max payload size is reasonable for stack and its likely + // we'll have stack space. We don't use much stack space in + // the future function calls either, so try a stack allocator + // here and fallback to heap as necessary. + var data_stack = std.heap.stackFallback( + max_payload_size, + alloc, + ); + const data_alloc = data_stack.get(); + const data = try data_alloc.alloc(u8, size); + defer data_alloc.free(data); + + // Base64 decode + Decoder.decode(data, payload_bytes) catch + return error.MalformedPayload; + + // Glyf.Entry borrows from `data`, but only for the duration of the + // decode call below. Glyf.Entry.decode returns an owned Outline, so + // it is safe to free `data` before returning that outline. + const glyf_entry = Glyf.Entry.init(data) catch return error.MalformedPayload; + return glyf_entry.decode(alloc) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + // Unsupported fields + error.CompositeNotSupported => error.CompositeUnsupported, + error.InstructionsNotSupported => error.HintingUnsupported, + // Various semantic issues + error.EndOfStream, + error.EndPointsOutOfOrder, + error.TooManyPoints, + error.CoordinateOverflow, + => error.MalformedPayload, + }; + } + /// Return the raw option portion of a valid register command. fn rawOptions(self: Register) []const u8 { assert(self.raw.len >= 2); @@ -690,6 +754,28 @@ test "register command with invalid payload" { try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload()); } +test "register decodes glyf payload" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer cmd.deinit(testing.allocator); + + var outline = try cmd.register.decodeGlyfPayload(testing.allocator); + defer outline.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "register rejects malformed glyf payload" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;%%%not-base64%%%"); + defer cmd.deinit(testing.allocator); + + try testing.expectError(error.MalformedPayload, cmd.register.decodeGlyfPayload(testing.allocator)); +} + test "register response without payload" { const testing = std.testing; From 59d2ad9b6a2fbf46b64df9fe82f93d153219f063 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 13:13:33 -0700 Subject: [PATCH 02/13] terminal: glyph protocol Glossary entry starting to take shape --- src/terminal/apc/glyph.zig | 6 + src/terminal/apc/glyph/Glossary.zig | 230 ++++++++++++++++++++++++++++ src/terminal/apc/glyph/execute.zig | 38 +++++ src/terminal/apc/glyph/response.zig | 10 +- 4 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 src/terminal/apc/glyph/Glossary.zig create mode 100644 src/terminal/apc/glyph/execute.zig diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig index 67eb5163f..08e1ead86 100644 --- a/src/terminal/apc/glyph.zig +++ b/src/terminal/apc/glyph.zig @@ -152,7 +152,13 @@ const std = @import("std"); pub const request = @import("glyph/request.zig"); pub const response = @import("glyph/response.zig"); +pub const execute = @import("glyph/execute.zig").execute; pub const CommandParser = request.CommandParser; +pub const Glossary = @import("glyph/Glossary.zig"); pub const Request = request.Request; pub const Response = response.Response; + +test { + std.testing.refAllDecls(@This()); +} diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig new file mode 100644 index 000000000..f3ef420f2 --- /dev/null +++ b/src/terminal/apc/glyph/Glossary.zig @@ -0,0 +1,230 @@ +/// Glossary is the per-terminal storage for Glyph Protocol +/// codepoints. We use the word Glossary to match up with the spec which +/// also uses this word. +const Glossary = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const CircBuf = @import("../../../datastruct/circ_buf.zig").CircBuf; +const face = @import("../../../font/face.zig"); +const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; +const glyf_rasterize = @import("../../../font/glyf_rasterize.zig"); + +const request = @import("request.zig"); +const RegisterReq = request.Request.Register; + +/// The set of entries in the glossary keyed by the codepoint. +/// +/// The array hash map preserves insertion order and has O(N) +/// orderedRemove, so we use it as a FIFO too for eviction when +/// the glossary is full. Since the specification limits the protocol +/// to 1024 maximum entries, ordered removal should never be that +/// expensive. +/// +/// 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), + +/// A single glyph registration entry. +pub const Entry = struct { + /// Stored glyph payload variants. + pub const Glyph = union(enum) { + glyf: Glyf.Outline, + }; + + /// The glyph itself. The tagged union only has glyf right now but + /// will eventually expand to support COLR and maybe other formats. + /// These are stored as raw outlines; rasterization is delayed to + /// renderers. The outlines have been validated. + glyph: Glyph, + + /// Authored metrics for the glyph's design coordinate space. + design: glyf_rasterize.DesignMetrics, + + /// Unicode cell width requested by the registration. + width: request.Width, + + /// Normalized scale, alignment, and padding behavior for rasterization. + constraint: face.RenderOptions.Constraint, + + /// Errors that can occur while constructing a glossary entry from a + /// register request. + pub const InitError = RegisterReq.DecodeError || error{ + /// The register request is missing a required option or has an invalid + /// explicitly-provided option value. + InvalidOptions, + + /// `cp` is not in any PUA range. + OutOfNamespace, + + /// The requested payload format is not supported by this glossary. + UnsupportedFormat, + }; + + /// Initialize a glossary entry from a register request. + /// + /// This validates the request fields needed to construct the entry, + /// 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; + + // Validate format + const fmt = register.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, + }; + 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; + + // Get our constraints + const constraint = try constraintFromRegister(register); + + // Decode the payload into some usable glyph format for + // future rasterization. + const glyph: Glyph = switch (fmt) { + .glyf => .{ .glyf = try register.decodeGlyfPayload(alloc) }, + .colrv0, .colrv1 => return error.UnsupportedFormat, + }; + + // No more errors, since we never do glyph cleanup above. + errdefer comptime unreachable; + + return .{ + .glyph = glyph, + .design = design, + .width = width, + .constraint = constraint, + }; + } + + /// Release memory owned by this entry. + pub fn deinit(self: *Entry, alloc: Allocator) void { + switch (self.glyph) { + .glyf => |*outline| outline.deinit(alloc), + } + self.* = undefined; + } + + /// Return the renderer constraint for a register request. + /// + /// Glyph Protocol §8.5 defines sizing, alignment, and padding in terms of + /// the authored extent and render span. Ghostty's existing constraint type + /// is the closest renderer-native representation for these controls, but + /// 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, + ) 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; + + return .{ + .size = switch (size) { + // The rasterizer's base transform already maps the design em + // to the cell height. That is the closest existing behavior to + // the protocol's default height-driven mode. + .height => .none, + // There is no width-driven, aspect-preserving constraint mode + // today. Leave the base transform intact rather than forcing a + // fit/contain policy that would unexpectedly prevent overflow. + .advance => .none, + // Constraint.cover currently scales preserving aspect ratio to + // the available bounds, which is the best existing match for + // the protocol's contain mode. + .contain => .cover, + // There is no true protocol-cover equivalent that chooses the + // larger axis scale, so use the nearest named renderer policy. + .cover => .cover, + .stretch => .stretch, + }, + .align_horizontal = switch (alignment.horizontal) { + .start => .start, + .center => .center, + .end => .end, + }, + .align_vertical = switch (alignment.vertical) { + .start => .start, + .center => .center, + .end => .end, + // The current constraint API has no baseline alignment mode. + // Start is the closest stable default because the glyf + // rasterizer's coordinate model already treats y=0 as the + // baseline/bottom before constraints are applied. + .baseline => .start, + }, + .pad_top = pad.top, + .pad_right = pad.right, + .pad_bottom = pad.bottom, + .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); + } +}; + +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, + else => unreachable, + } +} + +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); + + var entry = try Entry.init(alloc, register); + defer entry.deinit(alloc); + + try testing.expectEqual(@as(u32, 2048), entry.design.units_per_em); + try testing.expectEqual(@as(u32, 1024), entry.design.advance_width); + try testing.expectEqual(@as(u32, 1536), entry.design.line_height); + try testing.expectEqual(request.Width.wide, entry.width); + try testing.expectEqual(face.RenderOptions.Constraint.Size.stretch, entry.constraint.size); + try testing.expectEqual(face.RenderOptions.Constraint.Align.end, entry.constraint.align_horizontal); + try testing.expectEqual(face.RenderOptions.Constraint.Align.start, entry.constraint.align_vertical); + try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top); + try testing.expectEqual(@as(f64, 0.2), entry.constraint.pad_right); + 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); +} + +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); + + try testing.expectError(error.MalformedPayload, Entry.init(alloc, register)); +} diff --git a/src/terminal/apc/glyph/execute.zig b/src/terminal/apc/glyph/execute.zig new file mode 100644 index 000000000..6ed4cd793 --- /dev/null +++ b/src/terminal/apc/glyph/execute.zig @@ -0,0 +1,38 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const request = @import("request.zig"); +const response = @import("response.zig"); +const Glossary = @import("Glossary.zig"); +const Request = request.Request; +const Response = response.Response; + +const log = std.log.scoped(.glyph); + +/// Payload formats we support. Hardcoded because the support is +/// fixed. +pub const supported_formats: response.Response.Support.Formats = .{ + .glyf = true, +}; + +/// Execute a Glyph protocol request against the given state. +/// +/// This will never fail, but the response may indiciate an error and +/// the terminal state may not be updated to reflect the command. This will +/// never put the terminal in a corrupt or non-recoverable state. +/// +/// For example, allocation errors can happen, but they're wrapped up in +/// an out of memory response. +pub fn execute( + alloc: Allocator, + glossary: *Glossary, + req: *const Request, +) ?Response { + _ = alloc; + _ = glossary; + log.debug("executing glyph protocol request: {t}", .{req.*}); + return switch (req.*) { + .support => .{ .support = .{ .fmt = supported_formats } }, + .query, .register, .clear => @panic("TODO"), + }; +} diff --git a/src/terminal/apc/glyph/response.zig b/src/terminal/apc/glyph/response.zig index 4ed52b0b2..5b72495d7 100644 --- a/src/terminal/apc/glyph/response.zig +++ b/src/terminal/apc/glyph/response.zig @@ -303,11 +303,11 @@ test "register reason names" { const testing = std.testing; const Reason = Response.Register.Reason; - try testing.expectEqualStrings("out_of_namespace", Reason.out_of_namespace.name()); - try testing.expectEqualStrings("composite_unsupported", Reason.composite_unsupported.name()); - try testing.expectEqualStrings("hinting_unsupported", Reason.hinting_unsupported.name()); - try testing.expectEqualStrings("malformed_payload", Reason.malformed_payload.name()); - try testing.expectEqualStrings("payload_too_large", Reason.payload_too_large.name()); + try testing.expectEqualStrings("out_of_namespace", (Reason{ .out_of_namespace = {} }).name()); + try testing.expectEqualStrings("composite_unsupported", (Reason{ .composite_unsupported = {} }).name()); + try testing.expectEqualStrings("hinting_unsupported", (Reason{ .hinting_unsupported = {} }).name()); + try testing.expectEqualStrings("malformed_payload", (Reason{ .malformed_payload = {} }).name()); + try testing.expectEqualStrings("payload_too_large", (Reason{ .payload_too_large = {} }).name()); try testing.expectEqualStrings("future_reason", (Reason{ .other = "future_reason" }).name()); } From cf548a3aadf53f55865e270abc27db152f0002d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jun 2026 18:34:36 -0700 Subject: [PATCH 03/13] 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)); } From 0cd815f94a6d9f4c9ca4dd8bbd00f372c4a3e441 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jun 2026 18:52:13 -0700 Subject: [PATCH 04/13] terminal/apc: glyph glossary delete, contains, clear --- src/terminal/apc/glyph/Glossary.zig | 96 ++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig index 1af290127..46a276a66 100644 --- a/src/terminal/apc/glyph/Glossary.zig +++ b/src/terminal/apc/glyph/Glossary.zig @@ -78,6 +78,32 @@ pub fn register( self.entries.orderedRemoveAt(0); } +/// Delete a single entry from the glossary. If the entry doesn't exist, +/// then this does nothing and is safe. +pub fn delete( + self: *Glossary, + alloc: Allocator, + cp: u21, +) error{OutOfNamespace}!void { + if (!isPrivateUse(cp)) return error.OutOfNamespace; + const kv = self.entries.fetchOrderedRemove(cp) orelse return; + var entry = kv.value; + entry.deinit(alloc); +} + +/// Clear all entries from the glossary and free up any underlying +/// storage. +pub fn clearAndFree(self: *Glossary, alloc: Allocator) void { + for (self.entries.values()) |*entry| entry.deinit(alloc); + self.entries.deinit(alloc); + self.entries = .empty; +} + +/// Contains returns true if the codepoint is covered by the glossary. +pub fn contains(self: *Glossary, cp: u21) bool { + return self.entries.contains(cp); +} + /// A single glyph registration entry. pub const Entry = struct { /// Stored glyph payload variants. @@ -339,7 +365,73 @@ test "Glossary register rejects non-PUA codepoint" { defer glossary.deinit(alloc); var entry = try testRegisterEntry(alloc, 0xE000); - errdefer entry.deinit(alloc); - + defer entry.deinit(alloc); try testing.expectError(error.OutOfNamespace, glossary.register(alloc, 'A', entry)); } + +test "Glossary delete removes one PUA slot and ignores empty PUA slot" { + 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.delete(alloc, 0xE000); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(!glossary.contains(0xE000)); + try testing.expect(glossary.contains(0xE001)); + + try glossary.delete(alloc, 0xE000); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE001)); +} + +test "Glossary delete rejects non-PUA codepoint" { + 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 testing.expectError(error.OutOfNamespace, glossary.delete(alloc, 'A')); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE000)); +} + +test "Glossary clearAndFree removes all slots and remains reusable" { + 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)); + + glossary.clearAndFree(alloc); + try testing.expectEqual(@as(usize, 0), glossary.entries.count()); + try testing.expect(!glossary.contains(0xE000)); + try testing.expect(!glossary.contains(0xE001)); + + try glossary.register(alloc, 0xE002, try testRegisterEntry(alloc, 0xE002)); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE002)); +} + +test "Glossary contains reports registered slots" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try testing.expect(!glossary.contains(0xE000)); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try testing.expect(glossary.contains(0xE000)); + try testing.expect(!glossary.contains(0xE001)); +} From cc91940993e70e611ba5d8609ffecfcfa53ade39 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 06:32:41 -0700 Subject: [PATCH 05/13] terminal/glyph: register request --- src/terminal/apc/glyph/Glossary.zig | 7 +- src/terminal/apc/glyph/execute.zig | 142 +++++++++++++++++++++++++++- 2 files changed, 144 insertions(+), 5 deletions(-) diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig index 46a276a66..f2b102d8b 100644 --- a/src/terminal/apc/glyph/Glossary.zig +++ b/src/terminal/apc/glyph/Glossary.zig @@ -21,6 +21,9 @@ pub const max_entries = 1024; /// An empty glossary with no registered glyphs. pub const empty: Glossary = .{ .entries = .empty }; +/// Errors that can occur while registering a glossary entry. +pub const RegisterError = Allocator.Error || error{OutOfNamespace}; + /// The set of entries in the glossary keyed by the codepoint. /// /// The array hash map preserves insertion order and has O(N) @@ -49,7 +52,7 @@ pub fn register( alloc: Allocator, cp: u21, entry: Entry, -) (Allocator.Error || error{OutOfNamespace})!void { +) RegisterError!void { // Validate codepoint according to spec. if (!isPrivateUse(cp)) return error.OutOfNamespace; @@ -143,7 +146,7 @@ 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, req: RegisterReq) InitError!Entry { + pub fn init(alloc: Allocator, req: RegisterReq) Entry.InitError!Entry { // Validate format const fmt = req.get(.fmt) orelse return error.InvalidOptions; const design: glyf_rasterize.DesignMetrics = .{ diff --git a/src/terminal/apc/glyph/execute.zig b/src/terminal/apc/glyph/execute.zig index 6ed4cd793..6a695383b 100644 --- a/src/terminal/apc/glyph/execute.zig +++ b/src/terminal/apc/glyph/execute.zig @@ -28,11 +28,147 @@ pub fn execute( glossary: *Glossary, req: *const Request, ) ?Response { - _ = alloc; - _ = glossary; log.debug("executing glyph protocol request: {t}", .{req.*}); return switch (req.*) { .support => .{ .support = .{ .fmt = supported_formats } }, - .query, .register, .clear => @panic("TODO"), + .register => |reg| register(alloc, glossary, reg), + .query, .clear => @panic("TODO"), }; } + +fn register( + alloc: Allocator, + glossary: *Glossary, + reg: Request.Register, +) ?Response { + const reply = reg.get(.reply) orelse .all; + const cp = registerFallible(alloc, glossary, reg) catch |err| return switch (reply) { + .none => null, + .all, .failures => .{ .register = .{ + .cp = reg.get(.cp) orelse 0, + .status = .err, + .reason = switch (err) { + error.OutOfMemory => .{ .other = "out_of_memory" }, + error.OutOfNamespace => .out_of_namespace, + error.PayloadTooLarge => .payload_too_large, + error.MalformedPayload => .malformed_payload, + error.CompositeUnsupported => .composite_unsupported, + error.HintingUnsupported => .hinting_unsupported, + error.InvalidOptions, + error.UnsupportedFormat, + => .malformed_payload, + }, + } }, + }; + + return switch (reply) { + .none, .failures => null, + .all => .{ .register = .{ .cp = cp } }, + }; +} + +fn registerFallible( + alloc: Allocator, + glossary: *Glossary, + reg: Request.Register, +) (Glossary.Entry.InitError || Glossary.RegisterError)!u21 { + const cp = reg.get(.cp) orelse + return error.MalformedPayload; + + var entry = try Glossary.Entry.init(alloc, reg); + errdefer entry.deinit(alloc); + + try glossary.register(alloc, cp, entry); + return cp; +} + +fn testParse(alloc: Allocator, data: []const u8) !Request { + var parser = request.CommandParser.init(alloc, 1024 * 1024); + defer parser.deinit(); + for (data) |byte| try parser.feed(byte); + return try parser.complete(alloc); +} + +test "execute register stores glyph and returns success" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ .cp = 0xE0A0 }, + }, execute(alloc, &glossary, &req).?); + try testing.expect(glossary.contains(0xE0A0)); +} + +test "execute register reply failures suppresses success" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;reply=2;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expect(execute(alloc, &glossary, &req) == null); + try testing.expect(glossary.contains(0xE0A0)); +} + +test "execute register reply none suppresses failure" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=41;reply=0;%%%not-base64%%%"); + defer req.deinit(alloc); + + try testing.expect(execute(alloc, &glossary, &req) == null); + try testing.expect(!glossary.contains('A')); +} + +test "execute register rejects non-PUA" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=41;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ + .cp = 'A', + .status = .err, + .reason = .out_of_namespace, + }, + }, execute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains('A')); +} + +test "execute register reports malformed payload" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;%%%not-base64%%%"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ + .cp = 0xE0A0, + .status = .err, + .reason = .malformed_payload, + }, + }, execute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); +} From 6f83d8a4f14b7b095f0c6c415c01467afe395384 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 06:34:53 -0700 Subject: [PATCH 06/13] terminal/glyph: clear --- src/terminal/apc/glyph/Glossary.zig | 5 +- src/terminal/apc/glyph/execute.zig | 86 ++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig index f2b102d8b..316d36ae6 100644 --- a/src/terminal/apc/glyph/Glossary.zig +++ b/src/terminal/apc/glyph/Glossary.zig @@ -24,6 +24,9 @@ pub const empty: Glossary = .{ .entries = .empty }; /// Errors that can occur while registering a glossary entry. pub const RegisterError = Allocator.Error || error{OutOfNamespace}; +/// Errors that can occur while clearing glossary entries. +pub const ClearError = error{OutOfNamespace}; + /// The set of entries in the glossary keyed by the codepoint. /// /// The array hash map preserves insertion order and has O(N) @@ -87,7 +90,7 @@ pub fn delete( self: *Glossary, alloc: Allocator, cp: u21, -) error{OutOfNamespace}!void { +) ClearError!void { if (!isPrivateUse(cp)) return error.OutOfNamespace; const kv = self.entries.fetchOrderedRemove(cp) orelse return; var entry = kv.value; diff --git a/src/terminal/apc/glyph/execute.zig b/src/terminal/apc/glyph/execute.zig index 6a695383b..14f30686e 100644 --- a/src/terminal/apc/glyph/execute.zig +++ b/src/terminal/apc/glyph/execute.zig @@ -32,7 +32,8 @@ pub fn execute( return switch (req.*) { .support => .{ .support = .{ .fmt = supported_formats } }, .register => |reg| register(alloc, glossary, reg), - .query, .clear => @panic("TODO"), + .clear => |clr| clear(alloc, glossary, clr), + .query => @panic("TODO"), }; } @@ -82,6 +83,25 @@ fn registerFallible( return cp; } +fn clear( + alloc: Allocator, + glossary: *Glossary, + clr: Request.Clear, +) ?Response { + if (clr.get(.cp)) |cp| { + glossary.delete(alloc, cp) catch |err| return .{ .clear = .{ + .status = .err, + .reason = switch (err) { + error.OutOfNamespace => "out_of_namespace", + }, + } }; + } else { + glossary.clearAndFree(alloc); + } + + return .{ .clear = .{} }; +} + fn testParse(alloc: Allocator, data: []const u8) !Request { var parser = request.CommandParser.init(alloc, 1024 * 1024); defer parser.deinit(); @@ -172,3 +192,67 @@ test "execute register reports malformed payload" { }, execute(alloc, &glossary, &req).?); try testing.expect(!glossary.contains(0xE0A0)); } + +test "execute clear removes all glyphs" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = execute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = execute(alloc, &glossary, ®2); + + var req = try testParse(alloc, "c"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ .clear = .{} }, execute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); + try testing.expect(!glossary.contains(0xE0A1)); +} + +test "execute clear removes one glyph" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = execute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = execute(alloc, &glossary, ®2); + + var req = try testParse(alloc, "c;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ .clear = .{} }, execute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); + try testing.expect(glossary.contains(0xE0A1)); +} + +test "execute clear rejects non-PUA" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "c;cp=41"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .clear = .{ + .status = .err, + .reason = "out_of_namespace", + }, + }, execute(alloc, &glossary, &req).?); +} From d271b271f9e519ad21f70d3b37415d508480df35 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 06:36:42 -0700 Subject: [PATCH 07/13] terminal/glyph: query --- src/terminal/apc/glyph/execute.zig | 100 +++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/src/terminal/apc/glyph/execute.zig b/src/terminal/apc/glyph/execute.zig index 14f30686e..26dc6d2c9 100644 --- a/src/terminal/apc/glyph/execute.zig +++ b/src/terminal/apc/glyph/execute.zig @@ -23,6 +23,10 @@ pub const supported_formats: response.Response.Support.Formats = .{ /// /// For example, allocation errors can happen, but they're wrapped up in /// an out of memory response. +/// +/// Query responses only report glossary coverage. Callers that can determine +/// system font coverage must update the returned query response before sending +/// it to the client. pub fn execute( alloc: Allocator, glossary: *Glossary, @@ -31,12 +35,25 @@ pub fn execute( log.debug("executing glyph protocol request: {t}", .{req.*}); return switch (req.*) { .support => .{ .support = .{ .fmt = supported_formats } }, + .query => |qry| query(glossary, qry), .register => |reg| register(alloc, glossary, reg), .clear => |clr| clear(alloc, glossary, clr), - .query => @panic("TODO"), }; } +fn query( + glossary: *Glossary, + qry: Request.Query, +) ?Response { + const cp = qry.get(.cp) orelse return null; + return .{ .query = .{ + .cp = cp, + .status = .{ + .glossary = glossary.contains(cp), + }, + } }; +} + fn register( alloc: Allocator, glossary: *Glossary, @@ -109,6 +126,10 @@ fn testParse(alloc: Allocator, data: []const u8) !Request { return try parser.complete(alloc); } +fn testExecute(alloc: Allocator, glossary: *Glossary, req: *const Request) ?Response { + return execute(alloc, glossary, req); +} + test "execute register stores glyph and returns success" { const testing = std.testing; const alloc = testing.allocator; @@ -121,7 +142,7 @@ test "execute register stores glyph and returns success" { try testing.expectEqual(Response{ .register = .{ .cp = 0xE0A0 }, - }, execute(alloc, &glossary, &req).?); + }, testExecute(alloc, &glossary, &req).?); try testing.expect(glossary.contains(0xE0A0)); } @@ -135,7 +156,7 @@ test "execute register reply failures suppresses success" { var req = try testParse(alloc, "r;cp=e0a0;reply=2;AAAAAAAAAAAAAA=="); defer req.deinit(alloc); - try testing.expect(execute(alloc, &glossary, &req) == null); + try testing.expect(testExecute(alloc, &glossary, &req) == null); try testing.expect(glossary.contains(0xE0A0)); } @@ -149,7 +170,7 @@ test "execute register reply none suppresses failure" { var req = try testParse(alloc, "r;cp=41;reply=0;%%%not-base64%%%"); defer req.deinit(alloc); - try testing.expect(execute(alloc, &glossary, &req) == null); + try testing.expect(testExecute(alloc, &glossary, &req) == null); try testing.expect(!glossary.contains('A')); } @@ -169,7 +190,7 @@ test "execute register rejects non-PUA" { .status = .err, .reason = .out_of_namespace, }, - }, execute(alloc, &glossary, &req).?); + }, testExecute(alloc, &glossary, &req).?); try testing.expect(!glossary.contains('A')); } @@ -189,7 +210,7 @@ test "execute register reports malformed payload" { .status = .err, .reason = .malformed_payload, }, - }, execute(alloc, &glossary, &req).?); + }, testExecute(alloc, &glossary, &req).?); try testing.expect(!glossary.contains(0xE0A0)); } @@ -202,16 +223,16 @@ test "execute clear removes all glyphs" { var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); defer reg1.deinit(alloc); - _ = execute(alloc, &glossary, ®1); + _ = testExecute(alloc, &glossary, ®1); var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); defer reg2.deinit(alloc); - _ = execute(alloc, &glossary, ®2); + _ = testExecute(alloc, &glossary, ®2); var req = try testParse(alloc, "c"); defer req.deinit(alloc); - try testing.expectEqual(Response{ .clear = .{} }, execute(alloc, &glossary, &req).?); + try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?); try testing.expect(!glossary.contains(0xE0A0)); try testing.expect(!glossary.contains(0xE0A1)); } @@ -225,16 +246,16 @@ test "execute clear removes one glyph" { var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); defer reg1.deinit(alloc); - _ = execute(alloc, &glossary, ®1); + _ = testExecute(alloc, &glossary, ®1); var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); defer reg2.deinit(alloc); - _ = execute(alloc, &glossary, ®2); + _ = testExecute(alloc, &glossary, ®2); var req = try testParse(alloc, "c;cp=e0a0"); defer req.deinit(alloc); - try testing.expectEqual(Response{ .clear = .{} }, execute(alloc, &glossary, &req).?); + try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?); try testing.expect(!glossary.contains(0xE0A0)); try testing.expect(glossary.contains(0xE0A1)); } @@ -254,5 +275,58 @@ test "execute clear rejects non-PUA" { .status = .err, .reason = "out_of_namespace", }, - }, execute(alloc, &glossary, &req).?); + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute query reports no coverage" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "q;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .query = .{ + .cp = 0xE0A0, + .status = .{}, + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute query reports glossary coverage" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg.deinit(alloc); + _ = testExecute(alloc, &glossary, ®); + + var req = try testParse(alloc, "q;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .query = .{ + .cp = 0xE0A0, + .status = .{ .glossary = true }, + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute query without cp returns no response" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "q;foo=bar"); + defer req.deinit(alloc); + + try testing.expect(testExecute(alloc, &glossary, &req) == null); } From 1c0aac54bdd86a94ee6c3ec4ce974ae6797c85bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 06:48:37 -0700 Subject: [PATCH 08/13] font: reshuffle glyph sizing types to Glyph.zig --- src/font/CodepointResolver.zig | 2 +- src/font/Glyph.zig | 611 ++++++++++++++++++++++++++++ src/font/Metrics.zig | 4 + src/font/SharedGrid.zig | 2 +- src/font/face.zig | 596 --------------------------- src/font/face/coretext.zig | 2 +- src/font/face/freetype.zig | 4 +- src/font/face/web_canvas.zig | 2 +- src/font/glyf_rasterize.zig | 31 +- src/font/nerd_font_attributes.zig | 2 +- src/font/sprite/Face.zig | 2 +- src/terminal/apc/glyph.zig | 2 +- src/terminal/apc/glyph/Glossary.zig | 27 +- 13 files changed, 648 insertions(+), 639 deletions(-) diff --git a/src/font/CodepointResolver.zig b/src/font/CodepointResolver.zig index a4f13c290..90fe2491c 100644 --- a/src/font/CodepointResolver.zig +++ b/src/font/CodepointResolver.zig @@ -24,7 +24,7 @@ const Face = font.Face; const Glyph = font.Glyph; const Library = font.Library; const Presentation = font.Presentation; -const RenderOptions = font.face.RenderOptions; +const RenderOptions = font.Glyph.RenderOptions; const SpriteFace = font.SpriteFace; const Style = font.Style; diff --git a/src/font/Glyph.zig b/src/font/Glyph.zig index f99370271..62c44e227 100644 --- a/src/font/Glyph.zig +++ b/src/font/Glyph.zig @@ -1,6 +1,9 @@ //! Glyph is a single loaded glyph for a face. const Glyph = @This(); +const std = @import("std"); +const Metrics = @import("Metrics.zig"); + /// width of glyph in pixels width: u32, @@ -17,3 +20,611 @@ offset_y: i32, /// be normalized to be between 0 and 1 prior to use in shaders. atlas_x: u32, atlas_y: u32, + +/// The size and position of a glyph. +pub const Size = struct { + width: f64, + height: f64, + x: f64, + y: f64, +}; + +/// Metrics describing the authored glyph coordinate space. +pub const DesignMetrics = struct { + /// Units-per-em for outline/design coordinates. + units_per_em: u32, + + /// Authored advance width in design units. + advance_width: u32, + + /// Authored line height in design units. + line_height: u32, +}; + +/// Additional options for rendering glyphs. +pub const RenderOptions = struct { + /// The metrics that are defining the grid layout. These are usually + /// the metrics of the primary font face. The grid metrics are used + /// by the font face to better layout the glyph in situations where + /// the font is not exactly the same size as the grid. + grid_metrics: Metrics, + + /// The number of grid cells this glyph will take up. This can be used + /// optionally by the rasterizer to better layout the glyph. + cell_width: ?u2 = null, + + /// Constraint and alignment properties for the glyph. The rasterizer + /// should call the `constrain` function on this with the original size + /// and bearings of the glyph to get remapped values that the glyph + /// should be scaled/moved to. + constraint: Constraint = .none, + + /// The number of cells, horizontally that the glyph is free to take up + /// when resized and aligned by `constraint`. This is usually 1, but if + /// there's whitespace to the right of the cell then it can be 2. + constraint_width: u2 = 1, + + /// Thicken the glyph. This draws the glyph with a thicker stroke width. + /// This is purely an aesthetic setting. + /// + /// This only works with CoreText currently. + thicken: bool = false, + + /// "Strength" of the thickening, between `0` and `255`. + /// Only has an effect when `thicken` is enabled. + /// + /// `0` does not correspond to *no* thickening, + /// just the *lightest* thickening available. + /// + /// CoreText only. + thicken_strength: u8 = 255, + + /// See the `constraint` field. + pub const Constraint = struct { + /// Don't constrain the glyph in any way. + pub const none: Constraint = .{}; + + /// Sizing rule. + size: Constraint.Size = .none, + + /// Vertical alignment rule. + align_vertical: Align = .none, + /// Horizontal alignment rule. + align_horizontal: Align = .none, + + /// Top padding when resizing. + pad_top: f64 = 0.0, + /// Left padding when resizing. + pad_left: f64 = 0.0, + /// Right padding when resizing. + pad_right: f64 = 0.0, + /// Bottom padding when resizing. + pad_bottom: f64 = 0.0, + + // Size and bearings of the glyph relative + // to the bounding box of its scale group. + relative_width: f64 = 1.0, + relative_height: f64 = 1.0, + relative_x: f64 = 0.0, + relative_y: f64 = 0.0, + + /// Maximum aspect ratio (width/height) to allow when stretching. + max_xy_ratio: ?f64 = null, + + /// Maximum number of cells horizontally to use. + max_constraint_width: u2 = 2, + + /// What to use as the height metric when constraining the glyph and + /// the constraint width is 1, + height: Height = .cell, + + pub const Size = enum { + /// Don't change the size of this glyph. + none, + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. + fit, + /// Scale the glyph up or down to exactly match the bounds, + /// preserving aspect ratio. + cover, + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. If the glyph doesn't cover a + /// single cell, scale up. If the glyph exceeds a single + /// cell but is within the bounds, do nothing. + /// (Nerd Font specific rule.) + fit_cover1, + /// Stretch the glyph to exactly fit the bounds in both + /// directions, disregarding aspect ratio. + stretch, + }; + + pub const Align = enum { + /// Don't move the glyph on this axis. + none, + /// Move the glyph so that its leading (bottom/left) + /// edge aligns with the leading edge of the axis. + start, + /// Move the glyph so that its trailing (top/right) + /// edge aligns with the trailing edge of the axis. + end, + /// Move the glyph so that it is centered on this axis. + center, + /// Move the glyph so that it is centered on this axis, + /// but always with respect to the first cell even for + /// multi-cell constraints. (Nerd Font specific rule.) + center1, + }; + + pub const Height = enum { + /// Use the full line height of the primary face for + /// constraining this glyph. + cell, + /// Use the icon height from the grid metrics for + /// constraining this glyph. Unlike `cell`, the value of + /// this height depends on both the constraint width and the + /// affected by the `adjust-icon-height` config option. + icon, + }; + + /// Returns true if the constraint does anything. If it doesn't, + /// because it neither sizes nor positions the glyph, then this + /// returns false. + pub inline fn doesAnything(self: Constraint) bool { + return self.size != .none or + self.align_horizontal != .none or + self.align_vertical != .none; + } + + /// Apply this constraint to the provided glyph + /// size, given the available width and height. + pub fn constrain( + self: Constraint, + glyph: Glyph.Size, + metrics: Metrics, + /// Number of cells horizontally available for this glyph. + constraint_width: u2, + ) Glyph.Size { + if (!self.doesAnything()) return glyph; + + switch (self.size) { + .stretch => { + // Stretched glyphs are usually meant to align across cell + // boundaries, which works best if they're scaled and + // aligned to the grid rather than the face. This is most + // easily done by inserting this little fib in the metrics. + var m = metrics; + m.face_width = @floatFromInt(m.cell_width); + m.face_height = @floatFromInt(m.cell_height); + m.face_y = 0.0; + + // Negative padding for stretched glyphs is a band-aid to + // avoid gaps due to pixel rounding, but at the cost of + // unsightly overlap artifacts. Since we scale and align to + // the grid rather than the face, we don't need it. + var c = self; + c.pad_bottom = @max(0, c.pad_bottom); + c.pad_top = @max(0, c.pad_top); + c.pad_left = @max(0, c.pad_left); + c.pad_right = @max(0, c.pad_right); + + return c.constrainInner(glyph, m, constraint_width); + }, + else => return self.constrainInner(glyph, metrics, constraint_width), + } + } + + fn constrainInner( + self: Constraint, + glyph: Glyph.Size, + metrics: Metrics, + constraint_width: u2, + ) Glyph.Size { + // For extra wide font faces, never stretch glyphs across two cells. + // This mirrors font_patcher. + const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) + 1 + else + @min(self.max_constraint_width, constraint_width); + + // The bounding box for the glyph's scale group. + // Scaling and alignment rules are calculated for + // this box and then applied to the glyph. + var group: Glyph.Size = group: { + const group_width = glyph.width / self.relative_width; + const group_height = glyph.height / self.relative_height; + break :group .{ + .width = group_width, + .height = group_height, + .x = glyph.x - (group_width * self.relative_x), + .y = glyph.y - (group_height * self.relative_y), + }; + }; + + // Apply prescribed scaling, preserving the + // center bearings of the group bounding box + const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width); + const center_x = group.x + (group.width / 2); + const center_y = group.y + (group.height / 2); + group.width *= width_factor; + group.height *= height_factor; + group.x = center_x - (group.width / 2); + group.y = center_y - (group.height / 2); + + // NOTE: font_patcher jumps through a lot of hoops at this + // point to ensure that the glyph remains within the target + // bounding box after rounding to font definition units. + // This is irrelevant here as we're not rounding, we're + // staying in f64 and heading straight to rendering. + + // Apply prescribed alignment + group.y = self.aligned_y(group, metrics); + group.x = self.aligned_x(group, metrics, min_constraint_width); + + // Transfer the scaling and alignment back to the glyph and return. + return .{ + .width = width_factor * glyph.width, + .height = height_factor * glyph.height, + .x = group.x + (group.width * self.relative_x), + .y = group.y + (group.height * self.relative_y), + }; + } + + /// Return width and height scaling factors for this scaling group. + fn scale_factors( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + min_constraint_width: u2, + ) struct { f64, f64 } { + if (self.size == .none) { + return .{ 1.0, 1.0 }; + } + + const multi_cell = (min_constraint_width > 1); + + const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); + const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); + + const target_width = pad_width_factor * metrics.face_width; + const target_height = pad_height_factor * switch (self.height) { + .cell => metrics.face_height, + // Like font-patcher, the icon constraint height depends on the + // constraint width. Unlike font-patcher, the multi-cell + // icon_height may be different from face_height due to the + // `adjust-icon-height` config option. + .icon => if (multi_cell) + metrics.icon_height + else + metrics.icon_height_single, + }; + + var width_factor = target_width / group.width; + var height_factor = target_height / group.height; + + switch (self.size) { + .none => unreachable, + .fit => { + // Scale down to fit if needed + height_factor = @min(1, width_factor, height_factor); + width_factor = height_factor; + }, + .cover => { + // Scale to cover + height_factor = @min(width_factor, height_factor); + width_factor = height_factor; + }, + .fit_cover1 => { + // Scale down to fit or up to cover at least one cell + // NOTE: This is similar to font_patcher's "pa" mode, + // however, font_patcher will only do the upscaling + // part if the constraint width is 1, resulting in + // some icons becoming smaller when the constraint + // width increases. You'd see icons shrinking when + // opening up a space after them. This makes no + // sense, so we've fixed the rule such that these + // icons are scaled to the same size for multi-cell + // constraints as they would be for single-cell. + height_factor = @min(width_factor, height_factor); + if (multi_cell and (height_factor > 1)) { + // Call back into this function with + // constraint width 1 to get single-cell scale + // factors. We use the height factor as width + // could have been modified by max_xy_ratio. + _, const single_height_factor = self.scale_factors(group, metrics, 1); + height_factor = @max(1, single_height_factor); + } + width_factor = height_factor; + }, + .stretch => {}, + } + + // Reduce aspect ratio if required + if (self.max_xy_ratio) |ratio| { + if (group.width * width_factor > group.height * height_factor * ratio) { + width_factor = group.height * height_factor * ratio / group.width; + } + } + + return .{ width_factor, height_factor }; + } + + /// Return vertical bearing for aligning this group + fn aligned_y( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + ) f64 { + if ((self.size == .none) and (self.align_vertical == .none)) { + // If we don't have any constraints affecting the vertical axis, + // we don't touch vertical alignment. + return group.y; + } + // We use face_height and offset by face_y, rather than + // using cell_height directly, to account for the asymmetry + // of the pixel cell around the face (a consequence of + // aligning the baseline with a pixel boundary rather than + // vertically centering the face). + const pad_bottom_dy = self.pad_bottom * metrics.face_height; + const pad_top_dy = self.pad_top * metrics.face_height; + const start_y = metrics.face_y + pad_bottom_dy; + const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); + const center_y = (start_y + end_y) / 2; + return switch (self.align_vertical) { + // NOTE: Even if there is no prescribed alignment, we ensure + // that the group doesn't protrude outside the padded cell, + // since this is implied by every available size constraint. If + // the group is too high we fall back to centering, though if we + // hit the .none prong we always have self.size != .none, so + // this should never happen. + .none => if (end_y < start_y) + center_y + else + @max(start_y, @min(group.y, end_y)), + .start => start_y, + .end => end_y, + .center, .center1 => center_y, + }; + } + + /// Return horizontal bearing for aligning this group + fn aligned_x( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + min_constraint_width: u2, + ) f64 { + if ((self.size == .none) and (self.align_horizontal == .none)) { + // If we don't have any constraints affecting the horizontal + // axis, we don't touch horizontal alignment. + return group.x; + } + // For multi-cell constraints, we align relative to the span + // from the left edge of the first cell to the right edge of + // the last face cell assuming it's left-aligned within the + // rounded and adjusted pixel cell. Any horizontal offset to + // center the face within the grid cell is the responsibility + // of the backend-specific rendering code, and should be done + // after applying constraints. + const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); + const pad_left_dx = self.pad_left * metrics.face_width; + const pad_right_dx = self.pad_right * metrics.face_width; + const start_x = pad_left_dx; + const end_x = full_face_span - group.width - pad_right_dx; + return switch (self.align_horizontal) { + // NOTE: Even if there is no prescribed alignment, we ensure + // that the glyph doesn't protrude outside the padded cell, + // since this is implied by every available size constraint. The + // left-side bound has priority if the group is too wide, though + // if we hit the .none prong we always have self.size != .none, + // so this should never happen. + .none => @max(start_x, @min(group.x, end_x)), + .start => start_x, + .end => @max(start_x, end_x), + .center => @max(start_x, (start_x + end_x) / 2), + // NOTE: .center1 implements the font_patcher rule of centering + // in the first cell even for multi-cell constraints. Since glyphs + // are not allowed to protrude to the left, this results in the + // left-alignment like .start when the glyph is wider than a cell. + .center1 => center1: { + const end1_x = metrics.face_width - group.width - pad_right_dx; + break :center1 @max(start_x, (start_x + end1_x) / 2); + }, + }; + } + }; +}; + +test "Constraints" { + const comparison = @import("../datastruct/comparison.zig"); + const getConstraint = @import("nerd_font_attributes.zig").getConstraint; + const GlyphSize = Size; + + // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. + + // Define grid metrics (matches font-family = JetBrains Mono) + const metrics: Metrics = .{ + .cell_width = 10, + .cell_height = 22, + .cell_baseline = 5, + .underline_position = 19, + .underline_thickness = 1, + .strikethrough_position = 12, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = 22, + .icon_height = 21.12, + .icon_height_single = 44.48 / 3.0, + .face_width = 9.6, + .face_height = 21.12, + .face_y = 0.2, + }; + + // ASCII (no constraint). + { + const constraint: RenderOptions.Constraint = .none; + + // BBox of 'x' from JetBrains Mono. + const glyph_x: GlyphSize = .{ + .width = 6.784, + .height = 15.28, + .x = 1.408, + .y = 4.84, + }; + + // Any constraint width: do nothing. + inline for (.{ 1, 2 }) |constraint_width| { + try comparison.expectApproxEqual( + glyph_x, + constraint.constrain(glyph_x, metrics, constraint_width), + ); + } + } + + // Symbol (same constraint as hardcoded in Renderer.addGlyph). + { + const constraint: RenderOptions.Constraint = .{ .size = .fit }; + + // BBox of '■' (0x25A0 black square) from Iosevka. + // NOTE: This glyph is designed to span two cells. + const glyph_25A0: GlyphSize = .{ + .width = 10.272, + .height = 10.272, + .x = 2.864, + .y = 5.304, + }; + + // Constraint width 1: scale down and shift to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = metrics.face_width, + .height = metrics.face_width, + .x = 0, + .y = 5.64, + }, + constraint.constrain(glyph_25A0, metrics, 1), + ); + + // Constraint width 2: do nothing. + try comparison.expectApproxEqual( + glyph_25A0, + constraint.constrain(glyph_25A0, metrics, 2), + ); + } + + // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). + { + const constraint: RenderOptions.Constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + .pad_left = 0.025, + .pad_right = 0.025, + }; + + // BBox of '🥸' (0x1F978) from Apple Color Emoji. + const glyph_1F978: GlyphSize = .{ + .width = 20, + .height = 20, + .x = 0.46, + .y = 1, + }; + + // Constraint width 2: scale to cover two cells with padding, center; + try comparison.expectApproxEqual( + GlyphSize{ + .width = 18.72, + .height = 18.72, + .x = 0.44, + .y = 1.4, + }, + constraint.constrain(glyph_1F978, metrics, 2), + ); + } + + // Nerd Font default. + { + const constraint = getConstraint(0xea61).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.fit_cover1, constraint.size); + try std.testing.expectEqual(.icon, constraint.height); + try std.testing.expectEqual(.center1, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. + // NOTE: This icon is part of a group, so the + // constraint applies to a larger bounding box. + const glyph_EA61: GlyphSize = .{ + .width = 9.015625, + .height = 13.015625, + .x = 3.015625, + .y = 3.76525, + }; + + // Constraint width 1: scale and shift group to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = 7.2125, + .height = 10.4125, + .x = 0.8125, + .y = 5.950695224719102, + }, + constraint.constrain(glyph_EA61, metrics, 1), + ); + + // Constraint width 2: no scaling; left-align and vertically center group. + try comparison.expectApproxEqual( + GlyphSize{ + .width = glyph_EA61.width, + .height = glyph_EA61.height, + .x = 1.015625, + .y = 4.7483690308988775, + }, + constraint.constrain(glyph_EA61, metrics, 2), + ); + } + + // Nerd Font stretch. + { + const constraint = getConstraint(0xe0c0).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.stretch, constraint.size); + try std.testing.expectEqual(.cell, constraint.height); + try std.testing.expectEqual(.start, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. + const glyph_E0C0: GlyphSize = .{ + .width = 16.796875, + .height = 16.46875, + .x = -0.796875, + .y = 1.7109375, + }; + + // Constraint width 1: stretch and position to exactly cover one cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 1), + ); + + // Constraint width 1: stretch and position to exactly cover two cells. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(2 * metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 2), + ); + } +} diff --git a/src/font/Metrics.zig b/src/font/Metrics.zig index a72cb7bee..60a300b0c 100644 --- a/src/font/Metrics.zig +++ b/src/font/Metrics.zig @@ -560,6 +560,8 @@ pub const Modifier = union(enum) { } test "formatConfig percent" { + if (comptime @import("terminal_options").artifact == .lib) return; + const configpkg = @import("../config.zig"); const testing = std.testing; var buf: std.Io.Writer.Allocating = .init(testing.allocator); @@ -571,6 +573,8 @@ pub const Modifier = union(enum) { } test "formatConfig absolute" { + if (comptime @import("terminal_options").artifact == .lib) return; + const configpkg = @import("../config.zig"); const testing = std.testing; var buf: std.Io.Writer.Allocating = .init(testing.allocator); diff --git a/src/font/SharedGrid.zig b/src/font/SharedGrid.zig index 5fd729b30..18870aaba 100644 --- a/src/font/SharedGrid.zig +++ b/src/font/SharedGrid.zig @@ -33,7 +33,7 @@ const Library = font.Library; const Metrics = font.Metrics; const Presentation = font.Presentation; const Style = font.Style; -const RenderOptions = font.face.RenderOptions; +const RenderOptions = font.Glyph.RenderOptions; const log = std.log.scoped(.font_shared_grid); diff --git a/src/font/face.zig b/src/font/face.zig index d77253adf..aeb7280c7 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -2,7 +2,6 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); const options = @import("main.zig").options; -const Metrics = @import("main.zig").Metrics; const config = @import("../config.zig"); const freetype = @import("face/freetype.zig"); const coretext = @import("face/coretext.zig"); @@ -94,407 +93,6 @@ pub const Variation = struct { }; }; -/// The size and position of a glyph. -pub const GlyphSize = struct { - width: f64, - height: f64, - x: f64, - y: f64, -}; - -/// Additional options for rendering glyphs. -pub const RenderOptions = struct { - /// The metrics that are defining the grid layout. These are usually - /// the metrics of the primary font face. The grid metrics are used - /// by the font face to better layout the glyph in situations where - /// the font is not exactly the same size as the grid. - grid_metrics: Metrics, - - /// The number of grid cells this glyph will take up. This can be used - /// optionally by the rasterizer to better layout the glyph. - cell_width: ?u2 = null, - - /// Constraint and alignment properties for the glyph. The rasterizer - /// should call the `constrain` function on this with the original size - /// and bearings of the glyph to get remapped values that the glyph - /// should be scaled/moved to. - constraint: Constraint = .none, - - /// The number of cells, horizontally that the glyph is free to take up - /// when resized and aligned by `constraint`. This is usually 1, but if - /// there's whitespace to the right of the cell then it can be 2. - constraint_width: u2 = 1, - - /// Thicken the glyph. This draws the glyph with a thicker stroke width. - /// This is purely an aesthetic setting. - /// - /// This only works with CoreText currently. - thicken: bool = false, - - /// "Strength" of the thickening, between `0` and `255`. - /// Only has an effect when `thicken` is enabled. - /// - /// `0` does not correspond to *no* thickening, - /// just the *lightest* thickening available. - /// - /// CoreText only. - thicken_strength: u8 = 255, - - /// See the `constraint` field. - pub const Constraint = struct { - /// Don't constrain the glyph in any way. - pub const none: Constraint = .{}; - - /// Sizing rule. - size: Size = .none, - - /// Vertical alignment rule. - align_vertical: Align = .none, - /// Horizontal alignment rule. - align_horizontal: Align = .none, - - /// Top padding when resizing. - pad_top: f64 = 0.0, - /// Left padding when resizing. - pad_left: f64 = 0.0, - /// Right padding when resizing. - pad_right: f64 = 0.0, - /// Bottom padding when resizing. - pad_bottom: f64 = 0.0, - - // Size and bearings of the glyph relative - // to the bounding box of its scale group. - relative_width: f64 = 1.0, - relative_height: f64 = 1.0, - relative_x: f64 = 0.0, - relative_y: f64 = 0.0, - - /// Maximum aspect ratio (width/height) to allow when stretching. - max_xy_ratio: ?f64 = null, - - /// Maximum number of cells horizontally to use. - max_constraint_width: u2 = 2, - - /// What to use as the height metric when constraining the glyph and - /// the constraint width is 1, - height: Height = .cell, - - pub const Size = enum { - /// Don't change the size of this glyph. - none, - /// Scale the glyph down if needed to fit within the bounds, - /// preserving aspect ratio. - fit, - /// Scale the glyph up or down to exactly match the bounds, - /// preserving aspect ratio. - cover, - /// Scale the glyph down if needed to fit within the bounds, - /// preserving aspect ratio. If the glyph doesn't cover a - /// single cell, scale up. If the glyph exceeds a single - /// cell but is within the bounds, do nothing. - /// (Nerd Font specific rule.) - fit_cover1, - /// Stretch the glyph to exactly fit the bounds in both - /// directions, disregarding aspect ratio. - stretch, - }; - - pub const Align = enum { - /// Don't move the glyph on this axis. - none, - /// Move the glyph so that its leading (bottom/left) - /// edge aligns with the leading edge of the axis. - start, - /// Move the glyph so that its trailing (top/right) - /// edge aligns with the trailing edge of the axis. - end, - /// Move the glyph so that it is centered on this axis. - center, - /// Move the glyph so that it is centered on this axis, - /// but always with respect to the first cell even for - /// multi-cell constraints. (Nerd Font specific rule.) - center1, - }; - - pub const Height = enum { - /// Use the full line height of the primary face for - /// constraining this glyph. - cell, - /// Use the icon height from the grid metrics for - /// constraining this glyph. Unlike `cell`, the value of - /// this height depends on both the constraint width and the - /// affected by the `adjust-icon-height` config option. - icon, - }; - - /// Returns true if the constraint does anything. If it doesn't, - /// because it neither sizes nor positions the glyph, then this - /// returns false. - pub inline fn doesAnything(self: Constraint) bool { - return self.size != .none or - self.align_horizontal != .none or - self.align_vertical != .none; - } - - /// Apply this constraint to the provided glyph - /// size, given the available width and height. - pub fn constrain( - self: Constraint, - glyph: GlyphSize, - metrics: Metrics, - /// Number of cells horizontally available for this glyph. - constraint_width: u2, - ) GlyphSize { - if (!self.doesAnything()) return glyph; - - switch (self.size) { - .stretch => { - // Stretched glyphs are usually meant to align across cell - // boundaries, which works best if they're scaled and - // aligned to the grid rather than the face. This is most - // easily done by inserting this little fib in the metrics. - var m = metrics; - m.face_width = @floatFromInt(m.cell_width); - m.face_height = @floatFromInt(m.cell_height); - m.face_y = 0.0; - - // Negative padding for stretched glyphs is a band-aid to - // avoid gaps due to pixel rounding, but at the cost of - // unsightly overlap artifacts. Since we scale and align to - // the grid rather than the face, we don't need it. - var c = self; - c.pad_bottom = @max(0, c.pad_bottom); - c.pad_top = @max(0, c.pad_top); - c.pad_left = @max(0, c.pad_left); - c.pad_right = @max(0, c.pad_right); - - return c.constrainInner(glyph, m, constraint_width); - }, - else => return self.constrainInner(glyph, metrics, constraint_width), - } - } - - fn constrainInner( - self: Constraint, - glyph: GlyphSize, - metrics: Metrics, - constraint_width: u2, - ) GlyphSize { - // For extra wide font faces, never stretch glyphs across two cells. - // This mirrors font_patcher. - const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) - 1 - else - @min(self.max_constraint_width, constraint_width); - - // The bounding box for the glyph's scale group. - // Scaling and alignment rules are calculated for - // this box and then applied to the glyph. - var group: GlyphSize = group: { - const group_width = glyph.width / self.relative_width; - const group_height = glyph.height / self.relative_height; - break :group .{ - .width = group_width, - .height = group_height, - .x = glyph.x - (group_width * self.relative_x), - .y = glyph.y - (group_height * self.relative_y), - }; - }; - - // Apply prescribed scaling, preserving the - // center bearings of the group bounding box - const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width); - const center_x = group.x + (group.width / 2); - const center_y = group.y + (group.height / 2); - group.width *= width_factor; - group.height *= height_factor; - group.x = center_x - (group.width / 2); - group.y = center_y - (group.height / 2); - - // NOTE: font_patcher jumps through a lot of hoops at this - // point to ensure that the glyph remains within the target - // bounding box after rounding to font definition units. - // This is irrelevant here as we're not rounding, we're - // staying in f64 and heading straight to rendering. - - // Apply prescribed alignment - group.y = self.aligned_y(group, metrics); - group.x = self.aligned_x(group, metrics, min_constraint_width); - - // Transfer the scaling and alignment back to the glyph and return. - return .{ - .width = width_factor * glyph.width, - .height = height_factor * glyph.height, - .x = group.x + (group.width * self.relative_x), - .y = group.y + (group.height * self.relative_y), - }; - } - - /// Return width and height scaling factors for this scaling group. - fn scale_factors( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - min_constraint_width: u2, - ) struct { f64, f64 } { - if (self.size == .none) { - return .{ 1.0, 1.0 }; - } - - const multi_cell = (min_constraint_width > 1); - - const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); - const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); - - const target_width = pad_width_factor * metrics.face_width; - const target_height = pad_height_factor * switch (self.height) { - .cell => metrics.face_height, - // Like font-patcher, the icon constraint height depends on the - // constraint width. Unlike font-patcher, the multi-cell - // icon_height may be different from face_height due to the - // `adjust-icon-height` config option. - .icon => if (multi_cell) - metrics.icon_height - else - metrics.icon_height_single, - }; - - var width_factor = target_width / group.width; - var height_factor = target_height / group.height; - - switch (self.size) { - .none => unreachable, - .fit => { - // Scale down to fit if needed - height_factor = @min(1, width_factor, height_factor); - width_factor = height_factor; - }, - .cover => { - // Scale to cover - height_factor = @min(width_factor, height_factor); - width_factor = height_factor; - }, - .fit_cover1 => { - // Scale down to fit or up to cover at least one cell - // NOTE: This is similar to font_patcher's "pa" mode, - // however, font_patcher will only do the upscaling - // part if the constraint width is 1, resulting in - // some icons becoming smaller when the constraint - // width increases. You'd see icons shrinking when - // opening up a space after them. This makes no - // sense, so we've fixed the rule such that these - // icons are scaled to the same size for multi-cell - // constraints as they would be for single-cell. - height_factor = @min(width_factor, height_factor); - if (multi_cell and (height_factor > 1)) { - // Call back into this function with - // constraint width 1 to get single-cell scale - // factors. We use the height factor as width - // could have been modified by max_xy_ratio. - _, const single_height_factor = self.scale_factors(group, metrics, 1); - height_factor = @max(1, single_height_factor); - } - width_factor = height_factor; - }, - .stretch => {}, - } - - // Reduce aspect ratio if required - if (self.max_xy_ratio) |ratio| { - if (group.width * width_factor > group.height * height_factor * ratio) { - width_factor = group.height * height_factor * ratio / group.width; - } - } - - return .{ width_factor, height_factor }; - } - - /// Return vertical bearing for aligning this group - fn aligned_y( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - ) f64 { - if ((self.size == .none) and (self.align_vertical == .none)) { - // If we don't have any constraints affecting the vertical axis, - // we don't touch vertical alignment. - return group.y; - } - // We use face_height and offset by face_y, rather than - // using cell_height directly, to account for the asymmetry - // of the pixel cell around the face (a consequence of - // aligning the baseline with a pixel boundary rather than - // vertically centering the face). - const pad_bottom_dy = self.pad_bottom * metrics.face_height; - const pad_top_dy = self.pad_top * metrics.face_height; - const start_y = metrics.face_y + pad_bottom_dy; - const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); - const center_y = (start_y + end_y) / 2; - return switch (self.align_vertical) { - // NOTE: Even if there is no prescribed alignment, we ensure - // that the group doesn't protrude outside the padded cell, - // since this is implied by every available size constraint. If - // the group is too high we fall back to centering, though if we - // hit the .none prong we always have self.size != .none, so - // this should never happen. - .none => if (end_y < start_y) - center_y - else - @max(start_y, @min(group.y, end_y)), - .start => start_y, - .end => end_y, - .center, .center1 => center_y, - }; - } - - /// Return horizontal bearing for aligning this group - fn aligned_x( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - min_constraint_width: u2, - ) f64 { - if ((self.size == .none) and (self.align_horizontal == .none)) { - // If we don't have any constraints affecting the horizontal - // axis, we don't touch horizontal alignment. - return group.x; - } - // For multi-cell constraints, we align relative to the span - // from the left edge of the first cell to the right edge of - // the last face cell assuming it's left-aligned within the - // rounded and adjusted pixel cell. Any horizontal offset to - // center the face within the grid cell is the responsibility - // of the backend-specific rendering code, and should be done - // after applying constraints. - const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); - const pad_left_dx = self.pad_left * metrics.face_width; - const pad_right_dx = self.pad_right * metrics.face_width; - const start_x = pad_left_dx; - const end_x = full_face_span - group.width - pad_right_dx; - return switch (self.align_horizontal) { - // NOTE: Even if there is no prescribed alignment, we ensure - // that the glyph doesn't protrude outside the padded cell, - // since this is implied by every available size constraint. The - // left-side bound has priority if the group is too wide, though - // if we hit the .none prong we always have self.size != .none, - // so this should never happen. - .none => @max(start_x, @min(group.x, end_x)), - .start => start_x, - .end => @max(start_x, end_x), - .center => @max(start_x, (start_x + end_x) / 2), - // NOTE: .center1 implements the font_patcher rule of centering - // in the first cell even for multi-cell constraints. Since glyphs - // are not allowed to protrude to the left, this results in the - // left-alignment like .start when the glyph is wider than a cell. - .center1 => center1: { - const end1_x = metrics.face_width - group.width - pad_right_dx; - break :center1 @max(start_x, (start_x + end1_x) / 2); - }, - }; - } - }; -}; - test { @import("std").testing.refAllDecls(@This()); } @@ -512,197 +110,3 @@ test "Variation.Id: slnt should be 1936486004" { try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id))); try testing.expectEqualStrings("slnt", &(id.str())); } - -test "Constraints" { - const comparison = @import("../datastruct/comparison.zig"); - const getConstraint = @import("nerd_font_attributes.zig").getConstraint; - - // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. - - // Define grid metrics (matches font-family = JetBrains Mono) - const metrics: Metrics = .{ - .cell_width = 10, - .cell_height = 22, - .cell_baseline = 5, - .underline_position = 19, - .underline_thickness = 1, - .strikethrough_position = 12, - .strikethrough_thickness = 1, - .overline_position = 0, - .overline_thickness = 1, - .box_thickness = 1, - .cursor_thickness = 1, - .cursor_height = 22, - .icon_height = 21.12, - .icon_height_single = 44.48 / 3.0, - .face_width = 9.6, - .face_height = 21.12, - .face_y = 0.2, - }; - - // ASCII (no constraint). - { - const constraint: RenderOptions.Constraint = .none; - - // BBox of 'x' from JetBrains Mono. - const glyph_x: GlyphSize = .{ - .width = 6.784, - .height = 15.28, - .x = 1.408, - .y = 4.84, - }; - - // Any constraint width: do nothing. - inline for (.{ 1, 2 }) |constraint_width| { - try comparison.expectApproxEqual( - glyph_x, - constraint.constrain(glyph_x, metrics, constraint_width), - ); - } - } - - // Symbol (same constraint as hardcoded in Renderer.addGlyph). - { - const constraint: RenderOptions.Constraint = .{ .size = .fit }; - - // BBox of '■' (0x25A0 black square) from Iosevka. - // NOTE: This glyph is designed to span two cells. - const glyph_25A0: GlyphSize = .{ - .width = 10.272, - .height = 10.272, - .x = 2.864, - .y = 5.304, - }; - - // Constraint width 1: scale down and shift to fit a single cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = metrics.face_width, - .height = metrics.face_width, - .x = 0, - .y = 5.64, - }, - constraint.constrain(glyph_25A0, metrics, 1), - ); - - // Constraint width 2: do nothing. - try comparison.expectApproxEqual( - glyph_25A0, - constraint.constrain(glyph_25A0, metrics, 2), - ); - } - - // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). - { - const constraint: RenderOptions.Constraint = .{ - .size = .cover, - .align_horizontal = .center, - .align_vertical = .center, - .pad_left = 0.025, - .pad_right = 0.025, - }; - - // BBox of '🥸' (0x1F978) from Apple Color Emoji. - const glyph_1F978: GlyphSize = .{ - .width = 20, - .height = 20, - .x = 0.46, - .y = 1, - }; - - // Constraint width 2: scale to cover two cells with padding, center; - try comparison.expectApproxEqual( - GlyphSize{ - .width = 18.72, - .height = 18.72, - .x = 0.44, - .y = 1.4, - }, - constraint.constrain(glyph_1F978, metrics, 2), - ); - } - - // Nerd Font default. - { - const constraint = getConstraint(0xea61).?; - - // Verify that this is the constraint we expect. - try std.testing.expectEqual(.fit_cover1, constraint.size); - try std.testing.expectEqual(.icon, constraint.height); - try std.testing.expectEqual(.center1, constraint.align_horizontal); - try std.testing.expectEqual(.center1, constraint.align_vertical); - - // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. - // NOTE: This icon is part of a group, so the - // constraint applies to a larger bounding box. - const glyph_EA61: GlyphSize = .{ - .width = 9.015625, - .height = 13.015625, - .x = 3.015625, - .y = 3.76525, - }; - - // Constraint width 1: scale and shift group to fit a single cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = 7.2125, - .height = 10.4125, - .x = 0.8125, - .y = 5.950695224719102, - }, - constraint.constrain(glyph_EA61, metrics, 1), - ); - - // Constraint width 2: no scaling; left-align and vertically center group. - try comparison.expectApproxEqual( - GlyphSize{ - .width = glyph_EA61.width, - .height = glyph_EA61.height, - .x = 1.015625, - .y = 4.7483690308988775, - }, - constraint.constrain(glyph_EA61, metrics, 2), - ); - } - - // Nerd Font stretch. - { - const constraint = getConstraint(0xe0c0).?; - - // Verify that this is the constraint we expect. - try std.testing.expectEqual(.stretch, constraint.size); - try std.testing.expectEqual(.cell, constraint.height); - try std.testing.expectEqual(.start, constraint.align_horizontal); - try std.testing.expectEqual(.center1, constraint.align_vertical); - - // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. - const glyph_E0C0: GlyphSize = .{ - .width = 16.796875, - .height = 16.46875, - .x = -0.796875, - .y = 1.7109375, - }; - - // Constraint width 1: stretch and position to exactly cover one cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = @floatFromInt(metrics.cell_width), - .height = @floatFromInt(metrics.cell_height), - .x = 0, - .y = 0, - }, - constraint.constrain(glyph_E0C0, metrics, 1), - ); - - // Constraint width 1: stretch and position to exactly cover two cells. - try comparison.expectApproxEqual( - GlyphSize{ - .width = @floatFromInt(2 * metrics.cell_width), - .height = @floatFromInt(metrics.cell_height), - .x = 0, - .y = 0, - }, - constraint.constrain(glyph_E0C0, metrics, 2), - ); - } -} diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 1d1333882..cd8bc7d8b 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -291,7 +291,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { var glyphs = [_]macos.graphics.Glyph{@intCast(glyph_index)}; diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 528f72d52..d04fe1809 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -393,7 +393,7 @@ pub const Face = struct { } /// Get a rect that represents the position and size of the loaded glyph. - fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize { + fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.Glyph.Size { // If we're dealing with an outline glyph then we get the // outline's bounding box instead of using the built-in // metrics, since that's more precise and allows better @@ -427,7 +427,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !Glyph { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); diff --git a/src/font/face/web_canvas.zig b/src/font/face/web_canvas.zig index b4f9f5d5d..a36a482dd 100644 --- a/src/font/face/web_canvas.zig +++ b/src/font/face/web_canvas.zig @@ -189,7 +189,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { _ = opts; diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig index 43dde026f..f321214bc 100644 --- a/src/font/glyf_rasterize.zig +++ b/src/font/glyf_rasterize.zig @@ -9,21 +9,10 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const z2d = @import("z2d"); -const face = @import("face.zig"); +const Glyph = @import("Glyph.zig"); const glyf = @import("opentype/glyf.zig"); -/// Metrics describing the authored glyf coordinate space, since -/// a glyf table doesn't contain this on its own. -pub const DesignMetrics = struct { - /// Units-per-em for outline/design coordinates. - units_per_em: u32, - - /// Authored advance width in design units. - advance_width: u32, - - /// Authored line height in design units. - line_height: u32, -}; +const DesignMetrics = Glyph.DesignMetrics; /// An owned, tightly packed alpha8 bitmap. pub const Bitmap = struct { @@ -52,7 +41,7 @@ pub const Error = Allocator.Error || z2d.Path.Error || z2d.painter.FillError; /// /// The returned bitmap is always `grid_metrics.cell_width * cell_width` by /// `grid_metrics.cell_height`. `opts.constraint` is applied using the same -/// `face.RenderOptions.Constraint` machinery used by the platform font +/// `RenderOptions.Constraint` machinery used by the platform font /// backends. /// /// The caller owns the returned bitmap. @@ -60,7 +49,7 @@ pub fn rasterize( alloc: Allocator, outline: glyf.Glyf.Outline, design: DesignMetrics, - opts: face.RenderOptions, + opts: Glyph.RenderOptions, ) Error!Bitmap { assert(design.units_per_em > 0); assert(design.advance_width > 0); @@ -207,16 +196,16 @@ const Placement = struct { /// Bottom edge of the rasterized outline bounds in bitmap pixels, measured /// from the bitmap's bottom edge. This matches the cell-relative y axis - /// used by font.face.GlyphSize and is converted to z2d's y-down axis when + /// used by font.Glyph.Size and is converted to z2d's y-down axis when /// points are transformed. y: f64, /// Width of the rasterized outline bounds in bitmap pixels after applying - /// font.face.RenderOptions.Constraint. + /// font.Glyph.RenderOptions.Constraint. width: f64, /// Height of the rasterized outline bounds in bitmap pixels after applying - /// font.face.RenderOptions.Constraint. + /// font.Glyph.RenderOptions.Constraint. height: f64, /// Full bitmap height in pixels, used to convert cell-relative y-up-ish @@ -240,7 +229,7 @@ const Placement = struct { fn init( bounds: Bounds, design: DesignMetrics, - opts: face.RenderOptions, + opts: Glyph.RenderOptions, ) Placement { // Start with protocol-like design units mapped so that the em square // occupies one cell. This makes units_per_em the scale reference and @@ -253,7 +242,7 @@ const Placement = struct { // Convert the decoded point bounds into the same pixel coordinate space // expected by RenderOptions.Constraint. This rectangle is the visible // outline bounds, not the full advance/line-height layout box. - const glyph: face.GlyphSize = .{ + const glyph: Glyph.Size = .{ .width = bounds.width() * scale, .height = bounds.height() * scale, .x = bounds.x_min * scale, @@ -268,7 +257,7 @@ const Placement = struct { // Apply the same fit/cover/stretch/alignment/padding rules used by // normal font rendering. The result is still the outline bounds, but // placed as if its containing advance/line-height box was constrained. - const constraint: face.RenderOptions.Constraint = constraint: { + const constraint: Glyph.RenderOptions.Constraint = constraint: { var constraint = opts.constraint; if (group_width > 0 and group_height > 0) { // Tell Constraint that `glyph` is a sub-rectangle of the diff --git a/src/font/nerd_font_attributes.zig b/src/font/nerd_font_attributes.zig index f4a19d963..fc9884964 100644 --- a/src/font/nerd_font_attributes.zig +++ b/src/font/nerd_font_attributes.zig @@ -4,7 +4,7 @@ //! This file provides info extracted from the nerd fonts patcher script, //! specifying the scaling/positioning attributes of various glyphs. -const Constraint = @import("face.zig").RenderOptions.Constraint; +const Constraint = @import("Glyph.zig").RenderOptions.Constraint; /// Get the constraints for the provided codepoint. pub fn getConstraint(cp: u21) ?Constraint { diff --git a/src/font/sprite/Face.zig b/src/font/sprite/Face.zig index 596a92044..f5df2d421 100644 --- a/src/font/sprite/Face.zig +++ b/src/font/sprite/Face.zig @@ -176,7 +176,7 @@ pub fn renderGlyph( alloc: Allocator, atlas: *font.Atlas, cp: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { if (std.debug.runtime_safety) { if (!self.hasCodepoint(cp, null)) { diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig index 08e1ead86..727e1ab4d 100644 --- a/src/terminal/apc/glyph.zig +++ b/src/terminal/apc/glyph.zig @@ -155,9 +155,9 @@ pub const response = @import("glyph/response.zig"); pub const execute = @import("glyph/execute.zig").execute; pub const CommandParser = request.CommandParser; -pub const Glossary = @import("glyph/Glossary.zig"); pub const Request = request.Request; pub const Response = response.Response; +pub const Glossary = @import("glyph/Glossary.zig"); test { std.testing.refAllDecls(@This()); diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig index 316d36ae6..28458dbb2 100644 --- a/src/terminal/apc/glyph/Glossary.zig +++ b/src/terminal/apc/glyph/Glossary.zig @@ -7,13 +7,15 @@ 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"); +const FontGlyph = @import("../../../font/Glyph.zig"); const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; -const glyf_rasterize = @import("../../../font/glyf_rasterize.zig"); const request = @import("request.zig"); const RegisterReq = request.Request.Register; +const DesignMetrics = FontGlyph.DesignMetrics; +const Constraint = FontGlyph.RenderOptions.Constraint; + /// Maximum entries allowed in the glossary before eviction. /// Defined by the specification. pub const max_entries = 1024; @@ -124,13 +126,13 @@ pub const Entry = struct { glyph: Glyph, /// Authored metrics for the glyph's design coordinate space. - design: glyf_rasterize.DesignMetrics, + design: DesignMetrics, /// Unicode cell width requested by the registration. width: request.Width, /// Normalized scale, alignment, and padding behavior for rasterization. - constraint: face.RenderOptions.Constraint, + constraint: Constraint, /// Errors that can occur while constructing a glossary entry from a /// register request. @@ -152,7 +154,7 @@ pub const Entry = struct { pub fn init(alloc: Allocator, req: RegisterReq) Entry.InitError!Entry { // Validate format const fmt = req.get(.fmt) orelse return error.InvalidOptions; - const design: glyf_rasterize.DesignMetrics = .{ + const design: DesignMetrics = .{ .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, @@ -194,13 +196,12 @@ pub const Entry = struct { /// Return the renderer constraint for a register request. /// /// Glyph Protocol §8.5 defines sizing, alignment, and padding in terms of - /// the authored extent and render span. Ghostty's existing constraint type - /// is the closest renderer-native representation for these controls, but - /// it does not have exact equivalents for every protocol size mode, so this - /// function is the single normalization point for those policy choices. + /// the authored extent and render span. This function is the single + /// normalization point for how protocol sizing choices map to the + /// renderer-neutral constraint stored here. fn constraintFromRegister( req: RegisterReq, - ) error{InvalidOptions}!face.RenderOptions.Constraint { + ) error{InvalidOptions}!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 = req.get(.size) orelse return error.InvalidOptions; @@ -307,9 +308,9 @@ test "Entry init decodes glyf payload and applies register fields" { try testing.expectEqual(@as(u32, 1024), entry.design.advance_width); try testing.expectEqual(@as(u32, 1536), entry.design.line_height); try testing.expectEqual(request.Width.wide, entry.width); - try testing.expectEqual(face.RenderOptions.Constraint.Size.stretch, entry.constraint.size); - try testing.expectEqual(face.RenderOptions.Constraint.Align.end, entry.constraint.align_horizontal); - try testing.expectEqual(face.RenderOptions.Constraint.Align.start, entry.constraint.align_vertical); + try testing.expectEqual(Constraint.Size.stretch, entry.constraint.size); + try testing.expectEqual(Constraint.Align.end, entry.constraint.align_horizontal); + try testing.expectEqual(Constraint.Align.start, entry.constraint.align_vertical); try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top); try testing.expectEqual(@as(f64, 0.2), entry.constraint.pad_right); try testing.expectEqual(@as(f64, 0.3), entry.constraint.pad_bottom); From 05eeb439421a362e783ebead03edfa455e2bda38 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 08:10:41 -0700 Subject: [PATCH 09/13] font: exclude libghostty-vt from embedded font tests --- src/font/opentype/glyf.zig | 15 +++++++++++++++ src/font/opentype/sfnt.zig | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/src/font/opentype/glyf.zig b/src/font/opentype/glyf.zig index 194385345..912b5fe84 100644 --- a/src/font/opentype/glyf.zig +++ b/src/font/opentype/glyf.zig @@ -672,6 +672,9 @@ fn testAppendHeader( } test "glyf" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -874,6 +877,9 @@ test "glyf: decode contour ending at max point index" { } test "glyf: reject glyphs with instructions and composite glyphs" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; const test_font = @import("../embedded.zig").jetbrains_mono; @@ -908,6 +914,9 @@ test "glyf: reject glyphs with instructions and composite glyphs" { } test "glyf: reject truncated" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -926,6 +935,9 @@ test "glyf: reject truncated" { } test "glyf: reject endpoints out of order" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -952,6 +964,9 @@ test "glyf: reject endpoints out of order" { } test "glyf: reject too many points" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. diff --git a/src/font/opentype/sfnt.zig b/src/font/opentype/sfnt.zig index 9373cda03..55a9a0bdf 100644 --- a/src/font/opentype/sfnt.zig +++ b/src/font/opentype/sfnt.zig @@ -285,6 +285,9 @@ pub const SFNT = struct { const native_endian = @import("builtin").target.cpu.arch.endian(); test "parse font" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; @@ -298,6 +301,9 @@ test "parse font" { } test "get table" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; From f0d81f15ee886e17e188422ec057caf4cfcdaa92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 13:10:51 -0700 Subject: [PATCH 10/13] terminal/apc: reject malformed glyph clear cp Glyph clear execution previously treated an unparsable cp option the same as an omitted cp option. That made inputs such as c;cp=zz behave like a bare clear request and remove every glossary registration. Track clear option presence separately from successful decoding. A present but malformed cp now returns a malformed_payload clear failure without mutating the glossary, while an omitted cp still clears all registrations. --- src/terminal/apc/glyph/execute.zig | 35 ++++++++++++++++++++++++++++++ src/terminal/apc/glyph/request.zig | 25 +++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/terminal/apc/glyph/execute.zig b/src/terminal/apc/glyph/execute.zig index 26dc6d2c9..059029a62 100644 --- a/src/terminal/apc/glyph/execute.zig +++ b/src/terminal/apc/glyph/execute.zig @@ -112,6 +112,11 @@ fn clear( error.OutOfNamespace => "out_of_namespace", }, } }; + } else if (clr.has(.cp)) { + return .{ .clear = .{ + .status = .err, + .reason = "malformed_payload", + } }; } else { glossary.clearAndFree(alloc); } @@ -278,6 +283,36 @@ test "execute clear rejects non-PUA" { }, testExecute(alloc, &glossary, &req).?); } +test "execute clear rejects malformed cp without clearing glossary" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var req = try testParse(alloc, data); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .clear = .{ + .status = .err, + .reason = "malformed_payload", + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(glossary.contains(0xE0A0)); + try testing.expect(glossary.contains(0xE0A1)); + } +} + test "execute query reports no coverage" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig index 6d63673df..6fecbc287 100644 --- a/src/terminal/apc/glyph/request.zig +++ b/src/terminal/apc/glyph/request.zig @@ -350,6 +350,12 @@ pub const Request = union(enum) { .cp => std.fmt.parseInt(u21, value, 16) catch null, }; } + + /// Return whether the option is present in the raw option string, + /// independent of whether its value can be decoded. + pub fn present(comptime self: Option, raw: []const u8) bool { + return optionValue(raw, self.key()) != null; + } }; /// Lazily decode a clear option on demand. @@ -357,6 +363,11 @@ pub const Request = union(enum) { return option.read(self.rawOptions()); } + /// Return whether a clear option was provided, even if malformed. + pub fn has(self: Clear, comptime option: Option) bool { + return option.present(self.rawOptions()); + } + /// Return the raw option portion of a valid clear command. fn rawOptions(self: Clear) []const u8 { assert(self.raw.len >= 2); @@ -799,9 +810,23 @@ test "clear command" { defer cmd.deinit(testing.allocator); try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?); } +test "clear command tracks malformed cp presence" { + const testing = std.testing; + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var cmd = try testParse(testing.allocator, data); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); + try testing.expect(cmd.clear.get(.cp) == null); + } +} + test "invalid command" { const testing = std.testing; From e45f002d1aaee91a9c17dd9293e4262ef608149e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 13:14:01 -0700 Subject: [PATCH 11/13] terminal/apc: reject malformed glyph register input Register parsing now validates the full register request shape before constructing the parsed command. Inputs that only contain the verb separator, such as `r`, `r;cp=e0a0`, or `r;foo`, now fail with InvalidFormat instead of reaching Register invariants guarded by asserts. Valid empty-payload requests still parse when they include the payload separator, allowing execution to report malformed_payload through the normal protocol response path. --- src/terminal/apc/glyph/request.zig | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/terminal/apc/glyph/request.zig b/src/terminal/apc/glyph/request.zig index 6fecbc287..b33a28413 100644 --- a/src/terminal/apc/glyph/request.zig +++ b/src/terminal/apc/glyph/request.zig @@ -119,12 +119,12 @@ pub const Request = union(enum) { payload_idx: usize, /// Initialize a register command from owned raw command bytes. - pub fn init(raw: []const u8) Register { - assert(raw.len >= 2); - assert(raw[0] == 'r'); - assert(raw[1] == ';'); - const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';').?; - assert(payload_idx > 1); + pub fn init(raw: []const u8) ?Register { + if (raw.len < 2) return null; + if (raw[0] != 'r') return null; + if (raw[1] != ';') return null; + const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';') orelse return null; + if (payload_idx <= 1) return null; return .{ .raw = raw, @@ -395,7 +395,7 @@ pub const Request = union(enum) { return .support; }, 'q' => .{ .query = .init(raw) }, - 'r' => .{ .register = .init(raw) }, + 'r' => .{ .register = Register.init(raw) orelse return error.InvalidFormat }, 'c' => .{ .clear = .init(raw) }, else => error.InvalidFormat, }; @@ -765,6 +765,17 @@ test "register command with invalid payload" { try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload()); } +test "register command rejects missing payload separator" { + const testing = std.testing; + + for ([_][]const u8{ "r", "r;cp=e0a0", "r;foo" }) |data| { + try testing.expectError( + error.InvalidFormat, + testParse(testing.allocator, data), + ); + } +} + test "register decodes glyf payload" { const testing = std.testing; From b661adad2ec9e32d739684b3051c733dbcd021ea Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 13:32:57 -0700 Subject: [PATCH 12/13] font: add exact glyph protocol constraints Extend glyph render constraints with cell-span sizing modes for height, width, contain, cover bounds, and stretch bounds. These preserve the existing face-targeted behavior for platform fonts, emoji, and Nerd Font rules while giving registered glyphs a target based on terminal cell spans. Map Glyph Protocol registration options to the new constraint modes so sizing follows the spec formulas based on authored advance width and line height. Baseline alignment now places design-space y=0 on the terminal text baseline instead of approximating it as start alignment. Document the placement formulas in the local protocol summary and add focused tests for constraint mapping, cell-span padding, line-height and advance scaling, contain versus cover behavior, stretch, and baseline placement. --- src/font/Glyph.zig | 266 +++++++++++++++++++++++----- src/font/glyf_rasterize.zig | 161 ++++++++++++++++- src/terminal/apc/glyph.zig | 11 +- src/terminal/apc/glyph/Glossary.zig | 62 ++++--- 4 files changed, 431 insertions(+), 69 deletions(-) diff --git a/src/font/Glyph.zig b/src/font/Glyph.zig index 62c44e227..039d69c8a 100644 --- a/src/font/Glyph.zig +++ b/src/font/Glyph.zig @@ -87,6 +87,9 @@ pub const RenderOptions = struct { /// Sizing rule. size: Constraint.Size = .none, + /// Target coordinate space for sizing, alignment, and padding. + target: Target = .face, + /// Vertical alignment rule. align_vertical: Align = .none, /// Horizontal alignment rule. @@ -136,6 +139,21 @@ pub const RenderOptions = struct { /// Stretch the glyph to exactly fit the bounds in both /// directions, disregarding aspect ratio. stretch, + /// Scale the glyph up or down to exactly match the target height, + /// preserving aspect ratio. + height, + /// Scale the glyph up or down to exactly match the target width, + /// preserving aspect ratio. + width, + /// Scale the glyph up or down to fit within the target bounds, + /// preserving aspect ratio. + contain, + /// Scale the glyph up or down to cover the target bounds, + /// preserving aspect ratio. + cover_bounds, + /// Stretch the glyph to exactly fit the target bounds in both + /// directions, disregarding aspect ratio. + stretch_bounds, }; pub const Align = enum { @@ -153,6 +171,9 @@ pub const RenderOptions = struct { /// but always with respect to the first cell even for /// multi-cell constraints. (Nerd Font specific rule.) center1, + /// Move the glyph so that its design-space baseline aligns with + /// the terminal text baseline. + baseline, }; pub const Height = enum { @@ -166,6 +187,17 @@ pub const RenderOptions = struct { icon, }; + pub const Target = enum { + /// Size and align relative to the primary face metrics. This is + /// the default behavior used for normal fonts, emoji, and Nerd + /// Font constraints. + face, + /// Size and align relative to the full terminal cell span. The + /// width is `cell_width * constraint_width`; the height is + /// `cell_height`. + cell_span, + }; + /// Returns true if the constraint does anything. If it doesn't, /// because it neither sizes nor positions the glyph, then this /// returns false. @@ -221,10 +253,13 @@ pub const RenderOptions = struct { ) Glyph.Size { // For extra wide font faces, never stretch glyphs across two cells. // This mirrors font_patcher. - const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) - 1 - else - @min(self.max_constraint_width, constraint_width); + const min_constraint_width: u2 = switch (self.target) { + .cell_span => constraint_width, + .face => if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) + 1 + else + @min(self.max_constraint_width, constraint_width), + }; // The bounding box for the glyph's scale group. // Scaling and alignment rules are calculated for @@ -280,24 +315,9 @@ pub const RenderOptions = struct { return .{ 1.0, 1.0 }; } + const target_width, const target_height = self.targetSize(metrics, min_constraint_width); const multi_cell = (min_constraint_width > 1); - const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); - const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); - - const target_width = pad_width_factor * metrics.face_width; - const target_height = pad_height_factor * switch (self.height) { - .cell => metrics.face_height, - // Like font-patcher, the icon constraint height depends on the - // constraint width. Unlike font-patcher, the multi-cell - // icon_height may be different from face_height due to the - // `adjust-icon-height` config option. - .icon => if (multi_cell) - metrics.icon_height - else - metrics.icon_height_single, - }; - var width_factor = target_width / group.width; var height_factor = target_height / group.height; @@ -336,6 +356,21 @@ pub const RenderOptions = struct { width_factor = height_factor; }, .stretch => {}, + .height => { + width_factor = height_factor; + }, + .width => { + height_factor = width_factor; + }, + .contain => { + height_factor = @min(width_factor, height_factor); + width_factor = height_factor; + }, + .cover_bounds => { + height_factor = @max(width_factor, height_factor); + width_factor = height_factor; + }, + .stretch_bounds => {}, } // Reduce aspect ratio if required @@ -359,15 +394,23 @@ pub const RenderOptions = struct { // we don't touch vertical alignment. return group.y; } - // We use face_height and offset by face_y, rather than - // using cell_height directly, to account for the asymmetry - // of the pixel cell around the face (a consequence of - // aligning the baseline with a pixel boundary rather than - // vertically centering the face). - const pad_bottom_dy = self.pad_bottom * metrics.face_height; - const pad_top_dy = self.pad_top * metrics.face_height; - const start_y = metrics.face_y + pad_bottom_dy; - const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); + const span_height: f64 = switch (self.target) { + .face => metrics.face_height, + .cell_span => @floatFromInt(metrics.cell_height), + }; + const origin_y: f64 = switch (self.target) { + // We use face_height and offset by face_y, rather than using + // cell_height directly, to account for the asymmetry of the + // pixel cell around the face (a consequence of aligning the + // baseline with a pixel boundary rather than vertically + // centering the face). + .face => metrics.face_y, + .cell_span => 0, + }; + const pad_bottom_dy = self.pad_bottom * span_height; + const pad_top_dy = self.pad_top * span_height; + const start_y = origin_y + pad_bottom_dy; + const end_y = origin_y + (span_height - group.height - pad_top_dy); const center_y = (start_y + end_y) / 2; return switch (self.align_vertical) { // NOTE: Even if there is no prescribed alignment, we ensure @@ -383,6 +426,7 @@ pub const RenderOptions = struct { .start => start_y, .end => end_y, .center, .center1 => center_y, + .baseline => @floatFromInt(metrics.cell_baseline), }; } @@ -398,18 +442,27 @@ pub const RenderOptions = struct { // axis, we don't touch horizontal alignment. return group.x; } - // For multi-cell constraints, we align relative to the span - // from the left edge of the first cell to the right edge of - // the last face cell assuming it's left-aligned within the - // rounded and adjusted pixel cell. Any horizontal offset to - // center the face within the grid cell is the responsibility - // of the backend-specific rendering code, and should be done - // after applying constraints. - const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); - const pad_left_dx = self.pad_left * metrics.face_width; - const pad_right_dx = self.pad_right * metrics.face_width; + const span_width, const pad_width = switch (self.target) { + // For multi-cell constraints, we align relative to the span + // from the left edge of the first cell to the right edge of + // the last face cell assuming it's left-aligned within the + // rounded and adjusted pixel cell. Any horizontal offset to + // center the face within the grid cell is the responsibility + // of the backend-specific rendering code, and should be done + // after applying constraints. + .face => .{ + metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)), + metrics.face_width, + }, + .cell_span => .{ + @as(f64, @floatFromInt(min_constraint_width * metrics.cell_width)), + @as(f64, @floatFromInt(min_constraint_width * metrics.cell_width)), + }, + }; + const pad_left_dx = self.pad_left * pad_width; + const pad_right_dx = self.pad_right * pad_width; const start_x = pad_left_dx; - const end_x = full_face_span - group.width - pad_right_dx; + const end_x = span_width - group.width - pad_right_dx; return switch (self.align_horizontal) { // NOTE: Even if there is no prescribed alignment, we ensure // that the glyph doesn't protrude outside the padded cell, @@ -429,6 +482,66 @@ pub const RenderOptions = struct { const end1_x = metrics.face_width - group.width - pad_right_dx; break :center1 @max(start_x, (start_x + end1_x) / 2); }, + // Baseline is vertical-only. We share the Align enum between + // axes so registered-glyph options can be stored in the same + // Constraint struct, but the parser never maps a horizontal + // alignment to .baseline. + .baseline => unreachable, + }; + } + + /// Return the available target width and height after padding. + /// + /// Face-targeted constraints preserve the historical behavior used by + /// platform fonts and Nerd Font rules: horizontal padding is measured + /// against one face width, and the available span is one face width + /// plus any additional grid cells. Vertical sizing uses either the + /// face height or configured icon height. + /// + /// Cell-span constraints are for glyphs whose layout contract is the + /// terminal cells themselves. Padding is measured against the full + /// cell span, so a two-cell glyph with 10% left padding starts after + /// 10% of both cells combined, not 10% of a single face width. + fn targetSize( + self: Constraint, + metrics: Metrics, + min_constraint_width: u2, + ) struct { f64, f64 } { + const multi_cell = (min_constraint_width > 1); + + return switch (self.target) { + .face => .{ + // Historical font constraints measure horizontal padding + // in face-width units. Additional cells add raw grid-cell + // width to the available span, matching aligned_x. + (@as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right)) * metrics.face_width, + // Vertical face constraints operate on the selected face + // or icon height. Icon height may depend on whether the + // constraint spans multiple cells. + (1 - (self.pad_bottom + self.pad_top)) * switch (self.height) { + .cell => metrics.face_height, + // Like font-patcher, the icon constraint height depends on the + // constraint width. Unlike font-patcher, the multi-cell + // icon_height may be different from face_height due to the + // `adjust-icon-height` config option. + .icon => if (multi_cell) + metrics.icon_height + else + metrics.icon_height_single, + }, + }, + .cell_span => span: { + const span_width: f64 = @floatFromInt(min_constraint_width * metrics.cell_width); + const span_height: f64 = @floatFromInt(metrics.cell_height); + break :span .{ + // Cell-span constraints use the full terminal span as + // the target box. This is the exact contract needed by + // runtime registered glyphs, whose width is declared in + // terminal cells rather than face metrics. + (1 - (self.pad_left + self.pad_right)) * span_width, + (1 - (self.pad_bottom + self.pad_top)) * span_height, + }; + }, }; } }; @@ -544,6 +657,79 @@ test "Constraints" { ); } + // Cell-span constraints. These are generic target-bound sizing modes used + // by registered glyphs where the render span is defined by terminal cell + // dimensions rather than primary face metrics. + { + const glyph: GlyphSize = .{ + .width = 100, + .height = 200, + .x = 0, + .y = 0, + }; + + const base: RenderOptions.Constraint = .{ + .target = .cell_span, + .align_horizontal = .start, + .align_vertical = .start, + }; + + // Target span is two cells wide by one cell high: 20px x 22px. + var height_constraint = base; + height_constraint.size = .height; + try comparison.expectApproxEqual( + GlyphSize{ .width = 11, .height = 22, .x = 0, .y = 0 }, + height_constraint.constrain(glyph, metrics, 2), + ); + var width_constraint = base; + width_constraint.size = .width; + try comparison.expectApproxEqual( + GlyphSize{ .width = 20, .height = 40, .x = 0, .y = 0 }, + width_constraint.constrain(glyph, metrics, 2), + ); + var contain_constraint = base; + contain_constraint.size = .contain; + try comparison.expectApproxEqual( + GlyphSize{ .width = 11, .height = 22, .x = 0, .y = 0 }, + contain_constraint.constrain(glyph, metrics, 2), + ); + var cover_constraint = base; + cover_constraint.size = .cover_bounds; + try comparison.expectApproxEqual( + GlyphSize{ .width = 20, .height = 40, .x = 0, .y = 0 }, + cover_constraint.constrain(glyph, metrics, 2), + ); + var stretch_constraint = base; + stretch_constraint.size = .stretch_bounds; + try comparison.expectApproxEqual( + GlyphSize{ .width = 20, .height = 22, .x = 0, .y = 0 }, + stretch_constraint.constrain(glyph, metrics, 2), + ); + + // Baseline alignment places the group's y=0 at the terminal text + // baseline, independent of vertical padding. + try comparison.expectApproxEqual( + GlyphSize{ .width = 100, .height = 200, .x = 0, .y = 5 }, + (RenderOptions.Constraint{ + .target = .cell_span, + .align_vertical = .baseline, + }).constrain(glyph, metrics, 2), + ); + + // Cell-span padding is relative to the full two-cell span. + try comparison.expectApproxEqual( + GlyphSize{ .width = 10, .height = 20, .x = 5, .y = 0 }, + (RenderOptions.Constraint{ + .target = .cell_span, + .size = .width, + .align_horizontal = .start, + .align_vertical = .start, + .pad_left = 0.25, + .pad_right = 0.25, + }).constrain(glyph, metrics, 2), + ); + } + // Nerd Font default. { const constraint = getConstraint(0xea61).?; diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig index f321214bc..04bd08ee7 100644 --- a/src/font/glyf_rasterize.zig +++ b/src/font/glyf_rasterize.zig @@ -231,11 +231,12 @@ const Placement = struct { design: DesignMetrics, opts: Glyph.RenderOptions, ) Placement { - // Start with protocol-like design units mapped so that the em square - // occupies one cell. This makes units_per_em the scale reference and - // preserves the linked rasterizer's y=0 baseline/bottom behavior. - // Callers can then use RenderOptions.Constraint to fit/cover/stretch/ - // align the declared advance/line-height box using existing font logic. + // Start with design units mapped so that the em square occupies one + // cell. This normalizes coordinates into the same cell-relative pixel + // space used by RenderOptions.Constraint. Protocol sizing constraints + // then rescale the declared advance/line-height box so the final + // transform follows the protocol formulas based on advance width and + // line height rather than units-per-em. const scale = @as(f64, @floatFromInt(opts.grid_metrics.cell_height)) / @as(f64, @floatFromInt(design.units_per_em)); @@ -823,3 +824,153 @@ test "glyf_rasterize: line height does not change unconstrained em scale" { try testing.expect(bm.data[2 * bm.width + 10] > 200); try testing.expect(bm.data[17 * bm.width + 10] > 200); } + +test "glyf_rasterize: cell-span height sizing uses line height" { + const testing = std.testing; + + const placement = Placement.init(.{ + .x_min = 0, + .y_min = 0, + .x_max = 1000, + .y_max = 1000, + }, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 2000, + }, .{ + .grid_metrics = testMetrics(20, 20), + .constraint = .{ + .target = .cell_span, + .size = .height, + .align_horizontal = .start, + .align_vertical = .start, + }, + }); + + // Final scale is cell_height / lh = 20 / 2000 = 0.01, so this + // 1000-unit square is 10px high rather than a full 20px em. + try testing.expectApproxEqAbs(@as(f64, 10), placement.width, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 10), placement.height, 0.000001); +} + +test "glyf_rasterize: cell-span advance sizing uses registered width" { + const testing = std.testing; + + const placement = Placement.init(.{ + .x_min = 0, + .y_min = 0, + .x_max = 1000, + .y_max = 1000, + }, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + .constraint_width = 2, + .constraint = .{ + .target = .cell_span, + .size = .width, + .align_horizontal = .start, + .align_vertical = .start, + }, + }); + + // Final scale is span_width / aw = 40 / 1000 = 0.04. + try testing.expectApproxEqAbs(@as(f64, 40), placement.width, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 40), placement.height, 0.000001); +} + +test "glyf_rasterize: cell-span contain and cover choose different axes" { + const testing = std.testing; + + const bounds: Bounds = .{ + .x_min = 0, + .y_min = 0, + .x_max = 1000, + .y_max = 1000, + }; + const design: DesignMetrics = .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 2000, + }; + const base_opts: Glyph.RenderOptions = .{ + .grid_metrics = testMetrics(20, 20), + .constraint = .{ + .target = .cell_span, + .align_horizontal = .start, + .align_vertical = .start, + }, + }; + + var contain_opts = base_opts; + contain_opts.constraint.size = .contain; + const contain = Placement.init(bounds, design, contain_opts); + + var cover_opts = base_opts; + cover_opts.constraint.size = .cover_bounds; + const cover = Placement.init(bounds, design, cover_opts); + + try testing.expectApproxEqAbs(@as(f64, 10), contain.width, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 10), contain.height, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 20), cover.width, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 20), cover.height, 0.000001); +} + +test "glyf_rasterize: cell-span stretch uses independent axes" { + const testing = std.testing; + + const placement = Placement.init(.{ + .x_min = 0, + .y_min = 0, + .x_max = 1000, + .y_max = 2000, + }, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 2000, + }, .{ + .grid_metrics = testMetrics(20, 20), + .constraint_width = 2, + .constraint = .{ + .target = .cell_span, + .size = .stretch_bounds, + .align_horizontal = .start, + .align_vertical = .start, + }, + }); + + try testing.expectApproxEqAbs(@as(f64, 40), placement.width, 0.000001); + try testing.expectApproxEqAbs(@as(f64, 20), placement.height, 0.000001); +} + +test "glyf_rasterize: cell-span baseline aligns design y zero" { + const testing = std.testing; + + var metrics = testMetrics(20, 20); + metrics.cell_baseline = 5; + const bounds: Bounds = .{ + .x_min = 0, + .y_min = -250, + .x_max = 1000, + .y_max = 750, + }; + const placement = Placement.init(bounds, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = metrics, + .constraint = .{ + .target = .cell_span, + .size = .height, + .align_horizontal = .start, + .align_vertical = .baseline, + }, + }); + + const scale_y = placement.height / bounds.height(); + const design_zero_y = placement.y + ((0 - bounds.y_min) * scale_y); + try testing.expectApproxEqAbs(@as(f64, 5), design_zero_y, 0.000001); +} diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig index 727e1ab4d..02eb26525 100644 --- a/src/terminal/apc/glyph.zig +++ b/src/terminal/apc/glyph.zig @@ -96,9 +96,16 @@ //! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`. //! This is authoritative for cursor advance, wrapping, and //! selection geometry. -//! - `size` — scale policy. Default `height`. +//! - `size` — scale policy. Default `height`. Given the padded render span +//! width `W'`, padded render span height `H'`, authored advance +//! width `aw`, and authored line height `lh`, scale is: +//! `height = H' / lh`, `advance = W' / aw`, +//! `contain = min(W'/aw, H'/lh)`, +//! `cover = max(W'/aw, H'/lh)`, and +//! `stretch = (W'/aw, H'/lh)` independently on each axis. //! - `align` — horizontal and vertical placement within the render span. -//! Default `center,center`. +//! Default `center,center`. Vertical `baseline` aligns +//! design-space `y=0` to the terminal text baseline. //! - `pad` — fractional insets from the render span edges. Default //! `0,0,0,0`; degenerate padding is treated as no padding. //! - payload — base64-encoded payload for the selected `fmt`. diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig index 28458dbb2..5bad341dd 100644 --- a/src/terminal/apc/glyph/Glossary.zig +++ b/src/terminal/apc/glyph/Glossary.zig @@ -209,23 +209,13 @@ pub const Entry = struct { const pad = req.get(.pad) orelse return error.InvalidOptions; return .{ + .target = .cell_span, .size = switch (size) { - // The rasterizer's base transform already maps the design em - // to the cell height. That is the closest existing behavior to - // the protocol's default height-driven mode. - .height => .none, - // There is no width-driven, aspect-preserving constraint mode - // today. Leave the base transform intact rather than forcing a - // fit/contain policy that would unexpectedly prevent overflow. - .advance => .none, - // Constraint.cover currently scales preserving aspect ratio to - // the available bounds, which is the best existing match for - // the protocol's contain mode. - .contain => .cover, - // There is no true protocol-cover equivalent that chooses the - // larger axis scale, so use the nearest named renderer policy. - .cover => .cover, - .stretch => .stretch, + .height => .height, + .advance => .width, + .contain => .contain, + .cover => .cover_bounds, + .stretch => .stretch_bounds, }, .align_horizontal = switch (alignment.horizontal) { .start => .start, @@ -236,11 +226,7 @@ pub const Entry = struct { .start => .start, .center => .center, .end => .end, - // The current constraint API has no baseline alignment mode. - // Start is the closest stable default because the glyf - // rasterizer's coordinate model already treats y=0 as the - // baseline/bottom before constraints are applied. - .baseline => .start, + .baseline => .baseline, }, .pad_top = pad.top, .pad_right = pad.right, @@ -308,7 +294,8 @@ test "Entry init decodes glyf payload and applies register fields" { try testing.expectEqual(@as(u32, 1024), entry.design.advance_width); try testing.expectEqual(@as(u32, 1536), entry.design.line_height); try testing.expectEqual(request.Width.wide, entry.width); - try testing.expectEqual(Constraint.Size.stretch, entry.constraint.size); + try testing.expectEqual(Constraint.Target.cell_span, entry.constraint.target); + try testing.expectEqual(Constraint.Size.stretch_bounds, entry.constraint.size); try testing.expectEqual(Constraint.Align.end, entry.constraint.align_horizontal); try testing.expectEqual(Constraint.Align.start, entry.constraint.align_vertical); try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top); @@ -320,6 +307,37 @@ test "Entry init decodes glyf payload and applies register fields" { try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len); } +test "Entry constraintFromRegister maps sizing and baseline exactly" { + const testing = std.testing; + const alloc = testing.allocator; + + const cases = .{ + .{ "height", Constraint.Size.height }, + .{ "advance", Constraint.Size.width }, + .{ "contain", Constraint.Size.contain }, + .{ "cover", Constraint.Size.cover_bounds }, + .{ "stretch", Constraint.Size.stretch_bounds }, + }; + + inline for (cases) |case| { + const data = try std.fmt.allocPrint( + alloc, + "r;cp=e000;size={s};align=center,baseline;{s}", + .{ case[0], test_triangle_glyf_payload }, + ); + defer alloc.free(data); + + const req = try testParseRegister(alloc, data); + defer alloc.free(req.raw); + + const constraint = try Entry.constraintFromRegister(req); + try testing.expectEqual(Constraint.Target.cell_span, constraint.target); + try testing.expectEqual(case[1], constraint.size); + try testing.expectEqual(Constraint.Align.center, constraint.align_horizontal); + try testing.expectEqual(Constraint.Align.baseline, constraint.align_vertical); + } +} + test "Entry init rejects invalid register payload" { const testing = std.testing; const alloc = testing.allocator; From aab0f8079f6994be6b056a9737d46408ae0d9d82 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jun 2026 13:43:59 -0700 Subject: [PATCH 13/13] Revert "font: add exact glyph protocol constraints" This reverts commit b661adad2ec9e32d739684b3051c733dbcd021ea. --- src/font/Glyph.zig | 266 +++++----------------------- src/font/glyf_rasterize.zig | 161 +---------------- src/terminal/apc/glyph.zig | 11 +- src/terminal/apc/glyph/Glossary.zig | 62 +++---- 4 files changed, 69 insertions(+), 431 deletions(-) diff --git a/src/font/Glyph.zig b/src/font/Glyph.zig index 039d69c8a..62c44e227 100644 --- a/src/font/Glyph.zig +++ b/src/font/Glyph.zig @@ -87,9 +87,6 @@ pub const RenderOptions = struct { /// Sizing rule. size: Constraint.Size = .none, - /// Target coordinate space for sizing, alignment, and padding. - target: Target = .face, - /// Vertical alignment rule. align_vertical: Align = .none, /// Horizontal alignment rule. @@ -139,21 +136,6 @@ pub const RenderOptions = struct { /// Stretch the glyph to exactly fit the bounds in both /// directions, disregarding aspect ratio. stretch, - /// Scale the glyph up or down to exactly match the target height, - /// preserving aspect ratio. - height, - /// Scale the glyph up or down to exactly match the target width, - /// preserving aspect ratio. - width, - /// Scale the glyph up or down to fit within the target bounds, - /// preserving aspect ratio. - contain, - /// Scale the glyph up or down to cover the target bounds, - /// preserving aspect ratio. - cover_bounds, - /// Stretch the glyph to exactly fit the target bounds in both - /// directions, disregarding aspect ratio. - stretch_bounds, }; pub const Align = enum { @@ -171,9 +153,6 @@ pub const RenderOptions = struct { /// but always with respect to the first cell even for /// multi-cell constraints. (Nerd Font specific rule.) center1, - /// Move the glyph so that its design-space baseline aligns with - /// the terminal text baseline. - baseline, }; pub const Height = enum { @@ -187,17 +166,6 @@ pub const RenderOptions = struct { icon, }; - pub const Target = enum { - /// Size and align relative to the primary face metrics. This is - /// the default behavior used for normal fonts, emoji, and Nerd - /// Font constraints. - face, - /// Size and align relative to the full terminal cell span. The - /// width is `cell_width * constraint_width`; the height is - /// `cell_height`. - cell_span, - }; - /// Returns true if the constraint does anything. If it doesn't, /// because it neither sizes nor positions the glyph, then this /// returns false. @@ -253,13 +221,10 @@ pub const RenderOptions = struct { ) Glyph.Size { // For extra wide font faces, never stretch glyphs across two cells. // This mirrors font_patcher. - const min_constraint_width: u2 = switch (self.target) { - .cell_span => constraint_width, - .face => if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) - 1 - else - @min(self.max_constraint_width, constraint_width), - }; + const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) + 1 + else + @min(self.max_constraint_width, constraint_width); // The bounding box for the glyph's scale group. // Scaling and alignment rules are calculated for @@ -315,9 +280,24 @@ pub const RenderOptions = struct { return .{ 1.0, 1.0 }; } - const target_width, const target_height = self.targetSize(metrics, min_constraint_width); const multi_cell = (min_constraint_width > 1); + const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); + const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); + + const target_width = pad_width_factor * metrics.face_width; + const target_height = pad_height_factor * switch (self.height) { + .cell => metrics.face_height, + // Like font-patcher, the icon constraint height depends on the + // constraint width. Unlike font-patcher, the multi-cell + // icon_height may be different from face_height due to the + // `adjust-icon-height` config option. + .icon => if (multi_cell) + metrics.icon_height + else + metrics.icon_height_single, + }; + var width_factor = target_width / group.width; var height_factor = target_height / group.height; @@ -356,21 +336,6 @@ pub const RenderOptions = struct { width_factor = height_factor; }, .stretch => {}, - .height => { - width_factor = height_factor; - }, - .width => { - height_factor = width_factor; - }, - .contain => { - height_factor = @min(width_factor, height_factor); - width_factor = height_factor; - }, - .cover_bounds => { - height_factor = @max(width_factor, height_factor); - width_factor = height_factor; - }, - .stretch_bounds => {}, } // Reduce aspect ratio if required @@ -394,23 +359,15 @@ pub const RenderOptions = struct { // we don't touch vertical alignment. return group.y; } - const span_height: f64 = switch (self.target) { - .face => metrics.face_height, - .cell_span => @floatFromInt(metrics.cell_height), - }; - const origin_y: f64 = switch (self.target) { - // We use face_height and offset by face_y, rather than using - // cell_height directly, to account for the asymmetry of the - // pixel cell around the face (a consequence of aligning the - // baseline with a pixel boundary rather than vertically - // centering the face). - .face => metrics.face_y, - .cell_span => 0, - }; - const pad_bottom_dy = self.pad_bottom * span_height; - const pad_top_dy = self.pad_top * span_height; - const start_y = origin_y + pad_bottom_dy; - const end_y = origin_y + (span_height - group.height - pad_top_dy); + // We use face_height and offset by face_y, rather than + // using cell_height directly, to account for the asymmetry + // of the pixel cell around the face (a consequence of + // aligning the baseline with a pixel boundary rather than + // vertically centering the face). + const pad_bottom_dy = self.pad_bottom * metrics.face_height; + const pad_top_dy = self.pad_top * metrics.face_height; + const start_y = metrics.face_y + pad_bottom_dy; + const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); const center_y = (start_y + end_y) / 2; return switch (self.align_vertical) { // NOTE: Even if there is no prescribed alignment, we ensure @@ -426,7 +383,6 @@ pub const RenderOptions = struct { .start => start_y, .end => end_y, .center, .center1 => center_y, - .baseline => @floatFromInt(metrics.cell_baseline), }; } @@ -442,27 +398,18 @@ pub const RenderOptions = struct { // axis, we don't touch horizontal alignment. return group.x; } - const span_width, const pad_width = switch (self.target) { - // For multi-cell constraints, we align relative to the span - // from the left edge of the first cell to the right edge of - // the last face cell assuming it's left-aligned within the - // rounded and adjusted pixel cell. Any horizontal offset to - // center the face within the grid cell is the responsibility - // of the backend-specific rendering code, and should be done - // after applying constraints. - .face => .{ - metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)), - metrics.face_width, - }, - .cell_span => .{ - @as(f64, @floatFromInt(min_constraint_width * metrics.cell_width)), - @as(f64, @floatFromInt(min_constraint_width * metrics.cell_width)), - }, - }; - const pad_left_dx = self.pad_left * pad_width; - const pad_right_dx = self.pad_right * pad_width; + // For multi-cell constraints, we align relative to the span + // from the left edge of the first cell to the right edge of + // the last face cell assuming it's left-aligned within the + // rounded and adjusted pixel cell. Any horizontal offset to + // center the face within the grid cell is the responsibility + // of the backend-specific rendering code, and should be done + // after applying constraints. + const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); + const pad_left_dx = self.pad_left * metrics.face_width; + const pad_right_dx = self.pad_right * metrics.face_width; const start_x = pad_left_dx; - const end_x = span_width - group.width - pad_right_dx; + const end_x = full_face_span - group.width - pad_right_dx; return switch (self.align_horizontal) { // NOTE: Even if there is no prescribed alignment, we ensure // that the glyph doesn't protrude outside the padded cell, @@ -482,66 +429,6 @@ pub const RenderOptions = struct { const end1_x = metrics.face_width - group.width - pad_right_dx; break :center1 @max(start_x, (start_x + end1_x) / 2); }, - // Baseline is vertical-only. We share the Align enum between - // axes so registered-glyph options can be stored in the same - // Constraint struct, but the parser never maps a horizontal - // alignment to .baseline. - .baseline => unreachable, - }; - } - - /// Return the available target width and height after padding. - /// - /// Face-targeted constraints preserve the historical behavior used by - /// platform fonts and Nerd Font rules: horizontal padding is measured - /// against one face width, and the available span is one face width - /// plus any additional grid cells. Vertical sizing uses either the - /// face height or configured icon height. - /// - /// Cell-span constraints are for glyphs whose layout contract is the - /// terminal cells themselves. Padding is measured against the full - /// cell span, so a two-cell glyph with 10% left padding starts after - /// 10% of both cells combined, not 10% of a single face width. - fn targetSize( - self: Constraint, - metrics: Metrics, - min_constraint_width: u2, - ) struct { f64, f64 } { - const multi_cell = (min_constraint_width > 1); - - return switch (self.target) { - .face => .{ - // Historical font constraints measure horizontal padding - // in face-width units. Additional cells add raw grid-cell - // width to the available span, matching aligned_x. - (@as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right)) * metrics.face_width, - // Vertical face constraints operate on the selected face - // or icon height. Icon height may depend on whether the - // constraint spans multiple cells. - (1 - (self.pad_bottom + self.pad_top)) * switch (self.height) { - .cell => metrics.face_height, - // Like font-patcher, the icon constraint height depends on the - // constraint width. Unlike font-patcher, the multi-cell - // icon_height may be different from face_height due to the - // `adjust-icon-height` config option. - .icon => if (multi_cell) - metrics.icon_height - else - metrics.icon_height_single, - }, - }, - .cell_span => span: { - const span_width: f64 = @floatFromInt(min_constraint_width * metrics.cell_width); - const span_height: f64 = @floatFromInt(metrics.cell_height); - break :span .{ - // Cell-span constraints use the full terminal span as - // the target box. This is the exact contract needed by - // runtime registered glyphs, whose width is declared in - // terminal cells rather than face metrics. - (1 - (self.pad_left + self.pad_right)) * span_width, - (1 - (self.pad_bottom + self.pad_top)) * span_height, - }; - }, }; } }; @@ -657,79 +544,6 @@ test "Constraints" { ); } - // Cell-span constraints. These are generic target-bound sizing modes used - // by registered glyphs where the render span is defined by terminal cell - // dimensions rather than primary face metrics. - { - const glyph: GlyphSize = .{ - .width = 100, - .height = 200, - .x = 0, - .y = 0, - }; - - const base: RenderOptions.Constraint = .{ - .target = .cell_span, - .align_horizontal = .start, - .align_vertical = .start, - }; - - // Target span is two cells wide by one cell high: 20px x 22px. - var height_constraint = base; - height_constraint.size = .height; - try comparison.expectApproxEqual( - GlyphSize{ .width = 11, .height = 22, .x = 0, .y = 0 }, - height_constraint.constrain(glyph, metrics, 2), - ); - var width_constraint = base; - width_constraint.size = .width; - try comparison.expectApproxEqual( - GlyphSize{ .width = 20, .height = 40, .x = 0, .y = 0 }, - width_constraint.constrain(glyph, metrics, 2), - ); - var contain_constraint = base; - contain_constraint.size = .contain; - try comparison.expectApproxEqual( - GlyphSize{ .width = 11, .height = 22, .x = 0, .y = 0 }, - contain_constraint.constrain(glyph, metrics, 2), - ); - var cover_constraint = base; - cover_constraint.size = .cover_bounds; - try comparison.expectApproxEqual( - GlyphSize{ .width = 20, .height = 40, .x = 0, .y = 0 }, - cover_constraint.constrain(glyph, metrics, 2), - ); - var stretch_constraint = base; - stretch_constraint.size = .stretch_bounds; - try comparison.expectApproxEqual( - GlyphSize{ .width = 20, .height = 22, .x = 0, .y = 0 }, - stretch_constraint.constrain(glyph, metrics, 2), - ); - - // Baseline alignment places the group's y=0 at the terminal text - // baseline, independent of vertical padding. - try comparison.expectApproxEqual( - GlyphSize{ .width = 100, .height = 200, .x = 0, .y = 5 }, - (RenderOptions.Constraint{ - .target = .cell_span, - .align_vertical = .baseline, - }).constrain(glyph, metrics, 2), - ); - - // Cell-span padding is relative to the full two-cell span. - try comparison.expectApproxEqual( - GlyphSize{ .width = 10, .height = 20, .x = 5, .y = 0 }, - (RenderOptions.Constraint{ - .target = .cell_span, - .size = .width, - .align_horizontal = .start, - .align_vertical = .start, - .pad_left = 0.25, - .pad_right = 0.25, - }).constrain(glyph, metrics, 2), - ); - } - // Nerd Font default. { const constraint = getConstraint(0xea61).?; diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig index 04bd08ee7..f321214bc 100644 --- a/src/font/glyf_rasterize.zig +++ b/src/font/glyf_rasterize.zig @@ -231,12 +231,11 @@ const Placement = struct { design: DesignMetrics, opts: Glyph.RenderOptions, ) Placement { - // Start with design units mapped so that the em square occupies one - // cell. This normalizes coordinates into the same cell-relative pixel - // space used by RenderOptions.Constraint. Protocol sizing constraints - // then rescale the declared advance/line-height box so the final - // transform follows the protocol formulas based on advance width and - // line height rather than units-per-em. + // Start with protocol-like design units mapped so that the em square + // occupies one cell. This makes units_per_em the scale reference and + // preserves the linked rasterizer's y=0 baseline/bottom behavior. + // Callers can then use RenderOptions.Constraint to fit/cover/stretch/ + // align the declared advance/line-height box using existing font logic. const scale = @as(f64, @floatFromInt(opts.grid_metrics.cell_height)) / @as(f64, @floatFromInt(design.units_per_em)); @@ -824,153 +823,3 @@ test "glyf_rasterize: line height does not change unconstrained em scale" { try testing.expect(bm.data[2 * bm.width + 10] > 200); try testing.expect(bm.data[17 * bm.width + 10] > 200); } - -test "glyf_rasterize: cell-span height sizing uses line height" { - const testing = std.testing; - - const placement = Placement.init(.{ - .x_min = 0, - .y_min = 0, - .x_max = 1000, - .y_max = 1000, - }, .{ - .units_per_em = 1000, - .advance_width = 1000, - .line_height = 2000, - }, .{ - .grid_metrics = testMetrics(20, 20), - .constraint = .{ - .target = .cell_span, - .size = .height, - .align_horizontal = .start, - .align_vertical = .start, - }, - }); - - // Final scale is cell_height / lh = 20 / 2000 = 0.01, so this - // 1000-unit square is 10px high rather than a full 20px em. - try testing.expectApproxEqAbs(@as(f64, 10), placement.width, 0.000001); - try testing.expectApproxEqAbs(@as(f64, 10), placement.height, 0.000001); -} - -test "glyf_rasterize: cell-span advance sizing uses registered width" { - const testing = std.testing; - - const placement = Placement.init(.{ - .x_min = 0, - .y_min = 0, - .x_max = 1000, - .y_max = 1000, - }, .{ - .units_per_em = 1000, - .advance_width = 1000, - .line_height = 1000, - }, .{ - .grid_metrics = testMetrics(20, 20), - .constraint_width = 2, - .constraint = .{ - .target = .cell_span, - .size = .width, - .align_horizontal = .start, - .align_vertical = .start, - }, - }); - - // Final scale is span_width / aw = 40 / 1000 = 0.04. - try testing.expectApproxEqAbs(@as(f64, 40), placement.width, 0.000001); - try testing.expectApproxEqAbs(@as(f64, 40), placement.height, 0.000001); -} - -test "glyf_rasterize: cell-span contain and cover choose different axes" { - const testing = std.testing; - - const bounds: Bounds = .{ - .x_min = 0, - .y_min = 0, - .x_max = 1000, - .y_max = 1000, - }; - const design: DesignMetrics = .{ - .units_per_em = 1000, - .advance_width = 1000, - .line_height = 2000, - }; - const base_opts: Glyph.RenderOptions = .{ - .grid_metrics = testMetrics(20, 20), - .constraint = .{ - .target = .cell_span, - .align_horizontal = .start, - .align_vertical = .start, - }, - }; - - var contain_opts = base_opts; - contain_opts.constraint.size = .contain; - const contain = Placement.init(bounds, design, contain_opts); - - var cover_opts = base_opts; - cover_opts.constraint.size = .cover_bounds; - const cover = Placement.init(bounds, design, cover_opts); - - try testing.expectApproxEqAbs(@as(f64, 10), contain.width, 0.000001); - try testing.expectApproxEqAbs(@as(f64, 10), contain.height, 0.000001); - try testing.expectApproxEqAbs(@as(f64, 20), cover.width, 0.000001); - try testing.expectApproxEqAbs(@as(f64, 20), cover.height, 0.000001); -} - -test "glyf_rasterize: cell-span stretch uses independent axes" { - const testing = std.testing; - - const placement = Placement.init(.{ - .x_min = 0, - .y_min = 0, - .x_max = 1000, - .y_max = 2000, - }, .{ - .units_per_em = 1000, - .advance_width = 1000, - .line_height = 2000, - }, .{ - .grid_metrics = testMetrics(20, 20), - .constraint_width = 2, - .constraint = .{ - .target = .cell_span, - .size = .stretch_bounds, - .align_horizontal = .start, - .align_vertical = .start, - }, - }); - - try testing.expectApproxEqAbs(@as(f64, 40), placement.width, 0.000001); - try testing.expectApproxEqAbs(@as(f64, 20), placement.height, 0.000001); -} - -test "glyf_rasterize: cell-span baseline aligns design y zero" { - const testing = std.testing; - - var metrics = testMetrics(20, 20); - metrics.cell_baseline = 5; - const bounds: Bounds = .{ - .x_min = 0, - .y_min = -250, - .x_max = 1000, - .y_max = 750, - }; - const placement = Placement.init(bounds, .{ - .units_per_em = 1000, - .advance_width = 1000, - .line_height = 1000, - }, .{ - .grid_metrics = metrics, - .constraint = .{ - .target = .cell_span, - .size = .height, - .align_horizontal = .start, - .align_vertical = .baseline, - }, - }); - - const scale_y = placement.height / bounds.height(); - const design_zero_y = placement.y + ((0 - bounds.y_min) * scale_y); - try testing.expectApproxEqAbs(@as(f64, 5), design_zero_y, 0.000001); -} diff --git a/src/terminal/apc/glyph.zig b/src/terminal/apc/glyph.zig index 02eb26525..727e1ab4d 100644 --- a/src/terminal/apc/glyph.zig +++ b/src/terminal/apc/glyph.zig @@ -96,16 +96,9 @@ //! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`. //! This is authoritative for cursor advance, wrapping, and //! selection geometry. -//! - `size` — scale policy. Default `height`. Given the padded render span -//! width `W'`, padded render span height `H'`, authored advance -//! width `aw`, and authored line height `lh`, scale is: -//! `height = H' / lh`, `advance = W' / aw`, -//! `contain = min(W'/aw, H'/lh)`, -//! `cover = max(W'/aw, H'/lh)`, and -//! `stretch = (W'/aw, H'/lh)` independently on each axis. +//! - `size` — scale policy. Default `height`. //! - `align` — horizontal and vertical placement within the render span. -//! Default `center,center`. Vertical `baseline` aligns -//! design-space `y=0` to the terminal text baseline. +//! Default `center,center`. //! - `pad` — fractional insets from the render span edges. Default //! `0,0,0,0`; degenerate padding is treated as no padding. //! - payload — base64-encoded payload for the selected `fmt`. diff --git a/src/terminal/apc/glyph/Glossary.zig b/src/terminal/apc/glyph/Glossary.zig index 5bad341dd..28458dbb2 100644 --- a/src/terminal/apc/glyph/Glossary.zig +++ b/src/terminal/apc/glyph/Glossary.zig @@ -209,13 +209,23 @@ pub const Entry = struct { const pad = req.get(.pad) orelse return error.InvalidOptions; return .{ - .target = .cell_span, .size = switch (size) { - .height => .height, - .advance => .width, - .contain => .contain, - .cover => .cover_bounds, - .stretch => .stretch_bounds, + // The rasterizer's base transform already maps the design em + // to the cell height. That is the closest existing behavior to + // the protocol's default height-driven mode. + .height => .none, + // There is no width-driven, aspect-preserving constraint mode + // today. Leave the base transform intact rather than forcing a + // fit/contain policy that would unexpectedly prevent overflow. + .advance => .none, + // Constraint.cover currently scales preserving aspect ratio to + // the available bounds, which is the best existing match for + // the protocol's contain mode. + .contain => .cover, + // There is no true protocol-cover equivalent that chooses the + // larger axis scale, so use the nearest named renderer policy. + .cover => .cover, + .stretch => .stretch, }, .align_horizontal = switch (alignment.horizontal) { .start => .start, @@ -226,7 +236,11 @@ pub const Entry = struct { .start => .start, .center => .center, .end => .end, - .baseline => .baseline, + // The current constraint API has no baseline alignment mode. + // Start is the closest stable default because the glyf + // rasterizer's coordinate model already treats y=0 as the + // baseline/bottom before constraints are applied. + .baseline => .start, }, .pad_top = pad.top, .pad_right = pad.right, @@ -294,8 +308,7 @@ test "Entry init decodes glyf payload and applies register fields" { try testing.expectEqual(@as(u32, 1024), entry.design.advance_width); try testing.expectEqual(@as(u32, 1536), entry.design.line_height); try testing.expectEqual(request.Width.wide, entry.width); - try testing.expectEqual(Constraint.Target.cell_span, entry.constraint.target); - try testing.expectEqual(Constraint.Size.stretch_bounds, entry.constraint.size); + try testing.expectEqual(Constraint.Size.stretch, entry.constraint.size); try testing.expectEqual(Constraint.Align.end, entry.constraint.align_horizontal); try testing.expectEqual(Constraint.Align.start, entry.constraint.align_vertical); try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top); @@ -307,37 +320,6 @@ test "Entry init decodes glyf payload and applies register fields" { try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len); } -test "Entry constraintFromRegister maps sizing and baseline exactly" { - const testing = std.testing; - const alloc = testing.allocator; - - const cases = .{ - .{ "height", Constraint.Size.height }, - .{ "advance", Constraint.Size.width }, - .{ "contain", Constraint.Size.contain }, - .{ "cover", Constraint.Size.cover_bounds }, - .{ "stretch", Constraint.Size.stretch_bounds }, - }; - - inline for (cases) |case| { - const data = try std.fmt.allocPrint( - alloc, - "r;cp=e000;size={s};align=center,baseline;{s}", - .{ case[0], test_triangle_glyf_payload }, - ); - defer alloc.free(data); - - const req = try testParseRegister(alloc, data); - defer alloc.free(req.raw); - - const constraint = try Entry.constraintFromRegister(req); - try testing.expectEqual(Constraint.Target.cell_span, constraint.target); - try testing.expectEqual(case[1], constraint.size); - try testing.expectEqual(Constraint.Align.center, constraint.align_horizontal); - try testing.expectEqual(Constraint.Align.baseline, constraint.align_vertical); - } -} - test "Entry init rejects invalid register payload" { const testing = std.testing; const alloc = testing.allocator;