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(®istry, .{ .family = "noto sans", .weight = 350 }, &out)).face.?);
294 try std.testing.expectEqual(medium, (try resolve(®istry, .{ .family = "Noto Sans", .weight = 450 }, &out)).face.?);
295 try std.testing.expectEqual(heavy, (try resolve(®istry, .{ .family = "Noto Sans", .weight = 600 }, &out)).face.?);
296 const slanted = try resolve(®istry, .{ .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(®istry, .{ .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(®istry, "'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(®istry, .{ .family = "missing", .monospace = true, .scripts = bit(.latin) }, &out)).face.?);
326 const cyrl = try resolve(®istry, .{ .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(®istry, .{ .family = "Sans", .scripts = bit(.cjk) }, &out);
330 try std.testing.expectEqual(cjk, han.fallback[0]);
331 const pictograph = try resolve(®istry, .{ .family = "Sans", .scripts = bit(.emoji) }, &out);
332 try std.testing.expectEqual(emoji, pictograph.fallback[0]);
333 const outside_authored = try scriptFallbacks(®istry, 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 }