Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -400,3 +400,4 @@ FodyWeavers.xsd
# CnCNet
/Compiled
.idea/
!/References/*
83 changes: 83 additions & 0 deletions FontManagement/FontManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using Rampastring.Tools;
#if !XNA
using Forme;
using Forme.MonoGame;
#endif

namespace Rampastring.XNAUI.FontManagement;

Expand Down Expand Up @@ -218,6 +222,12 @@ private static void CreateFontIndexesFromIni(IniFile iniFile, ContentManager con
string sfName = Path.GetFileNameWithoutExtension(config.Path);
LoadSpriteFont(contentManager, searchPath, sfName);
break;

#if !XNA
case FontType.Forme:
CreateFormeFontIndex(i, config, searchPath);
break;
#endif
}
}
}
Expand Down Expand Up @@ -337,6 +347,79 @@ public FontConfig(string path, int size, FontType fontType, int fallback)
}
}

#if !XNA
/// <summary>
/// Creates a Forme GPU font index from a TTF or <c>.forme</c> file.
/// </summary>
/// <remarks>
/// <para>
/// If a pre-processed <c>.forme</c> file (same base name as the TTF, with <c>.forme</c>
/// extension) exists in the same directory, it is loaded instead of the TTF to avoid
/// reprocessing on every launch. Otherwise the TTF is processed at startup and, if the
/// directory is writable, the result is saved as a <c>.forme</c> file for future runs.
/// </para>
/// <para>
/// Glyphs are baked for <see cref="CharacterSet.BasicLatin"/> (Unicode Basic Latin,
/// U+0020–U+007F). To support a wider character set, pre-process the font with a custom
/// <see cref="CharacterSet"/> using <c>FormeFont.FromTtf</c> and save it as a
/// <c>.forme</c> file next to the TTF.
/// </para>
/// </remarks>
private static void CreateFormeFontIndex(int fontIndex, FontConfig config, string searchPath)
{
if (string.IsNullOrEmpty(config.Path))
{
Logger.Log($"FontManager: Font{fontIndex} - Forme font has no path configured, skipping");
return;
}

string ttfFullPath = SafePath.GetFile(searchPath, config.Path).FullName;
if (!File.Exists(ttfFullPath))
{
Logger.Log($"FontManager: Font{fontIndex} - Forme font file not found: {ttfFullPath}");
return;
}

try
{
// Look for a pre-processed .forme file alongside the TTF.
string formePath = Path.ChangeExtension(ttfFullPath, ".forme");
FormeFont formeFont;

if (File.Exists(formePath))
{
formeFont = FormeFont.FromFile(formePath);
Logger.Log($"FontManager: Font{fontIndex} - Loaded pre-processed font from {formePath}");
}
else
{
byte[] ttfData = File.ReadAllBytes(ttfFullPath);
formeFont = FormeFont.FromTtf(ttfData, CharacterSet.BasicLatin);
Logger.Log($"FontManager: Font{fontIndex} - Processed TTF font from {ttfFullPath}");

// Try to save the processed font for faster future loads.
try
{
formeFont.Save(formePath);
Logger.Log($"FontManager: Font{fontIndex} - Saved processed font to {formePath}");
}
catch (Exception ex)
{
Logger.Log($"FontManager: Font{fontIndex} - Could not save processed font to {formePath}: {ex.Message}");
}
}

var fontDevice = new FormeFontDevice(Renderer.GraphicsDevice, formeFont);
fonts.Add(new FormeFontWrapper(fontDevice, Renderer.FormeRenderer, config.Size));
Logger.Log($"FontManager: Created FontIndex {fonts.Count - 1}: Forme size {config.Size} ({Path.GetFileName(config.Path)})");
}
catch (Exception ex)
{
Logger.Log($"FontManager: Font{fontIndex} - Failed to create Forme font from {config.Path}: {ex.Message}");
}
}
#endif

/// <summary>
/// Loads a SpriteFont and adds it to the font list.
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion FontManagement/FontType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ namespace Rampastring.XNAUI.FontManagement;
public enum FontType
{
SpriteFont,
TrueType
TrueType,
Forme
}
131 changes: 131 additions & 0 deletions FontManagement/FormeFontWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#if !XNA
using FontStashSharp;
using Forme;
using Forme.MonoGame;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using System;

namespace Rampastring.XNAUI.FontManagement;

/// <summary>
/// A font wrapper that renders text using the Forme GPU-accelerated Slug algorithm.
/// </summary>
/// <remarks>
/// <para>
/// Forme renders text directly from quadratic Bezier glyph outlines on the GPU without
/// precomputed textures, distance fields, or rasterized atlases. Text remains sharp at
/// any size and scale.
/// </para>
/// <para>
/// Because Forme uses its own renderer rather than <c>SpriteBatch</c>, each
/// <see cref="DrawString(SpriteBatch, string, Vector2, Color, float, float)"/> call
/// flushes the active <c>SpriteBatch</c>, draws with <see cref="FormeRenderer"/>, then
/// restarts the <c>SpriteBatch</c>. This preserves correct draw order at the cost of one
/// extra flush per text call.
/// </para>
/// </remarks>
public class FormeFontWrapper : IFont, IDisposable
{
private readonly FormeFontDevice _fontDevice;
private readonly FormeRenderer _renderer;
private readonly int _sizePixels;

// Ascent in pixels at size 1.0: multiply by (sizePixels * scale) when drawing.
private readonly float _ascentRatio;

private bool _disposed;

/// <summary>
/// Initializes a new <see cref="FormeFontWrapper"/>.
/// </summary>
/// <param name="fontDevice">The GPU font device that owns the curve and band textures.</param>
/// <param name="renderer">The shared <see cref="FormeRenderer"/> used for drawing.</param>
/// <param name="sizePixels">The nominal em-square height in pixels for this font size.</param>
public FormeFontWrapper(FormeFontDevice fontDevice, FormeRenderer renderer, int sizePixels)
{
_fontDevice = fontDevice ?? throw new ArgumentNullException(nameof(fontDevice));
_renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
_sizePixels = sizePixels;

int unitsPerEm = Math.Max(1, fontDevice.Metrics.UnitsPerEm);
_ascentRatio = (float)fontDevice.Metrics.Ascent / unitsPerEm;
}

/// <inheritdoc/>
public Vector2 MeasureString(string text)
{
if (string.IsNullOrEmpty(text))
return Vector2.Zero;

var bounds = _fontDevice.Font.MeasureString(text.AsSpan(), (float)_sizePixels);
return new Vector2(bounds.Width, bounds.Height);
}

/// <inheritdoc/>
/// <remarks>
/// The <paramref name="location"/> is interpreted as the top-left corner of the text,
/// consistent with the rest of the XNAUI framework. Internally this is converted to a
/// baseline origin as required by <see cref="FormeRenderer"/>.
/// </remarks>
public void DrawString(SpriteBatch spriteBatch, string text, Vector2 location, Color color, float scale, float depth)
{
if (string.IsNullOrEmpty(text))
return;

// Forme expects a baseline origin; convert from the top-left corner used by XNAUI.
float ascentPixels = _ascentRatio * _sizePixels * scale;
Vector2 baseline = new Vector2(location.X, location.Y + ascentPixels);

// Flush the active SpriteBatch before using FormeRenderer, then restart it.
// FormeRenderer.Begin/End save and restore all GraphicsDevice state, so the
// SpriteBatch can resume with Begin() using the same settings.
Renderer.EndDraw();

_renderer.Begin();
// Note: explicitly passing through `new TextLayoutOptions()` to workaround the following issue:
// '\n' in DrawString doesn't work without TextLayoutOptions #12
// https://github.com/AristurtleDev/Forme/issues/12
_renderer.DrawString(_fontDevice, text, baseline, _sizePixels * scale, color, options: new TextLayoutOptions());
_renderer.End();

Renderer.BeginDraw();
}

/// <inheritdoc/>
public void DrawString(SpriteBatch spriteBatch, StringSegment text, Vector2 location, Color color, float rotation, Vector2 origin, Vector2 scale, float depth)
{
// StringSegment overload: convert to string and delegate.
// Rotation is not supported by FormeRenderer. For non-uniform scale, the larger
// axis is used so that text is never clipped.
float uniformScale = Math.Max(scale.X, scale.Y);
DrawString(spriteBatch, text.ToString(), location, color, uniformScale, depth);
}

/// <inheritdoc/>
/// <remarks>
/// Always returns <see langword="true"/>. Forme skips glyphs not present in the baked
/// character set rather than substituting a replacement character, so missing characters
/// are simply not rendered.
/// </remarks>
public bool HasCharacter(char c) => true;

/// <inheritdoc/>
/// <remarks>
/// Returns <paramref name="str"/> unchanged. Forme handles missing glyphs silently by
/// skipping them, so no character substitution is needed.
/// </remarks>
public string GetSafeString(string str) => str;

/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
return;

_disposed = true;
_fontDevice.Dispose();
GC.SuppressFinalize(this);
}
}
#endif
11 changes: 7 additions & 4 deletions Rampastring.XNAUI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,11 @@

<!--Switching between Configurations within VS IDE requires reloading the project file-->
<ItemGroup Condition="$(DefineConstants.Contains('DX'))">
<PackageReference Include="MonoGame.Framework.WindowsDX" Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'" Version="3.8.0.1641" />
<PackageReference Include="MonoGame.Framework.WindowsDX" Condition="'$(TargetFrameworkIdentifier)' != '.NETFramework'" Version="3.8.1.303" />
<PackageReference Include="MonoGame.Framework.WindowsDX" Version="3.8.0.1641" />
</ItemGroup>

<ItemGroup Condition="$(DefineConstants.Contains('GL'))">
<PackageReference Include="MonoGame.Framework.DesktopGL" GeneratePathProperty="true" Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'" Version="3.8.0.1641" />
<PackageReference Include="MonoGame.Framework.DesktopGL" GeneratePathProperty="true" Condition="'$(TargetFrameworkIdentifier)' != '.NETFramework'" Version="3.8.1.303" />
<PackageReference Include="MonoGame.Framework.DesktopGL" GeneratePathProperty="true" Version="3.8.0.1641" />
</ItemGroup>

<ItemGroup Condition="$(DefineConstants.Contains('XNA'))">
Expand Down Expand Up @@ -196,4 +194,9 @@
<PackageReference Include="FontStashSharp.XNA" Version="1.5.2" />
</ItemGroup>

<ItemGroup Condition="!$(DefineConstants.Contains('XNA'))">
<PackageReference Include="CnCNet.Forme.NET48" Version="0.0.3.1" />
<PackageReference Include="CnCNet.Forme.MonoGame.NET48" Version="0.0.3.1" />
</ItemGroup>

</Project>
Binary file not shown.
Binary file not shown.
Binary file added References/CnCNet.Forme.NET48.0.0.3.1.nupkg
Binary file not shown.
22 changes: 22 additions & 0 deletions Renderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
using Microsoft.Xna.Framework.Content;
using FontStashSharp;
using Rampastring.XNAUI.FontManagement;
#if !XNA
using Forme.MonoGame;
#endif
#if XNA
using System.Reflection;
#endif
Expand Down Expand Up @@ -40,12 +43,31 @@ public static class Renderer

internal static SpriteBatchSettings CurrentSettings;

#if !XNA
/// <summary>
/// Gets the <see cref="Microsoft.Xna.Framework.Graphics.GraphicsDevice"/> used for rendering.
/// Available after <see cref="Initialize"/> has been called.
/// </summary>
internal static GraphicsDevice GraphicsDevice { get; private set; }

/// <summary>
/// Gets the shared <see cref="Forme.MonoGame.FormeRenderer"/> used by <see cref="FormeFontWrapper"/>.
/// Available after <see cref="Initialize"/> has been called.
/// </summary>
internal static FormeRenderer FormeRenderer { get; private set; }
#endif

public static SpriteBatchSettings GetCurrentSettings() => CurrentSettings;

public static void Initialize(GraphicsDevice gd, ContentManager content)
{
spriteBatch = new SpriteBatch(gd);

#if !XNA
GraphicsDevice = gd;
FormeRenderer = new FormeRenderer(gd);
#endif

FontManager.Initialize();
FontManager.LoadFonts(content);

Expand Down
5 changes: 4 additions & 1 deletion nuget.config
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear/>
<add key="NuGet official package source" value="https://api.nuget.org/v3/index.json" />
<add key="Local file package source" value="References" />
</packageSources>
</configuration>
</configuration>