lib/filigree/src/font/description.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const binary = @import("binary.zig");
  3 const directory = @import("directory.zig");
  4 const model = @import("model.zig");
  5 const names = @import("names.zig");
  6 const cmap_owner = @import("cmap.zig");
  7 
  8 /// Coverage beyond the four OS/2 Unicode-range words.
  9 pub const extended_emoji_word: usize = 4;
 10 pub const extended_emoji_bit: u5 = 0;
 11 
 12 pub const Description = struct {
 13     family: []u8,
 14     weight_class: u16,
 15     width_class: u16,
 16     selection: u16,
 17     units_per_em: u16,
 18     ascent: f32,
 19     descent: f32,
 20     line_gap: f32,
 21     coverage: [8]u32,
 22     fixed_pitch: bool,
 23     color: bool,
 24     face_index: u32,
 25 
 26     pub fn deinit(self: *Description, allocator: std.mem.Allocator) void {
 27         allocator.free(self.family);
 28         self.* = undefined;
 29     }
 30 };
 31 
 32 pub fn describeAlloc(allocator: std.mem.Allocator, data: []const u8) (model.FontError || error{OutOfMemory})!Description {
 33     return describeAtAlloc(allocator, data, 0);
 34 }
 35 
 36 pub fn describeAtAlloc(allocator: std.mem.Allocator, data: []const u8, face_index: u32) (model.FontError || error{OutOfMemory})!Description {
 37     const offset = try directory.fontDirectoryOffset(data, face_index);
 38     const os2 = try directory.tableAt(data, offset, "OS/2");
 39     if (os2.len < 64) return error.InvalidFont;
 40     const head = try directory.tableAt(data, offset, "head");
 41     const hhea = try directory.tableAt(data, offset, "hhea");
 42     if (head.len < 54 or hhea.len < 10) return error.InvalidFont;
 43     const units_per_em = try binary.readU16(data, head.offset + 18);
 44     if (units_per_em == 0) return error.InvalidFont;
 45     const selection = try binary.readU16(data, os2.offset + 62);
 46     const metric_source = if (selection & 0x80 != 0) os2 else hhea;
 47     if (selection & 0x80 != 0 and os2.len < 78) return error.InvalidFont;
 48     const metric_offset: usize = if (selection & 0x80 != 0) 68 else 4;
 49     const scale = @as(f32, @floatFromInt(units_per_em));
 50     const cmap_table = try directory.tableAt(data, offset, "cmap");
 51     const cmap = try cmap_owner.Cmap.init(data, cmap_table);
 52     var coverage: [8]u32 = @splat(0);
 53     inline for (0..4) |word| {
 54         coverage[word] = try binary.readU32(data, os2.offset + 42 + word * 4);
 55     }
 56     if (try hasEmoji(data, cmap)) coverage[extended_emoji_word] |= @as(u32, 1) << extended_emoji_bit;
 57     const post = directory.tableAt(data, offset, "post") catch |err| switch (err) {
 58         error.MissingTable => null,
 59         else => return err,
 60     };
 61     const name_table = try directory.tableAt(data, offset, "name");
 62     const record = (try names.bestNameRecord(data, name_table, 16)) orelse
 63         (try names.bestNameRecord(data, name_table, 1)) orelse return error.InvalidFont;
 64     const family = names.utf16BeToUtf8Alloc(allocator, record.bytes) catch |err| switch (err) {
 65         error.OutOfMemory => return error.OutOfMemory,
 66         else => return error.InvalidFont,
 67     };
 68     errdefer allocator.free(family);
 69     return .{
 70         .family = family,
 71         .weight_class = try binary.readU16(data, os2.offset + 4),
 72         .width_class = try binary.readU16(data, os2.offset + 6),
 73         .selection = selection,
 74         .units_per_em = units_per_em,
 75         .ascent = @as(f32, @floatFromInt(try binary.readI16(data, metric_source.offset + metric_offset))) / scale,
 76         .descent = @as(f32, @floatFromInt(try binary.readI16(data, metric_source.offset + metric_offset + 2))) / scale,
 77         .line_gap = @as(f32, @floatFromInt(try binary.readI16(data, metric_source.offset + metric_offset + 4))) / scale,
 78         .coverage = coverage,
 79         .fixed_pitch = if (post) |table| try readFixedPitch(data, table) else false,
 80         .color = directory.optionalAt(data, offset, "COLR") != null or
 81             directory.optionalAt(data, offset, "CBDT") != null or
 82             directory.optionalAt(data, offset, "sbix") != null or
 83             directory.optionalAt(data, offset, "SVG ") != null,
 84         .face_index = face_index,
 85     };
 86 }
 87 
 88 fn hasEmoji(data: []const u8, cmap: cmap_owner.Cmap) model.FontError!bool {
 89     for (0x1f300..0x1fb00) |codepoint| {
 90         if (try cmap.map(data, @intCast(codepoint)) != 0) return true;
 91     }
 92     return false;
 93 }
 94 
 95 fn readFixedPitch(data: []const u8, table: binary.Table) model.FontError!bool {
 96     if (table.len < 16) return error.InvalidFont;
 97     return (try binary.readU32(data, table.offset + 12)) != 0;
 98 }
 99 
