Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Align TextBuilder characters by their baselines during addition/removal #4774

Merged
merged 8 commits into from
Sep 16, 2021
55 changes: 55 additions & 0 deletions osu.Framework.Benchmarks/BenchmarkTextBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Text;

namespace osu.Framework.Benchmarks
{
public class BenchmarkTextBuilder
{
private readonly ITexturedGlyphLookupStore store = new TestStore();

private TextBuilder textBuilder;

[Benchmark]
public void AddCharacters() => initialiseBuilder(false);

[Benchmark]
public void AddCharactersWithDifferentBaselines() => initialiseBuilder(true);

[Benchmark]
public void RemoveLastCharacter()
{
initialiseBuilder(false);
textBuilder.RemoveLastCharacter();
}

[Benchmark]
public void RemoveLastCharacterWithDifferentBaselines()
{
initialiseBuilder(true);
textBuilder.RemoveLastCharacter();
}

private void initialiseBuilder(bool withDifferentBaselines)
{
textBuilder = new TextBuilder(store, FontUsage.Default);

char different = 'B';

for (int i = 0; i < 100; i++)
textBuilder.AddCharacter(withDifferentBaselines && (i % 10 == 0) ? different++ : 'A');
}

private class TestStore : ITexturedGlyphLookupStore
{
public ITexturedCharacterGlyph Get(string fontName, char character) => new TexturedCharacterGlyph(new CharacterGlyph(character, character, character, character, character, null), Texture.WhitePixel);

public Task<ITexturedCharacterGlyph> GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character));
}
}
}
254 changes: 212 additions & 42 deletions osu.Framework.Tests/Text/TextBuilderTest.cs

Large diffs are not rendered by default.

13 changes: 2 additions & 11 deletions osu.Framework/Graphics/Sprites/SpriteText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -627,21 +627,12 @@ private TextBuilder getTextBuilder()

