diff --git a/.github/workflows/FontArtifacts.yml b/.github/workflows/FontArtifacts.yml new file mode 100644 index 00000000..9727d1c4 --- /dev/null +++ b/.github/workflows/FontArtifacts.yml @@ -0,0 +1,46 @@ +name: Verify generated font artifacts + +on: + push: + paths: + - CSharpMath.FontGenerator/** + - CSharpMath.FontGenerator.Tests/** + - CSharpMath.Rendering/Generated Fonts/** + - CSharpMath.Rendering/Reference Fonts/** + - .github/workflows/FontArtifacts.yml + pull_request: + paths: + - CSharpMath.FontGenerator/** + - CSharpMath.FontGenerator.Tests/** + - CSharpMath.Rendering/Generated Fonts/** + - CSharpMath.Rendering/Reference Fonts/** + - .github/workflows/FontArtifacts.yml + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.x + - name: Restore generator tests + run: dotnet restore CSharpMath.FontGenerator.Tests/CSharpMath.FontGenerator.Tests.csproj + - name: Build and test generator + run: dotnet test CSharpMath.FontGenerator.Tests/CSharpMath.FontGenerator.Tests.csproj --configuration Release --no-restore + - name: Verify checked-in source and payload hashes + run: >- + dotnet run --project CSharpMath.FontGenerator/CSharpMath.FontGenerator.csproj + --configuration Release --no-restore -- + verify "CSharpMath.Rendering/Reference Fonts" "CSharpMath.Rendering/Generated Fonts" + - name: Regenerate artifacts + run: >- + dotnet run --project CSharpMath.FontGenerator/CSharpMath.FontGenerator.csproj + --configuration Release --no-restore -- + generate "CSharpMath.Rendering/Reference Fonts" "$RUNNER_TEMP/csharpmath-fonts" + - name: Require byte-for-byte deterministic output + shell: bash + run: diff --recursive --unified "CSharpMath.Rendering/Generated Fonts" "$RUNNER_TEMP/csharpmath-fonts" diff --git a/.gitignore b/.gitignore index 04a3ae08..d5221ff3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ CSharpMath.Xaml.Tests.NuGet/Test.*.png # Ignore generated content /.benchmarkresults +**/GeneratedPrototype/ /.nupkgs /.testcoverage /.vscode @@ -439,4 +440,4 @@ FodyWeavers.xsd *.msi *.msix *.msm -*.msp \ No newline at end of file +*.msp diff --git a/CSharpMath.FontGenerator.Tests/CSharpMath.FontGenerator.Tests.csproj b/CSharpMath.FontGenerator.Tests/CSharpMath.FontGenerator.Tests.csproj new file mode 100644 index 00000000..73974f42 --- /dev/null +++ b/CSharpMath.FontGenerator.Tests/CSharpMath.FontGenerator.Tests.csproj @@ -0,0 +1,6 @@ + + net8.0false + + + + diff --git a/CSharpMath.FontGenerator.Tests/FontBlobTests.cs b/CSharpMath.FontGenerator.Tests/FontBlobTests.cs new file mode 100644 index 00000000..ee5e67f7 --- /dev/null +++ b/CSharpMath.FontGenerator.Tests/FontBlobTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using CSharpMath.FontGenerator; +using Xunit; + +namespace CSharpMath.FontGenerator.Tests; + +public sealed class FontBlobTests { + private static readonly string Root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../")); + private static readonly string Sources = Path.Combine(Root, "CSharpMath.Rendering", "Reference Fonts"); + private static readonly string Generated = Path.Combine(Root, "CSharpMath.Rendering", "Generated Fonts"); + private static readonly string[] Faces = { "latinmodern-math", "AMS-Capital-Blackboard-Bold", "cyrillic-modern-nmr10" }; + + [Fact] + public void AllFacesVerifyAndRepeatedGenerationIsStable() { + foreach (var face in Faces) { + var source = Path.Combine(Sources, face + ".otf"); + var blob = Path.Combine(Generated, face + ".csmfont"); + Assert.NotEmpty(FontBlob.ReadAndValidate(blob, source)); + var first = File.ReadAllBytes(blob); + using var temp = new TempDirectory(); + FontBlob.Write(source, Path.Combine(temp.Path, "font.csmfont")); + Assert.Equal(first, File.ReadAllBytes(Path.Combine(temp.Path, "font.csmfont"))); + } + } + + [Fact] + public void HeaderFailuresAreRejected() { + var source = Path.Combine(Sources, "latinmodern-math.otf"); + var original = File.ReadAllBytes(Path.Combine(Generated, "latinmodern-math.csmfont")); + foreach (var (offset, value) in new[] { (0, (byte)0), (4, (byte)0), (8, (byte)0), (120, (byte)0) }) { + var tampered = (byte[])original.Clone(); tampered[offset] ^= 0xFF; + using var temp = new TempDirectory(); var path = Path.Combine(temp.Path, "bad.csmfont"); File.WriteAllBytes(path, tampered); + Assert.Throws(() => FontBlob.ReadAndValidate(path, source)); + } + var wrongSource = Path.Combine(Sources, "cyrillic-modern-nmr10.otf"); + using var mismatch = new TempDirectory(); var mismatchPath = Path.Combine(mismatch.Path, "font.csmfont"); File.WriteAllBytes(mismatchPath, original); + Assert.Throws(() => FontBlob.ReadAndValidate(mismatchPath, wrongSource)); + } + + [Fact] + public void TableContainerExcludesShapingAndPreservesEveryOtherTable() { + foreach (var face in Faces) { + var sourceBytes = File.ReadAllBytes(Path.Combine(Sources, face + ".otf")); + var payload = FontBlob.BuildRequiredPayload(sourceBytes); + Assert.Equal(new byte[] { (byte)'T', (byte)'B', (byte)'L', 1 }, payload[..4]); + var tables = ReadTables(payload); + var tags = tables.Keys.ToArray(); + Assert.Equal(tags.OrderBy(x => x, StringComparer.Ordinal), tags); + var count = BinaryPrimitives.ReadUInt32LittleEndian(payload.AsSpan(4, 4)); var p = 8; + for (var i = 0; i < count; i++) { p += 4; var len = BinaryPrimitives.ReadUInt32LittleEndian(payload.AsSpan(p, 4)); p += 4 + checked((int)len); } + Assert.DoesNotContain("GSUB", tags); Assert.DoesNotContain("GPOS", tags); + foreach (var (tag, bytes) in SourceTables(sourceBytes)) if (tag is not ("GSUB" or "GPOS")) Assert.Equal(bytes, tables[tag]); + } + } + + [Fact] + public void ManifestFaceOrderIsStableAndVerifyRejectsTampering() { + using var generated = new TempDirectory(); + Assert.Equal(0, Program.Main(new[] { "generate", Sources, generated.Path })); + using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(generated.Path, "manifest.json"))); + var names = manifest.RootElement.GetProperty("fonts").EnumerateArray().Select(x => x.GetProperty("Source").GetString()).ToArray(); + Assert.Equal(new[] { "AMS-Capital-Blackboard-Bold.otf", "cyrillic-modern-nmr10.otf", "latinmodern-math.otf" }, names); + foreach (var file in Directory.GetFiles(Generated)) File.Copy(file, Path.Combine(generated.Path, Path.GetFileName(file)), true); + var tampered = Path.Combine(generated.Path, "latinmodern-math.csmfont"); + var bytes = File.ReadAllBytes(tampered); bytes[^1] ^= 0x80; File.WriteAllBytes(tampered, bytes); + Assert.Throws(() => Program.Main(new[] { "verify", Sources, generated.Path })); + } + + private static Dictionary ReadTables(byte[] payload) { var result = new Dictionary(StringComparer.Ordinal); var n = BinaryPrimitives.ReadUInt32LittleEndian(payload.AsSpan(4, 4)); var p = 8; for (var i = 0; i < n; i++) { var tag = System.Text.Encoding.ASCII.GetString(payload, p, 4); p += 4; var len = checked((int)BinaryPrimitives.ReadUInt32LittleEndian(payload.AsSpan(p, 4))); p += 4; result.Add(tag, payload.AsSpan(p, len).ToArray()); p += len; } return result; } + private static IEnumerable<(string Tag, byte[] Bytes)> SourceTables(byte[] bytes) { var n = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(4)); for (var i = 0; i < n; i++) { var p = 12 + i * 16; var tag = System.Text.Encoding.ASCII.GetString(bytes, p, 4); var offset = checked((int)BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(p + 8))); var len = checked((int)BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(p + 12))); yield return (tag, bytes.AsSpan(offset, len).ToArray()); } } + private sealed class TempDirectory : IDisposable { public string Path { get; } = System.IO.Directory.CreateTempSubdirectory("csmf-").FullName; public void Dispose() => Directory.Delete(Path, true); } +} diff --git a/CSharpMath.FontGenerator/CSharpMath.FontGenerator.csproj b/CSharpMath.FontGenerator/CSharpMath.FontGenerator.csproj new file mode 100644 index 00000000..ab2c9125 --- /dev/null +++ b/CSharpMath.FontGenerator/CSharpMath.FontGenerator.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/CSharpMath.FontGenerator/Program.cs b/CSharpMath.FontGenerator/Program.cs new file mode 100644 index 00000000..5be58872 --- /dev/null +++ b/CSharpMath.FontGenerator/Program.cs @@ -0,0 +1,163 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace CSharpMath.FontGenerator; + +/// Build-time format for the generated bundled-face inputs. +internal static class FontBlob { + public const uint Schema = 1; + private const uint Magic = 0x31464D43; // CMF1, little endian + private const uint LittleEndianMarker = 0x01020304; + + public static void Write(string source, string destination) { + var bytes = File.ReadAllBytes(source); + var hash = SHA256.HashData(bytes); + var name = Encoding.UTF8.GetBytes(Path.GetFileName(source)); + bytes = BuildRequiredPayload(bytes); + using var compressed = new MemoryStream(); + using (var brotli = new BrotliStream(compressed, CompressionLevel.SmallestSize, leaveOpen: true)) + brotli.Write(bytes); + var payload = compressed.ToArray(); + using var output = File.Create(destination); + Span header = stackalloc byte[88]; + BinaryPrimitives.WriteUInt32LittleEndian(header, Magic); + BinaryPrimitives.WriteUInt32LittleEndian(header[4..], Schema); + BinaryPrimitives.WriteUInt32LittleEndian(header[8..], LittleEndianMarker); + hash.CopyTo(header[12..]); + SHA256.HashData(bytes).CopyTo(header[44..]); + BinaryPrimitives.WriteUInt32LittleEndian(header[76..], (uint)name.Length); + BinaryPrimitives.WriteUInt64LittleEndian(header[80..], (ulong)payload.Length); + output.Write(header); + output.Write(name); + output.Write(payload); + } + + public static byte[] ReadAndValidate(string path, string source) { + using var input = File.OpenRead(path); + Span header = stackalloc byte[88]; + if (input.Read(header) != header.Length) throw new InvalidDataException("Font blob header is truncated."); + if (BinaryPrimitives.ReadUInt32LittleEndian(header) != Magic) throw new InvalidDataException("Font blob magic mismatch."); + if (BinaryPrimitives.ReadUInt32LittleEndian(header[4..]) != Schema) throw new InvalidDataException("Font blob schema mismatch."); + if (BinaryPrimitives.ReadUInt32LittleEndian(header[8..]) != LittleEndianMarker) throw new InvalidDataException("Font blob endianness mismatch."); + var expectedHash = header[12..44].ToArray(); + var payloadHash = header[44..76].ToArray(); + var nameLength = BinaryPrimitives.ReadUInt32LittleEndian(header[76..]); + var length = BinaryPrimitives.ReadUInt64LittleEndian(header[80..]); + if (nameLength > 1024 || length > int.MaxValue) throw new InvalidDataException("Font blob lengths are invalid."); + var nameBytes = new byte[(int)nameLength]; + try { input.ReadExactly(nameBytes); } catch (EndOfStreamException ex) { throw new InvalidDataException("Font blob name is truncated.", ex); } + var encodedName = Encoding.UTF8.GetString(nameBytes); + if (!string.Equals(encodedName, Path.GetFileName(source), StringComparison.Ordinal)) throw new InvalidDataException("Font blob source name mismatch."); + if (length > (ulong)(input.Length - input.Position)) throw new InvalidDataException("Font blob payload is truncated."); + var compressed = new byte[(int)length]; + if (input.Read(compressed) != compressed.Length) throw new InvalidDataException("Font blob payload is truncated."); + byte[] payload; + try { + using var compressedStream = new MemoryStream(compressed, writable: false); + using var brotli = new BrotliStream(compressedStream, CompressionMode.Decompress); + using var uncompressed = new MemoryStream(); + brotli.CopyTo(uncompressed); + payload = uncompressed.ToArray(); + if (compressedStream.Position != compressedStream.Length) throw new InvalidDataException("Font blob has trailing compressed data."); + } catch (Exception ex) when (ex is InvalidDataException or IOException or InvalidOperationException) { throw new InvalidDataException("Font blob compression is invalid.", ex); } + var sourceHash = SHA256.HashData(File.ReadAllBytes(source)); + if (!CryptographicOperations.FixedTimeEquals(expectedHash, sourceHash)) throw new InvalidDataException("Font source SHA-256 mismatch."); + if (!CryptographicOperations.FixedTimeEquals(payloadHash, SHA256.HashData(payload))) throw new InvalidDataException("Font blob payload SHA-256 mismatch."); + if (input.Position != input.Length) throw new InvalidDataException("Font blob has trailing data."); + return payload; + } + + // Keep only deterministic SFNT table records used by CSharpMath. GSUB/GPOS are + // deliberately omitted because the renderer does not perform OpenType shaping. + internal static byte[] BuildRequiredPayload(byte[] source) { + if (source.Length < 12) throw new InvalidDataException("Font SFNT header is truncated."); + var count = (source[4] << 8) | source[5]; + using var output = new MemoryStream(); + output.Write(new byte[] { (byte)'T', (byte)'B', (byte)'L', 1 }); + var kept = new List<(string Tag, int Offset, int Length)>(); + for (var i = 0; i < count; i++) { + var p = 12 + i * 16; + if (p + 16 > source.Length) throw new InvalidDataException("Font table directory is truncated."); + var tag = Encoding.ASCII.GetString(source, p, 4); + var offset = (int)BinaryPrimitives.ReadUInt32BigEndian(source.AsSpan(p + 8)); + var length = (int)BinaryPrimitives.ReadUInt32BigEndian(source.AsSpan(p + 12)); + if (tag is "GSUB" or "GPOS") continue; + if (offset < 0 || length < 0 || offset > source.Length - length) throw new InvalidDataException("Font table range is invalid."); + kept.Add((tag, offset, length)); + } + Span number = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(number, (uint)kept.Count); output.Write(number); + foreach (var table in kept.OrderBy(t => t.Tag, StringComparer.Ordinal)) { + output.Write(Encoding.ASCII.GetBytes(table.Tag)); + BinaryPrimitives.WriteUInt32LittleEndian(number, (uint)table.Length); output.Write(number); + output.Write(source, table.Offset, table.Length); + } + return output.ToArray(); + } +} + +internal static class Program { + private static readonly string[] Bundled = { "latinmodern-math.otf", "AMS-Capital-Blackboard-Bold.otf", "cyrillic-modern-nmr10.otf" }; + + 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]"); + 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); + Generate(sourceDirectory, outputDirectory, args.Length == 4 ? Path.GetFullPath(args[3]) : null); + return 0; + } + + private static int Verify(string sourceDirectory, string outputDirectory) { + var temporary = Directory.CreateTempSubdirectory("csmf-verify-"); + try { + Generate(sourceDirectory, temporary.FullName, null); + var expected = Directory.GetFiles(temporary.FullName, "*", SearchOption.AllDirectories).Select(p => Path.GetRelativePath(temporary.FullName, p)).OrderBy(p => p, StringComparer.Ordinal).ToArray(); + var actual = Directory.GetFiles(outputDirectory, "*", SearchOption.AllDirectories).Select(p => Path.GetRelativePath(outputDirectory, p)).OrderBy(p => p, StringComparer.Ordinal).ToArray(); + if (!expected.SequenceEqual(actual, StringComparer.Ordinal)) throw new InvalidDataException("Generated font artifact set differs from deterministic output."); + foreach (var relative in expected) { + var a = File.ReadAllBytes(Path.Combine(outputDirectory, relative)); + var b = File.ReadAllBytes(Path.Combine(temporary.FullName, relative)); + if (!a.AsSpan().SequenceEqual(b)) throw new InvalidDataException("Generated font artifact differs: " + relative); + } + Console.WriteLine("CSMF1 artifacts verified against source SHA-256."); + return 0; + } finally { temporary.Delete(true); } + } + + private static void Generate(string sourceDirectory, string outputDirectory, string? prototypeDirectory) { + Directory.CreateDirectory(outputDirectory); + var entries = new List(); + foreach (var fileName in Bundled.OrderBy(x => x, StringComparer.Ordinal)) { + var source = Path.Combine(sourceDirectory, fileName); + if (!File.Exists(source)) throw new FileNotFoundException("Bundled source font is missing", source); + var outputName = Path.ChangeExtension(fileName, ".csmfont"); + FontBlob.Write(source, Path.Combine(outputDirectory, outputName)); + var bytes = File.ReadAllBytes(source); + entries.Add(new ManifestEntry(fileName, outputName, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), bytes.Length, new FileInfo(Path.Combine(outputDirectory, outputName)).Length)); + } + 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); + } + + private static void WritePrototype(string outputDirectory, 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)); + 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"); + } + sb.AppendLine("}"); + File.WriteAllText(Path.Combine(prototypeDirectory, "BundledFontPrototype.g.cs"), sb.ToString(), new UTF8Encoding(false)); + } + + private sealed record ManifestEntry(string Source, string Output, string SourceSha256, int SourceBytes, long GeneratedBytes); +} diff --git a/CSharpMath.FontGenerator/Properties/AssemblyInfo.cs b/CSharpMath.FontGenerator/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..30d64262 --- /dev/null +++ b/CSharpMath.FontGenerator/Properties/AssemblyInfo.cs @@ -0,0 +1,2 @@ +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("CSharpMath.FontGenerator.Tests")] diff --git a/CSharpMath.FontGenerator/README.md b/CSharpMath.FontGenerator/README.md new file mode 100644 index 00000000..0c29140e --- /dev/null +++ b/CSharpMath.FontGenerator/README.md @@ -0,0 +1,28 @@ +# CSharpMath bundled-font generator + +This build-only tool turns the three checked-in reference OTFs into deterministic +`CSMF1` blobs and a stable `manifest.json`. The blob records magic, schema, +endianness, source SHA-256, source name, and payload length. `ReadAndValidate` +rejects a changed source, tampered payload, unknown schema, wrong byte order, or +truncated input before any data is consumed. + +The optional third argument emits a C# byte-array representation. It is a +prototype used for the #191 size/startup/allocation/AOT comparison; production +uses the binary representation. This envelope intentionally does not serialize +GSUB or GPOS: CSharpMath's typesetter does not perform OpenType shaping. + +Current checked-in size evidence (2026-08-31): raw OTF total 786,744 bytes; +CSMF1/TBL1 total 415,661 bytes (47.2% smaller). The optional C# prototype is +generated in a temporary directory for measurement only (1,900,468 bytes +total), and is not checked in. Fixed-width records and no reflection or +dynamic serialization keep the binary representation suitable for +AOT/trimming. This artifact phase is **not runtime-consumed yet** and changes +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. + +Example: + +```text +dotnet run --project CSharpMath.FontGenerator -- generate "CSharpMath.Rendering/Reference Fonts" generated generated-csharp +``` diff --git a/CSharpMath.Rendering/Generated Fonts/AMS-Capital-Blackboard-Bold.csmfont b/CSharpMath.Rendering/Generated Fonts/AMS-Capital-Blackboard-Bold.csmfont new file mode 100644 index 00000000..be2bc290 Binary files /dev/null and b/CSharpMath.Rendering/Generated Fonts/AMS-Capital-Blackboard-Bold.csmfont differ diff --git a/CSharpMath.Rendering/Generated Fonts/cyrillic-modern-nmr10.csmfont b/CSharpMath.Rendering/Generated Fonts/cyrillic-modern-nmr10.csmfont new file mode 100644 index 00000000..5d27ab4a Binary files /dev/null and b/CSharpMath.Rendering/Generated Fonts/cyrillic-modern-nmr10.csmfont differ diff --git a/CSharpMath.Rendering/Generated Fonts/latinmodern-math.csmfont b/CSharpMath.Rendering/Generated Fonts/latinmodern-math.csmfont new file mode 100644 index 00000000..3224e014 Binary files /dev/null and b/CSharpMath.Rendering/Generated Fonts/latinmodern-math.csmfont differ diff --git a/CSharpMath.Rendering/Generated Fonts/manifest.json b/CSharpMath.Rendering/Generated Fonts/manifest.json new file mode 100644 index 00000000..81ff0455 --- /dev/null +++ b/CSharpMath.Rendering/Generated Fonts/manifest.json @@ -0,0 +1,29 @@ +{ + "schema": 1, + "byteOrder": "little", + "format": "CSMF1", + "shaping": "GSUB/GPOS intentionally excluded; CSharpMath does not shape text with them.", + "fonts": [ + { + "Source": "AMS-Capital-Blackboard-Bold.otf", + "Output": "AMS-Capital-Blackboard-Bold.csmfont", + "SourceSha256": "9578b5b9c86e6ab03846080b9d6fa4f7bc6b3044ac15604b8e7bfd4330295dda", + "SourceBytes": 8716, + "GeneratedBytes": 5417 + }, + { + "Source": "cyrillic-modern-nmr10.otf", + "Output": "cyrillic-modern-nmr10.csmfont", + "SourceSha256": "5b8e360154685a1117e7f93542b89d5263db58f41a40d6df8f131c5fe5032c0c", + "SourceBytes": 44292, + "GeneratedBytes": 23348 + }, + { + "Source": "latinmodern-math.otf", + "Output": "latinmodern-math.csmfont", + "SourceSha256": "6075562b771f8b82f0c179e363389684f2dd09de30038269e2628e504bd7be0f", + "SourceBytes": 733736, + "GeneratedBytes": 386896 + } + ] +} diff --git a/CSharpMath.slnx b/CSharpMath.slnx index 305791b5..58578246 100644 --- a/CSharpMath.slnx +++ b/CSharpMath.slnx @@ -96,6 +96,12 @@ + + + + + +