100 test "face description reads family and OS/2 fields" {
101     const fixture = @import("../fixture/root.zig");
102     const allocator = std.testing.allocator;
103     const bytes = try fixture.createWithMetricVariations(allocator);
104     defer allocator.free(bytes);
105     const os2 = try directory.table(bytes, "OS/2");
106     std.mem.writeInt(u16, bytes[os2.offset + 4 ..][0..2], 700, .big);
107     std.mem.writeInt(u16, bytes[os2.offset + 6 ..][0..2], 3, .big);
108     std.mem.writeInt(u32, bytes[os2.offset + 42 ..][0..4], 0x201, .big);
109     std.mem.writeInt(u16, bytes[os2.offset + 62 ..][0..2], 0x21, .big);
110     var result = try describeAlloc(allocator, bytes);
111     defer result.deinit(allocator);
112     try std.testing.expectEqualStrings("Tiny Fixture", result.family);
113     try std.testing.expectEqual(@as(u16, 700), result.weight_class);
114     try std.testing.expectEqual(@as(u16, 3), result.width_class);
115     try std.testing.expectEqual(@as(u16, 0x21), result.selection);
116     try std.testing.expectEqual(@as(u32, 0x201), result.coverage[0]);
117     try std.testing.expectEqual(@as(u16, 1000), result.units_per_em);
118     try std.testing.expectEqual(@as(f32, 0.8), result.ascent);
119     try std.testing.expectEqual(@as(f32, -0.2), result.descent);
120     try std.testing.expectEqual(@as(f32, 0), result.line_gap);
121     try std.testing.expect(!result.fixed_pitch);
122     try std.testing.expect(!result.color);
123     try std.testing.expectEqual(@as(u32, 0), result.face_index);
124 }
125 
126 test "face description selects OS/2 typo metrics when requested" {
127     const fixture = @import("../fixture/root.zig");
128     const allocator = std.testing.allocator;
129     const bytes = try fixture.createWithMetricVariations(allocator);
130     defer allocator.free(bytes);
131     var result = try describeAlloc(allocator, bytes);
132     defer result.deinit(allocator);
133     try std.testing.expectEqual(@as(f32, 0.8), result.ascent);
134     try std.testing.expectEqual(@as(f32, -0.2), result.descent);
135     try std.testing.expectEqual(@as(f32, 0.04), result.line_gap);
136 }
137 
138 test "face description derives extended emoji coverage from cmap" {
139     var bytes: [40]u8 = @splat(0);
140     std.mem.writeInt(u16, bytes[2..4], 1, .big);
141     std.mem.writeInt(u16, bytes[4..6], 3, .big);
142     std.mem.writeInt(u16, bytes[6..8], 10, .big);
143     std.mem.writeInt(u32, bytes[8..12], 12, .big);
144     std.mem.writeInt(u16, bytes[12..14], 12, .big);
145     std.mem.writeInt(u32, bytes[16..20], 28, .big);
146     std.mem.writeInt(u32, bytes[24..28], 1, .big);
147     std.mem.writeInt(u32, bytes[28..32], 0x1f600, .big);
148     std.mem.writeInt(u32, bytes[32..36], 0x1f600, .big);
149     std.mem.writeInt(u32, bytes[36..40], 1, .big);
150     const cmap = try cmap_owner.Cmap.init(&bytes, .{ .offset = 0, .len = bytes.len });
151     try std.testing.expect(try hasEmoji(&bytes, cmap));
152 }
153 
154 test "face description reads post fixed pitch and rejects a short table" {
155     var bytes: [16]u8 = @splat(0);
156     std.mem.writeInt(u32, bytes[12..16], 1, .big);
157     try std.testing.expect(try readFixedPitch(&bytes, .{ .offset = 0, .len = bytes.len }));
158     try std.testing.expectError(error.InvalidFont, readFixedPitch(&bytes, .{ .offset = 0, .len = 15 }));
159 }
160 
161 test "face description rejects missing and truncated OS/2" {
162     const fixture = @import("../fixture/root.zig");
163     const allocator = std.testing.allocator;
164     const missing = try fixture.create(allocator);
165     defer allocator.free(missing);
166     try std.testing.expectError(error.MissingTable, describeAlloc(allocator, missing));
167     const bytes = try fixture.createWithMetricVariations(allocator);
168     defer allocator.free(bytes);
169     const os2 = try directory.table(bytes, "OS/2");
170     const records = try binary.readU16(bytes, 4);
171     for (0..records) |index| {
172         const row = 12 + index * 16;
173         if (try binary.readU32(bytes, row) != model.tag("OS/2")) continue;
174         std.mem.writeInt(u32, bytes[row + 12 ..][0..4], 63, .big);
175         break;
176     }
177     _ = os2;
178     try std.testing.expectError(error.InvalidFont, describeAlloc(allocator, bytes));
179 }