From 8eff74ef7680f40bbcc03d634ad1ad11417c29f3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 1 Jun 2026 19:44:10 -0700 Subject: [PATCH] font: add glyf rasterizer --- src/font/glyf_rasterize.zig | 832 ++++++++++++++++++++++++++++++++++++ src/font/main.zig | 1 + src/font/opentype/glyf.zig | 6 +- 3 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 src/font/glyf_rasterize.zig diff --git a/src/font/glyf_rasterize.zig b/src/font/glyf_rasterize.zig new file mode 100644 index 000000000..f1b1eb3e7 --- /dev/null +++ b/src/font/glyf_rasterize.zig @@ -0,0 +1,832 @@ +//! Rasterization for OpenType glyf outlines. +//! +//! This module intentionally lives in `font` rather than `font/opentype` +//! because I wanted to keep `font/opentype` dependency free on the font +//! package. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const z2d = @import("z2d"); + +const face = @import("face.zig"); +const glyf = @import("opentype/glyf.zig"); + +/// Metrics describing the authored glyf coordinate space, since +/// a glyf table doesn't contain this on its own. +pub const DesignMetrics = struct { + /// Units-per-em for outline/design coordinates. + units_per_em: u32, + + /// Authored advance width in design units. + advance_width: u32, + + /// Authored line height in design units. + line_height: u32, +}; + +/// An owned, tightly packed alpha8 bitmap. +pub const Bitmap = struct { + width: u32, + height: u32, + data: []u8, + + // An empty 0x0 bitmap. + pub const empty: Bitmap = .{ .width = 0, .height = 0, .data = "" }; + + pub fn initEmpty(alloc: Allocator, width: u32, height: u32) Allocator.Error!Bitmap { + const data = try alloc.alloc(u8, @as(usize, width) * @as(usize, height)); + @memset(data, 0); + return .{ .width = width, .height = height, .data = data }; + } + + pub fn deinit(self: *Bitmap, alloc: Allocator) void { + alloc.free(self.data); + self.* = undefined; + } +}; + +pub const Error = Allocator.Error || z2d.Path.Error || z2d.painter.FillError; + +/// Rasterize a decoded glyf outline to a full-cell alpha bitmap. +/// +/// The returned bitmap is always `grid_metrics.cell_width * cell_width` by +/// `grid_metrics.cell_height`. `opts.constraint` is applied using the same +/// `face.RenderOptions.Constraint` machinery used by the platform font +/// backends. +/// +/// The caller owns the returned bitmap. +pub fn rasterize( + alloc: Allocator, + outline: glyf.Glyf.Outline, + design: DesignMetrics, + opts: face.RenderOptions, +) Error!Bitmap { + assert(design.units_per_em > 0); + assert(design.advance_width > 0); + assert(design.line_height > 0); + + // Calculate our final width/height. + const width: u32 = std.math.mul( + u32, + opts.grid_metrics.cell_width, + opts.cell_width orelse 1, + ) catch std.math.maxInt(u32); + const height = opts.grid_metrics.cell_height; + assert(width > 0 and height > 0); + + // If we have no contours or points then we have no drawable shape, but the + // caller still asked for a cell-sized bitmap. Return that full bitmap with + // zero coverage so downstream atlas/upload code doesn't need a separate + // size contract for empty glyphs. + if (outline.contours.len == 0 or outline.points.len == 0) return Bitmap.initEmpty(alloc, width, height); + + // Glyf entries have a header bounding box, but this rasterizer operates on + // the decoded Outline only. Recompute bounds from the decoded coordinate + // data so placement and scaling follow the geometry we actually draw. If a + // source glyf header disagrees with its points, the point data is the safer + // source of truth for rasterization; the header belongs in decode-time + // validation/metadata, not this font-level drawing API. + const bounds: Bounds = bounds: { + var bounds: Bounds = .{ + .x_min = @floatFromInt(outline.points[0].x), + .y_min = @floatFromInt(outline.points[0].y), + .x_max = @floatFromInt(outline.points[0].x), + .y_max = @floatFromInt(outline.points[0].y), + }; + for (outline.points[1..]) |p| { + const x: f64 = @floatFromInt(p.x); + const y: f64 = @floatFromInt(p.y); + bounds.x_min = @min(bounds.x_min, x); + bounds.y_min = @min(bounds.y_min, y); + bounds.x_max = @max(bounds.x_max, x); + bounds.y_max = @max(bounds.y_max, y); + } + break :bounds bounds; + }; + + // Degenerate point bounds can't produce filled area and would make the + // point-to-bitmap transform divide by zero, so return a full transparent + // bitmap just like an empty outline. + if (bounds.width() == 0 or bounds.height() == 0) return Bitmap.initEmpty(alloc, width, height); + + // Build the surface we'll draw on. This is a simple alpha8 drawing. + var sfc: z2d.Surface = try .init( + .image_surface_alpha8, + alloc, + @intCast(width), + @intCast(height), + ); + defer sfc.deinit(alloc); + + var path: z2d.Path = .empty; + defer path.deinit(alloc); + + const placement: Placement = .init(bounds, design, opts); + for (0..outline.contours.len) |i| try appendContourPath( + alloc, + &path, + outline.contour(i), + bounds, + placement, + ); + + try z2d.painter.fill( + alloc, + &sfc, + &.{ .opaque_pattern = .{ + .pixel = .{ .alpha8 = .{ .a = 255 } }, + } }, + path.nodes.items, + .{}, + ); + + return .{ + .width = width, + .height = height, + .data = try alloc.dupe(u8, std.mem.sliceAsBytes(sfc.image_surface_alpha8.buf)), + }; +} + +const Bounds = struct { + x_min: f64, + y_min: f64, + x_max: f64, + y_max: f64, + + fn width(self: Bounds) f64 { + return self.x_max - self.x_min; + } + + fn height(self: Bounds) f64 { + return self.y_max - self.y_min; + } +}; + +/// Cell-relative pixel rectangle where the decoded outline bounds should be +/// rasterized within the output bitmap. +/// +/// This is deliberately the placement of the outline's computed point bounds, +/// not the full declared advance/line-height box. `advance_width` and +/// `line_height` describe the design-space layout box the outline was drawn +/// within: they include intentional bearings and whitespace around the visible +/// points. We use that declared box when applying `RenderOptions.Constraint` so +/// sizing and alignment preserve those bearings consistently with other font +/// backends; once that is resolved, we rasterize only the actual outline bounds +/// into this rectangle. +/// +/// ```text +/// output bitmap / terminal cell +/// ╭────────────────────────────────────────────────────────────────────────╮ top +/// │ │ +/// │ declared advance/line-height box │ +/// │ (outer layout box used for constraints) │ +/// │ ╭────────────────────────────────────────────────────────────────╮ │ +/// │ │ │ │ +/// │◀──────── x ────────▶╭────────── width ──────────╮ │ │ +/// │ │ │ Placement │ ▲ height │ │ +/// │ │ │ outline point bounds │ │ │ │ +/// │ │ │ pixels to draw │ │ │ │ +/// │ │ │ │ │ │ │ +/// │ │ ╰───────────────────────────╯ ▼ │ │ +/// │ ╰─────────────────▲──────────────────────────────────────────────╯ │ +/// │ │ y │ +/// ╰────────────────────────────────────────────────────────────────────────╯ bottom +/// x is measured from the bitmap left to the Placement left. +/// y is measured from the bitmap bottom to the Placement bottom. +/// bitmap_height is the full top-to-bottom bitmap height. +/// ``` +/// +/// Constraints are applied to the outer box so the whitespace remains part of +/// alignment decisions. `Placement` is the inner rectangle after that outer box +/// has been constrained. +const Placement = struct { + /// Left edge of the rasterized outline bounds in bitmap pixels, measured + /// from the bitmap's left edge. + x: f64, + + /// Bottom edge of the rasterized outline bounds in bitmap pixels, measured + /// from the bitmap's bottom edge. This matches the cell-relative y axis + /// used by font.face.GlyphSize and is converted to z2d's y-down axis when + /// points are transformed. + y: f64, + + /// Width of the rasterized outline bounds in bitmap pixels after applying + /// font.face.RenderOptions.Constraint. + width: f64, + + /// Height of the rasterized outline bounds in bitmap pixels after applying + /// font.face.RenderOptions.Constraint. + height: f64, + + /// Full bitmap height in pixels, used to convert cell-relative y-up-ish + /// placement into the y-down coordinate system used by z2d surfaces. + bitmap_height: f64, + + /// Calculate where the decoded point bounds should land in the output + /// bitmap. + /// + /// The glyf protocol supplies declared metrics (`units_per_em`, + /// `advance_width`, and `line_height`) in design units, while Ghostty's + /// font constraint code works in cell-relative pixels. We first map the em + /// square to one cell height, matching the linked glyph rasterizer's + /// baseline model where design-space `y=0` is the bottom/baseline of the em + /// and `y=units_per_em` is its top. Then we describe the actual outline + /// bounds as a relative sub-rectangle of the declared advance/line-height + /// box. That declared box includes any intentional side bearings or + /// vertical whitespace around the outline; constraints should apply to that + /// layout box rather than to the tight point bounds alone. This returns the + /// final pixel rectangle for only the outline bounds that we will rasterize. + fn init( + bounds: Bounds, + design: DesignMetrics, + opts: face.RenderOptions, + ) Placement { + // Start with protocol-like design units mapped so that the em square + // occupies one cell. This makes units_per_em the scale reference and + // preserves the linked rasterizer's y=0 baseline/bottom behavior. + // Callers can then use RenderOptions.Constraint to fit/cover/stretch/ + // align the declared advance/line-height box using existing font logic. + const scale = @as(f64, @floatFromInt(opts.grid_metrics.cell_height)) / + @as(f64, @floatFromInt(design.units_per_em)); + + // Convert the decoded point bounds into the same pixel coordinate space + // expected by RenderOptions.Constraint. This rectangle is the visible + // outline bounds, not the full advance/line-height layout box. + const glyph: face.GlyphSize = .{ + .width = bounds.width() * scale, + .height = bounds.height() * scale, + .x = bounds.x_min * scale, + .y = bounds.y_min * scale, + }; + + // Convert the declared layout box to pixels. This is the box that + // carries intentional bearings/whitespace and should be constrained. + const group_width = @as(f64, @floatFromInt(design.advance_width)) * scale; + const group_height = @as(f64, @floatFromInt(design.line_height)) * scale; + + // Apply the same fit/cover/stretch/alignment/padding rules used by + // normal font rendering. The result is still the outline bounds, but + // placed as if its containing advance/line-height box was constrained. + const constraint: face.RenderOptions.Constraint = constraint: { + var constraint = opts.constraint; + if (group_width > 0 and group_height > 0) { + // Tell Constraint that `glyph` is a sub-rectangle of the + // declared layout box. Constraint will size/align the outer box + // and then return the corresponding transformed inner box. + constraint.relative_width = glyph.width / group_width; + constraint.relative_height = glyph.height / group_height; + constraint.relative_x = glyph.x / group_width; + constraint.relative_y = glyph.y / group_height; + } + break :constraint constraint; + }; + const constrained = constraint.constrain( + glyph, + opts.grid_metrics, + opts.constraint_width, + ); + + // Store the final outline placement plus the full bitmap height needed + // later to flip from cell-relative y to z2d's y-down surface space. + return .{ + .x = constrained.x, + .y = constrained.y, + .width = constrained.width, + .height = constrained.height, + .bitmap_height = @floatFromInt(opts.grid_metrics.cell_height), + }; + } +}; + +const Point = struct { + x: f64, + y: f64, +}; + +/// Append one contour to a z2d path. +/// +/// Glyf contours are quadratic outlines with explicit on-curve points and +/// off-curve control points. Consecutive off-curve points imply an on-curve +/// point halfway between them, and a contour may begin with an off-curve point. +/// This normalizes those cases while walking the closed contour and emits z2d +/// line/cubic-curve operations in bitmap coordinates. +fn appendContourPath( + alloc: Allocator, + path: *z2d.Path, + contour: []const glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Error!void { + if (contour.len == 0) return; + + const first = contour[0]; + const last = contour[contour.len - 1]; + + var current: Point = undefined; + var i: usize = 0; + + // Choose the starting on-curve point for this closed contour. If the first + // point is off-curve then the contour logically starts either at the final + // on-curve point, or at the implied midpoint between the final and first + // off-curve points. + if (first.on_curve) { + i = 1; + current = transformPoint( + first, + bounds, + placement, + ); + } else if (last.on_curve) { + current = transformPoint( + last, + bounds, + placement, + ); + } else { + current = midpoint( + transformPoint(last, bounds, placement), + transformPoint(first, bounds, placement), + ); + } + + // Move to the beginning + try path.moveTo(alloc, current.x, current.y); + + // Go through the points and connect em! + while (i < contour.len) { + const p = contour[i]; + + // On-curve points connect to the current point with a straight line. + if (p.on_curve) { + current = transformPoint(p, bounds, placement); + try path.lineTo(alloc, current.x, current.y); + i += 1; + continue; + } + + // Off-curve points are quadratic control points. The following point is + // either the curve endpoint or, if it is also off-curve, contributes an + // implied on-curve endpoint halfway between the two controls. + const control = transformPoint(p, bounds, placement); + const next = contour[(i + 1) % contour.len]; + const end = if (next.on_curve) transformPoint( + next, + bounds, + placement, + ) else midpoint( + control, + transformPoint(next, bounds, placement), + ); + + // z2d paths only expose cubic curves, so convert the TrueType + // quadratic segment to an equivalent cubic segment before appending it. + const c1 = Point{ + .x = current.x + ((2.0 / 3.0) * (control.x - current.x)), + .y = current.y + ((2.0 / 3.0) * (control.y - current.y)), + }; + const c2 = Point{ + .x = end.x + ((2.0 / 3.0) * (control.x - end.x)), + .y = end.y + ((2.0 / 3.0) * (control.y - end.y)), + }; + try path.curveTo( + alloc, + c1.x, + c1.y, + c2.x, + c2.y, + end.x, + end.y, + ); + + current = end; + + // If we consumed an explicit on-curve endpoint then skip it; otherwise + // the next off-curve point still needs to be used as the control point + // for the following quadratic segment. + i += if (next.on_curve) 2 else 1; + } + + try path.close(alloc); +} + +/// Convert a decoded glyf point from design-space coordinates to z2d bitmap +/// coordinates. +/// +/// `bounds` describes the decoded outline's point/control bounds in glyf +/// design units. `placement` describes where those bounds should land in the +/// output bitmap after constraints are applied. Glyf coordinates are y-up; z2d +/// surfaces are y-down, so this also flips the y axis using +/// `placement.bitmap_height`. +fn transformPoint( + p: glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Point { + const scale_x = placement.width / bounds.width(); + const scale_y = placement.height / bounds.height(); + const x_design: f64 = @floatFromInt(p.x); + const y_design: f64 = @floatFromInt(p.y); + return .{ + .x = placement.x + ((x_design - bounds.x_min) * scale_x), + .y = placement.bitmap_height - placement.y - + ((y_design - bounds.y_min) * scale_y), + }; +} + +/// Return the implied on-curve point between two off-curve TrueType control +/// points. +fn midpoint(a: Point, b: Point) Point { + return .{ + .x = (a.x + b.x) / 2.0, + .y = (a.y + b.y) / 2.0, + }; +} + +fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { + return .{ + .cell_width = width, + .cell_height = height, + .cell_baseline = 0, + .underline_position = height, + .underline_thickness = 1, + .strikethrough_position = height / 2, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = height, + .icon_height = @floatFromInt(height), + .icon_height_single = @floatFromInt(height), + .face_width = @floatFromInt(width), + .face_height = @floatFromInt(height), + .face_y = 0, + }; +} + +test "glyf_rasterize: empty outline returns empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + var bm = try rasterize(alloc, .{ .points = &.{}, .contours = &.{} }, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: square fills bitmap center" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); +} + +test "glyf_rasterize: quadratic contour renders" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: consecutive off-curve points render" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: units per em controls baseline scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 2000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + // With a 2000-unit em in a 20px cell, this 1000-unit square occupies the + // bottom half of the cell. This matches the linked rasterizer's y=0 + // baseline/bottom behavior and proves units_per_em is the scale reference. + try testing.expect(bm.data[15 * bm.width + 5] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[5 * bm.width + 5]); +} + +test "glyf_rasterize: degenerate outline returns full empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: contour can start off curve with final on curve point" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: contour can start with implied midpoint" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: multiple contours render independently" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 400, .on_curve = true }, + .{ .x = 0, .y = 400, .on_curve = true }, + .{ .x = 600, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 600, .y = 1000, .on_curve = true }, + }, + .contours = &.{ 3, 7 }, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[16 * bm.width + 4] > 200); + try testing.expect(bm.data[4 * bm.width + 16] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 10]); +} + +test "glyf_rasterize: non-zero bearings preserve declared whitespace" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 17]); +} + +test "glyf_rasterize: negative y coordinates descend below baseline" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = 750, .on_curve = true }, + .{ .x = 0, .y = 750, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[2 * bm.width + 10]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[18 * bm.width + 10] > 200); +} + +test "glyf_rasterize: two-cell bitmap and constraint render within width" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + }, + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 40), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 20] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 37]); +} + +test "glyf_rasterize: line height does not change unconstrained em scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 2000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[2 * bm.width + 10] > 200); + try testing.expect(bm.data[17 * bm.width + 10] > 200); +} diff --git a/src/font/main.zig b/src/font/main.zig index a8522afe1..0c218bbda 100644 --- a/src/font/main.zig +++ b/src/font/main.zig @@ -15,6 +15,7 @@ pub const Collection = @import("Collection.zig"); pub const DeferredFace = @import("DeferredFace.zig"); pub const Face = face.Face; pub const Glyph = @import("Glyph.zig"); +pub const glyf_rasterize = @import("glyf_rasterize.zig"); pub const Metrics = @import("Metrics.zig"); pub const opentype = @import("opentype.zig"); pub const shape = @import("shape.zig"); diff --git a/src/font/opentype/glyf.zig b/src/font/opentype/glyf.zig index fb9621221..6f94264c8 100644 --- a/src/font/opentype/glyf.zig +++ b/src/font/opentype/glyf.zig @@ -25,10 +25,10 @@ pub const Glyf = struct { pub const Outline = struct { /// List of contour end points. Calculate the full list of /// points using points[prev...this+1] - contours: []sfnt.uint16, + contours: []const sfnt.uint16, /// The backing storage of all points in the entry. - points: []Point, + points: []const Point, /// A single decoded point in a simple glyph contour. pub const Point = struct { @@ -41,7 +41,7 @@ pub const Glyf = struct { /// /// The returned slice references `points` and is invalidated when /// this outline is deinitialized. - pub fn contour(self: Outline, index: usize) []Point { + pub fn contour(self: Outline, index: usize) []const Point { const start = if (index == 0) 0 else