public override string ToString() => $@"""{displayedText}"" " + base.ToString();

/// <summary>
/// Gets the base height of the font used by this text. If the font of this text is invalid, 0 is returned.
/// </summary>
public float LineBaseHeight
{
get
{
var baseHeight = store.GetBaseHeight(Font.FontName);
if (baseHeight.HasValue)
return baseHeight.Value * Font.Size;

if (string.IsNullOrEmpty(displayedText))
return 0;

return store.GetBaseHeight(displayedText[0]).GetValueOrDefault() * Font.Size;
computeCharacters();
return textBuilderCache.Value.LineBaseHeight;
}
}

Expand Down
39 changes: 0 additions & 39 deletions osu.Framework/IO/Stores/FontStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,45 +174,6 @@ public ITexturedCharacterGlyph Get(string fontName, char character)

public Task<ITexturedCharacterGlyph> GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character));

public float? GetBaseHeight(char c)
{
foreach (var store in glyphStores)
{
if (store.HasGlyph(c))
return store.GetBaseHeight() / ScaleAdjust;
}

foreach (var store in nestedFontStores)
{
var height = store.GetBaseHeight(c);
if (height.HasValue)
return height;
}

return null;
}

public float? GetBaseHeight(string fontName)
{
foreach (var store in glyphStores)
{
if (store.FontName != fontName)
continue;

var bh = store.GetBaseHeight();
return bh / ScaleAdjust;
}

foreach (var store in nestedFontStores)
{
var height = store.GetBaseHeight(fontName);
if (height.HasValue)
return height;
}

return null;
}

protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Expand Down
10 changes: 6 additions & 4 deletions osu.Framework/IO/Stores/GlyphStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public class GlyphStore : IResourceStore<TextureUpload>, IGlyphStore

public string FontName { get; }

public float? Baseline => Font?.Common.Base;

protected readonly ResourceStore<byte[]> Store;

[CanBeNull]
Expand All @@ -39,7 +41,7 @@ public class GlyphStore : IResourceStore<TextureUpload>, IGlyphStore
/// Create a new glyph store.
/// </summary>
/// <param name="store">The store to provide font resources.</param>
/// <param name="assetName">The base name of thße font.</param>
/// <param name="assetName">The base name of the font.</param>
/// <param name="textureLoader">An optional platform-specific store for loading textures. Should load for the store provided in <param ref="param"/>.</param>
public GlyphStore(ResourceStore<byte[]> store, string assetName = null, IResourceStore<TextureUpload> textureLoader = null)
{
Expand Down Expand Up @@ -76,8 +78,6 @@ public Task LoadFontAsync() => fontLoadTask ??= Task.Factory.StartNew(() =>

public bool HasGlyph(char c) => Font?.Characters.ContainsKey(c) == true;

public int GetBaseHeight() => Font?.Common.Base ?? 0;

protected virtual TextureUpload GetPageImage(int page)
{
if (TextureLoader != null)
Expand All @@ -99,8 +99,10 @@ public CharacterGlyph Get(char character)
if (Font == null)
return null;

Debug.Assert(Baseline != null);

var bmCharacter = Font.GetCharacter(character);
return new CharacterGlyph(character, bmCharacter.XOffset, bmCharacter.YOffset, bmCharacter.XAdvance, this);
return new CharacterGlyph(character, bmCharacter.XOffset, bmCharacter.YOffset, bmCharacter.XAdvance, Baseline.Value, this);
}

public int GetKerning(char left, char right) => Font?.GetKerningAmount(left, right) ?? 0;
Expand Down
10 changes: 5 additions & 5 deletions osu.Framework/IO/Stores/IGlyphStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ public interface IGlyphStore : IResourceStore<CharacterGlyph>
/// </summary>
string FontName { get; }

/// <summary>
/// The font's baseline position, or <see langword="null"/> if not available (i.e. font not loaded or failed to load).
/// </summary>
float? Baseline { get; }

/// <summary>
/// Loads glyph information for consumption asynchronously.
/// </summary>
Expand All @@ -26,11 +31,6 @@ public interface IGlyphStore : IResourceStore<CharacterGlyph>
/// </summary>
bool HasGlyph(char c);

/// <summary>
/// Retrieves the height from the top of the glyph cell to the baseline.
/// </summary>
int GetBaseHeight();

/// <summary>
/// Retrieves a <see cref="CharacterGlyph"/> that contains associated spacing information for a character.
/// </summary>
Expand Down
4 changes: 3 additions & 1 deletion osu.Framework/Text/CharacterGlyph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,20 @@ public sealed class CharacterGlyph : ICharacterGlyph
public float XOffset { get; }
public float YOffset { get; }
public float XAdvance { get; }
public float Baseline { get; }
public char Character { get; }

private readonly IGlyphStore containingStore;

public CharacterGlyph(char character, float xOffset, float yOffset, float xAdvance, [CanBeNull] IGlyphStore containingStore)
public CharacterGlyph(char character, float xOffset, float yOffset, float xAdvance, float baseline, [CanBeNull] IGlyphStore containingStore)
{
this.containingStore = containingStore;

Character = character;
XOffset = xOffset;
YOffset = yOffset;
XAdvance = xAdvance;
Baseline = baseline;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
Expand Down
5 changes: 5 additions & 0 deletions osu.Framework/Text/ICharacterGlyph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ public interface ICharacterGlyph
/// </summary>
float XAdvance { get; }

/// <summary>
/// The position of the baseline in this glyph.
/// </summary>
float Baseline { get; }

/// <summary>
/// The character represented by this glyph.
/// </summary>
Expand Down
63 changes: 60 additions & 3 deletions osu.Framework/Text/TextBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace osu.Framework.Text
/// <summary>
/// A text builder for <see cref="SpriteText"/> and other text-based display components.
/// </summary>
public class TextBuilder
public class TextBuilder : IHasLineBaseHeight
{
/// <summary>
/// The bounding size of the composite text.
Expand All @@ -37,8 +37,26 @@ public class TextBuilder

private Vector2 currentPos;
private float currentLineHeight;
private float? currentLineBase;
private bool currentNewLine = true;

/// <summary>
/// Gets the current base height of the text in this <see cref="TextBuilder"/>.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when attempting to access this property on a <see cref="TextBuilder"/> with multiple lines added.
/// </exception>
public float LineBaseHeight
{
get
{
if (currentPos.Y > startOffset.Y)
throw new InvalidOperationException($"Cannot return a {nameof(LineBaseHeight)} from a text builder with multiple lines.");

return currentLineBase ?? 0f;
}
}

/// <summary>
/// Creates a new <see cref="TextBuilder"/>.
/// </summary>
Expand Down Expand Up @@ -79,6 +97,7 @@ public virtual void Reset()
Characters.Clear();

currentPos = startOffset;
currentLineBase = null;
currentLineHeight = 0;
currentNewLine = true;
}
Expand Down Expand Up @@ -118,7 +137,8 @@ public bool AddCharacter(char character)
// 1. Add the kerning to the current position if required.
// 2. Draw the character at the current position offset by the glyph.
// The offset is not applied to the current position, it is only a value to be used at draw-time.
// 3. Advance the current position by glyph's XAdvance.
// 3. If this character has a different baseline from the previous, adjust either the previous characters or this character's to align on one baseline.
// 4. Advance the current position by glyph's XAdvance.

float kerning = 0;

Expand All @@ -144,9 +164,26 @@ public bool AddCharacter(char character)

glyph.DrawRectangle = new RectangleF(new Vector2(currentPos.X + glyph.XOffset, currentPos.Y + glyph.YOffset), new Vector2(glyph.Width, glyph.Height));
glyph.OnNewLine = currentNewLine;

if (glyph.Baseline > currentLineBase)
{
for (int i = Characters.Count - 1; i >= 0; --i)
{
var previous = Characters[i];
previous.DrawRectangle = previous.DrawRectangle.Offset(0, glyph.Baseline - currentLineBase.Value);
Characters[i] = previous;

if (previous.OnNewLine)
break;
}
}
else if (glyph.Baseline < currentLineBase)
glyph.DrawRectangle = glyph.DrawRectangle.Offset(0, currentLineBase.Value - glyph.Baseline);

Characters.Add(glyph);

currentPos.X += glyph.XAdvance;
currentLineBase = currentLineBase == null ? glyph.Baseline : Math.Max(currentLineBase.Value, glyph.Baseline);
currentLineHeight = Math.Max(currentLineHeight, getGlyphHeight(ref glyph));
currentNewLine = false;

Expand All @@ -169,6 +206,7 @@ public void AddNewLine()
currentPos.X = startOffset.X;
currentPos.Y += currentLineHeight + spacing.Y;

currentLineBase = null;
currentLineHeight = 0;
currentNewLine = true;
}
Expand All @@ -188,19 +226,24 @@ public void RemoveLastCharacter()
Characters.RemoveAt(Characters.Count - 1);

// For each character that is removed:
// 1. Calculate the line height of the last line.
// 1. Calculate the new baseline and line height of the last line.
// 2. If the character is the first on a new line, move the current position upwards by the calculated line height and to the end of the previous line.
// The position at the end of the line is the post-XAdvanced position.
// 3. If the character is not the first on a new line, move the current position backwards by the XAdvance and the kerning from the previous glyph.
// This brings the current position to the post-XAdvanced position of the previous glyph.
// 4. Also if the character is not the first on a new line and removing it changed the baseline, adjust the characters behind it to the new baseline.

var lastLineBase = currentLineBase;

currentLineBase = null;
currentLineHeight = 0;

// This is O(n^2) for removing all characters within a line, but is generally not used in such a case
for (int i = Characters.Count - 1; i >= 0; i--)
{
var character = Characters[i];

currentLineBase = currentLineBase == null ? character.Baseline : Math.Max(currentLineBase.Value, character.Baseline);
currentLineHeight = Math.Max(currentLineHeight, getGlyphHeight(ref character));

if (character.OnNewLine)
Expand Down Expand Up @@ -233,6 +276,20 @@ public void RemoveLastCharacter()

if (previousCharacter != null)
currentPos.X -= removedCharacter.GetKerning(previousCharacter.Value) + spacing.X;

// Adjust the alignment of the previous characters if the baseline position lowered after removing the character.
if (currentLineBase < lastLineBase)
{
for (int i = Characters.Count - 1; i >= 0; i--)
{
var character = Characters[i];
character.DrawRectangle = character.DrawRectangle.Offset(0, currentLineBase.Value - lastLineBase.Value);
Characters[i] = character;

if (character.OnNewLine)
break;
}
}
}

Bounds = Vector2.Zero;
Expand Down
1 change: 1 addition & 0 deletions osu.Framework/Text/TextBuilderGlyph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public struct TextBuilderGlyph : ITexturedCharacterGlyph
public readonly float XOffset => ((fixedWidth - Glyph.Width) / 2 ?? Glyph.XOffset) * textSize;
public readonly float YOffset => Glyph.YOffset * textSize;
public readonly float XAdvance => (fixedWidth ?? Glyph.XAdvance) * textSize;
public readonly float Baseline => Glyph.Baseline * textSize;
public readonly float Width => Glyph.Width * textSize;
public readonly float Height => Glyph.Height * textSize;
public readonly char Character => Glyph.Character;
Expand Down
1 change: 1 addition & 0 deletions osu.Framework/Text/TexturedCharacterGlyph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public sealed class TexturedCharacterGlyph : ITexturedCharacterGlyph
public float XOffset => glyph.XOffset * Scale;
public float YOffset => glyph.YOffset * Scale;
public float XAdvance => glyph.XAdvance * Scale;
public float Baseline => glyph.Baseline * Scale;
public char Character => glyph.Character;
public float Width => Texture.Width * Scale;
public float Height => Texture.Height * Scale;
Expand Down