Skip to content
Open
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
Binary file not shown.
12 changes: 12 additions & 0 deletions CSharpMath.FontGenerator.Tests/Generated/BundledFontPrototype.g.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Generated by CSharpMath.FontGenerator. Do not edit.
namespace CSharpMath.Generated;

internal static class BundledFontPrototype
{
internal static readonly byte[] AMS_Capital_Blackboard_Bold = new byte[] { 67, 77, 82, 49, 1, 0, 0, 0, 4, 3, 2, 1, 96, 0, 0, 0, 1, 0, 0, 0, 96, 0, 0, 0, 24, 0, 0, 0, 149, 120, 181, 185, 200, 110, 106, 176, 56, 70, 8, 11, 157, 111, 164, 247, 188, 107, 48, 68, 172, 21, 96, 75, 142, 123, 253, 67, 48, 41, 93, 218, 24, 69, 60, 202, 198, 102, 14, 79, 234, 151, 177, 88, 6, 91, 33, 22, 124, 229, 145, 126, 145, 243, 125, 13, 115, 147, 236, 241, 79, 217, 235, 225, 0, 0, 0, 0, 78, 65, 77, 69, 120, 0, 0, 0, 31, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 65, 77, 83, 45, 67, 97, 112, 105, 116, 97, 108, 45, 66, 108, 97, 99, 107, 98, 111, 97, 114, 100, 45, 66, 111, 108, 100, 46, 111, 116, 102 };

internal static readonly byte[] cyrillic_modern_nmr10 = new byte[] { 67, 77, 82, 49, 1, 0, 0, 0, 4, 3, 2, 1, 96, 0, 0, 0, 1, 0, 0, 0, 96, 0, 0, 0, 24, 0, 0, 0, 91, 142, 54, 1, 84, 104, 90, 17, 23, 231, 249, 53, 66, 184, 157, 82, 99, 219, 88, 244, 26, 64, 214, 223, 143, 19, 28, 95, 229, 3, 44, 12, 120, 232, 87, 167, 54, 141, 101, 77, 222, 251, 34, 226, 21, 66, 99, 206, 163, 32, 40, 253, 48, 141, 246, 243, 86, 55, 186, 211, 7, 232, 207, 185, 0, 0, 0, 0, 78, 65, 77, 69, 120, 0, 0, 0, 25, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 99, 121, 114, 105, 108, 108, 105, 99, 45, 109, 111, 100, 101, 114, 110, 45, 110, 109, 114, 49, 48, 46, 111, 116, 102 };

internal static readonly byte[] latinmodern_math = new byte[] { 67, 77, 82, 49, 1, 0, 0, 0, 4, 3, 2, 1, 96, 0, 0, 0, 1, 0, 0, 0, 96, 0, 0, 0, 24, 0, 0, 0, 96, 117, 86, 43, 119, 31, 139, 130, 240, 193, 121, 227, 99, 56, 150, 132, 242, 221, 9, 222, 48, 3, 130, 105, 226, 98, 142, 80, 75, 215, 190, 15, 140, 47, 37, 155, 192, 18, 251, 229, 64, 230, 192, 174, 179, 91, 90, 161, 86, 67, 234, 26, 75, 212, 185, 82, 243, 244, 21, 218, 3, 223, 63, 102, 0, 0, 0, 0, 78, 65, 77, 69, 120, 0, 0, 0, 20, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 108, 97, 116, 105, 110, 109, 111, 100, 101, 114, 110, 45, 109, 97, 116, 104, 46, 111, 116, 102 };

}
Binary file not shown.
Binary file not shown.
79 changes: 79 additions & 0 deletions CSharpMath.FontGenerator.Tests/RendererRecordTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using CSharpMath.FontGenerator;
using Xunit;

namespace CSharpMath.FontGenerator.Tests;

