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);
+ }
+}