From e441094af036ba3df6047331543e8868e63c1ab4 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 3 Jul 2025 16:02:12 -0600 Subject: [PATCH] font: add constraint logic to rasterizers This is in preparation to move constraint off the GPU to simplify our shaders, instead we only need to constrain once at raster time and never again. This also significantly reworks the freetype renderGlyph function to be generally much cleaner and more straightforward. This commit doesn't actually apply the constraints to anything yet, that will be in following commits. --- pkg/freetype/freetype-zig.h | 2 + pkg/macos/graphics/context.zig | 16 + src/config/Config.zig | 19 +- src/font/face.zig | 203 ++++++++++++ src/font/face/coretext.zig | 138 +++++---- src/font/face/freetype.zig | 551 ++++++++++++++++++--------------- 6 files changed, 608 insertions(+), 321 deletions(-) diff --git a/pkg/freetype/freetype-zig.h b/pkg/freetype/freetype-zig.h index 29e546154..dcc65e514 100644 --- a/pkg/freetype/freetype-zig.h +++ b/pkg/freetype/freetype-zig.h @@ -5,3 +5,5 @@ #include #include #include +#include +#include diff --git a/pkg/macos/graphics/context.zig b/pkg/macos/graphics/context.zig index d1c6c943f..77e4344e0 100644 --- a/pkg/macos/graphics/context.zig +++ b/pkg/macos/graphics/context.zig @@ -141,6 +141,22 @@ pub fn Context(comptime T: type) type { @bitCast(rect), ); } + + pub fn scaleCTM(self: *T, sx: c.CGFloat, sy: c.CGFloat) void { + c.CGContextScaleCTM( + @ptrCast(self), + sx, + sy, + ); + } + + pub fn translateCTM(self: *T, tx: c.CGFloat, ty: c.CGFloat) void { + c.CGContextTranslateCTM( + @ptrCast(self), + tx, + ty, + ); + } }; } diff --git a/src/config/Config.zig b/src/config/Config.zig index f36132ea9..68b456d7a 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -425,13 +425,16 @@ pub const compatibility = std.StaticStringMap( /// /// Available flags: /// -/// * `hinting` - Enable or disable hinting, enabled by default. -/// * `force-autohint` - Use the freetype auto-hinter rather than the -/// font's native hinter. Enabled by default. -/// * `monochrome` - Instructs renderer to use 1-bit monochrome -/// rendering. This option doesn't impact the hinter. -/// Enabled by default. -/// * `autohint` - Use the freetype auto-hinter. Enabled by default. +/// * `hinting` - Enable or disable hinting. Enabled by default. +/// +/// * `force-autohint` - Always use the freetype auto-hinter instead of +/// the font's native hinter. Enabled by default. +/// +/// * `monochrome` - Instructs renderer to use 1-bit monochrome rendering. +/// This will disable anti-aliasing, and probably not look very good unless +/// you're using a pixel font. Disabled by default. +/// +/// * `autohint` - Enable the freetype auto-hinter. Enabled by default. /// /// Example: `hinting`, `no-hinting`, `force-autohint`, `no-force-autohint` @"freetype-load-flags": FreetypeLoadFlags = .{}, @@ -6961,7 +6964,7 @@ pub const FreetypeLoadFlags = packed struct { // to these defaults. hinting: bool = true, @"force-autohint": bool = true, - monochrome: bool = true, + monochrome: bool = false, autohint: bool = true, }; diff --git a/src/font/face.zig b/src/font/face.zig index 6355578db..363576ff0 100644 --- a/src/font/face.zig +++ b/src/font/face.zig @@ -94,6 +94,17 @@ pub const RenderOptions = struct { /// 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. /// @@ -108,6 +119,198 @@ pub const RenderOptions = struct { /// /// 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 = .{}; + + /// Vertical sizing rule. + size_vertical: Size = .none, + /// Horizontal sizing rule. + size_horizontal: 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, + + /// Maximum ratio of width to height when resizing. + max_xy_ratio: ?f64 = null, + + pub const Size = enum { + /// Don't change the size of this glyph. + none, + /// Move the glyph and optionally scale it down + /// proportionally to fit within the given axis. + fit, + /// Move and resize the glyph proportionally to + /// cover the given axis. + cover, + /// Same as `cover` but not proportional. + 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, + }; + + /// The size and position of a glyph. + pub const GlyphSize = struct { + width: f64, + height: f64, + x: f64, + y: f64, + }; + + /// Apply this constraint to the provided glyph + /// size, given the available width and height. + pub fn constrain( + self: Constraint, + glyph: GlyphSize, + /// Available width + cell_width: f64, + /// Available height + cell_height: f64, + ) GlyphSize { + var g = glyph; + + const w = cell_width - + self.pad_left * cell_width - + self.pad_right * cell_width; + const h = cell_height - + self.pad_top * cell_height - + self.pad_bottom * cell_height; + + // Subtract padding from the bearings so that our + // alignment and sizing code works correctly. We + // re-add before returning. + g.x -= self.pad_left * cell_width; + g.y -= self.pad_bottom * cell_height; + + switch (self.size_horizontal) { + .none => {}, + .fit => if (g.width > w) { + const orig_height = g.height; + // Adjust our height and width to proportionally + // scale them to fit the glyph to the cell width. + g.height *= w / g.width; + g.width = w; + // Set our x to 0 since anything else would mean + // the glyph extends outside of the cell width. + g.x = 0; + // Compensate our y to keep things vertically + // centered as they're scaled down. + g.y += (orig_height - g.height) / 2; + } else if (g.width + g.x > w) { + // If the width of the glyph can fit in the cell but + // is currently outside due to the left bearing, then + // we reduce the left bearing just enough to fit it + // back in the cell. + g.x = w - g.width; + } else if (g.x < 0) { + g.x = 0; + }, + .cover => { + const orig_height = g.height; + + g.height *= w / g.width; + g.width = w; + + g.x = 0; + + g.y += (orig_height - g.height) / 2; + }, + .stretch => { + g.width = w; + g.x = 0; + }, + } + + switch (self.size_vertical) { + .none => {}, + .fit => if (g.height > h) { + const orig_width = g.width; + // Adjust our height and width to proportionally + // scale them to fit the glyph to the cell height. + g.width *= h / g.height; + g.height = h; + // Set our y to 0 since anything else would mean + // the glyph extends outside of the cell height. + g.y = 0; + // Compensate our x to keep things horizontally + // centered as they're scaled down. + g.x += (orig_width - g.width) / 2; + } else if (g.height + g.y > h) { + // If the height of the glyph can fit in the cell but + // is currently outside due to the bottom bearing, then + // we reduce the bottom bearing just enough to fit it + // back in the cell. + g.y = h - g.height; + } else if (g.y < 0) { + g.y = 0; + }, + .cover => { + const orig_width = g.width; + + g.width *= h / g.height; + g.height = h; + + g.y = 0; + + g.x += (orig_width - g.width) / 2; + }, + .stretch => { + g.height = h; + g.y = 0; + }, + } + + if (self.max_xy_ratio) |ratio| if (g.width > g.height * ratio) { + const orig_width = g.width; + g.width = g.height * ratio; + g.x += (orig_width - g.width) / 2; + }; + + switch (self.align_horizontal) { + .none => {}, + .start => g.x = 0, + .end => g.x = w - g.width, + .center => g.x = (w - g.width) / 2, + } + + switch (self.align_vertical) { + .none => {}, + .start => g.y = 0, + .end => g.y = h - g.height, + .center => g.y = (h - g.height) / 2, + } + + // Re-add our padding before returning. + g.x += self.pad_left * cell_width; + g.y += self.pad_bottom * cell_height; + + return g; + } + }; }; test { diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 06bba661f..35f094848 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -291,22 +291,29 @@ pub const Face = struct { // in the bottom left and +Y pointing up. var rect = self.font.getBoundingRectsForGlyphs(.horizontal, &glyphs, null); + // Determine whether this is a color glyph. + const is_color = self.isColorGlyph(glyph_index); + // And whether it's (probably) a bitmap (sbix). + const sbix = is_color and self.color != null and self.color.?.sbix; + // If we're rendering a synthetic bold then we will gain 50% of // the line width on every edge, which means we should increase // our width and height by the line width and subtract half from // our origin points. - if (self.synthetic_bold) |line_width| { + // + // We don't add extra size if it's a sbix color font though, + // since bitmaps aren't affected by synthetic bold. + if (!sbix) if (self.synthetic_bold) |line_width| { rect.size.width += line_width; rect.size.height += line_width; rect.origin.x -= line_width / 2; rect.origin.y -= line_width / 2; - } + }; // We make an assumption that font smoothing ("thicken") // adds no more than 1 extra pixel to any edge. We don't // add extra size if it's a sbix color font though, since // bitmaps aren't affected by smoothing. - const sbix = self.color != null and self.color.?.sbix; if (opts.thicken and !sbix) { rect.size.width += 2.0; rect.size.height += 2.0; @@ -314,29 +321,43 @@ pub const Face = struct { rect.origin.y -= 1.0; } - // We compute the minimum and maximum x and y values. - // We round our min points down and max points up. - const x0: i32, const x1: i32, const y0: i32, const y1: i32 = .{ - @intFromFloat(@floor(rect.origin.x)), - @intFromFloat(@ceil(rect.origin.x) + @ceil(rect.size.width)), - @intFromFloat(@floor(rect.origin.y)), - @intFromFloat(@ceil(rect.origin.y) + @ceil(rect.size.height)), - }; + // If our rect is smaller than a quarter pixel in either axis + // then it has no outlines or they're too small to render. + // + // In this case we just return 0-sized glyph struct. + if (rect.size.width < 0.25 or rect.size.height < 0.25) + return font.Glyph{ + .width = 0, + .height = 0, + .offset_x = 0, + .offset_y = 0, + .atlas_x = 0, + .atlas_y = 0, + .advance_x = 0, + }; - // This bitmap is blank. I've seen it happen in a font, I don't know why. - // If it is empty, we just return a valid glyph struct that does nothing. - if (x1 <= x0 or y1 <= y0) return font.Glyph{ - .width = 0, - .height = 0, - .offset_x = 0, - .offset_y = 0, - .atlas_x = 0, - .atlas_y = 0, - .advance_x = 0, - }; + const metrics = opts.grid_metrics; + const cell_width: f64 = @floatFromInt(metrics.cell_width * opts.constraint_width); + const cell_height: f64 = @floatFromInt(metrics.cell_height); - const width: u32 = @intCast(x1 - x0); - const height: u32 = @intCast(y1 - y0); + const glyph_size = opts.constraint.constrain( + .{ + .width = rect.size.width, + .height = rect.size.height, + .x = rect.origin.x, + .y = rect.origin.y + @as(f64, @floatFromInt(metrics.cell_baseline)), + }, + cell_width, + cell_height, + ); + + const width = glyph_size.width; + const height = glyph_size.height; + const x = glyph_size.x; + const y = glyph_size.y; + + const px_width: u32 = @intFromFloat(@ceil(width)); + const px_height: u32 = @intFromFloat(@ceil(height)); // Settings that are specific to if we are rendering text or emoji. const color: struct { @@ -344,7 +365,7 @@ pub const Face = struct { depth: u32, space: *macos.graphics.ColorSpace, context_opts: c_uint, - } = if (!self.isColorGlyph(glyph_index)) .{ + } = if (!is_color) .{ .color = false, .depth = 1, .space = try macos.graphics.ColorSpace.createNamed(.linearGray), @@ -371,17 +392,17 @@ pub const Face = struct { // usually stabilizes pretty quickly and is very infrequent so I think // the allocation overhead is acceptable compared to the cost of // caching it forever or having to deal with a cache lifetime. - const buf = try alloc.alloc(u8, width * height * color.depth); + const buf = try alloc.alloc(u8, px_width * px_height * color.depth); defer alloc.free(buf); @memset(buf, 0); const context = macos.graphics.BitmapContext.context; const ctx = try macos.graphics.BitmapContext.create( buf, - width, - height, + px_width, + px_height, 8, - width * color.depth, + px_width * color.depth, color.space, color.context_opts, ); @@ -390,14 +411,14 @@ pub const Face = struct { // Perform an initial fill. This ensures that we don't have any // uninitialized pixels in the bitmap. if (color.color) - context.setRGBFillColor(ctx, 1, 1, 1, 0) + context.setRGBFillColor(ctx, 0, 0, 0, 0) else - context.setGrayFillColor(ctx, 1, 0); + context.setGrayFillColor(ctx, 0, 0); context.fillRect(ctx, .{ .origin = .{ .x = 0, .y = 0 }, .size = .{ - .width = @floatFromInt(width), - .height = @floatFromInt(height), + .width = @floatFromInt(px_width), + .height = @floatFromInt(px_height), }, }); @@ -427,49 +448,34 @@ pub const Face = struct { context.setLineWidth(ctx, line_width); } + context.scaleCTM( + ctx, + width / rect.size.width, + height / rect.size.height, + ); + // We want to render the glyphs at (0,0), but the glyphs themselves // are offset by bearings, so we have to undo those bearings in order // to get them to 0,0. - self.font.drawGlyphs(&glyphs, &.{ - .{ - .x = @floatFromInt(-x0), - .y = @floatFromInt(-y0), - }, - }, ctx); + self.font.drawGlyphs(&glyphs, &.{.{ + .x = -@floor(rect.origin.x), + .y = -@floor(rect.origin.y), + }}, ctx); - const region = region: { - // We reserve a region that's 1px wider and taller than we need - // in order to create a 1px separation between adjacent glyphs - // to prevent interpolation with adjacent glyphs while sampling - // from the atlas. - var region = try atlas.reserve( - alloc, - width + 1, - height + 1, - ); - - // We adjust the region width and height back down since we - // don't need the extra pixel, we just needed to reserve it - // so that it isn't used for other glyphs in the future. - region.width -= 1; - region.height -= 1; - break :region region; - }; + // Write our rasterized glyph to the atlas. + const region = try atlas.reserve(alloc, px_width, px_height); atlas.set(region, buf); - const metrics = opts.grid_metrics; - // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. - // - // The calculation is distance from bottom of cell to - // baseline plus distance from baseline to top of glyph. - const offset_y: i32 = @as(i32, @intCast(metrics.cell_baseline)) + y1; + const offset_y: i32 = + @as(i32, @intFromFloat(@floor(y))) + + @as(i32, @intCast(px_height)); // This should be the distance from the left of // the cell to the left of the glyph's bounding box. const offset_x: i32 = offset_x: { - var result: i32 = x0; + var result: i32 = @intFromFloat(@round(x)); // If our cell was resized then we adjust our glyph's // position relative to the new center. This keeps glyphs @@ -490,8 +496,8 @@ pub const Face = struct { _ = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, &advances); return .{ - .width = width, - .height = height, + .width = px_width, + .height = px_height, .offset_x = offset_x, .offset_y = offset_y, .atlas_x = region.x, diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index accb891a4..9e057ceea 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -21,6 +21,8 @@ const fastmem = @import("../../fastmem.zig"); const quirks = @import("../../quirks.zig"); const config = @import("../../config.zig"); +const F26Dot6 = opentype.sfnt.F26Dot6; + const log = std.log.scoped(.font_face); pub const Face = struct { @@ -58,14 +60,6 @@ pub const Face = struct { bold: bool = false, } = .{}, - /// The matrix applied to a regular font to create a synthetic italic. - const italic_matrix: freetype.c.FT_Matrix = .{ - .xx = 0x10000, - .xy = 0x044ED, // approx. tan(15) - .yx = 0, - .yy = 0x10000, - }; - /// Initialize a new font face with the given source in-memory. pub fn initFile( lib: Library, @@ -330,26 +324,32 @@ pub const Face = struct { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); - const metrics = opts.grid_metrics; + // We enable hinting by default, and disable it if either of the + // constraint alignments are not center or none, since this means + // that the glyph needs to be aligned flush to the cell edge, and + // hinting can mess that up. + const do_hinting = self.load_flags.hinting and + switch (opts.constraint.align_horizontal) { + .start, .end => false, + .center, .none => true, + } and + switch (opts.constraint.align_vertical) { + .start, .end => false, + .center, .none => true, + }; - // If we have synthetic italic, then we apply a transformation matrix. - // We have to undo this because synthetic italic works by increasing - // the ref count of the base face. - if (self.synthetic.italic) self.face.setTransform(&italic_matrix, null); - defer if (self.synthetic.italic) self.face.setTransform(null, null); - - // If our glyph has color, we want to render the color + // Load the glyph. try self.face.loadGlyph(glyph_index, .{ + // If our glyph has color, we want to render the color .color = self.face.hasColor(), - // If we have synthetic bold, we have to set some additional - // glyph properties before render so we don't render here. - .render = !self.synthetic.bold, + // We don't render, because we'll invoke the render + // manually after applying constraints further down. + .render = false, // use options from config - .no_hinting = !self.load_flags.hinting, + .no_hinting = !do_hinting, .force_autohint = !self.load_flags.@"force-autohint", - .monochrome = !self.load_flags.monochrome, .no_autohint = !self.load_flags.autohint, // NO_SVG set to true because we don't currently support rendering @@ -359,260 +359,310 @@ pub const Face = struct { }); const glyph = self.face.handle.*.glyph; - // For synthetic bold, we embolden the glyph and render it. + const glyph_width: f64 = f26dot6ToF64(glyph.*.metrics.width); + const glyph_height: f64 = f26dot6ToF64(glyph.*.metrics.height); + + // If our glyph is smaller than a quarter pixel in either axis + // then it has no outlines or they're too small to render. + // + // In this case we just return 0-sized glyph struct. + if (glyph_width < 0.25 or glyph_height < 0.25) + return font.Glyph{ + .width = 0, + .height = 0, + .offset_x = 0, + .offset_y = 0, + .atlas_x = 0, + .atlas_y = 0, + .advance_x = 0, + }; + + // For synthetic bold, we embolden the glyph. if (self.synthetic.bold) { // We need to scale the embolden amount based on the font size. // This is a heuristic I found worked well across a variety of // founts: 1 pixel per 64 units of height. - const height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height); + const font_height: f64 = @floatFromInt(self.face.handle.*.size.*.metrics.height); const ratio: f64 = 64.0 / 2048.0; - const amount = @ceil(height * ratio); + const amount = @ceil(font_height * ratio); _ = freetype.c.FT_Outline_Embolden(&glyph.*.outline, @intFromFloat(amount)); - try self.face.renderGlyph(.normal); } - // This bitmap is blank. I've seen it happen in a font, I don't know why. - // If it is empty, we just return a valid glyph struct that does nothing. - const bitmap_ft = glyph.*.bitmap; - if (bitmap_ft.rows == 0) return .{ - .width = 0, - .height = 0, - .offset_x = 0, - .offset_y = 0, - .atlas_x = 0, - .atlas_y = 0, - .advance_x = 0, - }; + // Next we need to apply any constraints. + const metrics = opts.grid_metrics; - // Ensure we know how to work with the font format. And assure that - // or color depth is as expected on the texture atlas. If format is null - // it means there is no native color format for our Atlas and we must try - // conversion. - const format: ?font.Atlas.Format = switch (bitmap_ft.pixel_mode) { - freetype.c.FT_PIXEL_MODE_MONO => null, - freetype.c.FT_PIXEL_MODE_GRAY => .grayscale, - freetype.c.FT_PIXEL_MODE_BGRA => .bgra, + const cell_width: f64 = @floatFromInt(metrics.cell_width * opts.constraint_width); + const cell_height: f64 = @floatFromInt(metrics.cell_height); + + const glyph_x: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingX); + const glyph_y: f64 = f26dot6ToF64(glyph.*.metrics.horiBearingY) - glyph_height; + + const glyph_size = opts.constraint.constrain( + .{ + .width = glyph_width, + .height = glyph_height, + .x = glyph_x, + .y = glyph_y + @as(f64, @floatFromInt(metrics.cell_baseline)), + }, + cell_width, + cell_height, + ); + + const width = glyph_size.width; + const height = glyph_size.height; + // This may need to be adjusted later on. + var x = glyph_size.x; + const y = glyph_size.y; + + // Now we can render the glyph. + var bitmap: freetype.c.FT_Bitmap = undefined; + _ = freetype.c.FT_Bitmap_Init(&bitmap); + defer _ = freetype.c.FT_Bitmap_Done(self.lib.lib.handle, &bitmap); + switch (glyph.*.format) { + freetype.c.FT_GLYPH_FORMAT_OUTLINE => { + // Manually adjust the glyph outline with this transform. + // + // This offers better precision than using the freetype transform + // matrix, since that has 16.16 coefficients, and also I was having + // weird issues that I can only assume where due to freetype doing + // some bad caching or something when I did this using the matrix. + const scale_x = width / glyph_width; + const scale_y = height / glyph_height; + const skew: f64 = + if (self.synthetic.italic) + // We skew by 12 degrees to synthesize italics. + @tan(std.math.degreesToRadians(12)) + else + 0.0; + + var bbox_before: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_before); + + const outline = &glyph.*.outline; + for (outline.points[0..@intCast(outline.n_points)]) |*p| { + // Convert to f64 for processing + var px = f26dot6ToF64(p.x); + var py = f26dot6ToF64(p.y); + + // Scale + px *= scale_x; + py *= scale_y; + + // Skew + px += py * skew; + + // Convert back and store + p.x = @as(i32, @bitCast(F26Dot6.from(px))); + p.y = @as(i32, @bitCast(F26Dot6.from(py))); + } + + var bbox_after: freetype.c.FT_BBox = undefined; + _ = freetype.c.FT_Outline_Get_BBox(&glyph.*.outline, &bbox_after); + + // If our bounding box changed, account for the lsb difference. + // + // This can happen when we skew glyphs that have a bit sticking + // out to the left higher up, like the top of the T or the serif + // on the lower case l in many monospace fonts. + x += f26dot6ToF64(bbox_after.xMin) - f26dot6ToF64(bbox_before.xMin); + + try self.face.renderGlyph( + if (self.load_flags.monochrome) + .mono + else + .normal, + ); + + // Copy the glyph's bitmap, making sure + // that it's 8bpp and densely packed. + if (freetype.c.FT_Bitmap_Convert( + self.lib.lib.handle, + &glyph.*.bitmap, + &bitmap, + 1, + ) != 0) { + return error.BitmapHandlingError; + } + }, + + freetype.c.FT_GLYPH_FORMAT_BITMAP => { + // If our glyph has a non-color bitmap, we need + // to convert it to dense 8bpp so that the scale + // operation works correctly. + switch (glyph.*.bitmap.pixel_mode) { + freetype.c.FT_PIXEL_MODE_BGRA, + freetype.c.FT_PIXEL_MODE_GRAY, + => {}, + else => { + var converted: freetype.c.FT_Bitmap = undefined; + freetype.c.FT_Bitmap_Init(&converted); + if (freetype.c.FT_Bitmap_Convert( + self.lib.lib.handle, + &glyph.*.bitmap, + &converted, + 1, + ) != 0) { + return error.BitmapHandlingError; + } + // Free the existing glyph bitmap and + // replace it with the converted one. + _ = freetype.c.FT_Bitmap_Done( + self.lib.lib.handle, + &glyph.*.bitmap, + ); + glyph.*.bitmap = converted; + }, + } + + const glyph_bitmap = glyph.*.bitmap; + + // Round our target width and height + // as the size for our scaled bitmap. + const w: u32 = @intFromFloat(@round(width)); + const h: u32 = @intFromFloat(@round(height)); + const pitch = w * atlas.format.depth(); + + // Allocate a buffer for our scaled bitmap. + // + // We'll copy this to the original bitmap once we're + // done so we can free it at the end of this scope. + const buf = try alloc.alloc(u8, pitch * h); + defer alloc.free(buf); + + // Resize + if (stb.stbir_resize_uint8( + glyph_bitmap.buffer, + @intCast(glyph_bitmap.width), + @intCast(glyph_bitmap.rows), + glyph_bitmap.pitch, + buf.ptr, + @intCast(w), + @intCast(h), + @intCast(pitch), + atlas.format.depth(), + ) == 0) { + // This should never fail because this is a + // fairly straightforward in-memory operation... + return error.GlyphResizeFailed; + } + + const scaled_bitmap: freetype.c.FT_Bitmap = .{ + .buffer = buf.ptr, + .width = @intCast(w), + .rows = @intCast(h), + .pitch = @intCast(pitch), + .pixel_mode = glyph_bitmap.pixel_mode, + .num_grays = glyph_bitmap.num_grays, + }; + + // Replace the bitmap's buffer and size info. + if (freetype.c.FT_Bitmap_Copy( + self.lib.lib.handle, + &scaled_bitmap, + &bitmap, + ) != 0) { + return error.BitmapHandlingError; + } + }, + + else => |f| { + // Glyph formats are tags, so we can + // output a semi-readable error here. + log.err( + "Can't render glyph with unsupported glyph format \"{s}\"", + .{[4]u8{ + @truncate(f >> 24), + @truncate(f >> 16), + @truncate(f >> 8), + @truncate(f >> 0), + }}, + ); + return error.UnsupportedGlyphFormat; + }, + } + + // If this is a color glyph but we're trying to render it to the + // grayscale atlas, or vice versa, then we throw and error. Maybe + // in the future we could convert, but for now it should be fine. + switch (bitmap.pixel_mode) { + freetype.c.FT_PIXEL_MODE_GRAY => if (atlas.format != .grayscale) { + return error.WrongAtlas; + }, + freetype.c.FT_PIXEL_MODE_BGRA => if (atlas.format != .bgra) { + return error.WrongAtlas; + }, else => { - log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode }); + log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap.pixel_mode }); @panic("unsupported pixel mode"); }, - }; - - // If our atlas format doesn't match, look for conversions if possible. - const bitmap_converted = if (format == null or atlas.format != format.?) blk: { - const func = convert.map[bitmap_ft.pixel_mode].get(atlas.format) orelse { - log.warn("glyph={} pixel mode={}", .{ glyph_index, bitmap_ft.pixel_mode }); - return error.UnsupportedPixelMode; - }; - - log.debug("converting from pixel_mode={} to atlas_format={}", .{ - bitmap_ft.pixel_mode, - atlas.format, - }); - break :blk try func(alloc, bitmap_ft); - } else null; - defer if (bitmap_converted) |bm| { - const len = @as(usize, @intCast(bm.pitch)) * @as(usize, @intCast(bm.rows)); - alloc.free(bm.buffer[0..len]); - }; - - // Now we need to see if we need to resize this bitmap. This can happen - // in scenarios where we have fixed size glyphs. For example, emoji - // can be quite large (i.e. 128x128) when we have a cell width of 24! - // The issue with large bitmaps is they take a huge amount of space in - // the atlas and force resizes quite frequently. We pay some CPU cost - // up front to resize the glyph to avoid significant CPU cost to resize - // and copy the atlas. - const bitmap_original = bitmap_converted orelse bitmap_ft; - const bitmap_resized: ?freetype.c.struct_FT_Bitmap_ = resized: { - const original_width = bitmap_original.width; - const original_height = bitmap_original.rows; - var result = bitmap_original; - // TODO: We are limiting this to only color glyphs, so mainly emoji. - // We can rework this after a future improvement (promised by Qwerasd) - // which implements more flexible resizing rules. - if (atlas.format != .grayscale and opts.cell_width != null) { - const cell_width = opts.cell_width orelse unreachable; - // If we have a cell_width, we constrain - // the glyph to fit within the cell(s). - result.width = metrics.cell_width * @as(u32, cell_width); - result.rows = (result.width * original_height) / original_width; - } else { - // If we don't have a cell_width, we scale to fill vertically - result.rows = metrics.cell_height; - result.width = (metrics.cell_height * original_width) / original_height; - } - - // If we already fit, we don't need to resize - if (original_height <= result.rows and original_width <= result.width) { - break :resized null; - } - - result.pitch = @as(c_int, @intCast(result.width)) * atlas.format.depth(); - - const buf = try alloc.alloc( - u8, - @as(usize, @intCast(result.pitch)) * @as(usize, @intCast(result.rows)), - ); - result.buffer = buf.ptr; - errdefer alloc.free(buf); - - if (stb.stbir_resize_uint8( - bitmap_original.buffer, - @intCast(original_width), - @intCast(original_height), - bitmap_original.pitch, - result.buffer, - @intCast(result.width), - @intCast(result.rows), - result.pitch, - atlas.format.depth(), - ) == 0) { - // This should never fail because this is a fairly straightforward - // in-memory operation... - return error.GlyphResizeFailed; - } - - break :resized result; - }; - defer if (bitmap_resized) |bm| { - const len = @as(usize, @intCast(bm.pitch)) * @as(usize, @intCast(bm.rows)); - alloc.free(bm.buffer[0..len]); - }; - - const bitmap = bitmap_resized orelse (bitmap_converted orelse bitmap_ft); - const tgt_w = bitmap.width; - const tgt_h = bitmap.rows; - - // Must have non-empty bitmap because we return earlier - // if zero. We assume the rest of this that it is nont-zero so - // this is important. - assert(tgt_w > 0 and tgt_h > 0); - - // If we resized our bitmap, we need to recalculate some metrics that - // we use such as the top/left offsets. These need to be scaled by the - // same ratio as the resize. - const glyph_metrics = if (bitmap_resized) |bm| metrics: { - // Our ratio for the resize - const ratio = ratio: { - const new: f64 = @floatFromInt(bm.rows); - const old: f64 = @floatFromInt(bitmap_original.rows); - break :ratio new / old; - }; - - var copy = glyph.*; - copy.bitmap_top = @as(c_int, @intFromFloat(@round(@as(f64, @floatFromInt(copy.bitmap_top)) * ratio))); - copy.bitmap_left = @as(c_int, @intFromFloat(@round(@as(f64, @floatFromInt(copy.bitmap_left)) * ratio))); - break :metrics copy; - } else glyph.*; - - // Allocate our texture atlas region - const region = region: { - // We need to add a 1px padding to the font so that we don't - // get fuzzy issues when blending textures. - const padding = 1; - - // Get the full padded region - var region = try atlas.reserve( - alloc, - tgt_w + (padding * 2), // * 2 because left+right - tgt_h + (padding * 2), // * 2 because top+bottom - ); - - // Modify the region so that we remove the padding so that - // we write to the non-zero location. The data in an Altlas - // is always initialized to zero (Atlas.clear) so we don't - // need to worry about zero-ing that. - region.x += padding; - region.y += padding; - region.width -= padding * 2; - region.height -= padding * 2; - break :region region; - }; - - // Copy the image into the region. - assert(region.width > 0 and region.height > 0); - { - const depth = atlas.format.depth(); - - // We can avoid a buffer copy if our atlas width and bitmap - // width match and the bitmap pitch is just the width (meaning - // the data is tightly packed). - const needs_copy = !(tgt_w == bitmap.width and (bitmap.width * depth) == bitmap.pitch); - - // If we need to copy the data, we copy it into a temporary buffer. - const buffer = if (needs_copy) buffer: { - const temp = try alloc.alloc(u8, tgt_w * tgt_h * depth); - var dst_ptr = temp; - var src_ptr = bitmap.buffer; - var i: usize = 0; - while (i < bitmap.rows) : (i += 1) { - fastmem.copy(u8, dst_ptr, src_ptr[0 .. bitmap.width * depth]); - dst_ptr = dst_ptr[tgt_w * depth ..]; - src_ptr += @as(usize, @intCast(bitmap.pitch)); - } - break :buffer temp; - } else bitmap.buffer[0..(tgt_w * tgt_h * depth)]; - defer if (buffer.ptr != bitmap.buffer) alloc.free(buffer); - - // Write the glyph information into the atlas - assert(region.width == tgt_w); - assert(region.height == tgt_h); - atlas.set(region, buffer); } - const offset_y: c_int = offset_y: { - // For non-scalable colorized fonts, we assume they are pictographic - // and just center the glyph. So far this has only applied to emoji - // fonts. Emoji fonts don't always report a correct ascender/descender - // (mainly Apple Emoji) so we just center them. Also, since emoji font - // aren't scalable, cell_baseline is incorrect anyways. - // - // NOTE(mitchellh): I don't know if this is right, this doesn't - // _feel_ right, but it makes all my limited test cases work. - if (self.face.hasColor() and !self.face.isScalable()) { - break :offset_y @intCast(tgt_h + (metrics.cell_height -| tgt_h) / 2); + const px_width = bitmap.width; + const px_height = bitmap.rows; + const len: usize = @intCast( + @as(c_uint, @intCast(@abs(bitmap.pitch))) * bitmap.rows, + ); + + // If our bitmap is grayscale, make sure to multiply all pixel + // values by the right factor to bring `num_grays` up to 256. + // + // This is necessary because FT_Bitmap_Convert doesn't do this, + // it just sets num_grays to the correct number and uses the + // original smaller pixel values. + if (bitmap.pixel_mode == freetype.c.FT_PIXEL_MODE_GRAY and + bitmap.num_grays < 256) + { + const factor: u8 = @intCast(255 / (bitmap.num_grays - 1)); + for (bitmap.buffer[0..len]) |*p| { + p.* *= factor; } + bitmap.num_grays = 256; + } - // The Y offset is the offset of the top of our bitmap PLUS our - // baseline calculation. The baseline calculation is so that everything - // is properly centered when we render it out into a monospace grid. - // Note: we add here because our X/Y is actually reversed, adding goes UP. - break :offset_y glyph_metrics.bitmap_top + @as(c_int, @intCast(metrics.cell_baseline)); - }; + // Must have non-empty bitmap because we return earlier if zero. + // We assume the rest of this that it is non-zero so this is important. + assert(px_width > 0 and px_height > 0); + // If this doesn't match then something is wrong. + assert(px_width * atlas.format.depth() == bitmap.pitch); + + // Allocate our texture atlas region and copy our bitmap in to it. + const region = try atlas.reserve(alloc, px_width, px_height); + atlas.set(region, bitmap.buffer[0..len]); + + // This should be the distance from the bottom of + // the cell to the top of the glyph's bounding box. + const offset_y: i32 = + @as(i32, @intFromFloat(@floor(y))) + + @as(i32, @intCast(px_height)); + + // This should be the distance from the left of + // the cell to the left of the glyph's bounding box. const offset_x: i32 = offset_x: { - var result: i32 = glyph_metrics.bitmap_left; + var result: i32 = @intFromFloat(@floor(x)); - // If our cell was resized to be wider then we center our - // glyph in the cell. + // If our cell was resized then we adjust our glyph's + // position relative to the new center. This keeps glyphs + // centered in the cell whether it was made wider or narrower. if (metrics.original_cell_width) |original_width| { - if (original_width < metrics.cell_width) { - const diff = (metrics.cell_width - original_width) / 2; - result += @intCast(diff); - } + const before: i32 = @intCast(original_width); + const after: i32 = @intCast(metrics.cell_width); + // Increase the offset by half of the difference + // between the widths to keep things centered. + result += @divTrunc(after - before, 2); } break :offset_x result; }; - // log.warn("renderGlyph width={} height={} offset_x={} offset_y={} glyph_metrics={}", .{ - // tgt_w, - // tgt_h, - // glyph_metrics.bitmap_left, - // offset_y, - // glyph_metrics, - // }); - - // Store glyph metadata return Glyph{ - .width = tgt_w, - .height = tgt_h, + .width = px_width, + .height = px_height, .offset_x = offset_x, .offset_y = offset_y, .atlas_x = region.x, .atlas_y = region.y, - .advance_x = f26dot6ToFloat(glyph_metrics.advance.x), + .advance_x = f26dot6ToFloat(glyph.*.advance.x), }; } @@ -631,7 +681,7 @@ pub const Face = struct { } fn f26dot6ToF64(v: freetype.c.FT_F26Dot6) f64 { - return @as(opentype.sfnt.F26Dot6, @bitCast(@as(u32, @intCast(v)))).to(f64); + return @as(F26Dot6, @bitCast(@as(i32, @intCast(v)))).to(f64); } pub const GetMetricsError = error{ @@ -950,13 +1000,15 @@ test "color emoji" { } // resize + // TODO: Comprehensive tests for constraints, + // this is just an adapted legacy test. { const glyph = try ft_font.renderGlyph( alloc, &atlas, ft_font.glyphIndex('🥸').?, .{ .grid_metrics = .{ - .cell_width = 10, + .cell_width = 13, .cell_height = 24, .cell_baseline = 0, .underline_position = 0, @@ -967,6 +1019,11 @@ test "color emoji" { .overline_thickness = 0, .box_thickness = 0, .cursor_height = 0, + }, .constraint_width = 2, .constraint = .{ + .size_horizontal = .cover, + .size_vertical = .cover, + .align_horizontal = .center, + .align_vertical = .center, } }, ); try testing.expectEqual(@as(u32, 24), glyph.height);