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
6 changes: 4 additions & 2 deletions Sakura.Framework/Graphics/Drawables/SpriteText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,14 @@ private void computeLayout()

if (resolvedFont == null) return;

var fallbacks = fontStore.GetFallbacks(fontUsage);

window.GetPhysicalSize(out int physW, out int physH);
float dpiScale = (float)physW / window.Width;

if (dpiScale <= 0) dpiScale = 1.0f;

shapedText = resolvedFont.ProcessText(Text, fontUsage.Size, dpiScale);
shapedText = resolvedFont.ProcessText(Text, fontUsage.Size, dpiScale, fallbacks);
ContentSize = new Vector2(shapedText.BoundingBox.X, shapedText.BoundingBox.Y);

if (Math.Abs(Size.X - ContentSize.X) > 1.0f || Math.Abs(Size.Y - ContentSize.Y) > 1.0f)
Expand Down
222 changes: 180 additions & 42 deletions Sakura.Framework/Graphics/Text/Font.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using HarfBuzzSharp;
using Sakura.Framework.Graphics.Textures;
using Sakura.Framework.Maths;
using Logger = Sakura.Framework.Logging.Logger;

namespace Sakura.Framework.Graphics.Text;

Expand All @@ -33,6 +34,7 @@ public class Font : IDisposable
private readonly Dictionary<(uint CodePoint, float PhysicalSize), GlyphData> glyphCache = new();

private float currentPhysicalSize = 24;
private float currentGlyphScale = 1.0f;

private readonly Lock stateLock = new Lock();

Expand Down Expand Up @@ -74,38 +76,126 @@ public Font(string name, byte[] fontData, TextureAtlas atlas)

private GCHandle pinnedFontData;

public ShapedText ProcessText(string text, float fontSize, float dpiScale = 1.0f)
public ShapedText ProcessText(string text, float fontSize, float dpiScale = 1.0f, IEnumerable<Font>? fallbacks = null)
{
if (string.IsNullOrEmpty(text))
return ShapedText.Empty;

float renderFontSize = fontSize * dpiScale;

// Determine base vertical metrics from the PRIMARY font so line height stays consistent
float ascenderPx = 0;
float lineHeightPx = Size;

lock (stateLock)
{
float renderFontSize = fontSize * dpiScale;
updateFontSize(renderFontSize);
unsafe
{
var face = (FT_FaceRec_*)faceHandle;
ascenderPx = face->size->metrics.ascender / 64f;
lineHeightPx = face->size->metrics.height / 64f;
}
}

// Segment the text into runs based on font support
var glyphs = new List<TextGlyph>();
float cursorX = 0;

int currentRunStart = 0;
Font? currentFont = null;

int i = 0;
while (i < text.Length)
{
// Handle surrogate pairs properly (e.g., Emojis)
int charLen = char.IsSurrogatePair(text, i) ? 2 : 1;
uint codepoint = (uint)char.ConvertToUtf32(text, i);

// Find which font supports this codepoint
Font assignedFont = this;
if (!HasGlyph(codepoint))
{
if (fallbacks != null)
{
foreach (var fallback in fallbacks)
{
if (fallback.HasGlyph(codepoint))
{
assignedFont = fallback;
break;
}
}
}
}

// 1. Update Font Size if needed
if (Math.Abs(currentPhysicalSize - renderFontSize) > 0.01f)
// If the font changed, process the previous run
if (currentFont != assignedFont)
{
currentPhysicalSize = renderFontSize;
unsafe
if (currentFont != null)
{
FT.FT_Set_Pixel_Sizes((FT_FaceRec_*)faceHandle, 0, (uint)currentPhysicalSize);
string runText = text.Substring(currentRunStart, i - currentRunStart);
var runGlyphs = currentFont.shapeRun(runText, renderFontSize, dpiScale, ascenderPx, ref cursorX);
glyphs.AddRange(runGlyphs);
}
hbFont.SetScale((int)(currentPhysicalSize * 64), (int)(currentPhysicalSize * 64));
currentFont = assignedFont;
currentRunStart = i;
}

// 2. Get Vertical Metrics
float ascenderPx = 0;
float lineHeightPx = Size;
i += charLen;
}

// 3. Process the final remaining run
if (currentFont != null && currentRunStart < text.Length)
{
string runText = text.Substring(currentRunStart);
var runGlyphs = currentFont.shapeRun(runText, renderFontSize, dpiScale, ascenderPx, ref cursorX);
glyphs.AddRange(runGlyphs);
}

return new ShapedText(glyphs, new Vector2(cursorX / dpiScale, lineHeightPx / dpiScale));
}

