using System.Buffers.Binary; using System.Text; using LunarLabs.Fonts; namespace XFESystem.Platform; public sealed record PlatformFontAssets(byte[] Mono, byte[] Sans, byte[] SansBold, byte[] Cjk, byte[] CjkBold, byte[] Icons); public readonly record struct PlatformTextMask(int Width, int Height, byte[] Alpha); /// Font bytes are trusted bundled assets, never downloaded author fonts. Each canvas owns its caches. public sealed class PlatformFonts { private sealed class Face(byte[] bytes, string name) { public readonly Font Font = new(bytes); public readonly string Name = name; public readonly int UnitsPerEm = ReadUnitsPerEm(bytes); public int Ascent, Descent, Gap; } private readonly Face mono, sans, bold, cjk, cjkBold, icons; private readonly PlatformFontAssets assets; private readonly Dictionary<(Face Face, char Character, double Size), FontGlyph> glyphs = []; private long cachedGlyphBytes; public int CachedGlyphCount => glyphs.Count; public long CachedGlyphBytes => cachedGlyphBytes; public PlatformFonts(PlatformFontAssets assets) { ArgumentNullException.ThrowIfNull(assets); this.assets = assets; mono = Load(assets.Mono, "Cascadia Mono"); sans = Load(assets.Sans, "Lato"); bold = Load(assets.SansBold, "Lato Bold"); cjk = Load(assets.Cjk, "XFE Web CJK"); cjkBold = Load(assets.CjkBold, "XFE Web CJK Bold"); icons = Load(assets.Icons, "Bootstrap Icons"); } public static PlatformFonts CreateBundled() => new(Bundled.Assets); public PlatformFonts CreateIndependent() => new(assets); internal bool HasSameAssets(PlatformFonts other) => ReferenceEquals(assets, other.assets); private static class Bundled { internal static readonly PlatformFontAssets Assets = new(Read("Mono.ttf"), Read("Sans.ttf"), Read("SansBold.ttf"), Read("Cjk.ttf"), Read("CjkBold.ttf"), Read("Icons.ttf")); private static byte[] Read(string name) => PlatformResources.Read("PlatformFonts/" + name); } private static Face Load(byte[] bytes, string name) { var face = new Face(bytes, name); face.Font.GetFontVMetrics(out face.Ascent, out face.Descent, out face.Gap); if (face.Ascent <= face.Descent) throw new InvalidDataException("Font vertical metrics are invalid."); return face; } public string DescribeFace(string requested, int weight) => Primary(requested, weight).Name; public bool HasGlyph(char character, string family, int weight) => Resolve(Primary(family, weight), character, weight).Font.HasGlyph(character); public double LineHeight(double size, string family, int weight) { size = ValidSize(size); var face = Primary(family, weight); return (face.Ascent - face.Descent + Math.Max(0, face.Gap)) * size / face.UnitsPerEm; } public double Measure(string text, double size, string family, int weight) { ArgumentNullException.ThrowIfNull(text); size = ValidSize(size); var primary = Primary(family, weight); double position = 0; Face? previousFace = null; char previous = '\0'; foreach (var rune in text.EnumerateRunes()) { char ch = Bmp(rune); if (ch is '\r' or '\n') break; var face = Resolve(primary, ch, weight); ch = Supported(face, ch); if (ch == '\t') { position += Advance(face, ' ', size) * 4; previousFace = null; continue; } if (ReferenceEquals(face, previousFace)) position += face.Font.GetKerning(previous, ch, (float)(size / face.UnitsPerEm)); position += Advance(face, ch, size); previousFace = face; previous = ch; } return Math.Max(0, position); } public PlatformTextMask Rasterize(string text, double size, string family, int weight) { ArgumentNullException.ThrowIfNull(text); size = ValidSize(size); int width = Math.Max(1, (int)Math.Ceiling(Measure(text, size, family, weight))); int height = Math.Max(1, (int)Math.Ceiling(LineHeight(size, family, weight))); if ((long)width * height > 4 * 1024 * 1024) throw new ArgumentException("Text coverage exceeds its bounded raster budget.", nameof(text)); byte[] coverage = new byte[width * height]; var primary = Primary(family, weight); double position = 0; Face? previousFace = null; char previous = '\0'; double baseline = primary.Ascent * size / primary.UnitsPerEm; foreach (var rune in text.EnumerateRunes()) { char ch = Bmp(rune); if (ch is '\r' or '\n') break; var face = Resolve(primary, ch, weight); ch = Supported(face, ch); if (ch == '\t') { position += Advance(face, ' ', size) * 4; previousFace = null; continue; } if (ReferenceEquals(face, previousFace)) position += face.Font.GetKerning(previous, ch, (float)(size / face.UnitsPerEm)); // Blank glyphs (including Monaco's U+00A0 non-breaking spaces) // have real advances but no outline. The unmodified LunarFonts // bitmap API rejects a zero-sized outline; only advance these. if (!char.IsWhiteSpace(ch)) { var glyph = Glyph(face, ch, size); if (glyph is not null) { int gx = (int)Math.Round(position) + glyph.xOfs, gy = (int)Math.Round(baseline) + glyph.yOfs; for (int y = 0; y < glyph.Image.Height; y++) { int targetY = gy + y; if (targetY < 0 || targetY >= height) continue; for (int x = 0; x < glyph.Image.Width; x++) { int targetX = gx + x; if (targetX < 0 || targetX >= width) continue; int at = targetY * width + targetX; byte alpha = glyph.Image.Pixels[y * glyph.Image.Width + x]; coverage[at] = (byte)(alpha + (coverage[at] * (255 - alpha) + 127) / 255); } } } } position += Advance(face, ch, size); previousFace = face; previous = ch; } return new(width, height, coverage); } private FontGlyph? Glyph(Face face, char character, double size) { var key = (face, character, size); if (glyphs.TryGetValue(key, out var found)) return found; var glyph = face.Font.RenderGlyph(character, (float)(size / face.UnitsPerEm)); if (glyph is null) return null; if (glyphs.Count < 4096 && cachedGlyphBytes + glyph.Image.Pixels.Length <= 8 * 1024 * 1024) { glyphs.Add(key, glyph); cachedGlyphBytes += glyph.Image.Pixels.Length; } return glyph; } private Face Primary(string family, int weight) { ArgumentNullException.ThrowIfNull(family); if (family.Contains("bootstrap-icons", StringComparison.OrdinalIgnoreCase)) return icons; if (family.Contains("mono", StringComparison.OrdinalIgnoreCase) || family.Contains("Consolas", StringComparison.OrdinalIgnoreCase) || family.Contains("Courier", StringComparison.OrdinalIgnoreCase)) return mono; return weight >= 600 ? bold : sans; } private Face Resolve(Face primary, char ch, int weight) { if (primary.Font.HasGlyph(ch)) return primary; var fallback = weight >= 600 ? cjkBold : cjk; return fallback.Font.HasGlyph(ch) ? fallback : primary; } private static char Supported(Face face, char character) => face.Font.HasGlyph(character) ? character : face.Font.HasGlyph('\uFFFD') ? '\uFFFD' : '?'; private static char Bmp(Rune rune) => rune.Value <= char.MaxValue ? (char)rune.Value : '\uFFFD'; private static double Advance(Face face, char ch, double size) { face.Font.GetCodepointHMetrics(ch, out int advance, out _); return advance * size / face.UnitsPerEm; } private static double ValidSize(double size) { if (!double.IsFinite(size) || size <= 0 || size > 1024) throw new ArgumentOutOfRangeException(nameof(size)); return size; } private static int ReadUnitsPerEm(byte[] bytes) { if (bytes.Length < 12) throw new InvalidDataException("Font header is truncated."); int count = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(4, 2)); if (count > 256 || 12L + count * 16 > bytes.Length) throw new InvalidDataException("Font directory is invalid."); for (int i = 0; i < count; i++) { int p = 12 + i * 16; uint offset = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(p + 8, 4)), length = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(p + 12, 4)); if ((ulong)offset + length > (ulong)bytes.Length) throw new InvalidDataException("Font table extends beyond its bytes."); if (bytes[p] != 'h' || bytes[p + 1] != 'e' || bytes[p + 2] != 'a' || bytes[p + 3] != 'd') continue; if (length < 54) throw new InvalidDataException("Font head table is truncated."); int em = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(checked((int)offset + 18), 2)); if (em is < 16 or > 16384) throw new InvalidDataException("Font em size is invalid."); return em; } throw new InvalidDataException("The font has no head table."); } } internal static class PlatformResources { internal static byte[] Read(string name) { using var stream = typeof(PlatformResources).Assembly.GetManifestResourceStream(name) ?? throw new InvalidOperationException("Missing bundled resource: " + name); if (stream.Length > 32 * 1024 * 1024) throw new InvalidDataException("Bundled asset is too large."); byte[] bytes = new byte[checked((int)stream.Length)]; stream.ReadExactly(bytes); return bytes; } }