diff --git a/CSharpMath.Rendering.Benchmarks/Program.cs b/CSharpMath.Rendering.Benchmarks/Program.cs index 3bb44b08..cd12b3f4 100644 --- a/CSharpMath.Rendering.Benchmarks/Program.cs +++ b/CSharpMath.Rendering.Benchmarks/Program.cs @@ -79,12 +79,55 @@ public static void Run(string[] args) { public static void Worker(string scenario, string[] args) { try { int iterations = GetInt(args, "--font-profile-iterations", 10), batches = GetInt(args, "--font-profile-batches", 3); var result = new Sample { Scenario = scenario }; long before = GC.GetTotalAllocatedBytes(true), retainedBefore = GC.GetTotalMemory(true); int[] gcBefore = { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) }; var timer = Stopwatch.StartNew(); - if (scenario.StartsWith("parse:", StringComparison.Ordinal)) result.Font = Describe(scenario[6..], ReadBundled(scenario[6..]), "embedded Reference Fonts"); else if (scenario.StartsWith("font:", StringComparison.Ordinal)) { byte[] face = ReadBundled(scenario[5..]); result.Font = Describe(scenario[5..], face, "embedded Reference Fonts"); result.ExercisedPaths.Add("LocalTypeface:" + scenario[5..]); result.GlyphCorpus = GlyphCorpusFor(face, scenario[5..]); result.GlyphsAvailable = HasCorpusGlyphs(face, result.GlyphCorpus); for (int i = 0; i < iterations; i++) RenderMath(result.GlyphCorpus, face); } else if (scenario == "first-math") { result.ExercisedPaths.Add("global math parse/layout/draw"); RenderMath(Math); } else if (scenario == "first-mixed") { result.ExercisedPaths.Add("TextPainter text+math layout/draw"); RenderText(Mixed); } else if (scenario == "global-batch" || scenario == "custom-comic-neue") { byte[] custom = ReadCustom(); var faces = Bundled.Select(ReadBundled).Concat(new[] { custom }).ToArray(); result.SwitchingSequence = Bundled.Concat(new[] { "ComicNeue_Bold.otf" }).ToArray(); var corpora = faces.Select((face, i) => { string corpus = GlyphCorpusFor(face, result.SwitchingSequence[i]); return new FaceRenderEvidence { Face = result.SwitchingSequence[i], RequestedCorpus = corpus, CmapGlyphIndices = GlyphIndicesFor(face, corpus), CmapValidated = HasCorpusGlyphs(face, corpus), LocalTypeface = true }; }).ToArray(); if (scenario == "custom-comic-neue") { result.GlyphCorpus = corpora[^1].RequestedCorpus; result.GlyphsAvailable = corpora[^1].CmapValidated; } int iterationsToRun = System.Math.Max(iterations, faces.Length); for (int batch = 0; batch < batches; batch++) { long batchBefore = GC.GetTotalMemory(true); for (int i = 0; i < iterationsToRun; i++) { int selected = i % faces.Length; var evidence = corpora[selected]; RenderMath(evidence.RequestedCorpus, faces[selected]); RenderText(Mixed); evidence.DrawSucceeded = true; result.SelectedFaces.Add(evidence.Face); if (!result.GlobalFaces.Any(f => f.Face == evidence.Face)) result.GlobalFaces.Add(evidence); } GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); result.Batches.Add(new BatchResult { Number = batch, RetainedManagedBytesAfterGc = GC.GetTotalMemory(true) - batchBefore }); } result.ExercisedPaths.Add("repeated local-face switching with cmap-validated corpora"); } else throw new ArgumentException("unknown profile worker scenario: " + scenario); + if (scenario.StartsWith("parse:", StringComparison.Ordinal)) result.Font = Describe(scenario[6..], ReadBundled(scenario[6..]), "embedded Reference Fonts"); else if (scenario.StartsWith("font:", StringComparison.Ordinal)) { byte[] face = ReadBundled(scenario[5..]); result.Font = Describe(scenario[5..], face, "embedded Reference Fonts"); result.ExercisedPaths.Add("LocalTypeface:" + scenario[5..]); result.GlyphCorpus = GlyphCorpusFor(face, scenario[5..]); result.GlyphsAvailable = HasCorpusGlyphs(face, result.GlyphCorpus); for (int i = 0; i < iterations; i++) RenderMath(result.GlyphCorpus, face); } else if (scenario == "first-math") { result.ExercisedPaths.Add("global math parse/layout/draw"); RenderMath(Math); } else if (scenario == "first-mixed") { result.ExercisedPaths.Add("TextPainter text+math layout/draw"); RenderText(Mixed); } else if (scenario == "global-batch" || scenario == "custom-comic-neue") RunSwitchingScenario(scenario, iterations, batches, result); else throw new ArgumentException("unknown profile worker scenario: " + scenario); timer.Stop(); result.WallMilliseconds = timer.Elapsed.TotalMilliseconds; result.TotalManagedAllocatedBytes = GC.GetTotalAllocatedBytes(true) - before; result.RetainedManagedBytesAfterGc = GC.GetTotalMemory(true) - retainedBefore; result.GcCollections = new[] { GC.CollectionCount(0) - gcBefore[0], GC.CollectionCount(1) - gcBefore[1], GC.CollectionCount(2) - gcBefore[2] }; Console.Write(JsonSerializer.Serialize(result, Json)); } catch (Exception ex) { Console.Write(JsonSerializer.Serialize(new Sample { Scenario = scenario, Errors = new List { ex.ToString() } }, Json)); Environment.ExitCode = 1; } } static void RenderMath(string latex, byte[]? custom = null) { MemoryStream? stream = null; Typeface[] faces = Array.Empty(); if (custom != null) { var face = ReadTypeface(custom, out stream); faces = new[] { face }; } using (stream) { var painter = new MathPainter { LaTeX = latex, LocalTypefaces = faces }; using (painter.DrawAsStream()) { } } } static void RenderText(string latex) { var painter = new TextPainter { LaTeX = latex }; using (painter.DrawAsStream()) { } } + static void RunSwitchingScenario(string scenario, int iterations, int batches, Sample result) { + byte[] custom = ReadCustom(); + var faceBytes = Bundled.Select(ReadBundled).Concat(new[] { custom }).ToArray(); + result.SwitchingSequence = Bundled.Concat(new[] { "ComicNeue_Bold.otf" }).ToArray(); + var corpora = faceBytes.Select((face, i) => { + string corpus = GlyphCorpusFor(face, result.SwitchingSequence[i]); + return new FaceRenderEvidence { Face = result.SwitchingSequence[i], RequestedCorpus = corpus, CmapGlyphIndices = GlyphIndicesFor(face, corpus), CmapValidated = HasCorpusGlyphs(face, corpus), LocalTypeface = true }; + }).ToArray(); + if (scenario == "custom-comic-neue") { + result.GlyphCorpus = corpora[^1].RequestedCorpus; + result.GlyphsAvailable = corpora[^1].CmapValidated; + } + var streams = new MemoryStream[faceBytes.Length]; + var parsedFaces = new Typeface[faceBytes.Length]; + try { + for (int i = 0; i < faceBytes.Length; i++) + parsedFaces[i] = ReadTypeface(faceBytes[i], out streams[i]); + int iterationsToRun = System.Math.Max(iterations, parsedFaces.Length); + var painter = new MathPainter(); + for (int batch = 0; batch < batches; batch++) { + long batchBefore = GC.GetTotalMemory(true); + for (int i = 0; i < iterationsToRun; i++) { + int selected = i % parsedFaces.Length; + var evidence = corpora[selected]; + painter.LocalTypefaces = new[] { parsedFaces[selected] }; + painter.FontSize = 14 + selected; + painter.LaTeX = evidence.RequestedCorpus; + using (painter.DrawAsStream()) { } + RenderText(Mixed); + evidence.DrawSucceeded = true; + result.SelectedFaces.Add(evidence.Face); + if (!result.GlobalFaces.Any(f => f.Face == evidence.Face)) result.GlobalFaces.Add(evidence); + } + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + result.Batches.Add(new BatchResult { Number = batch, RetainedManagedBytesAfterGc = GC.GetTotalMemory(true) - batchBefore }); + } + } finally { + foreach (var stream in streams) stream?.Dispose(); + } + result.ExercisedPaths.Add("same MathPainter repeated local-face and FontSize switching with cmap-validated corpora"); + } static string CorpusFor(string name) => name.StartsWith("cyrillic", StringComparison.Ordinal) ? "\u0410 \u0411 \u0412 \u0413 \u0414 \u0416 \u042F" : name.StartsWith("AMS-", StringComparison.Ordinal) ? FindAvailableGlyphs(ReadBundled(name), 7) : name.StartsWith("Comic", StringComparison.Ordinal) ? "Comic Neue sample" : Math; static string GlyphCorpusFor(byte[] bytes, string name) => name.StartsWith("AMS-", StringComparison.Ordinal) ? "ℂℍℕℙℚℝℤ" : name.StartsWith("cyrillic", StringComparison.Ordinal) ? "АБВГДЖЯ" : name.StartsWith("Comic", StringComparison.Ordinal) ? "Comic Neue sample" : "xy"; static bool HasCorpusGlyphs(byte[] bytes, string corpus) { using var stream = new MemoryStream(bytes, false); var face = new OpenFontReader().Read(stream) ?? throw new InvalidDataException("font parse failed"); var codepoints = corpus.EnumerateRunes().Where(r => !char.IsWhiteSpace((char)r.Value)).Select(r => r.Value).Distinct(); return codepoints.All(cp => face.GetGlyphIndex(cp) != 0); } diff --git a/CSharpMath.Rendering.Tests/TestFonts.cs b/CSharpMath.Rendering.Tests/TestFonts.cs new file mode 100644 index 00000000..f468ea73 --- /dev/null +++ b/CSharpMath.Rendering.Tests/TestFonts.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Typography.OpenFont; +using Xunit; + +namespace CSharpMath.Rendering.Tests { + using BackEnd; + using SkiaSharp; + + public class TestFonts { + sealed class ProbePainter : CSharpMath.SkiaSharp.MathPainter { + public Fonts CurrentFonts => Fonts; + } + sealed class SingleUseEnumerable : IEnumerable { + readonly IReadOnlyList _items; + bool _used; + public int EnumerationCount { get; private set; } + public SingleUseEnumerable(IEnumerable items) => _items = items.ToArray(); + public IEnumerator GetEnumerator() { + if (_used) throw new InvalidOperationException("The enumerable was enumerated more than once."); + _used = true; + EnumerationCount++; + return _items.GetEnumerator(); + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Fact] + public void ConstructorSnapshotsCallerAndPreservesPublicEnumerableSemantics() { + var source = new SingleUseEnumerable(new[] { Fonts.GlobalTypefaces.First() }); + var fonts = new Fonts(source, 12); + var resized = new Fonts(fonts, 24); + var resizedAgain = new Fonts(resized, 36); + + Assert.Equal(1, source.EnumerationCount); + Assert.Equal(4, fonts.Typefaces.Count()); + Assert.Equal(7, resized.Typefaces.Count()); + Assert.Equal(10, resizedAgain.Typefaces.Count()); + Assert.Equal(3, Fonts.GlobalTypefaces.Count()); + Assert.Equal(fonts.Typefaces.Concat(Fonts.GlobalTypefaces), resized.Typefaces); + Assert.Equal(resized.Typefaces.Concat(Fonts.GlobalTypefaces), resizedAgain.Typefaces); + + // The public constructor intentionally consumes exactly the enumerable + // supplied to it; passing an existing Fonts therefore preserves its + // public enumerable semantics (including the global faces). + var publicCopy = new Fonts(fonts, 48); + Assert.Equal(7, publicCopy.Typefaces.Count()); + } + + [Fact] + public void ConstructorAndPainterExposeDefensiveSnapshots() { + var first = Fonts.GlobalTypefaces.First(); + var second = Fonts.GlobalTypefaces.Skip(1).First(); + var supplied = new[] { first }; + var fonts = new Fonts(supplied, 12); + supplied[0] = second; + Assert.Same(first, fonts.Typefaces.First()); + Assert.Throws(() => ((IList)fonts.Typefaces).Add(second)); + + var painter = new ProbePainter { LocalTypefaces = supplied }; + supplied[0] = first; + Assert.Same(second, painter.LocalTypefaces.Single()); + Assert.Throws(() => ((IList)painter.LocalTypefaces).Add(first)); + } + + [Fact] + public void PainterRetainsLocalSnapshotAcrossRepeatedSizeChanges() { + var source = new SingleUseEnumerable(new[] { Fonts.GlobalTypefaces.First() }); + var painter = new ProbePainter { LocalTypefaces = source }; + + painter.FontSize = 13; + painter.FontSize = 14; + painter.FontSize = 15; + + Assert.Equal(1, source.EnumerationCount); + Assert.Single(painter.LocalTypefaces); + Assert.Equal(4, painter.CurrentFonts.Typefaces.Count()); + Assert.Equal(painter.LocalTypefaces.Concat(Fonts.GlobalTypefaces), painter.CurrentFonts.Typefaces); + } + + [Fact] + public void SamePainterCanSwitchLocalSnapshotsWithoutAccumulatingThem() { + var first = Fonts.GlobalTypefaces.First(); + var second = Fonts.GlobalTypefaces.Skip(1).First(); + var painter = new ProbePainter { LocalTypefaces = new[] { first } }; + painter.FontSize = 21; + painter.LocalTypefaces = new[] { second }; + painter.FontSize = 22; + painter.LocalTypefaces = new[] { first, second }; + painter.FontSize = 23; + + Assert.Equal(new[] { first, second }, painter.LocalTypefaces); + Assert.Equal(new[] { first, second }.Concat(Fonts.GlobalTypefaces), painter.CurrentFonts.Typefaces); + } + + [Fact] + public void ExistingFontsSeeLateMutationOfCallerOwnedLocalList() { + var first = Fonts.GlobalTypefaces.First(); + var second = Fonts.GlobalTypefaces.Skip(1).First(); + var locals = new List { first }; + var painter = new ProbePainter { LocalTypefaces = locals }; + Assert.Same(first, painter.LocalTypefaces.Single()); + locals[0] = second; + Assert.Same(second, painter.LocalTypefaces.Single()); + Assert.Same(second, painter.CurrentFonts.Typefaces.First()); + } + + [Fact] + public void ComicNeueLocalTypefaceRemainsUsableAcrossResizeAndSwitchCycles() { + var path = Path.Combine(TestRenderingFixture.ThisDirectory.FullName, "ComicNeue_Bold.otf"); + using var stream = File.OpenRead(path); + var comic = new OpenFontReader().Read(stream); + Assert.NotNull(comic); + var painter = new ProbePainter { LaTeX = "Comic Neue sample", LocalTypefaces = new[] { comic! } }; + painter.FontSize = 18; + painter.LocalTypefaces = new[] { Fonts.GlobalTypefaces.First(), comic! }; + painter.FontSize = 24; + painter.LocalTypefaces = new[] { comic! }; + using var rendered = painter.DrawAsStream(); + Assert.NotNull(rendered); + Assert.True(rendered!.Length > 0); + Assert.Same(comic, painter.LocalTypefaces.Single()); + Assert.Same(comic, painter.CurrentFonts.Typefaces.First()); + } + } +} diff --git a/CSharpMath.Rendering/BackEnd/Fonts.cs b/CSharpMath.Rendering/BackEnd/Fonts.cs index 40dffef7..ed5a1fe7 100644 --- a/CSharpMath.Rendering/BackEnd/Fonts.cs +++ b/CSharpMath.Rendering/BackEnd/Fonts.cs @@ -1,3 +1,4 @@ +using System; using System.Collections; using System.Collections.Generic; using System.Linq; @@ -24,16 +25,50 @@ Typeface LoadFont(string fileName) { } public Fonts(IEnumerable localTypefaces, float pointSize) { PointSize = pointSize; - Typefaces = localTypefaces.Concat(GlobalTypefaces); + _localTypefaces = NormalizeLocals(localTypefaces); + Typefaces = new DynamicTypefaces(_localTypefaces); MathTypeface = Typefaces.First(t => t.HasMathTable()); MathConsts = MathTypeface.MathConsts ?? throw new Atom.InvalidCodePathException(nameof(MathTypeface) + " doesn't have " + nameof(MathConsts)); } + // The public constructor clones this private local-only snapshot. It must + // not receive Typefaces, which also contains globals and would retain every + // preceding Fonts instance through a growing concatenation chain. + internal static Fonts Resize(Fonts source, float pointSize) => + new Fonts(source._localTypefaces, pointSize); public static readonly Typefaces GlobalTypefaces = GetGlobalTypefaces(); public float PointSize { get; } public IEnumerable Typefaces { get; } + private readonly IEnumerable _localTypefaces; + internal IEnumerable LocalTypefacesSnapshot => _localTypefaces; public Typeface MathTypeface { get; } public Typography.OpenFont.MathGlyphs.MathConstants MathConsts { get; } public IEnumerator GetEnumerator() => Typefaces.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => Typefaces.GetEnumerator(); + + // Read globals at enumeration time so late AddSupplement/AddOverride calls + // are visible to existing painters. Locals remain a defensive snapshot. + sealed class DynamicTypefaces : IList { + readonly IEnumerable locals; + public DynamicTypefaces(IEnumerable locals) => this.locals = locals; + IEnumerable Current => locals.Concat(GlobalTypefaces); + public int Count => Current.Count(); + public bool IsReadOnly => true; + public Typeface this[int index] { get => Current.ElementAt(index); set => throw new NotSupportedException(); } + public IEnumerator GetEnumerator() => Current.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public bool Contains(Typeface item) => Current.Contains(item); + public int IndexOf(Typeface item) => Current.ToList().IndexOf(item); + public void CopyTo(Typeface[] array, int arrayIndex) => Current.ToList().CopyTo(array, arrayIndex); + public void Add(Typeface item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public void Insert(int index, Typeface item) => throw new NotSupportedException(); + public bool Remove(Typeface item) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + } + static IEnumerable NormalizeLocals(IEnumerable input) { + if (input is Typeface[]) return input.ToArray(); + if (input is ICollection || input is IReadOnlyCollection) return input; + return input.ToArray(); + } } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/BackEnd/TypesettingContext.cs b/CSharpMath.Rendering/BackEnd/TypesettingContext.cs index d12319ed..7585e88c 100644 --- a/CSharpMath.Rendering/BackEnd/TypesettingContext.cs +++ b/CSharpMath.Rendering/BackEnd/TypesettingContext.cs @@ -2,10 +2,10 @@ namespace CSharpMath.Rendering.BackEnd { public static class TypesettingContext { public static Display.FrontEnd.TypesettingContext Instance { get; } = new Display.FrontEnd.TypesettingContext( - (fonts, size) => new Fonts(fonts, size), + (fonts, size) => Fonts.Resize(fonts, size), GlyphBoundsProvider.Instance, GlyphFinder.Instance, MathTable.Instance ); } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/FrontEnd/Painter.cs b/CSharpMath.Rendering/FrontEnd/Painter.cs index 02acaa6f..ca0b908b 100644 --- a/CSharpMath.Rendering/FrontEnd/Painter.cs +++ b/CSharpMath.Rendering/FrontEnd/Painter.cs @@ -44,9 +44,19 @@ public Painter() { protected abstract void SetRedisplay(); protected Fonts Fonts { get; private set; } = new Fonts(Array.Empty(), DefaultFontSize); /// Unit of measure: points - public float FontSize { get => Fonts.PointSize; set { Fonts = new Fonts(Fonts, value); SetRedisplay(); } } + public float FontSize { get => Fonts.PointSize; set { Fonts = Fonts.Resize(Fonts, value); SetRedisplay(); } } IEnumerable __localTypefaces = Array.Empty(); - public IEnumerable LocalTypefaces { get => __localTypefaces; set { Fonts = new Fonts(value, FontSize); __localTypefaces = value; SetRedisplay(); } } + public IEnumerable LocalTypefaces { + get => __localTypefaces; + set { + var fonts = new Fonts(value, FontSize); + Fonts = fonts; + // Expose the stable snapshot, rather than retaining a caller-owned + // lazy/single-use enumerable. + __localTypefaces = fonts.LocalTypefacesSnapshot; + SetRedisplay(); + } + } Atom.LineStyle __style = Atom.LineStyle.Display; public Atom.LineStyle LineStyle { get => __style; set { __style = value; SetRedisplay(); } } TContent? __content; @@ -71,7 +81,7 @@ protected void UpdateDisplay(float textPainterCanvasWidth) { UpdateDisplayCore(textPainterCanvasWidth); if (Display == null && DisplayErrorInline && ErrorMessage != null) { var font = Fonts; - if (ErrorFontSize is { } errorSize) font = new Fonts(font, errorSize); + if (ErrorFontSize is { } errorSize) font = Fonts.Resize(font, errorSize); var errorLines = ErrorMessage.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); var runs = new List>(); float y = 0; @@ -132,4 +142,4 @@ GlyphBoxColor is var (glyph, textRun) ? Nullable((WrapColor(glyph), WrapColor(te public Painter ShallowClone() => (Painter)MemberwiseClone(); #endregion Methods } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/Text/TextTypesetter.cs b/CSharpMath.Rendering/Text/TextTypesetter.cs index 02552cd2..bcab6084 100644 --- a/CSharpMath.Rendering/Text/TextTypesetter.cs +++ b/CSharpMath.Rendering/Text/TextTypesetter.cs @@ -56,7 +56,7 @@ void AddDisplaysWithLineBreaks( break; case TextAtom.Size sz: AddDisplaysWithLineBreaks - (sz.Content, new Fonts(fonts, sz.PointSize), line, displayList, + (sz.Content, Fonts.Resize(fonts, sz.PointSize), line, displayList, displayMathList, style, color); break; case TextAtom.Colored c: @@ -216,4 +216,4 @@ void FinalizeInlineDisplay(float ascender, float rawDescender, return (new Display(relativePositionList), new Display(absolutePositionList)); } } -} \ No newline at end of file +}