private void updateFontSize(float renderFontSize)
{
if (Math.Abs(currentPhysicalSize - renderFontSize) > 0.01f)
{
currentPhysicalSize = renderFontSize;
currentGlyphScale = 1.0f; // Reset scale for normal fonts

unsafe
{
var face = (FT_FaceRec_*)faceHandle;
ascenderPx = face->size->metrics.ascender / 64f;
lineHeightPx = face->size->metrics.height / 64f;

// Try to set the exact scalable pixel size
var err = FT.FT_Set_Pixel_Sizes(face, 0, (uint)currentPhysicalSize);

// If the font is a bitmap-only font (like Color Emojis), it will fail if the size isn't exact.
if (err != FT_Error.FT_Err_Ok && face->num_fixed_sizes > 0)
{
// Fallback to the first available fixed size strike
FT.FT_Select_Size(face, 0);

// Pull the height directly from the fixed size strike array
float actualSize = face->available_sizes[0].height;
if (actualSize > 0)
{
currentGlyphScale = currentPhysicalSize / actualSize;
}
}
}
hbFont.SetScale((int)(currentPhysicalSize * 64), (int)(currentPhysicalSize * 64));
}
}

private List<TextGlyph> shapeRun(string text, float renderFontSize, float dpiScale, float baselineY, ref float cursorX)
{
var glyphs = new List<TextGlyph>();

lock (stateLock)
{
updateFontSize(renderFontSize);

// 3. Shape Text
sharedBuffer.ClearContents();
sharedBuffer.AddUtf16(text);
sharedBuffer.GuessSegmentProperties();
Expand All @@ -115,32 +205,27 @@ public ShapedText ProcessText(string text, float fontSize, float dpiScale = 1.0f
var info = sharedBuffer.GlyphInfos;
var pos = sharedBuffer.GlyphPositions;

var glyphs = new List<TextGlyph>(length);

float cursorX = 0;
float baselineY = ascenderPx;

for (int i = 0; i < length; i++)
{
uint codepoint = info[i].Codepoint;
// HarfBuzz info[i].Codepoint is actually the specific glyph index for THIS font face.
uint glyphIndex = info[i].Codepoint;

float xAdvance = pos[i].XAdvance / 64.0f;
float yAdvance = pos[i].YAdvance / 64.0f;

// Since change render to DPI scaling, change to cache by real render size
var cacheKey = (codepoint, renderFontSize);
// Cache by glyph index and size.
var cacheKey = (glyphIndex, renderFontSize);

if (!glyphCache.TryGetValue(cacheKey, out GlyphData data))
{
// Rasterize will use the current 'Size' set above
var loaded = rasterizeGlyph(codepoint);
var loaded = rasterizeGlyph(glyphIndex);
if (loaded.HasValue)
{
data = loaded.Value;
glyphCache[cacheKey] = data;
}
else
{
// Move the cursor even if it's an invisible character like a space
cursorX += xAdvance;
continue;
}
Expand All @@ -149,37 +234,54 @@ public ShapedText ProcessText(string text, float fontSize, float dpiScale = 1.0f
float hbXOffset = pos[i].XOffset / 64.0f;
float hbYOffset = pos[i].YOffset / 64.0f;

float finalX = cursorX + hbXOffset + data.BitmapLeft;
float finalY = baselineY - hbYOffset - data.BitmapTop;
float scaledLeft = data.BitmapLeft * currentGlyphScale;
float scaledTop = data.BitmapTop * currentGlyphScale;
float scaledWidth = data.Texture.Width * currentGlyphScale;
float scaledHeight = data.Texture.Height * currentGlyphScale;

float finalX = cursorX + hbXOffset + scaledLeft;
float finalY = baselineY - hbYOffset - scaledTop;

glyphs.Add(new TextGlyph
{
Texture = data.Texture,
Position = new Vector2(finalX / dpiScale, finalY / dpiScale),
Size = new Vector2(data.Texture.Width / dpiScale, data.Texture.Height / dpiScale)
Size = new Vector2(scaledWidth / dpiScale, scaledHeight / dpiScale)
});

cursorX += xAdvance;
}

return new ShapedText(glyphs, new Vector2(cursorX / dpiScale, lineHeightPx / dpiScale));
}

return glyphs;
}

private unsafe GlyphData? rasterizeGlyph(uint glyphIndex)
{
var facePtr = (FT_FaceRec_*)faceHandle;
const int ft_load_default = 0;

// FT_LOAD_COLOR is 1 << 20 in FreeType. This tells FreeType to load colored emoji bitmaps if they exist.
const int FT_LOAD_COLOR = 1 << 20;
int loadFlags = 0 | FT_LOAD_COLOR; // 0 is FT_LOAD_DEFAULT

// Load glyph
var err = FT.FT_Load_Glyph(facePtr, glyphIndex, ft_load_default);
if (err != FT_Error.FT_Err_Ok) return null;
var err = FT.FT_Load_Glyph(facePtr, glyphIndex, (FreeTypeSharp.FT_LOAD)loadFlags);
if (err != FT_Error.FT_Err_Ok)
{
Logger.Error($"Failed to load glyph index {glyphIndex} with FT_LOAD_COLOR. Error: {err}. Retrying without color flag.");
err = FT.FT_Load_Glyph(facePtr, glyphIndex, FreeTypeSharp.FT_LOAD.FT_LOAD_DEFAULT);
if (err != FT_Error.FT_Err_Ok) return null;
}

var glyphSlotPtr = facePtr->glyph;

// Render to bitmap
err = FT.FT_Render_Glyph(glyphSlotPtr, FT_Render_Mode_.FT_RENDER_MODE_NORMAL);
if (err != FT_Error.FT_Err_Ok) return null;
// Only render if the glyph is a vector outline
// If it's an emoji, it will already be FT_GLYPH_FORMAT_BITMAP
if (glyphSlotPtr->format != FreeTypeSharp.FT_Glyph_Format_.FT_GLYPH_FORMAT_BITMAP)
{
err = FT.FT_Render_Glyph(glyphSlotPtr, FreeTypeSharp.FT_Render_Mode_.FT_RENDER_MODE_NORMAL);
if (err != FT_Error.FT_Err_Ok) return null;
}

// Get bitmap info
FT_Bitmap_ bitmap = glyphSlotPtr->bitmap;
Expand All @@ -199,13 +301,40 @@ public ShapedText ProcessText(string text, float fontSize, float dpiScale = 1.0f
byte[] rgba = new byte[width * height * 4];
byte* buffer = bitmap.buffer;

for (int i = 0; i < width * height; i++)
int pitch = Math.Abs(bitmap.pitch);

if (bitmap.pixel_mode == FT_Pixel_Mode_.FT_PIXEL_MODE_BGRA)
{
byte val = buffer[i];
rgba[i * 4 + 0] = 255;
rgba[i * 4 + 1] = 255;
rgba[i * 4 + 2] = 255;
rgba[i * 4 + 3] = val;
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int src = y * pitch + x * 4;
int dst = (y * width + x) * 4;
// Convert BGRA to RGBA for OpenGL
rgba[dst + 0] = buffer[src + 2]; // R
rgba[dst + 1] = buffer[src + 1]; // G
rgba[dst + 2] = buffer[src + 0]; // B
rgba[dst + 3] = buffer[src + 3]; // A
}
}
}
else
{
// Standard grayscale anti-aliased font
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int src = y * pitch + x;
int dst = (y * width + x) * 4;
byte val = buffer[src];
rgba[dst + 0] = 255;
rgba[dst + 1] = 255;
rgba[dst + 2] = 255;
rgba[dst + 3] = val;
}
}
}

var texture = atlas.AddRegion(width, height, rgba);
Expand All @@ -219,6 +348,15 @@ public ShapedText ProcessText(string text, float fontSize, float dpiScale = 1.0f
};
}

public bool HasGlyph(uint codepoint)
{
unsafe
{
// FT_Get_Char_Index returns 0 if the glyph is missing
return FT.FT_Get_Char_Index((FT_FaceRec_*)faceHandle, codepoint) > 0;
}
}

public void Dispose()
{
sharedBuffer.Dispose();
Expand Down
Loading