From e1629898f1d564fe8974bdbbf4fa654e3e4addcd Mon Sep 17 00:00:00 2001 From: Christian Rondeau Date: Thu, 9 Jul 2026 21:47:21 -0400 Subject: [PATCH] Faux italic when the font does not support it --- src/SixLabors.Fonts/FontGlyphMetrics.cs | 54 +++++++ .../Tables/Cff/CffGlyphMetrics.cs | 8 +- .../Tables/TrueType/TrueTypeGlyphMetrics.cs | 8 + .../FontSynthesisTests.cs | 137 ++++++++++++++++++ 4 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs diff --git a/src/SixLabors.Fonts/FontGlyphMetrics.cs b/src/SixLabors.Fonts/FontGlyphMetrics.cs index 96978973..74883360 100644 --- a/src/SixLabors.Fonts/FontGlyphMetrics.cs +++ b/src/SixLabors.Fonts/FontGlyphMetrics.cs @@ -16,6 +16,13 @@ public abstract class FontGlyphMetrics { private static readonly Vector2 YInverter = new(1, -1); + /// + /// The horizontal shear applied to synthesize an oblique (faux italic) slant when an italic + /// style is requested but the resolved font face provides no italic face. This equals the + /// tangent of a 14 degree slant, matching the default oblique angle used by web browsers. + /// + private const float SyntheticObliqueSkew = 0.24932839F; + internal FontGlyphMetrics( StreamFontMetrics font, ushort glyphId, @@ -240,6 +247,42 @@ internal void ApplyAdvance(short x, short y) /// The y-advance. internal void SetAdvanceHeight(ushort y) => this.AdvanceHeight = y; + /// + /// Gets the horizontal shear factor used to synthesize an oblique (faux italic) slant for + /// this glyph. A non-zero value is returned only when the associated text run requests an + /// italic style that the resolved font face does not itself provide, mirroring the CSS + /// font-synthesis: style behavior used by web browsers. + /// + /// + /// The shear factor to apply in the glyph's Y-up coordinate space, or 0 when no + /// synthesis is required. + /// + internal float GetObliqueSkew() + { + Font? font = this.TextRun?.Font; + if (font is null) + { + return 0F; + } + + bool requestedItalic = (font.RequestedStyle & FontStyle.Italic) == FontStyle.Italic; + bool resolvedItalic = (this.FontMetrics.Description.Style & FontStyle.Italic) == FontStyle.Italic; + return requestedItalic && !resolvedItalic ? SyntheticObliqueSkew : 0F; + } + + /// + /// Creates a horizontal shear matrix for the given oblique skew factor, expressed in the + /// glyph's Y-up coordinate space. + /// + /// The horizontal shear factor. + /// The shear . + internal static Matrix3x2 CreateObliqueMatrix(float skew) + { + Matrix3x2 matrix = Matrix3x2.Identity; + matrix.M21 = skew; + return matrix; + } + /// /// Calculates the glyph bounding box in device-space (Y-down) coordinates, /// given the layout mode, render origin, and scaled point size. @@ -279,6 +322,17 @@ internal FontRectangle GetBoundingBox(GlyphLayoutMode mode, Vector2 origin, floa // 2) Rotate for vertical rotated layout. Vector2 offsetUp = this.Offset; + + // Apply synthetic oblique (faux italic) shear before rotation so that the reported ink + // bounds match the sheared outline produced during rendering. + float skew = this.GetObliqueSkew(); + if (skew != 0F) + { + Matrix3x2 oblique = CreateObliqueMatrix(skew); + b = Bounds.Transform(in b, oblique); + offsetUp = Vector2.Transform(offsetUp, oblique); + } + if (mode == GlyphLayoutMode.VerticalRotated) { Matrix3x2 rot = Matrix3x2.CreateRotation(-MathF.PI / 2F); diff --git a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs index 2b7e6898..57e967f2 100644 --- a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs +++ b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs @@ -148,6 +148,12 @@ internal override void RenderTo( float scaledPPEM = this.GetScaledSize(pointSize, dpi); Matrix3x2 rotation = GetRotationMatrix(mode); + + // Synthesize an oblique (faux italic) slant when requested but unavailable by shearing + // the outline in the glyph's Y-up space before rotation is applied. + float skew = this.GetObliqueSkew(); + Matrix3x2 transform = skew != 0F ? CreateObliqueMatrix(skew) * rotation : rotation; + FontRectangle box = this.GetBoundingBox(mode, glyphOrigin, scaledPPEM); GlyphRendererParameters parameters = new(this, this.TextRun, pointSize, dpi, mode, graphemeIndex); @@ -167,7 +173,7 @@ internal override void RenderTo( } Vector2 scaledOffset = this.Offset * scale; - this.glyphData.RenderTo(renderer, glyphOrigin, scale, scaledOffset, rotation); + this.glyphData.RenderTo(renderer, glyphOrigin, scale, scaledOffset, transform); } renderer.EndGlyph(); diff --git a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs index 257779c3..27d08ef2 100644 --- a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs +++ b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs @@ -175,6 +175,14 @@ internal override void RenderTo( float pixelSize = scaledPPEM / 72F; this.FontMetrics.ApplyTrueTypeHinting(this.GetHintingMode(options.HintingMode), this, ref clone, scale, pixelSize); + // Synthesize an oblique (faux italic) slant when requested but unavailable. + // Applied after hinting so the hinter operates on the upright outline. + float skew = this.GetObliqueSkew(); + if (skew != 0F) + { + GlyphVector.TransformInPlace(ref clone, CreateObliqueMatrix(skew)); + } + // Rotation must happen after hinting. GlyphVector.TransformInPlace(ref clone, rotation); return clone; diff --git a/tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs b/tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs new file mode 100644 index 00000000..5bd6720f --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using System.Numerics; +using SixLabors.Fonts.Rendering; +using SixLabors.Fonts.Unicode; + +namespace SixLabors.Fonts.Tests; + +public class FontSynthesisTests +{ + // tan(14 degrees) - the default oblique slant browsers apply for synthesized italics. + private static readonly float ExpectedSkew = MathF.Tan(14F * MathF.PI / 180F); + + private static readonly ApproximateFloatComparer SkewComparer = new(0.001F); + + private static readonly ApproximateFloatComparer GeometryComparer = new(0.1F); + + private static FontFamily RegularOnlyFamily(string file) + => new FontCollection().Add(file); + + [Fact] + public void FamilyWithoutItalic_FallsBackToRegularMetrics() + { + FontFamily family = RegularOnlyFamily(TestFonts.OpenSansFile); + Font italic = new(family, 24, FontStyle.Italic); + + // Italic was requested but the resolved face is still the regular one. + Assert.Equal(FontStyle.Italic, italic.RequestedStyle); + Assert.False(italic.IsItalic); + } + + [Fact] + public void GetObliqueSkew_TrueType_NonZeroOnlyWhenItalicSynthesized() + => AssertObliqueSkew(TestFonts.OpenSansFile); + + [Fact] + public void GetObliqueSkew_Cff_NonZeroOnlyWhenItalicSynthesized() + => AssertObliqueSkew(TestFonts.PlantinStdRegularFile); + + [Fact] + public void SyntheticItalic_ShearsGlyphOutline_TrueType() + => AssertSyntheticItalicShear(TestFonts.OpenSansFile, "Hg"); + + [Fact] + public void SyntheticItalic_ShearsGlyphOutline_Cff() + => AssertSyntheticItalicShear(TestFonts.PlantinStdRegularFile, "Hg"); + + [Fact] + public void SyntheticItalic_DoesNotChangeAdvance_ButWidensBounds() + { + FontFamily family = RegularOnlyFamily(TestFonts.OpenSansFile); + Font regular = new(family, 48, FontStyle.Regular); + Font italic = new(family, 48, FontStyle.Italic); + + const string text = "Hg"; + + // The advance width is a layout metric and must be unchanged by synthesis so that + // spacing continues to match a browser rendering the same regular face. + FontRectangle regularAdvance = TextMeasurer.MeasureAdvance(text, new TextOptions(regular)); + FontRectangle italicAdvance = TextMeasurer.MeasureAdvance(text, new TextOptions(italic)); + Assert.Equal(regularAdvance.Width, italicAdvance.Width, SkewComparer); + + // The rendered ink bounds however widen because the outline is sheared. + FontRectangle regularBounds = TextMeasurer.MeasureBounds(text, new TextOptions(regular)); + FontRectangle italicBounds = TextMeasurer.MeasureBounds(text, new TextOptions(italic)); + Assert.True(italicBounds.Width > regularBounds.Width); + } + + [Fact] + public void GetObliqueSkew_ReturnsZero_WhenGlyphHasNoTextRun() + { + // The metrics returned directly from the font (i.e. not cloned for a specific run) + // carry no text run, so synthesis cannot be determined and must be disabled. + Font italic = new(RegularOnlyFamily(TestFonts.OpenSansFile), 24, FontStyle.Italic); + + Assert.True(italic.FontMetrics.TryGetGlyphMetrics( + new CodePoint('H'), + TextAttributes.None, + TextDecorations.None, + LayoutMode.HorizontalTopBottom, + ColorFontSupport.None, + out FontGlyphMetrics metrics)); + + Assert.Equal(0F, metrics.GetObliqueSkew()); + } + + private static void AssertObliqueSkew(string file) + { + FontFamily family = RegularOnlyFamily(file); + CodePoint codePoint = new('H'); + + Font regular = new(family, 24, FontStyle.Regular); + Font italic = new(family, 24, FontStyle.Italic); + + Assert.True(regular.TryGetGlyphs(codePoint, out Glyph? regularGlyph)); + Assert.True(italic.TryGetGlyphs(codePoint, out Glyph? italicGlyph)); + + Assert.Equal(0F, regularGlyph.Value.GlyphMetrics.GetObliqueSkew()); + Assert.Equal(ExpectedSkew, italicGlyph.Value.GlyphMetrics.GetObliqueSkew(), SkewComparer); + } + + private static void AssertSyntheticItalicShear(string file, string text) + { + FontFamily family = RegularOnlyFamily(file); + Font regular = new(family, 48, FontStyle.Regular); + Font italic = new(family, 48, FontStyle.Italic); + + GlyphRenderer regularRenderer = new(); + TextRenderer.RenderTextTo(regularRenderer, text, new TextOptions(regular) { HintingMode = HintingMode.None }); + + GlyphRenderer italicRenderer = new(); + TextRenderer.RenderTextTo(italicRenderer, text, new TextOptions(italic) { HintingMode = HintingMode.None }); + + List r = regularRenderer.ControlPoints; + List i = italicRenderer.ControlPoints; + + Assert.NotEmpty(r); + Assert.Equal(r.Count, i.Count); + + // The synthesized slant is a pure horizontal shear applied in the glyph's Y-up space. + // In device space (Y-down) this means the Y coordinate of every point is preserved and + // the horizontal displacement satisfies xi - xr = skew * (originY - yr), so the quantity + // (xi - xr) + skew * yr is constant across every emitted control point. + float invariant = (i[0].X - r[0].X) + (ExpectedSkew * r[0].Y); + float maxHorizontalShift = 0F; + for (int p = 0; p < r.Count; p++) + { + Assert.Equal(r[p].Y, i[p].Y, GeometryComparer); + Assert.Equal(invariant, (i[p].X - r[p].X) + (ExpectedSkew * r[p].Y), GeometryComparer); + maxHorizontalShift = MathF.Max(maxHorizontalShift, MathF.Abs(i[p].X - r[p].X)); + } + + // Ensure the glyph was actually slanted and not left upright. + Assert.True(maxHorizontalShift > 1F); + } +}