public sealed class RendererRecordTests {
private const int DirectoryOffset = RendererRecordCodec.HeaderSize;
[Fact]
public void SyntheticRecordRoundTripsByteForByteAndIsOrdered() {
using var source = new TempFile(new byte[] { 1, 2, 3 });
var hash = SHA256.HashData(File.ReadAllBytes(source.Path));
var face = new RendererFace("synthetic.otf", hash, new[] {
new RendererSection("glyf", 1, new byte[] { 3, 2, 1 }),
new RendererSection("head", 1, new byte[] { 9 })
});
var bytes = RendererRecordCodec.Serialize(face);
using var blob = new TempFile(bytes);
var read = RendererRecordCodec.ReadAndValidate(blob.Path, source.Path);
Assert.Equal(bytes, RendererRecordCodec.Serialize(read));
Assert.Equal(new[] { "glyf", "head" }, read.Sections.Select(x => x.Tag));
}

[Fact]
public void BundledFacesHaveStableLogicalRoundTrips() {
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../"));
var dir = Path.Combine(root, "CSharpMath.Rendering", "Reference Fonts");
foreach (var name in new[] { "latinmodern-math", "AMS-Capital-Blackboard-Bold", "cyrillic-modern-nmr10" }) {
var source = Path.Combine(dir, name + ".otf");
var first = RendererRecordCodec.Serialize(RendererRecordCodec.Build(source));
using var blob = new TempFile(first);
Assert.Equal(first, RendererRecordCodec.Serialize(RendererRecordCodec.ReadAndValidate(blob.Path, source)));
}
}

[Fact]
public void RejectsHeaderBoundsOverlapTruncationSourceDriftAndTrailingBytes() {
using var source = new TempFile(new byte[] { 4, 5, 6 });
var face = new RendererFace("synthetic.otf", SHA256.HashData(File.ReadAllBytes(source.Path)), new[] { new RendererSection("head", 1, new byte[] { 1, 2 }) });
var original = RendererRecordCodec.Serialize(face);
foreach (var mutate in new Action<byte[]>[] {
x => x[0] ^= 1, x => x[4] ^= 1, x => x[8] ^= 1, x => x[20] = 0, x => x[36] ^= 1,
x => x[DirectoryOffset + 4] = 0, x => x[DirectoryOffset + 8] = 0, x => x[DirectoryOffset + 16] = 1, x => x[^1] ^= 1
}) {
var bytes = (byte[])original.Clone(); mutate(bytes); using var blob = new TempFile(bytes);
Assert.Throws<InvalidDataException>(() => RendererRecordCodec.ReadAndValidate(blob.Path, source.Path));
}
using var trailing = new TempFile(original.Concat(new byte[] { 0 }).ToArray());
Assert.Throws<InvalidDataException>(() => RendererRecordCodec.ReadAndValidate(trailing.Path, source.Path));
File.WriteAllBytes(source.Path, new byte[] { 7 });
using var drift = new TempFile(original);
Assert.Throws<InvalidDataException>(() => RendererRecordCodec.ReadAndValidate(drift.Path, source.Path));
}

[Fact]
public void BundledFacesHaveDistinctSourceIdentity() {
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../")); var dir = Path.Combine(root, "CSharpMath.Rendering", "Reference Fonts");
var hashes = new[] { "latinmodern-math", "AMS-Capital-Blackboard-Bold", "cyrillic-modern-nmr10" }.Select(x => RendererRecordCodec.Build(Path.Combine(dir, x + ".otf")).SourceHash).Select(Convert.ToHexString).ToArray();
Assert.Equal(3, hashes.Distinct(StringComparer.Ordinal).Count());
}

[Fact]
public void BundledRecordsContainIdentityOnly() {
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../")); var dir = Path.Combine(root, "CSharpMath.Rendering", "Reference Fonts");
foreach (var name in new[] { "latinmodern-math", "AMS-Capital-Blackboard-Bold", "cyrillic-modern-nmr10" }) {
var face = RendererRecordCodec.Build(Path.Combine(dir, name + ".otf"));
Assert.Single(face.Sections); Assert.Equal("NAME", face.Sections[0].Tag); Assert.Equal(name + ".otf", System.Text.Encoding.UTF8.GetString(face.Sections[0].Data));
}
}

private sealed class TempFile : IDisposable {
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "csmr-" + Guid.NewGuid().ToString("N"));
public TempFile(byte[] bytes) => File.WriteAllBytes(Path, bytes);
public void Dispose() { if (File.Exists(Path)) File.Delete(Path); }
}
}
22 changes: 16 additions & 6 deletions CSharpMath.FontGenerator/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,17 @@ internal static class Program {

public static int Main(string[] args) {
if ((args.Length is < 3 or > 4) || (!string.Equals(args[0], "generate", StringComparison.OrdinalIgnoreCase) && !string.Equals(args[0], "verify", StringComparison.OrdinalIgnoreCase))) {
Console.Error.WriteLine("Usage: CSharpMath.FontGenerator generate <reference-font-directory> <output-directory> [prototype-directory]");
Console.Error.WriteLine("Usage: CSharpMath.FontGenerator generate <reference-font-directory> <output-directory> [prototype-directory] | verify <reference-font-directory> <output-directory> [prototype-directory]");
return 2;
}
var sourceDirectory = Path.GetFullPath(args[1]);
var outputDirectory = Path.GetFullPath(args[2]);
if (string.Equals(args[0], "verify", StringComparison.OrdinalIgnoreCase)) return Verify(sourceDirectory, outputDirectory);
if (string.Equals(args[0], "verify", StringComparison.OrdinalIgnoreCase)) return Verify(sourceDirectory, outputDirectory, args.Length == 4 ? Path.GetFullPath(args[3]) : null);
Generate(sourceDirectory, outputDirectory, args.Length == 4 ? Path.GetFullPath(args[3]) : null);
return 0;
}

private static int Verify(string sourceDirectory, string outputDirectory) {
private static int Verify(string sourceDirectory, string outputDirectory, string? prototypeDirectory) {
var temporary = Directory.CreateTempSubdirectory("csmf-verify-");
try {
Generate(sourceDirectory, temporary.FullName, null);
Expand All @@ -126,6 +126,14 @@ private static int Verify(string sourceDirectory, string outputDirectory) {
var b = File.ReadAllBytes(Path.Combine(temporary.FullName, relative));
if (!a.AsSpan().SequenceEqual(b)) throw new InvalidDataException("Generated font artifact differs: " + relative);
}
var rendererPrototype = Path.Combine(temporary.FullName, "renderer-csharp");
WritePrototype(sourceDirectory, rendererPrototype, Bundled.OrderBy(x => x, StringComparer.Ordinal).Select(x => new ManifestEntry(x, Path.ChangeExtension(x, ".csmfont"), "", 0, 0)).ToArray());
if (prototypeDirectory != null) {
var expectedFiles = Directory.GetFiles(rendererPrototype).Select(Path.GetFileName).OrderBy(x => x, StringComparer.Ordinal).ToArray();
var actualFiles = Directory.GetFiles(prototypeDirectory).Select(Path.GetFileName).OrderBy(x => x, StringComparer.Ordinal).ToArray();
if (!expectedFiles.SequenceEqual(actualFiles, StringComparer.Ordinal)) throw new InvalidDataException("Generated renderer-record artifact set differs.");
foreach (var file in expectedFiles) if (!File.ReadAllBytes(Path.Combine(rendererPrototype, file!)).AsSpan().SequenceEqual(File.ReadAllBytes(Path.Combine(prototypeDirectory, file!)))) throw new InvalidDataException("Generated renderer-record artifact differs: " + file);
}
Console.WriteLine("CSMF1 artifacts verified against source SHA-256.");
return 0;
} finally { temporary.Delete(true); }
Expand All @@ -144,14 +152,16 @@ private static void Generate(string sourceDirectory, string outputDirectory, str
}
var manifest = new { schema = FontBlob.Schema, byteOrder = "little", format = "CSMF1", shaping = "GSUB/GPOS intentionally excluded; CSharpMath does not shape text with them.", fonts = entries };
File.WriteAllText(Path.Combine(outputDirectory, "manifest.json"), JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }) + Environment.NewLine, new UTF8Encoding(false));
if (prototypeDirectory != null) WritePrototype(outputDirectory, prototypeDirectory, entries);
if (prototypeDirectory != null) WritePrototype(sourceDirectory, prototypeDirectory, entries);
}

