Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/SixLabors.Fonts/FontGlyphMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ public abstract class FontGlyphMetrics
{
private static readonly Vector2 YInverter = new(1, -1);

/// <summary>
/// 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.
/// </summary>
private const float SyntheticObliqueSkew = 0.24932839F;

internal FontGlyphMetrics(
StreamFontMetrics font,
ushort glyphId,
Expand Down Expand Up @@ -240,6 +247,42 @@ internal void ApplyAdvance(short x, short y)
/// <param name="y">The y-advance.</param>
internal void SetAdvanceHeight(ushort y) => this.AdvanceHeight = y;

/// <summary>
/// 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
/// <c>font-synthesis: style</c> behavior used by web browsers.
/// </summary>
/// <returns>
/// The shear factor to apply in the glyph's Y-up coordinate space, or <c>0</c> when no
/// synthesis is required.
/// </returns>
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;
}

/// <summary>
/// Creates a horizontal shear matrix for the given oblique skew factor, expressed in the
/// glyph's Y-up coordinate space.
/// </summary>
/// <param name="skew">The horizontal shear factor.</param>
/// <returns>The shear <see cref="Matrix3x2"/>.</returns>
internal static Matrix3x2 CreateObliqueMatrix(float skew)
{
Matrix3x2 matrix = Matrix3x2.Identity;
matrix.M21 = skew;
return matrix;
}

/// <summary>
/// Calculates the glyph bounding box in device-space (Y-down) coordinates,
/// given the layout mode, render origin, and scaled point size.
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 7 additions & 1 deletion src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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();
Expand Down
8 changes: 8 additions & 0 deletions src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
137 changes: 137 additions & 0 deletions tests/SixLabors.Fonts.Tests/FontSynthesisTests.cs
Original file line number Diff line number Diff line change
@@ -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<Vector2> r = regularRenderer.ControlPoints;
List<Vector2> 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);
}
}
Loading