lib/filigree/src/font/raster.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 const sys = @import("sys");
  3 const coverage = @import("coverage.zig");
  4 const face_table = @import("face.zig");
  5 const outline = @import("outline.zig");
  6 
  7 const cff_test_font_env = "FILIGREE_CFF_TEST_FONT";
  8 const max_test_font_bytes = 128 * 1024 * 1024;
  9 
 10 pub const Error = outline.Error || error{
 11     GlyphIdTooLarge,
 12     InvalidAtlas,
 13     InvalidPixelSize,
 14 };
 15 
 16 pub const Rectangle = struct {
 17     x: f32 = 0,
 18     y: f32 = 0,
 19     width: f32 = 0,
 20     height: f32 = 0,
 21 };
 22 
 23 pub const GlyphBitmap = struct {
 24     codepoint: i32 = 0,
 25     glyph_id: u32 = 0,
 26     width: i32 = 0,
 27     height: i32 = 0,
 28     offset_x: i32 = 0,
 29     offset_y: i32 = 0,
 30     advance_x: i32 = 0,
 31     alpha: []u8 = &.{},
 32 
 33     pub fn deinit(self: GlyphBitmap, allocator: std.mem.Allocator) void {
 34         if (self.alpha.len > 0) allocator.free(self.alpha);
 35     }
 36 };
 37 
 38 pub const AtlasGlyph = struct {
 39     codepoint: i32 = 0,
 40     glyph_id: u32 = 0,
 41     width: i32 = 0,
 42     height: i32 = 0,
 43     offset_x: i32 = 0,
 44     offset_y: i32 = 0,
 45     advance_x: i32 = 0,
 46 };
 47 
 48 pub const Atlas = struct {
 49     rgba: []u8 = &.{},
 50     width: i32 = 0,
 51     height: i32 = 0,
 52     glyphs: []AtlasGlyph = &.{},
 53     recs: []Rectangle = &.{},
 54     base_size: i32 = 0,
 55     glyph_padding: i32 = 0,
 56 
 57     pub fn deinitPixels(self: *Atlas, allocator: std.mem.Allocator) void {
 58         if (self.rgba.len > 0) allocator.free(self.rgba);
 59         self.rgba = &.{};
 60     }
 61 
 62     pub fn deinit(self: Atlas, allocator: std.mem.Allocator) void {
 63         if (self.rgba.len > 0) allocator.free(self.rgba);
 64         if (self.glyphs.len > 0) allocator.free(self.glyphs);
 65         if (self.recs.len > 0) allocator.free(self.recs);
 66     }
 67 };
 68 
 69 const FloatPoint = struct {
 70     x: f32,
 71     y: f32,
 72     on_curve: bool = true,
 73 };
 74 
 75 const ContourRange = struct {
 76     start: usize,
 77     end: usize,
 78 };
 79 
 80 const PixelBounds = struct {
 81     left: i32,
 82     top: i32,
 83     right: i32,
 84     bottom: i32,
 85 
 86     fn width(self: PixelBounds) i32 {
 87         return @max(0, self.right - self.left);
 88     }
 89 
 90     fn height(self: PixelBounds) i32 {
 91         return @max(0, self.bottom - self.top);
 92     }
 93 };
 94 
 95 /// Rasterizes one glyph. Scratch allocations end before return; `alpha` belongs to
 96 /// `output_allocator` and must be released with `GlyphBitmap.deinit` and the same allocator.
 97 pub fn glyphBitmapAlloc(
 98     output_allocator: std.mem.Allocator,
 99     scratch_allocator: std.mem.Allocator,
100     face: face_table.Face,
101     glyph_id: u32,
102     pixel_size: i32,
103 ) Error!GlyphBitmap {
104     if (pixel_size <= 0) return error.InvalidPixelSize;
105     const scale = fontScale(face, pixel_size);
106     const advance_x = scaleMetric(face.advanceWidth(glyph_id), scale);
107     var glyph = try outline.glyphAlloc(scratch_allocator, face, glyph_id);
108     defer glyph.deinit(scratch_allocator);
109 
110     if (glyph.points.len == 0 or glyph.bounds.x_max <= glyph.bounds.x_min or glyph.bounds.y_max <= glyph.bounds.y_min) {
111         return blankGlyph(glyph_id, advance_x);
112     }
113 
114     const pixel_bounds = glyphPixelBounds(glyph.bounds, face.ascender, scale);
115     const width = pixel_bounds.width();
116     const height = pixel_bounds.height();
117     if (width <= 0 or height <= 0) return blankGlyph(glyph_id, advance_x);
118 
119     var points = std.ArrayListUnmanaged(FloatPoint).empty;
120     defer points.deinit(scratch_allocator);
121     var contours = std.ArrayListUnmanaged(ContourRange).empty;
122     defer contours.deinit(scratch_allocator);
123     try flattenGlyph(scratch_allocator, glyph, face.ascender, pixel_bounds, scale, &points, &contours);
124 
125     const alpha = try output_allocator.alloc(u8, try pixelCount(width, height));
126     errdefer output_allocator.free(alpha);
127     @memset(alpha, 0);
128     if (points.items.len > 0) {
129         try coverage.rasterizeAlloc(scratch_allocator, alpha, width, height, points.items, contours.items);
130     }
131 
132     return .{
133         .glyph_id = glyph_id,
134         .width = width,
135         .height = height,
136         .offset_x = pixel_bounds.left,
137         .offset_y = pixel_bounds.top,
138         .advance_x = advance_x,
139         .alpha = alpha,
140     };
141 }
142 
143 pub fn loadAtlasAlloc(
144     output_allocator: std.mem.Allocator,
145     scratch_allocator: std.mem.Allocator,
146     font_bytes: []const u8,
147     font_size: i32,
148     glyph_ids: []const i32,
149     codepoints: []const i32,
150     padding: i32,
151 ) Error!Atlas {
152     if (font_size <= 0) return error.InvalidPixelSize;
153     if (padding < 0 or glyph_ids.len == 0 or glyph_ids.len != codepoints.len) return error.InvalidAtlas;
154     const face = face_table.Face.init(font_bytes) catch return error.InvalidFont;
155     if ((face.tableSlice("glyf") == null or face.tableSlice("loca") == null) and face.tableSlice("CFF ") == null) return error.UnsupportedOutline;
156 
157     const bitmaps = try scratch_allocator.alloc(GlyphBitmap, glyph_ids.len);
158     var bitmap_count: usize = 0;
159     errdefer {
160         for (bitmaps[0..bitmap_count]) |bitmap| bitmap.deinit(scratch_allocator);
161         scratch_allocator.free(bitmaps);
162     }
163     var glyph_scratch = std.heap.ArenaAllocator.init(scratch_allocator);
164     defer glyph_scratch.deinit();
165     for (glyph_ids, codepoints) |raw_glyph_id, codepoint| {
166         const glyph_id = std.math.cast(u32, raw_glyph_id) orelse return error.GlyphIdTooLarge;
167         var bitmap = glyphBitmapAlloc(scratch_allocator, glyph_scratch.allocator(), face, glyph_id, font_size) catch |err| switch (err) {
168             error.UnsupportedOutline => blankGlyph(glyph_id, scaleMetric(face.advanceWidth(glyph_id), fontScale(face, font_size))),
169             else => return err,
170         };
171         bitmap.codepoint = codepoint;
172         bitmaps[bitmap_count] = bitmap;
173         bitmap_count += 1;
174         _ = glyph_scratch.reset(.retain_capacity);
175     }
176 
177     const atlas = try packAtlasAlloc(output_allocator, bitmaps, font_size, padding);
178     for (bitmaps) |bitmap| bitmap.deinit(scratch_allocator);
179     scratch_allocator.free(bitmaps);
180     return atlas;
181 }
182 
183 fn blankGlyph(glyph_id: u32, advance_x: i32) GlyphBitmap {
184     return .{
185         .glyph_id = glyph_id,
186         .advance_x = advance_x,
187     };
188 }
189 
190 fn packAtlasAlloc(
191     allocator: std.mem.Allocator,
192     bitmaps: []const GlyphBitmap,
193     font_size: i32,
194     padding: i32,
195 ) Error!Atlas {
196     const glyphs = try allocator.alloc(AtlasGlyph, bitmaps.len);
197     errdefer allocator.free(glyphs);
198     const recs = try allocator.alloc(Rectangle, bitmaps.len);
199     errdefer allocator.free(recs);
200 
201     const target_width = chooseAtlasWidth(bitmaps, padding);
202     var x: i32 = 0;
203     var y: i32 = 0;
204     var row_height: i32 = 0;
205     var used_width: i32 = 0;
206     for (bitmaps, 0..) |bitmap, index| {
207         const packed_width = @max(1, bitmap.width + padding * 2);
208         const packed_height = @max(1, bitmap.height + padding * 2);
209         if (x > 0 and x + packed_width > target_width) {
210             y += row_height;
211             x = 0;
212             row_height = 0;
213         }
214         recs[index] = .{
215             .x = @floatFromInt(x + padding),
216             .y = @floatFromInt(y + padding),
217             .width = @floatFromInt(bitmap.width),
218             .height = @floatFromInt(bitmap.height),
219         };
220         glyphs[index] = .{
221             .codepoint = bitmap.codepoint,
222             .glyph_id = bitmap.glyph_id,
223             .width = bitmap.width,
224             .height = bitmap.height,
225             .offset_x = bitmap.offset_x,
226             .offset_y = bitmap.offset_y,
227             .advance_x = bitmap.advance_x,
228         };
229         x += packed_width;
230         used_width = @max(used_width, x);
231         row_height = @max(row_height, packed_height);
232     }
233 
234     const width = @max(1, used_width);
235     const height = @max(1, y + row_height);
236     const rgba_len = std.math.mul(usize, try pixelCount(width, height), 4) catch return error.InvalidAtlas;
237     const rgba = try allocator.alloc(u8, rgba_len);
238     errdefer allocator.free(rgba);
239     @memset(rgba, 0);
240     for (bitmaps, recs) |bitmap, rec| {
241         blitBitmap(rgba, width, height, bitmap, rec);
242     }
243 
244     return .{
245         .rgba = rgba,
246         .width = width,
247         .height = height,
248         .glyphs = glyphs,
249         .recs = recs,
250         .base_size = font_size,
251         .glyph_padding = padding,
252     };
253 }
254 
255 fn chooseAtlasWidth(bitmaps: []const GlyphBitmap, padding: i32) i32 {
256     var total_area: u64 = 0;
257     var min_width: i32 = 1;
258     for (bitmaps) |bitmap| {
259         const packed_width = @max(1, bitmap.width + padding * 2);
260         const packed_height = @max(1, bitmap.height + padding * 2);
261         min_width = @max(min_width, packed_width);
262         total_area += @as(u64, @intCast(packed_width)) * @as(u64, @intCast(packed_height));
263     }
264 
265     var width: i32 = 64;
266     while (width < min_width) width *= 2;
267     while (@as(u64, @intCast(width)) * @as(u64, @intCast(width)) < total_area and width < 2048) width *= 2;
268     return width;
269 }
270 
271 fn blitBitmap(rgba: []u8, atlas_width: i32, atlas_height: i32, bitmap: GlyphBitmap, rec: Rectangle) void {
272     if (bitmap.width <= 0 or bitmap.height <= 0 or bitmap.alpha.len == 0) return;
273     const dst_x0: i32 = @intFromFloat(rec.x);
274     const dst_y0: i32 = @intFromFloat(rec.y);
275     var row: i32 = 0;
276     while (row < bitmap.height) : (row += 1) {
277         var col: i32 = 0;
278         while (col < bitmap.width) : (col += 1) {
279             const dst_x = dst_x0 + col;
280             const dst_y = dst_y0 + row;
281             if (dst_x < 0 or dst_y < 0 or dst_x >= atlas_width or dst_y >= atlas_height) continue;
282             const src_index = @as(usize, @intCast(row)) * @as(usize, @intCast(bitmap.width)) + @as(usize, @intCast(col));
283             const dst_index = (@as(usize, @intCast(dst_y)) * @as(usize, @intCast(atlas_width)) + @as(usize, @intCast(dst_x))) * 4;
284             rgba[dst_index] = 255;
285             rgba[dst_index + 1] = 255;
286             rgba[dst_index + 2] = 255;
287             rgba[dst_index + 3] = bitmap.alpha[src_index];
288         }
289     }
290 }
291 
292 fn flattenGlyph(
293     allocator: std.mem.Allocator,
294     glyph: outline.Glyph,
295     ascender: i32,
296     pixel_bounds: PixelBounds,
297     scale: f32,
298     points: *std.ArrayListUnmanaged(FloatPoint),
299     contours: *std.ArrayListUnmanaged(ContourRange),
300 ) Error!void {
301     for (glyph.contours) |contour| {
302         if (contour.start >= contour.end or contour.end > glyph.points.len) return error.InvalidFont;
303         try flattenContour(
304             allocator,
305             glyph.points[contour.start..contour.end],
306             ascender,
307             pixel_bounds,
308             scale,
309             points,
310             contours,
311         );
312     }
313 }
314 
315 fn flattenContour(
316     allocator: std.mem.Allocator,
317     raw: []const outline.Point,
318     ascender: i32,
319     pixel_bounds: PixelBounds,
320     scale: f32,
321     points: *std.ArrayListUnmanaged(FloatPoint),
322     contours: *std.ArrayListUnmanaged(ContourRange),
323 ) Error!void {
324     if (raw.len == 0) return;
325     var expanded = std.ArrayListUnmanaged(FloatPoint).empty;
326     defer expanded.deinit(allocator);
327 
328     for (raw, 0..) |point, index| {
329         const next = raw[(index + 1) % raw.len];
330         const converted = convertPoint(point, ascender, pixel_bounds, scale);
331         try expanded.append(allocator, converted);
332         if (!point.on_curve and !next.on_curve) {
333             try expanded.append(allocator, midpoint(converted, convertPoint(next, ascender, pixel_bounds, scale)));
334         }
335     }
336 
337     const start_index = firstOnCurve(expanded.items) orelse return;
338     const range_start = points.items.len;
339     var current = expanded.items[start_index];
340     current.on_curve = true;
341     try points.append(allocator, current);
342 
343     var relative: usize = 1;
344     while (relative < expanded.items.len) {
345         const next = expanded.items[(start_index + relative) % expanded.items.len];
346         if (next.on_curve) {
347             try points.append(allocator, next);
348             current = next;
349             relative += 1;
350             continue;
351         }
352 
353         const end_index = (start_index + relative + 1) % expanded.items.len;
354         const end = expanded.items[end_index];
355         if (!end.on_curve) return error.InvalidFont;
356         try appendQuadratic(allocator, points, current, next, end);
357         current = end;
358         relative += 2;
359     }
360 
361     if (points.items.len - range_start >= 3) {
362         try contours.append(allocator, .{ .start = range_start, .end = points.items.len });
363     } else {
364         points.shrinkRetainingCapacity(range_start);
365     }
366 }
367 
368 fn convertPoint(point: outline.Point, ascender: i32, pixel_bounds: PixelBounds, scale: f32) FloatPoint {
369     return .{
370         .x = @as(f32, @floatFromInt(point.x)) * scale - @as(f32, @floatFromInt(pixel_bounds.left)),
371         .y = @as(f32, @floatFromInt(ascender - point.y)) * scale - @as(f32, @floatFromInt(pixel_bounds.top)),
372         .on_curve = point.on_curve,
373     };
374 }
375 
376 fn midpoint(a: FloatPoint, b: FloatPoint) FloatPoint {
377     return .{
378         .x = (a.x + b.x) * 0.5,
379         .y = (a.y + b.y) * 0.5,
380         .on_curve = true,
381     };
382 }
383 
384 fn firstOnCurve(points: []const FloatPoint) ?usize {
385     for (points, 0..) |point, index| {
386         if (point.on_curve) return index;
387     }
388     return null;
389 }
390 
391 fn appendQuadratic(
392     allocator: std.mem.Allocator,
393     points: *std.ArrayListUnmanaged(FloatPoint),
394     p0: FloatPoint,
395     p1: FloatPoint,
396     p2: FloatPoint,
397 ) Error!void {
398     const steps: usize = 8;
399     for (1..(steps + 1)) |step| {
400         const t = @as(f32, @floatFromInt(step)) / @as(f32, @floatFromInt(steps));
401         const mt = 1.0 - t;
402         try points.append(allocator, .{
403             .x = mt * mt * p0.x + 2.0 * mt * t * p1.x + t * t * p2.x,
404             .y = mt * mt * p0.y + 2.0 * mt * t * p1.y + t * t * p2.y,
405         });
406     }
407 }
408 
409 fn fontScale(face: face_table.Face, pixel_size: i32) f32 {
410     return @as(f32, @floatFromInt(pixel_size)) / @as(f32, @floatFromInt(face.units_per_em));
411 }
412 
413 fn glyphPixelBounds(bounds: outline.Bounds, ascender: i32, scale: f32) PixelBounds {
414     return .{
415         .left = scaleFloor(bounds.x_min, scale),
416         .top = scaleFloor(ascender - bounds.y_max, scale),
417         .right = scaleCeil(bounds.x_max, scale),
418         .bottom = scaleCeil(ascender - bounds.y_min, scale),
419     };
420 }
421 
422 fn scaleMetric(value: u16, scale: f32) i32 {
423     return scaleRound(value, scale);
424 }
425 
426 fn scaleFloor(value: i32, scale: f32) i32 {
427     return @intFromFloat(@floor(@as(f32, @floatFromInt(value)) * scale));
428 }
429 
430 fn scaleCeil(value: i32, scale: f32) i32 {
431     return @intFromFloat(@ceil(@as(f32, @floatFromInt(value)) * scale));
432 }
433 
434 fn scaleRound(value: anytype, scale: f32) i32 {
435     return @intFromFloat(@round(@as(f32, @floatFromInt(value)) * scale));
436 }
437 
438 fn pixelCount(width: i32, height: i32) Error!usize {
439     if (width <= 0 or height <= 0) return error.InvalidAtlas;
440     return std.math.mul(usize, @intCast(width), @intCast(height)) catch error.InvalidAtlas;
441 }
442 
443 fn hasVisiblePixel(bytes: []const u8) bool {
444     for (bytes) |byte| {
445         if (byte != 0) return true;
446     }
447     return false;
448 }
449 
450 fn expectGlyphCoverageMatchesReference(
451     allocator: std.mem.Allocator,
452     face: face_table.Face,
453     glyph_id: u32,
454     pixel_size: i32,
455 ) !void {
456     const scale = fontScale(face, pixel_size);
457     var glyph = try outline.glyphAlloc(allocator, face, glyph_id);
458     defer glyph.deinit(allocator);
459     if (glyph.points.len == 0 or glyph.bounds.x_max <= glyph.bounds.x_min or glyph.bounds.y_max <= glyph.bounds.y_min) return;
460 
461     const pixel_bounds = glyphPixelBounds(glyph.bounds, face.ascender, scale);
462     const width = pixel_bounds.width();
463     const height = pixel_bounds.height();
464     if (width <= 0 or height <= 0) return;
465 
466     var points = std.ArrayListUnmanaged(FloatPoint).empty;
467     defer points.deinit(allocator);
468     var contours = std.ArrayListUnmanaged(ContourRange).empty;
469     defer contours.deinit(allocator);
470     try flattenGlyph(allocator, glyph, face.ascender, pixel_bounds, scale, &points, &contours);
471 
472     const expected = try allocator.alloc(u8, try pixelCount(width, height));
473     defer allocator.free(expected);
474     coverage.rasterizeReference(expected, width, height, points.items, contours.items);
475 
476     const bitmap = try glyphBitmapAlloc(allocator, allocator, face, glyph_id, pixel_size);
477     defer bitmap.deinit(allocator);
478     try std.testing.expectEqualSlices(u8, expected, bitmap.alpha);
479 }
480 
481 test "scanline rasterization matches reference fixture outlines" {
482     const fixtures = @import("../fixture/root.zig");
483     const allocator = std.testing.allocator;
484 
485     const true_type_bytes = try fixtures.createWithOutlines(allocator);
486     defer allocator.free(true_type_bytes);
487     const true_type_face = try face_table.Face.init(true_type_bytes);
488     for ([_]i32{ 13, 20, 32 }) |pixel_size| {
489         for ([_]u21{ 'A', 'B', 'i' }) |codepoint| {
490             try expectGlyphCoverageMatchesReference(allocator, true_type_face, true_type_face.glyphId(codepoint), pixel_size);
491         }
492     }
493 
494     const cff_bytes = try fixtures.createWithCffOutlines(allocator);
495     defer allocator.free(cff_bytes);
496     const cff_face = try face_table.Face.init(cff_bytes);
497     for ([_]i32{ 13, 20, 32 }) |pixel_size| {
498         try expectGlyphCoverageMatchesReference(allocator, cff_face, cff_face.glyphId('A'), pixel_size);
499     }
500 }
501 
502 test "raster renders fixture outline alpha" {
503     const fixtures = @import("../fixture/root.zig");
504     const allocator = std.testing.allocator;
505     const bytes = try fixtures.createWithOutlines(allocator);
506     defer allocator.free(bytes);
507 
508     const face = try face_table.Face.init(bytes);
509     const glyph_id = face.glyphId('A');
510     const bitmap = try glyphBitmapAlloc(allocator, allocator, face, glyph_id, 20);
511     defer bitmap.deinit(allocator);
512 
513     try std.testing.expect(bitmap.width > 0);
514     try std.testing.expect(bitmap.height > 0);
515     try std.testing.expectEqual(@as(i32, 10), bitmap.advance_x);
516     try std.testing.expect(hasVisiblePixel(bitmap.alpha));
517 }
518 
519 test "raster snaps glyph bitmap bounds from shared pixel edges" {
520     const bounds = outline.Bounds{ .x_min = 25, .y_min = 1, .x_max = 626, .y_max = 700 };
521     const pixel_bounds = glyphPixelBounds(bounds, 1000, 0.016);
522 
523     try std.testing.expectEqual(@as(i32, 0), pixel_bounds.left);
524     try std.testing.expectEqual(@as(i32, 4), pixel_bounds.top);
525     try std.testing.expectEqual(@as(i32, 11), pixel_bounds.right);
526     try std.testing.expectEqual(@as(i32, 16), pixel_bounds.bottom);
527     try std.testing.expectEqual(@as(i32, 11), pixel_bounds.width());
528     try std.testing.expectEqual(@as(i32, 12), pixel_bounds.height());
529 
530     const top_left = convertPoint(.{ .x = 25, .y = 700, .on_curve = true }, 1000, pixel_bounds, 0.016);
531     const bottom_right = convertPoint(.{ .x = 626, .y = 1, .on_curve = true }, 1000, pixel_bounds, 0.016);
532 
533     try std.testing.expectApproxEqAbs(@as(f32, 0.4), top_left.x, 0.001);
534     try std.testing.expectApproxEqAbs(@as(f32, 0.8), top_left.y, 0.001);
535     try std.testing.expectApproxEqAbs(@as(f32, 10.016), bottom_right.x, 0.001);
536     try std.testing.expectApproxEqAbs(@as(f32, 11.984), bottom_right.y, 0.001);
537 }
538 
539 test "raster renders composite fixture outline alpha" {
540     const fixtures = @import("../fixture/root.zig");
541     const allocator = std.testing.allocator;
542     const bytes = try fixtures.createWithOutlines(allocator);
543     defer allocator.free(bytes);
544 
545     const face = try face_table.Face.init(bytes);
546     const bitmap = try glyphBitmapAlloc(allocator, allocator, face, face.glyphId('i'), 20);
547     defer bitmap.deinit(allocator);
548 
549     try std.testing.expectEqual(@as(i32, 18), bitmap.width);
550     try std.testing.expectEqual(@as(i32, 14), bitmap.height);
551     try std.testing.expectEqual(@as(i32, 1), bitmap.offset_x);
552     try std.testing.expectEqual(@as(i32, 2), bitmap.offset_y);
553     try std.testing.expectEqual(@as(i32, 10), bitmap.advance_x);
554     try std.testing.expectEqual(@as(usize, 252), bitmap.alpha.len);
555 
556     var row: usize = 0;
557     while (row < 14) : (row += 1) {
558         var col: usize = 0;
559         while (col < 18) : (col += 1) {
560             const expected: u8 = if (col < 8 or col >= 10) 255 else 0;
561             try std.testing.expectEqual(expected, bitmap.alpha[row * 18 + col]);
562         }
563     }
564 }
565 
566 test "atlas packs fixture glyph bitmaps as rgba" {
567     const fixtures = @import("../fixture/root.zig");
568     const allocator = std.testing.allocator;
569     const bytes = try fixtures.createWithOutlines(allocator);
570     defer allocator.free(bytes);
571     const face = try face_table.Face.init(bytes);
572     var glyph_ids = [_]i32{
573         @intCast(face.glyphId('A')),
574         @intCast(face.glyphId('B')),
575         @intCast(face.glyphId(' ')),
576     };
577     const codepoints = [_]i32{ 'A', 'B', ' ' };
578 
579     const atlas = try loadAtlasAlloc(allocator, allocator, bytes, 20, &glyph_ids, &codepoints, 2);
580     defer atlas.deinit(allocator);
581 
582     try std.testing.expectEqual(@as(usize, 3), atlas.glyphs.len);
583     try std.testing.expectEqual(@as(i32, 'A'), atlas.glyphs[0].codepoint);
584     try std.testing.expect(atlas.width > 0);
585     try std.testing.expect(atlas.height > 0);
586     try std.testing.expect(atlas.rgba.len >= 4);
587     try std.testing.expect(hasVisiblePixel(atlas.rgba));
588 }
589 
590 test "raster renders CFF fixture outline alpha" {
591     const fixtures = @import("../fixture/root.zig");
592     const allocator = std.testing.allocator;
593     const bytes = try fixtures.createWithCffOutlines(allocator);
594     defer allocator.free(bytes);
595 
596     const face = try face_table.Face.init(bytes);
597     const glyph_id = face.glyphId('A');
598     const bitmap = try glyphBitmapAlloc(allocator, allocator, face, glyph_id, 20);
599     defer bitmap.deinit(allocator);
600 
601     try std.testing.expect(bitmap.width > 0);
602     try std.testing.expect(bitmap.height > 0);
603     try std.testing.expectEqual(@as(i32, 10), bitmap.advance_x);
604     try std.testing.expect(hasVisiblePixel(bitmap.alpha));
605 }
606 
607 test "raster renders configured CFF font alpha" {
608     const allocator = std.testing.allocator;
609     const path = sys.env.getOwned(allocator, cff_test_font_env) catch null orelse return error.SkipZigTest;
610     defer allocator.free(path);
611     const bytes = try sys.fs.readFileAlloc(allocator, path, max_test_font_bytes);
612     defer allocator.free(bytes);
613 
614     const face = try face_table.Face.init(bytes);
615     if (face.tableSlice("CFF ") == null) return error.SkipZigTest;
616     const glyph_id = face.glyphId('A');
617     if (glyph_id == 0) return error.SkipZigTest;
618     const bitmap = try glyphBitmapAlloc(allocator, allocator, face, glyph_id, 32);
619     defer bitmap.deinit(allocator);
620 
621     try std.testing.expect(bitmap.width > 0);
622     try std.testing.expect(bitmap.height > 0);
623     try std.testing.expect(hasVisiblePixel(bitmap.alpha));
624 }