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
45 changes: 44 additions & 1 deletion CSharpMath.Rendering.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> { ex.ToString() } }, Json)); Environment.ExitCode = 1; }
}
static void RenderMath(string latex, byte[]? custom = null) { MemoryStream? stream = null; Typeface[] faces = Array.Empty<Typeface>(); 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); }
Expand Down
129 changes: 129 additions & 0 deletions CSharpMath.Rendering.Tests/TestFonts.cs
Original file line number Diff line number Diff line change
@@ -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<Typeface> {
readonly IReadOnlyList<Typeface> _items;
bool _used;
public int EnumerationCount { get; private set; }
public SingleUseEnumerable(IEnumerable<Typeface> items) => _items = items.ToArray();
public IEnumerator<Typeface> 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<NotSupportedException>(() => ((IList<Typeface>)fonts.Typefaces).Add(second));

var painter = new ProbePainter { LocalTypefaces = supplied };
supplied[0] = first;
Assert.Same(second, painter.LocalTypefaces.Single());
Assert.Throws<NotSupportedException>(() => ((IList<Typeface>)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<Typeface> { 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());
}
}
}
39 changes: 37 additions & 2 deletions CSharpMath.Rendering/BackEnd/Fonts.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
Expand All @@ -24,16 +25,50 @@ Typeface LoadFont(string fileName) {
}
public Fonts(IEnumerable<Typeface> 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<Typeface> Typefaces { get; }
private readonly IEnumerable<Typeface> _localTypefaces;
internal IEnumerable<Typeface> LocalTypefacesSnapshot => _localTypefaces;
public Typeface MathTypeface { get; }
public Typography.OpenFont.MathGlyphs.MathConstants MathConsts { get; }
public IEnumerator<Typeface> 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<Typeface> {
readonly IEnumerable<Typeface> locals;
public DynamicTypefaces(IEnumerable<Typeface> locals) => this.locals = locals;
IEnumerable<Typeface> 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<Typeface> 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<Typeface> NormalizeLocals(IEnumerable<Typeface> input) {
if (input is Typeface[]) return input.ToArray();
if (input is ICollection<Typeface> || input is IReadOnlyCollection<Typeface>) return input;
return input.ToArray();
}
}
}
}
4 changes: 2 additions & 2 deletions CSharpMath.Rendering/BackEnd/TypesettingContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ namespace CSharpMath.Rendering.BackEnd {
public static class TypesettingContext {
public static Display.FrontEnd.TypesettingContext<Fonts, Glyph> Instance { get; } =
new Display.FrontEnd.TypesettingContext<Fonts, Glyph>(
(fonts, size) => new Fonts(fonts, size),
(fonts, size) => Fonts.Resize(fonts, size),
GlyphBoundsProvider.Instance,
GlyphFinder.Instance,
MathTable.Instance
);
}
}
}
18 changes: 14 additions & 4 deletions CSharpMath.Rendering/FrontEnd/Painter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,19 @@ public Painter() {
protected abstract void SetRedisplay();
protected Fonts Fonts { get; private set; } = new Fonts(Array.Empty<Typeface>(), DefaultFontSize);
/// <summary>Unit of measure: points</summary>
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<Typeface> __localTypefaces = Array.Empty<Typeface>();
public IEnumerable<Typeface> LocalTypefaces { get => __localTypefaces; set { Fonts = new Fonts(value, FontSize); __localTypefaces = value; SetRedisplay(); } }
public IEnumerable<Typeface> 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;
Expand All @@ -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<Display.Displays.TextRunDisplay<Fonts, Glyph>>();
float y = 0;
Expand Down Expand Up @@ -132,4 +142,4 @@ GlyphBoxColor is var (glyph, textRun) ? Nullable((WrapColor(glyph), WrapColor(te
public Painter<TCanvas, TContent, TColor> ShallowClone() => (Painter<TCanvas, TContent, TColor>)MemberwiseClone();
#endregion Methods
}
}
}
Loading
Loading