diff --git a/CSharpMath.FontGenerator.Tests/Generated/AMS-Capital-Blackboard-Bold.csmrecord b/CSharpMath.FontGenerator.Tests/Generated/AMS-Capital-Blackboard-Bold.csmrecord new file mode 100644 index 00000000..adbd205f Binary files /dev/null and b/CSharpMath.FontGenerator.Tests/Generated/AMS-Capital-Blackboard-Bold.csmrecord differ diff --git a/CSharpMath.FontGenerator.Tests/Generated/BundledFontPrototype.g.cs b/CSharpMath.FontGenerator.Tests/Generated/BundledFontPrototype.g.cs new file mode 100644 index 00000000..827a4f3f --- /dev/null +++ b/CSharpMath.FontGenerator.Tests/Generated/BundledFontPrototype.g.cs @@ -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 }; + +} diff --git a/CSharpMath.FontGenerator.Tests/Generated/cyrillic-modern-nmr10.csmrecord b/CSharpMath.FontGenerator.Tests/Generated/cyrillic-modern-nmr10.csmrecord new file mode 100644 index 00000000..880699b0 Binary files /dev/null and b/CSharpMath.FontGenerator.Tests/Generated/cyrillic-modern-nmr10.csmrecord differ diff --git a/CSharpMath.FontGenerator.Tests/Generated/latinmodern-math.csmrecord b/CSharpMath.FontGenerator.Tests/Generated/latinmodern-math.csmrecord new file mode 100644 index 00000000..f40d1216 Binary files /dev/null and b/CSharpMath.FontGenerator.Tests/Generated/latinmodern-math.csmrecord differ diff --git a/CSharpMath.FontGenerator.Tests/RendererRecordTests.cs b/CSharpMath.FontGenerator.Tests/RendererRecordTests.cs new file mode 100644 index 00000000..bf062122 --- /dev/null +++ b/CSharpMath.FontGenerator.Tests/RendererRecordTests.cs @@ -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[] { + 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(() => RendererRecordCodec.ReadAndValidate(blob.Path, source.Path)); + } + using var trailing = new TempFile(original.Concat(new byte[] { 0 }).ToArray()); + Assert.Throws(() => RendererRecordCodec.ReadAndValidate(trailing.Path, source.Path)); + File.WriteAllBytes(source.Path, new byte[] { 7 }); + using var drift = new TempFile(original); + Assert.Throws(() => 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); } + } +} diff --git a/CSharpMath.FontGenerator/Program.cs b/CSharpMath.FontGenerator/Program.cs index 5be58872..5e515137 100644 --- a/CSharpMath.FontGenerator/Program.cs +++ b/CSharpMath.FontGenerator/Program.cs @@ -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 [prototype-directory]"); + Console.Error.WriteLine("Usage: CSharpMath.FontGenerator generate [prototype-directory] | verify [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); @@ -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); } @@ -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 entries) { + private static void WritePrototype(string sourceDirectory, string prototypeDirectory, IReadOnlyList 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"); } diff --git a/CSharpMath.FontGenerator/README.md b/CSharpMath.FontGenerator/README.md index 0c29140e..aab805d9 100644 --- a/CSharpMath.FontGenerator/README.md +++ b/CSharpMath.FontGenerator/README.md @@ -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 diff --git a/CSharpMath.FontGenerator/RendererRecord.cs b/CSharpMath.FontGenerator/RendererRecord.cs new file mode 100644 index 00000000..9492699f --- /dev/null +++ b/CSharpMath.FontGenerator/RendererRecord.cs @@ -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 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 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(); 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); + } +}