Files
RobustToolbox/Robust.Client/Graphics/FontManager.cs

532 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using JetBrains.Annotations;
using Robust.Client.Utility;
using Robust.Shared.Log;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
using SharpFont;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace Robust.Client.Graphics
{
internal sealed class FontManager : IFontManagerInternal
{
private const int SheetWidth = 256;
private const int SheetHeight = 256;
// FreeType's stroker radius is expressed in 26.6 fixed-point pixel units.
private const float FreeType26Dot6Scale = 64f;
private readonly IClyde _clyde;
private readonly ISawmill _sawmill;
private uint _baseFontDpi = 96;
private readonly Library _library;
private readonly Dictionary<(FontFaceHandle, int fontSize), FontInstanceHandle> _loadedInstances =
new();
public FontManager(IClyde clyde, ILogManager logManager)
{
_clyde = clyde;
_library = new Library();
_sawmill = logManager.GetSawmill("font");
}
public IFontFaceHandle Load(Stream stream, int index = 0)
{
// Freetype directly operates on the font memory managed by us.
// As such, the font data should be pinned in POH.
var fontData = stream.CopyToPinnedArray();
return Load(new ArrayMemoryHandle(fontData), index);
}
public IFontFaceHandle Load(IFontMemoryHandle memory, int index = 0)
{
var face = FaceLoad(memory, index);
var handle = new FontFaceHandle(face, memory);
return handle;
}
public IFontFaceHandle LoadWithPostscriptName(IFontMemoryHandle memory, string postscriptName)
{
var numFaces = 1;
for (var i = 0; i < numFaces; i++)
{
var face = FaceLoad(memory, i);
numFaces = face.FaceCount;
if (face.GetPostscriptName() == postscriptName)
return new FontFaceHandle(face, memory);
face.Dispose();
}
// Fallback, load SOMETHING.
_sawmill.Warning($"Failed to load correct font via postscript name! {postscriptName}");
return new FontFaceHandle(FaceLoad(memory, 0), memory);
}
private unsafe Face FaceLoad(IFontMemoryHandle memory, int index)
{
return new Face(_library,
(nint)memory.GetData(),
checked((int)memory.GetDataSize()),
index);
}
void IFontManagerInternal.SetFontDpi(uint fontDpi)
{
_baseFontDpi = fontDpi;
}
public IFontInstanceHandle MakeInstance(IFontFaceHandle handle, int size)
{
var fontFaceHandle = (FontFaceHandle) handle;
if (_loadedInstances.TryGetValue((fontFaceHandle, size), out var instance))
{
return instance;
}
instance = new FontInstanceHandle(this, size, fontFaceHandle);
_loadedInstances.Add((fontFaceHandle, size), instance);
return instance;
}
public void ClearFontCache()
{
foreach (var fontInstance in _loadedInstances)
{
fontInstance.Value.ClearSizeData();
}
}
private ScaledFontData _generateScaledDatum(FontInstanceHandle instance, float scale)
{
var ftFace = instance.FaceHandle.Face;
ftFace.SetCharSize(0, instance.Size, 0, (uint) (_baseFontDpi * scale));
var ascent = ftFace.Size.Metrics.Ascender.ToInt32();
var descent = -ftFace.Size.Metrics.Descender.ToInt32();
var lineHeight = ftFace.Size.Metrics.Height.ToInt32();
var data = new ScaledFontData(ascent, descent, ascent + descent, lineHeight, _library);
return data;
}
private GlyphInfo EnsureGlyphCached(FontInstanceHandle instance, ScaledFontData scaled, float scale, uint glyph)
{
// Check if already cached.
if (scaled.GlyphInfos.TryGetValue(glyph, out var info))
return info;
info = new GlyphInfo();
var face = instance.FaceHandle.Face;
face.SetCharSize(0, instance.Size, 0, (uint) (_baseFontDpi * scale));
face.LoadGlyph(glyph, LoadFlags.Default, LoadTarget.Normal);
face.Glyph.RenderGlyph(RenderMode.Normal);
var glyphMetrics = face.Glyph.Metrics;
info.Metrics = new CharMetrics(glyphMetrics.HorizontalBearingX.ToInt32(),
glyphMetrics.HorizontalBearingY.ToInt32(),
glyphMetrics.HorizontalAdvance.ToInt32(),
glyphMetrics.Width.ToInt32(),
glyphMetrics.Height.ToInt32());
using var bitmap = face.Glyph.Bitmap;
info.Texture = CacheBitmap(instance, scaled, scale, bitmap);
scaled.GlyphInfos.Add(glyph, info);
return info;
}
private OutlinedGlyphInfo EnsureOutlinedGlyphCached(
FontInstanceHandle instance,
ScaledFontData scaled,
float scale,
uint glyph,
int radius)
{
if (scaled.OutlinedGlyphInfos.TryGetValue((glyph, radius), out var info))
return info;
var face = instance.FaceHandle.Face;
face.SetCharSize(0, instance.Size, 0, (uint) (_baseFontDpi * scale));
face.LoadGlyph(glyph, LoadFlags.Default, LoadTarget.Normal);
using var sourceGlyph = face.Glyph.GetGlyph();
var stroker = scaled.Stroker;
stroker.Set(radius, StrokerLineCap.Round, StrokerLineJoin.Round, Fixed16Dot16.FromInt32(0));
stroker.Rewind();
using var strokedGlyph = sourceGlyph.StrokeBorder(stroker, false, true);
strokedGlyph.ToBitmap(RenderMode.Normal, new FTVector26Dot6(0, 0), true);
var bitmapGlyph = strokedGlyph.ToBitmapGlyph();
using var bitmap = bitmapGlyph.Bitmap;
info = new OutlinedGlyphInfo
{
Texture = CacheBitmap(instance, scaled, scale, bitmap),
Left = bitmapGlyph.Left,
Top = bitmapGlyph.Top,
};
scaled.OutlinedGlyphInfos.Add((glyph, radius), info);
return info;
}
private AtlasTexture? CacheBitmap(
FontInstanceHandle instance,
ScaledFontData scaled,
float scale,
FTBitmap bitmap)
{
if (bitmap.Pitch < 0)
throw new NotImplementedException();
if (bitmap.Pitch == 0)
return null;
Image<A8> img;
switch (bitmap.PixelMode)
{
case PixelMode.Mono:
img = MonoBitMapToImage(bitmap);
break;
case PixelMode.Gray:
{
ReadOnlySpan<A8> span;
unsafe
{
span = new ReadOnlySpan<A8>((void*) bitmap.Buffer, bitmap.Pitch * bitmap.Rows);
}
img = new Image<A8>(bitmap.Width, bitmap.Rows);
span.Blit(
bitmap.Pitch,
UIBox2i.FromDimensions(0, 0, bitmap.Pitch, bitmap.Rows),
img,
(0, 0));
break;
}
case PixelMode.Gray2:
case PixelMode.Gray4:
case PixelMode.Lcd:
case PixelMode.VerticalLcd:
case PixelMode.Bgra:
throw new NotImplementedException();
default:
throw new ArgumentOutOfRangeException();
}
OwnedTexture sheet;
if (scaled.AtlasTextures.Count == 0)
sheet = GenSheet();
else
sheet = scaled.AtlasTextures[^1];
var (sheetW, sheetH) = sheet.Size;
if (sheetW - scaled.CurSheetX < img.Width)
{
scaled.CurSheetX = 0;
// +1 adds vertical padding to avoid artifacts when aliasing.
scaled.CurSheetY = scaled.CurSheetMaxY + 1;
}
if (sheetH - scaled.CurSheetY < img.Height)
{
scaled.CurSheetY = 0;
scaled.CurSheetX = 0;
scaled.CurSheetMaxY = 0;
sheet = GenSheet();
}
sheet.SetSubImage((scaled.CurSheetX, scaled.CurSheetY), img);
var atlasTexture = new AtlasTexture(
sheet,
UIBox2.FromDimensions(scaled.CurSheetX, scaled.CurSheetY, bitmap.Width, bitmap.Rows));
scaled.CurSheetMaxY = Math.Max(scaled.CurSheetMaxY, scaled.CurSheetY + bitmap.Rows);
// +1 adds horizontal padding to avoid artifacts when aliasing.
scaled.CurSheetX += bitmap.Width + 1;
return atlasTexture;
OwnedTexture GenSheet()
{
var face = instance.FaceHandle.Face;
var texture = _clyde.CreateBlankTexture<A8>((SheetWidth, SheetHeight),
$"font-{face.FamilyName}-{instance.Size}-{(uint) (_baseFontDpi * scale)}-sheet{scaled.AtlasTextures.Count}");
scaled.AtlasTextures.Add(texture);
return texture;
}
}
private static Image<A8> MonoBitMapToImage(FTBitmap bitmap)
{
DebugTools.Assert(bitmap.PixelMode == PixelMode.Mono);
DebugTools.Assert(bitmap.Pitch > 0);
ReadOnlySpan<byte> span;
unsafe
{
span = new ReadOnlySpan<byte>((void*) bitmap.Buffer, bitmap.Rows * bitmap.Pitch);
}
var bitmapImage = new Image<A8>(bitmap.Width, bitmap.Rows);
for (var y = 0; y < bitmap.Rows; y++)
{
for (var x = 0; x < bitmap.Width; x++)
{
var byteIndex = y * bitmap.Pitch + (x / 8);
var bitIndex = x % 8;
var bit = (span[byteIndex] & (1 << (7 - bitIndex))) != 0;
bitmapImage[x, y] = new A8(bit ? byte.MaxValue : byte.MinValue);
}
}
return bitmapImage;
}
private sealed class FontFaceHandle : IFontFaceHandle
{
// Keep this alive to avoid it being GC'd.
private readonly IFontMemoryHandle _memoryHandle;
public Face Face { get; }
public FontFaceHandle(Face face, IFontMemoryHandle memoryHandle)
{
_memoryHandle = memoryHandle;
Face = face;
}
}
[PublicAPI]
private sealed class FontInstanceHandle : IFontInstanceHandle
{
public FontFaceHandle FaceHandle { get; }
public int Size { get; }
private readonly Dictionary<float, ScaledFontData> _scaledData = new();
private readonly FontManager _fontManager;
public readonly Dictionary<Rune, uint> GlyphMap;
public FontInstanceHandle(FontManager fontManager, int size, FontFaceHandle faceHandle)
{
GlyphMap = new Dictionary<Rune, uint>();
_fontManager = fontManager;
Size = size;
FaceHandle = faceHandle;
}
public void ClearSizeData()
{
foreach (var scaleData in _scaledData)
{
foreach (var ownedTexture in scaleData.Value.AtlasTextures)
{
ownedTexture.Dispose();
}
scaleData.Value.Stroker.Dispose();
}
_scaledData.Clear();
}
public bool TryGetGlyph(
Rune codePoint,
float scale,
float outlineThickness,
out CharMetrics metrics,
out Texture? texture,
out OutlinedGlyph? outlinedGlyph)
{
var glyph = GetGlyph(codePoint);
if (glyph == 0)
{
metrics = default;
texture = null;
outlinedGlyph = null;
return false;
}
var scaled = GetScaleDatum(scale);
var glyphInfo = _fontManager.EnsureGlyphCached(this, scaled, scale, glyph);
metrics = glyphInfo.Metrics;
texture = glyphInfo.Texture;
outlinedGlyph = null;
// An outline is never rendered without the normal glyph, so avoid generating or
// looking up outline data when the glyph has no bitmap.
if (texture == null || outlineThickness <= 0)
return true;
var radius = (int) MathF.Round(outlineThickness * scale * FreeType26Dot6Scale);
if (radius <= 0)
return true;
var outlinedInfo = _fontManager.EnsureOutlinedGlyphCached(this, scaled, scale, glyph, radius);
if (outlinedInfo.Texture != null)
outlinedGlyph = new OutlinedGlyph(outlinedInfo.Texture, outlinedInfo.Left, outlinedInfo.Top);
return true;
}
public Texture? GetCharTexture(Rune codePoint, float scale)
{
return TryGetGlyph(codePoint, scale, 0, out _, out var texture, out _) ? texture : null;
}
public OutlinedGlyph? GetOutlinedChar(Rune codePoint, float scale, float thickness)
{
if (thickness <= 0)
return null;
var glyph = GetGlyph(codePoint);
if (glyph == 0)
return null;
var radius = (int) MathF.Round(thickness * scale * FreeType26Dot6Scale);
if (radius <= 0)
return null;
var scaled = GetScaleDatum(scale);
var info = _fontManager.EnsureOutlinedGlyphCached(this, scaled, scale, glyph, radius);
if (info.Texture == null)
return null;
return new OutlinedGlyph(info.Texture, info.Left, info.Top);
}
public CharMetrics? GetCharMetrics(Rune codePoint, float scale)
{
return TryGetGlyph(codePoint, scale, 0, out var metrics, out _, out _) ? metrics : null;
}
public int GetAscent(float scale)
{
var scaled = GetScaleDatum(scale);
return scaled.Ascent;
}
public int GetDescent(float scale)
{
var scaled = GetScaleDatum(scale);
return scaled.Descent;
}
public int GetHeight(float scale)
{
var scaled = GetScaleDatum(scale);
return scaled.Height;
}
public int GetLineHeight(float scale)
{
var scaled = GetScaleDatum(scale);
return scaled.LineHeight;
}
private uint GetGlyph(Rune chr)
{
if (GlyphMap.TryGetValue(chr, out var glyph))
{
return glyph;
}
// Check FreeType to see if it exists.
var index = FaceHandle.Face.GetCharIndex((uint) chr.Value);
GlyphMap.Add(chr, index);
return index;
}
private ScaledFontData GetScaleDatum(float scale)
{
if (_scaledData.TryGetValue(scale, out var datum))
{
return datum;
}
datum = _fontManager._generateScaledDatum(this, scale);
_scaledData.Add(scale, datum);
return datum;
}
}
private sealed class ScaledFontData
{
public ScaledFontData(int ascent, int descent, int height, int lineHeight, Library library)
{
Ascent = ascent;
Descent = descent;
Height = height;
LineHeight = lineHeight;
Stroker = new Stroker(library);
}
public readonly List<OwnedTexture> AtlasTextures = new();
public readonly Dictionary<uint, GlyphInfo> GlyphInfos = new();
public readonly Dictionary<(uint Glyph, int Radius), OutlinedGlyphInfo> OutlinedGlyphInfos = new();
public readonly Stroker Stroker;
public readonly int Ascent;
public readonly int Descent;
public readonly int Height;
public readonly int LineHeight;
public int CurSheetX;
public int CurSheetY;
public int CurSheetMaxY;
}
public sealed class GlyphInfo
{
public CharMetrics Metrics;
public AtlasTexture? Texture;
}
private sealed class OutlinedGlyphInfo
{
public AtlasTexture? Texture;
public int Left;
public int Top;
}
private sealed class ArrayMemoryHandle(byte[] array) : IFontMemoryHandle
{
private GCHandle _gcHandle = GCHandle.Alloc(array, GCHandleType.Pinned);
public unsafe byte* GetData()
{
return (byte*) _gcHandle.AddrOfPinnedObject();
}
public IntPtr GetDataSize()
{
return array.Length;
}
public void Dispose()
{
_gcHandle.Free();
_gcHandle = default;
GC.SuppressFinalize(this);
}
~ArrayMemoryHandle()
{
Dispose();
}
}
}
}