lib/ui/src/asset/resolve.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const css = @import("css");
  3 const unicode = @import("unicode");
  4 
  5 const model = @import("model.zig");
  6 const Registry = @import("registry.zig").Registry;
  7 
  8 pub const Coverage = enum(u6) {
  9     latin,
 10     cyrillic,
 11     cjk,
 12     emoji,
 13     greek,
 14     arabic,
 15     hebrew,
 16     devanagari,
 17     other,
 18 };
 19 
 20 pub fn bit(value: Coverage) u64 {
 21     return @as(u64, 1) << @backingInt(value);
 22 }
 23 
 24 /// The first four words use OS/2 Unicode-range positions. Word four bit zero
 25 /// denotes cmap coverage in the assigned emoji pictograph range.
 26 pub fn coverageForScripts(scripts: u64) [8]u32 {
 27     var mask: [8]u32 = @splat(0);
 28     if (scripts & bit(.latin) != 0) mask[0] |= 0x2000007f;
 29     if (scripts & bit(.cyrillic) != 0) mask[0] |= @as(u32, 1) << 9;
 30     if (scripts & bit(.greek) != 0) mask[0] |= @as(u32, 1) << 7;
 31     if (scripts & bit(.arabic) != 0) mask[0] |= @as(u32, 1) << 13;
 32     if (scripts & bit(.hebrew) != 0) mask[0] |= @as(u32, 1) << 11;
 33     if (scripts & bit(.devanagari) != 0) mask[0] |= @as(u32, 1) << 15;
 34     if (scripts & bit(.cjk) != 0) {
 35         mask[0] |= @as(u32, 1) << 28;
 36         mask[1] |= 0x29df0000;
 37     }
 38     if (scripts & bit(.emoji) != 0) mask[4] |= 1;
 39     return mask;
 40 }
 41 
 42 fn overlap(coverage: [8]u32, wanted: [8]u32) u32 {
 43     var count: u32 = 0;
 44     for (coverage, wanted) |available, requested| count += @popCount(available & requested);
 45     return count;
 46 }
 47 
 48 pub fn scriptsFromRuns(runs: []const unicode.ScriptRun, emoji_present: bool) u64 {
 49     var mask: u64 = if (emoji_present) bit(.emoji) else 0;
 50     for (runs) |run| {
 51         mask |= switch (run.script) {
 52             .latin => bit(.latin),
 53             .cyrillic => bit(.cyrillic),
 54             .han, .hiragana, .katakana, .hangul, .bopomofo => bit(.cjk),
 55             .greek => bit(.greek),
 56             .arabic => bit(.arabic),
 57             .hebrew => bit(.hebrew),
 58             .devanagari => bit(.devanagari),
 59             .common, .inherited, .unknown => 0,
 60             else => bit(.other),
 61         };
 62     }
 63     return mask;
 64 }
 65 
 66 pub const Request = struct {
 67     family: []const u8,
 68     weight: u16 = 400,
 69     slant: u8 = 0,
 70     monospace: bool = false,
 71     scripts: u64 = 0,
 72 };
 73 
 74 pub const Resolution = struct {
 75     face: ?model.AssetHandle,
 76     synthetic_slant: bool,
 77     fallback: []const model.AssetHandle,
 78 };
 79 
 80 pub const Primary = struct {
 81     face: ?model.AssetHandle,
 82     synthetic_slant: bool,
 83 };
 84 
 85 pub const Authored = struct {
 86     face: ?model.AssetHandle,
 87     synthetic_slant: bool,
 88     fallback: []const model.AssetHandle,
 89 };
 90 
 91 pub const Error = error{FallbackCapacityExceeded};
 92 
 93 pub fn authored(registry: *const Registry, family_list: []const u8, weight: u16, slant: u8, fallback_out: []model.AssetHandle) Error!Authored {
 94     if (fallback_out.len < registry.font_used -| 1) return error.FallbackCapacityExceeded;
 95     var selected: ?model.AssetHandle = null;
 96     var used: usize = 0;
 97     var cursor: usize = 0;
 98     var monospace = false;
 99     while (nextFamily(family_list, &cursor)) |raw_family| {
100         var decoded: [64]u8 = undefined;
101         const family = if (css.token.hasEscape(raw_family))
102             css.token.unescape(raw_family, &decoded) orelse continue
103         else
104             raw_family;
105         if (std.ascii.eqlIgnoreCase(family, "monospace")) monospace = true;
106         const choice = primary(registry, .{ .family = family, .weight = weight, .slant = slant });
107         const face = choice.face orelse continue;
108         const found = registry.font(face) catch unreachable;
109         if (!std.ascii.eqlIgnoreCase(found.family, family)) continue;
110         if (selected == null) {
111             selected = face;
112         } else if (!std.meta.eql(selected.?, face) and !containsHandle(fallback_out[0..used], face)) {
113             fallback_out[used] = face;
114             used += 1;
115         }
116     }
117     const face = selected orelse primary(registry, .{
118         .family = "",
119         .weight = weight,
120         .slant = slant,
121         .monospace = monospace,
122     }).face;
123     return .{
124         .face = face,
125         .synthetic_slant = if (face) |handle| slant != 0 and registry.fonts[handle.index].slant == 0 else false,
126         .fallback = fallback_out[0..used],
127     };
128 }
129 
130 fn containsHandle(handles: []const model.AssetHandle, needle: model.AssetHandle) bool {
131     for (handles) |handle| if (std.meta.eql(handle, needle)) return true;
132     return false;
133 }
134 
135 fn nextFamily(list: []const u8, cursor: *usize) ?[]const u8 {
136     while (cursor.* < list.len and (list[cursor.*] == ',' or std.ascii.isWhitespace(list[cursor.*]))) cursor.* += 1;
137     if (cursor.* == list.len) return null;
138     const quoted = list[cursor.*] == '\'' or list[cursor.*] == '"';
139     const quote = if (quoted) list[cursor.*] else 0;
140     if (quoted) cursor.* += 1;
141     const start = cursor.*;
142     while (cursor.* < list.len and list[cursor.*] != (if (quoted) quote else ',')) {
143         if (list[cursor.*] == '\\' and cursor.* + 1 < list.len) cursor.* += 1;
144         cursor.* += 1;
145     }
146     const family = std.mem.trim(u8, list[start..cursor.*], " \t\r\n");
147     if (quoted and cursor.* < list.len) cursor.* += 1;
148     while (cursor.* < list.len and list[cursor.*] != ',') cursor.* += 1;
149     if (cursor.* < list.len) cursor.* += 1;
150     return family;
151 }
152 
153 pub fn primary(registry: *const Registry, request: Request) Primary {
154     if (registry.font_used == 0) return .{ .face = null, .synthetic_slant = false };
155     const wanted = coverageForScripts(request.scripts);
156     var chosen_face: ?model.AssetHandle = null;
157     for (registry.fonts, 0..) |_, index| {
158         const handle = model.AssetHandle{ .index = @intCast(index), .generation = registry.font_generation[index] };
159         if (!registry.hasFont(handle)) continue;
160         if (chosen_face) |chosen| {
161             if (preferPrimary(registry, handle, chosen, request, wanted)) chosen_face = handle;
162         } else chosen_face = handle;
163     }
164     const face = chosen_face.?;
165     return .{
166         .face = face,
167         .synthetic_slant = request.slant != 0 and registry.fonts[face.index].slant == 0,
168     };
169 }
170 
171 pub fn resolve(registry: *const Registry, request: Request, fallback_out: []model.AssetHandle) Error!Resolution {
172     const selected = primary(registry, request);
173     const face = selected.face orelse return .{ .face = null, .synthetic_slant = false, .fallback = fallback_out[0..0] };
174     const fallback = try scriptFallbacks(registry, request.scripts, &.{face}, fallback_out);
175     return .{
176         .face = face,
177         .synthetic_slant = selected.synthetic_slant,
178         .fallback = fallback,
179     };
180 }
181 
182 /// Orders faces outside an authored chain for one itemized script run.
183 pub fn scriptFallbacks(registry: *const Registry, scripts: u64, excluded: []const model.AssetHandle, fallback_out: []model.AssetHandle) Error![]const model.AssetHandle {
184     const wanted = coverageForScripts(scripts);
185     var wanted_bits: usize = 0;
186     for (wanted) |word| wanted_bits += @popCount(word);
187     const bucket_count = wanted_bits + 1;
188     var counts: [257]u32 = undefined;
189     @memset(counts[0..bucket_count], 0);
190     for (registry.fonts, 0..) |_, index| {
191         const handle = model.AssetHandle{ .index = @intCast(index), .generation = registry.font_generation[index] };
192         if (!registry.hasFont(handle) or containsHandle(excluded, handle)) continue;
193         counts[overlap(registry.fonts[index].coverage, wanted)] += 1;
194     }
195     var starts: [257]u32 = undefined;
196     var used: u32 = 0;
197     var score: usize = bucket_count;
198     while (score > 0) {
199         score -= 1;
200         starts[score] = used;
201         used += counts[score];
202     }
203     if (fallback_out.len < used) return error.FallbackCapacityExceeded;
204     for (registry.fonts, 0..) |_, index| {
205         const handle = model.AssetHandle{ .index = @intCast(index), .generation = registry.font_generation[index] };
206         if (!registry.hasFont(handle) or containsHandle(excluded, handle)) continue;
207         const rank = overlap(registry.fonts[index].coverage, wanted);
208         fallback_out[starts[rank]] = handle;
209         starts[rank] += 1;
210     }
211     return fallback_out[0..used];
212 }
213 
214 fn preferPrimary(registry: *const Registry, candidate: model.AssetHandle, current: model.AssetHandle, request: Request, wanted: [8]u32) bool {
215     const candidate_font = registry.font(candidate) catch unreachable;
216     const current_font = registry.font(current) catch unreachable;
217     const candidate_family = std.ascii.eqlIgnoreCase(candidate_font.family, request.family);
218     const current_family = std.ascii.eqlIgnoreCase(current_font.family, request.family);
219     if (candidate_family != current_family) return candidate_family;
220     if (!candidate_family) {
221         if (request.monospace and candidate_font.descriptor.monospace != current_font.descriptor.monospace) {
222             return candidate_font.descriptor.monospace != 0;
223         }
224         const candidate_overlap = overlap(candidate_font.descriptor.coverage, wanted);
225         const current_overlap = overlap(current_font.descriptor.coverage, wanted);
226         if (candidate_overlap != current_overlap) return candidate_overlap > current_overlap;
227     }
228     if (weightBefore(request.weight, candidate_font.descriptor.weight, current_font.descriptor.weight)) return true;
229     if (weightBefore(request.weight, current_font.descriptor.weight, candidate_font.descriptor.weight)) return false;
230     const candidate_slant = slantRank(request.slant, candidate_font.descriptor.slant);
231     const current_slant = slantRank(request.slant, current_font.descriptor.slant);
232     if (candidate_slant != current_slant) return candidate_slant < current_slant;
233     if (request.monospace and candidate_font.descriptor.monospace != current_font.descriptor.monospace) {
234         return candidate_font.descriptor.monospace != 0;
235     }
236     return candidate.index < current.index;
237 }
238 
239 fn slantRank(requested: u8, available: u8) u8 {
240     if (requested == available) return 0;
241     if (requested != 0 and available != 0) return 1;
242     if (available == 0) return 2;
243     return 3;
244 }
245 
246 fn weightBefore(requested: u16, candidate: u16, current: u16) bool {
247     const a = weightRank(requested, candidate);
248     const b = weightRank(requested, current);
249     return a.group < b.group or (a.group == b.group and a.distance < b.distance);
250 }
251 
252 const WeightRank = struct { group: u8, distance: u16 };
253 
254 fn weightRank(requested: u16, available: u16) WeightRank {
255     if (requested < 400) {
256         return if (available <= requested)
257             .{ .group = 0, .distance = requested - available }
258         else
259             .{ .group = 1, .distance = available - requested };
260     }
261     if (requested <= 500) {
262         if (available >= requested and available <= 500) return .{ .group = 0, .distance = available - requested };
263         if (available < requested) return .{ .group = 1, .distance = requested - available };
264         return .{ .group = 2, .distance = available - 500 };
265     }
266     return if (available >= requested)
267         .{ .group = 0, .distance = available - requested }
268     else
269         .{ .group = 1, .distance = requested - available };
270 }
271 
272 test "script coverage derives from Unicode runs and explicit emoji presence" {
273     var context = try unicode.ScriptRunContext.init(std.testing.allocator, .{ .max_source_units = 32 });
274     defer context.deinit(std.testing.allocator);
275     context.activate();
276     const runs = try context.itemize(.{ .text = .{ .utf8 = "AБ漢😀" } });
277     const scripts = scriptsFromRuns(runs, true);
278     try std.testing.expect(scripts & bit(.latin) != 0);
279     try std.testing.expect(scripts & bit(.cyrillic) != 0);
280     try std.testing.expect(scripts & bit(.cjk) != 0);
281     try std.testing.expect(scripts & bit(.emoji) != 0);
282 }
283 
284 test "font resolution follows family CSS weight and synthetic slant" {
285     var registry = try Registry.init(std.testing.allocator, .{ .fonts = 8, .images = 0, .owned_bytes = 0 });
286     defer registry.deinit(std.testing.allocator);
287     registry.activate();
288     const light = try registry.registerFont("Noto Sans", .{ .weight = 300, .coverage = coverageForScripts(bit(.latin)) }, .{ .borrowed = "" });
289     const regular = try registry.registerFont("Noto Sans", .{ .weight = 400, .coverage = coverageForScripts(bit(.latin)) }, .{ .borrowed = "" });
290     const medium = try registry.registerFont("Noto Sans", .{ .weight = 500, .coverage = coverageForScripts(bit(.latin)) }, .{ .borrowed = "" });
291     const heavy = try registry.registerFont("Noto Sans", .{ .weight = 700, .coverage = coverageForScripts(bit(.latin)) }, .{ .borrowed = "" });
292     var out: [7]model.AssetHandle = undefined;
293     try std.testing.expectEqual(light, (try resolve(&registry, .{ .family = "noto sans", .weight = 350 }, &out)).face.?);
294     try std.testing.expectEqual(medium, (try resolve(&registry, .{ .family = "Noto Sans", .weight = 450 }, &out)).face.?);
295     try std.testing.expectEqual(heavy, (try resolve(&registry, .{ .family = "Noto Sans", .weight = 600 }, &out)).face.?);
296     const slanted = try resolve(&registry, .{ .family = "Noto Sans", .weight = 400, .slant = 1 }, &out);
297     try std.testing.expectEqual(regular, slanted.face.?);
298     try std.testing.expect(slanted.synthetic_slant);
299     const italic = try registry.registerFont("Noto Sans", .{ .weight = 400, .slant = 1, .coverage = coverageForScripts(bit(.latin)) }, .{ .borrowed = "" });
300     try std.testing.expectEqual(italic, (try resolve(&registry, .{ .family = "Noto Sans", .weight = 400, .slant = 1 }, &out)).face.?);
301 }
302 
303 test "authored font families decode CSS escapes before matching" {
304     var registry = try Registry.init(std.testing.allocator, .{ .fonts = 2, .images = 0, .owned_bytes = 0 });
305     defer registry.deinit(std.testing.allocator);
306     registry.activate();
307     const apostrophe = try registry.registerFont("O'Connor", .{}, .{ .borrowed = "" });
308     const comma = try registry.registerFont("A,B", .{}, .{ .borrowed = "" });
309     var out: [2]model.AssetHandle = undefined;
310     const result = try authored(&registry, "'O\\'Connor', A\\,B", 400, 0, &out);
311     try std.testing.expectEqual(apostrophe, result.face.?);
312     try std.testing.expectEqualSlices(model.AssetHandle, &.{comma}, result.fallback);
313 }
314 
315 test "font resolution prefers monospace and orders script coverage fallbacks" {
316     var registry = try Registry.init(std.testing.allocator, .{ .fonts = 8, .images = 0, .owned_bytes = 0 });
317     defer registry.deinit(std.testing.allocator);
318     registry.activate();
319     _ = try registry.registerFont("Sans", .{ .coverage = coverageForScripts(bit(.latin)) }, .{ .borrowed = "" });
320     const mono = try registry.registerFont("Mono", .{ .monospace = 1, .coverage = coverageForScripts(bit(.latin) | bit(.cyrillic)) }, .{ .borrowed = "" });
321     const cyrillic = try registry.registerFont("Cyrillic", .{ .coverage = coverageForScripts(bit(.cyrillic)) }, .{ .borrowed = "" });
322     const cjk = try registry.registerFont("CJK", .{ .coverage = coverageForScripts(bit(.cjk)) }, .{ .borrowed = "" });
323     const emoji = try registry.registerFont("Emoji", .{ .coverage = coverageForScripts(bit(.emoji)) }, .{ .borrowed = "" });
324     var out: [7]model.AssetHandle = undefined;
325     try std.testing.expectEqual(mono, (try resolve(&registry, .{ .family = "missing", .monospace = true, .scripts = bit(.latin) }, &out)).face.?);
326     const cyrl = try resolve(&registry, .{ .family = "Sans", .scripts = bit(.cyrillic) }, &out);
327     try std.testing.expectEqual(mono, cyrl.fallback[0]);
328     try std.testing.expectEqual(cyrillic, cyrl.fallback[1]);
329     const han = try resolve(&registry, .{ .family = "Sans", .scripts = bit(.cjk) }, &out);
330     try std.testing.expectEqual(cjk, han.fallback[0]);
331     const pictograph = try resolve(&registry, .{ .family = "Sans", .scripts = bit(.emoji) }, &out);
332     try std.testing.expectEqual(emoji, pictograph.fallback[0]);
333     const outside_authored = try scriptFallbacks(&registry, bit(.emoji), &.{ mono, cyrillic }, &out);
334     try std.testing.expectEqual(emoji, outside_authored[0]);
335     for (outside_authored) |face| {
336         try std.testing.expect(!std.meta.eql(face, mono));
337         try std.testing.expect(!std.meta.eql(face, cyrillic));
338     }
339 }