private static void WritePrototype(string outputDirectory, string prototypeDirectory, IReadOnlyList<ManifestEntry> entries) {
private static void WritePrototype(string sourceDirectory, string prototypeDirectory, IReadOnlyList<ManifestEntry> entries) {
Directory.CreateDirectory(prototypeDirectory);
var sb = new StringBuilder("// Generated by CSharpMath.FontGenerator. Do not edit.\nnamespace CSharpMath.Generated;\n\ninternal static class BundledFontPrototype\n{\n");
foreach (var entry in entries) {
var bytes = File.ReadAllBytes(Path.Combine(outputDirectory, entry.Output));
// The binary and C# forms are emitted from the same logical record model.
var bytes = RendererRecordCodec.Serialize(RendererRecordCodec.Build(Path.Combine(sourceDirectory, entry.Source)));
File.WriteAllBytes(Path.Combine(prototypeDirectory, Path.ChangeExtension(entry.Output, ".csmrecord")), bytes);
sb.Append(" internal static readonly byte[] ").Append(Path.GetFileNameWithoutExtension(entry.Output).Replace('-', '_')).Append(" = new byte[] { ");
sb.Append(string.Join(", ", bytes.Select(b => b.ToString()))).AppendLine(" };\n");
}
Expand Down
22 changes: 22 additions & 0 deletions CSharpMath.FontGenerator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,28 @@ no startup behavior. The #293 cold-process methodology (fresh process,
first-render timing, allocated/retained managed bytes) carries to #321 for the
runtime decoder comparison.

The `CMR1` renderer-record schema is a separate versioned envelope. Its fixed
little-endian header is followed by a sorted directory of fixed-width records;
each record contains an explicit offset, byte length, and logical count. Readers
reject unknown schema/byte order, out-of-range or overlapping sections,
truncation, source-hash drift, and trailing bytes. Schema versions are not
silently upgraded: an incompatible layout requires a new schema number.
Binary and generated-C# prototypes are serialized from the same immutable
logical model, with ordinal face and section ordering. Counts and lengths are
capped before allocation. The codec uses only BCL APIs and fixed records, with
no reflection or dynamic code, so it is AOT/trimming-safe; applications using
linker trimming should preserve the codec entry points.

Generation keeps legacy `.csmfont` artifacts and can emit `.csmrecord` CMR1
artifacts plus `BundledFontPrototype.g.cs`; these are build/measurement inputs,
never runtime inputs. `verify` accepts an optional prototype directory and
compares its complete immutable file set with fresh temporary regeneration.
Source inputs are capped at 16 MiB, records at 128 MiB, and directories at
4096 sections before allocation; offset arithmetic is checked.
The current CMR1 logical model intentionally contains only the face identity
(`NAME`) and source hash; font tables and rendering payloads are deferred to
later schema work.

Example:

```text
Expand Down
43 changes: 43 additions & 0 deletions CSharpMath.FontGenerator/RendererRecord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;

namespace CSharpMath.FontGenerator;

internal sealed record RendererFace(string Name, byte[] SourceHash, IReadOnlyList<RendererSection> Sections);
internal sealed record RendererSection(string Tag, uint Count, byte[] Data);

internal static class RendererRecordCodec {
internal const uint Schema = 1, Magic = 0x31524D43, LittleEndianMarker = 0x01020304;
internal const int HeaderSize = 96, DirectoryEntrySize = 24;
internal const int MaxSections = 4096, MaxPayload = 128 * 1024 * 1024, MaxSource = 16 * 1024 * 1024;
private static bool NonZero(ReadOnlySpan<byte> bytes) { foreach (var b in bytes) if (b != 0) return true; return false; }

private static byte[] ReadBounded(string path, int max) {
var length = new FileInfo(path).Length;
if (length > max || length > int.MaxValue) throw new InvalidDataException("Input exceeds format limit.");
var bytes = new byte[(int)length]; using var stream = File.OpenRead(path); stream.ReadExactly(bytes); return bytes;
}
internal static RendererFace Build(string source) => Build(Path.GetFileName(source), ReadBounded(source, MaxSource));
internal static RendererFace Build(string name, byte[] source) {
if (Encoding.UTF8.GetByteCount(name) > 1024) throw new InvalidDataException("Face name exceeds limit.");
return new RendererFace(name, SHA256.HashData(source), new[] { new RendererSection("NAME", 1, Encoding.UTF8.GetBytes(name)) });
}
internal static byte[] Serialize(RendererFace face) {
if (face.SourceHash.Length != 32 || face.Sections.Count > MaxSections || face.Sections.GroupBy(x => x.Tag, StringComparer.Ordinal).Any(x => x.Count() != 1)) throw new InvalidDataException("Invalid face metadata.");
var sections = face.Sections.OrderBy(x => x.Tag, StringComparer.Ordinal).ToArray(); if (sections.Any(x => x.Tag.Length != 4 || x.Count == 0 || x.Data.Length > MaxPayload)) throw new InvalidDataException("Invalid section metadata.");
var dataStart = checked(HeaderSize + sections.Length * DirectoryEntrySize); var total = checked(dataStart + sections.Sum(x => x.Data.Length)); if (total > MaxPayload) throw new InvalidDataException("Record exceeds limit.");
var bytes = new byte[total]; var h = bytes.AsSpan(0, HeaderSize); BinaryPrimitives.WriteUInt32LittleEndian(h, Magic); BinaryPrimitives.WriteUInt32LittleEndian(h[4..], Schema); BinaryPrimitives.WriteUInt32LittleEndian(h[8..], LittleEndianMarker); BinaryPrimitives.WriteUInt32LittleEndian(h[12..], HeaderSize); BinaryPrimitives.WriteUInt32LittleEndian(h[16..], (uint)sections.Length); BinaryPrimitives.WriteUInt32LittleEndian(h[20..], HeaderSize); BinaryPrimitives.WriteUInt32LittleEndian(h[24..], (uint)(sections.Length * DirectoryEntrySize)); face.SourceHash.CopyTo(h[28..]);
var offset = dataStart; for (var i = 0; i < sections.Length; i++) { var e = bytes.AsSpan(HeaderSize + i * DirectoryEntrySize, DirectoryEntrySize); Encoding.ASCII.GetBytes(sections[i].Tag).CopyTo(e); BinaryPrimitives.WriteUInt32LittleEndian(e[4..], (uint)offset); BinaryPrimitives.WriteUInt32LittleEndian(e[8..], (uint)sections[i].Data.Length); BinaryPrimitives.WriteUInt32LittleEndian(e[12..], sections[i].Count); sections[i].Data.CopyTo(bytes.AsSpan(offset)); offset += sections[i].Data.Length; }
SHA256.HashData(bytes.AsSpan(dataStart)).CopyTo(bytes.AsSpan(60, 32)); return bytes;
}
internal static void Write(string source, string destination) => File.WriteAllBytes(destination, Serialize(Build(source)));
internal static RendererFace ReadAndValidate(string path, string source) {
var bytes = ReadBounded(path, MaxPayload); if (bytes.Length < HeaderSize) throw new InvalidDataException("Record header is truncated."); var h = bytes.AsSpan();
if (BinaryPrimitives.ReadUInt32LittleEndian(h) != Magic || BinaryPrimitives.ReadUInt32LittleEndian(h[4..]) != Schema || BinaryPrimitives.ReadUInt32LittleEndian(h[8..]) != LittleEndianMarker || BinaryPrimitives.ReadUInt32LittleEndian(h[12..]) != HeaderSize) throw new InvalidDataException("Record header is invalid.");
if (NonZero(h[92..96])) throw new InvalidDataException("Header reserved bytes are nonzero.");
var n = BinaryPrimitives.ReadUInt32LittleEndian(h[16..]); var dir = BinaryPrimitives.ReadUInt32LittleEndian(h[20..]); var dl = BinaryPrimitives.ReadUInt32LittleEndian(h[24..]); if (n > MaxSections || dir != HeaderSize || dl != n * DirectoryEntrySize || dl > bytes.Length - dir) throw new InvalidDataException("Directory bounds are invalid."); if (!CryptographicOperations.FixedTimeEquals(h[28..60], SHA256.HashData(ReadBounded(source, MaxSource)))) throw new InvalidDataException("Source hash mismatch.");
var sections = new List<RendererSection>(); var previous = ""; var end = checked((int)(dir + dl)); for (var i = 0; i < n; i++) { var e = bytes.AsSpan((int)dir + i * DirectoryEntrySize, DirectoryEntrySize); if (NonZero(e[16..24])) throw new InvalidDataException("Directory reserved bytes are nonzero."); var tag = Encoding.ASCII.GetString(e[..4]); var offset = BinaryPrimitives.ReadUInt32LittleEndian(e[4..]); var length = BinaryPrimitives.ReadUInt32LittleEndian(e[8..]); var count = BinaryPrimitives.ReadUInt32LittleEndian(e[12..]); if (string.CompareOrdinal(previous, tag) >= 0 || count == 0 || offset < end || offset > bytes.Length || length > bytes.Length - offset) throw new InvalidDataException("Section bounds or ordering are invalid."); sections.Add(new RendererSection(tag, count, bytes.AsSpan((int)offset, (int)length).ToArray())); previous = tag; end = checked((int)(offset + length)); }
if (end != bytes.Length || !CryptographicOperations.FixedTimeEquals(h[60..92], SHA256.HashData(bytes.AsSpan((int)(dir + dl))))) throw new InvalidDataException("Record payload is invalid."); return new RendererFace(Path.GetFileName(source), h[28..60].ToArray(), sections);
}
}
Loading