Skip to content

Commit

Permalink
Handle fonts with missing or invalid OS/2 tables (#2985)
Browse files Browse the repository at this point in the history
It turns out this happens more than I thought. Most notably, apple's
fallback font for a bunch of symbols, `Apple Gothic`, has no OS/2 table
🙃. I've gone ahead and fixed it so that both CoreText and FreeType
handle this gracefully.

Speaking of graceful handling, this problem came to attention due to our
very *un*-graceful handling of when a font fails to load due to a
metrics calculation error. It results in a bunch of blanked out text,
very bad, we should fix that.

*At some point I'll try to collect a group of test fonts with all sorts
of weirdnesses to exercise the weird edge cases in our handling.*
  • Loading branch information
mitchellh authored Dec 16, 2024
2 parents bd48dcc + 2b78ac4 commit 56f285c
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 145 deletions.
4 changes: 4 additions & 0 deletions pkg/macos/text/font.zig
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ pub const FontOrientation = enum(c_uint) {

pub const FontTableTag = enum(u32) {
svg = c.kCTFontTableSVG,
os2 = c.kCTFontTableOS2,
head = c.kCTFontTableHead,
hhea = c.kCTFontTableHhea,
post = c.kCTFontTablePost,
_,

pub fn init(v: *const [4]u8) FontTableTag {
Expand Down
133 changes: 74 additions & 59 deletions src/font/face/coretext.zig
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,6 @@ pub const Face = struct {
CopyTableError,
InvalidHeadTable,
InvalidPostTable,
InvalidOS2Table,
OS2VersionNotSupported,
InvalidHheaTable,
};

Expand Down Expand Up @@ -569,18 +567,16 @@ pub const Face = struct {
};
};

// Read the 'OS/2' table out of the font data.
const os2: opentype.OS2 = os2: {
// Read the 'OS/2' table out of the font data if it's available.
const os2_: ?opentype.OS2 = os2: {
const tag = macos.text.FontTableTag.init("OS/2");
const data = ct_font.copyTable(tag) orelse return error.CopyTableError;
const data = ct_font.copyTable(tag) orelse break :os2 null;
defer data.release();
const ptr = data.getPointer();
const len = data.getLength();
break :os2 opentype.OS2.init(ptr[0..len]) catch |err| {
return switch (err) {
error.EndOfStream => error.InvalidOS2Table,
error.OS2VersionNotSupported => error.OS2VersionNotSupported,
};
log.warn("error parsing OS/2 table: {}", .{err});
break :os2 null;
};
};

Expand All @@ -603,54 +599,59 @@ pub const Face = struct {
const px_per_unit: f64 = px_per_em / units_per_em;

const ascent: f64, const descent: f64, const line_gap: f64 = vertical_metrics: {
const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender);
const os2_descent: f64 = @floatFromInt(os2.sTypoDescender);
const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap);
const hhea_ascent: f64 = @floatFromInt(hhea.ascender);
const hhea_descent: f64 = @floatFromInt(hhea.descender);
const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap);

// If the font says to use typo metrics, trust it.
if (os2.fsSelection.use_typo_metrics) {
break :vertical_metrics .{
if (os2_) |os2| {
const os2_ascent: f64 = @floatFromInt(os2.sTypoAscender);
const os2_descent: f64 = @floatFromInt(os2.sTypoDescender);
const os2_line_gap: f64 = @floatFromInt(os2.sTypoLineGap);

// If the font says to use typo metrics, trust it.
if (os2.fsSelection.use_typo_metrics) break :vertical_metrics .{
os2_ascent * px_per_unit,
os2_descent * px_per_unit,
os2_line_gap * px_per_unit,
};
}

// Otherwise we prefer the height metrics from 'hhea' if they
// are available, or else OS/2 sTypo* metrics, and if all else
// fails then we use OS/2 usWin* metrics.
//
// This is not "standard" behavior, but it's our best bet to
// account for fonts being... just weird. It's pretty much what
// FreeType does to get its generic ascent and descent metrics.

if (hhea.ascender != 0 or hhea.descender != 0) {
const hhea_ascent: f64 = @floatFromInt(hhea.ascender);
const hhea_descent: f64 = @floatFromInt(hhea.descender);
const hhea_line_gap: f64 = @floatFromInt(hhea.lineGap);
break :vertical_metrics .{
// Otherwise we prefer the height metrics from 'hhea' if they
// are available, or else OS/2 sTypo* metrics, and if all else
// fails then we use OS/2 usWin* metrics.
//
// This is not "standard" behavior, but it's our best bet to
// account for fonts being... just weird. It's pretty much what
// FreeType does to get its generic ascent and descent metrics.

if (hhea.ascender != 0 or hhea.descender != 0) break :vertical_metrics .{
hhea_ascent * px_per_unit,
hhea_descent * px_per_unit,
hhea_line_gap * px_per_unit,
};
}

if (os2_ascent != 0 or os2_descent != 0) {
break :vertical_metrics .{
if (os2_ascent != 0 or os2_descent != 0) break :vertical_metrics .{
os2_ascent * px_per_unit,
os2_descent * px_per_unit,
os2_line_gap * px_per_unit,
};

const win_ascent: f64 = @floatFromInt(os2.usWinAscent);
const win_descent: f64 = @floatFromInt(os2.usWinDescent);
break :vertical_metrics .{
win_ascent * px_per_unit,
// usWinDescent is *positive* -> down unlike sTypoDescender
// and hhea.Descender, so we flip its sign to fix this.
-win_descent * px_per_unit,
0.0,
};
}

const win_ascent: f64 = @floatFromInt(os2.usWinAscent);
const win_descent: f64 = @floatFromInt(os2.usWinDescent);
// If our font has no OS/2 table, then we just
// blindly use the metrics from the hhea table.
break :vertical_metrics .{
win_ascent * px_per_unit,
// usWinDescent is *positive* -> down unlike sTypoDescender
// and hhea.Descender, so we flip its sign to fix this.
-win_descent * px_per_unit,
0.0,
hhea_ascent * px_per_unit,
hhea_descent * px_per_unit,
hhea_line_gap * px_per_unit,
};
};

Expand All @@ -672,30 +673,44 @@ pub const Face = struct {
@as(f64, @floatFromInt(post.underlineThickness)) * px_per_unit;

// Similar logic to the underline above.
const has_broken_strikethrough = os2.yStrikeoutSize == 0;
const strikethrough_position, const strikethrough_thickness = st: {
const os2 = os2_ orelse break :st .{ null, null };

const strikethrough_position: ?f64 = if (has_broken_strikethrough and os2.yStrikeoutPosition == 0)
null
else
@as(f64, @floatFromInt(os2.yStrikeoutPosition)) * px_per_unit;
const has_broken_strikethrough = os2.yStrikeoutSize == 0;

const strikethrough_thickness: ?f64 = if (has_broken_strikethrough)
null
else
@as(f64, @floatFromInt(os2.yStrikeoutSize)) * px_per_unit;
const pos: ?f64 = if (has_broken_strikethrough and os2.yStrikeoutPosition == 0)
null
else
@as(f64, @floatFromInt(os2.yStrikeoutPosition)) * px_per_unit;

// We fall back to whatever CoreText does if
// the OS/2 table doesn't specify a cap height.
const cap_height: f64 = if (os2.sCapHeight) |sCapHeight|
@as(f64, @floatFromInt(sCapHeight)) * px_per_unit
else
ct_font.getCapHeight();
const thick: ?f64 = if (has_broken_strikethrough)
null
else
@as(f64, @floatFromInt(os2.yStrikeoutSize)) * px_per_unit;

// Ditto for ex height.
const ex_height: f64 = if (os2.sxHeight) |sxHeight|
@as(f64, @floatFromInt(sxHeight)) * px_per_unit
else
ct_font.getXHeight();
break :st .{ pos, thick };
};

// We fall back to whatever CoreText does if the
// OS/2 table doesn't specify a cap or ex height.
const cap_height: f64, const ex_height: f64 = heights: {
const os2 = os2_ orelse break :heights .{
ct_font.getCapHeight(),
ct_font.getXHeight(),
};

break :heights .{
if (os2.sCapHeight) |sCapHeight|
@as(f64, @floatFromInt(sCapHeight)) * px_per_unit
else
ct_font.getCapHeight(),

if (os2.sxHeight) |sxHeight|
@as(f64, @floatFromInt(sxHeight)) * px_per_unit
else
ct_font.getXHeight(),
};
};

// Cell width is calculated by calculating the widest width of the
// visible ASCII characters. Usually 'M' is widest but we just take
Expand Down
Loading

0 comments on commit 56f285c

Please sign in to comment.