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
82 changes: 82 additions & 0 deletions CSharpMath.Core.Tests/Atom/RelativeSizeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using CSharpMath.Atom;
using Xunit;

namespace CSharpMath.Core.AtomTests {
public class RelativeSizeTests {
[Theory]
[InlineData("tiny")]
[InlineData("scriptsize")]
[InlineData("footnotesize")]
[InlineData("small")]
[InlineData("normalsize")]
[InlineData("large")]
[InlineData("Large")]
[InlineData("LARGE")]
[InlineData("huge")]
[InlineData("Huge")]
public void AllDeclarationsArePreserved(string command) {
var list = LaTeXParserTest.ParseLaTeX($@"\{command} ab");
Assert.Equal(2, list.Count);
Assert.Equal($@"\{command} ab", LaTeXParser.MathListToLaTeX(list).ToString());
}

[Fact]
public void TransitionsScopesAndRoundTripAreStructural() {
const string input = @"\small a{\large b}c\normalsize d";
var serialized = LaTeXParser.MathListToLaTeX(LaTeXParserTest.ParseLaTeX(input)).ToString();
var reparsed = LaTeXParserTest.ParseLaTeX(serialized);
Assert.Equal(serialized, LaTeXParser.MathListToLaTeX(reparsed).ToString());
Assert.Contains(@"\large", serialized);
Assert.Contains(@"\normalsize", serialized);
}

[Fact]
public void GroupedLargeFollowedByDefaultRoundTrips() {
const string input = @"{\large a}b";
var serialized = LaTeXParser.MathListToLaTeX(LaTeXParserTest.ParseLaTeX(input)).ToString();
Assert.Equal(serialized, LaTeXParser.MathListToLaTeX(LaTeXParserTest.ParseLaTeX(serialized)).ToString());
Assert.Equal(LaTeXParserTest.ParseLaTeX(input), LaTeXParserTest.ParseLaTeX(serialized));
}

[Fact]
public void PublicRelativeSizeMutationAffectsEqualityAndRejectsInvalidValues() {
var a = LaTeXParserTest.ParseLaTeX("a")[0];
var b = a.Clone(false);
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}

[Fact]
public void ExplicitNormalSizeEqualsImplicitDefaultButSerializesWhenDeclared() {
var implicitDefault = LaTeXParserTest.ParseLaTeX("a");
var explicitDefault = LaTeXParserTest.ParseLaTeX(@"\normalsize a");
Assert.Equal(implicitDefault, explicitDefault);
Assert.Equal(implicitDefault[0].GetHashCode(), explicitDefault[0].GetHashCode());
Assert.Contains(@"\normalsize", LaTeXParser.MathListToLaTeX(explicitDefault).ToString());
}

[Fact]
public void EmptyGroupRestoresSizeAndScriptsInheritOnce() {
var list = LaTeXParserTest.ParseLaTeX(@"\large {}x^2y");
Assert.Equal(2, list.Count);
}

[Fact]
public void ParserReportsErrorsAndDoesNotLeakState() {
var parser = new LaTeXParser(@"\small{a");
var (_, error) = parser.Build();
Assert.NotNull(error);
Assert.Equal("b", LaTeXParserTest.ParseLaTeX("b")[0].Nucleus);
}

[Fact]
public void SameParserRestoresSizeAfterNestedError() {
var parser = new LaTeXParser(@"\large\unknown c");
Assert.NotNull(parser.Build().Error);
MathList? tailList = null;
parser.Build().Match(value => tailList = value, Assert.Null);
Assert.NotNull(tailList);
Assert.Equal("u", tailList![0].Nucleus);
}
}
}
63 changes: 63 additions & 0 deletions CSharpMath.Core.Tests/Display/RelativeSizeGeometryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using CSharpMath.Atom;
using CSharpMath.Core.AtomTests;
using CSharpMath.Core.BackEnd;
using CSharpMath.Display;
using Xunit;

namespace CSharpMath.Core.DisplayTests {
public class RelativeSizeGeometryTests {
static readonly TestFont Font = new TestFont(10);
static readonly Display.FrontEnd.TypesettingContext<TestFont, System.Text.Rune> Context = TestTypesettingContext.Instance;
[Theory]
[InlineData("tiny", .5f)]
[InlineData("scriptsize", .7f)]
[InlineData("footnotesize", .8f)]
[InlineData("small", .9f)]
[InlineData("normalsize", 1f)]
[InlineData("large", 1.2f)]
[InlineData("Large", 1.44f)]
[InlineData("LARGE", 1.728f)]
[InlineData("huge", 2.074f)]
[InlineData("Huge", 2.488f)]
public void RelativeSizesScaleRenderedGlyph(string command, float ratio) {
var plain = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX("a"), Font, Context, LineStyle.Text);
var sized = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX($@"\{command} a"), Font, Context, LineStyle.Text);
Assert.InRange(sized.Width / plain.Width, ratio - .002f, ratio + .002f);
Assert.InRange(sized.Ascent / plain.Ascent, ratio - .01f, ratio + .01f);
Assert.InRange(sized.Descent / plain.Descent, ratio - .01f, ratio + .01f);
}
[Fact]
public void LargeScriptScalesExactlyOnce() {
var normal = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX("x^2"), Font, Context, LineStyle.Text);
var large = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(@"\large x^2"), Font, Context, LineStyle.Text);
Assert.InRange(large.Width / normal.Width, 1.19f, 1.21f);
Assert.InRange(large.Ascent / normal.Ascent, 1.19f, 1.21f);
Assert.InRange(large.Descent / normal.Descent, 1.19f, 1.21f);
}
[Theory]
[InlineData(LineStyle.Display)]
[InlineData(LineStyle.Text)]
[InlineData(LineStyle.Script)]
[InlineData(LineStyle.ScriptScript)]
public void CubeRootDegreeUsesLegacyStyleBaseAndScalesOnce(LineStyle style) {
var normal = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(@"\sqrt[3]2"), Font, Context, style);
var large = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(@"\large \sqrt[3]2"), Font, Context, style);
Assert.True(normal.Width > 0 && normal.Ascent > 0 && normal.Descent >= 0);
Assert.InRange(large.Width / normal.Width, 1.19f, 1.21f);
}
[Theory]
[InlineData(@"\frac{a}{b}", @"\large \frac{a}{b}")]
[InlineData(@"\sqrt{a}", @"\large \sqrt{a}")]
[InlineData(@"\left(a\right)", @"\large \left(a\right)")]
[InlineData(@"\hat{a}", @"\large \hat{a}")]
[InlineData(@"\underline{a}", @"\large \underline{a}")]
[InlineData(@"\begin{matrix}a&b\end{matrix}", @"\large \begin{matrix}a&b\end{matrix}")]
public void CompoundRelativeSizeScalesGeometry(string baselineLatex, string sizedLatex) {
var baseline = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(baselineLatex), Font, Context, LineStyle.Display);
var sized = Typesetter.CreateLine(LaTeXParserTest.ParseLaTeX(sizedLatex), Font, Context, LineStyle.Display);
Assert.InRange(sized.Width / baseline.Width, 1.19f, 1.21f);
Assert.InRange(sized.Ascent / baseline.Ascent, 1.15f, 1.25f);
Assert.InRange(sized.Descent / baseline.Descent, 1.15f, 1.25f);
}
}
}
64 changes: 63 additions & 1 deletion CSharpMath.Rendering.Tests/TestRendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using SkiaSharp;
using Xunit;

namespace CSharpMath.Rendering.Tests {
Expand Down Expand Up @@ -68,6 +69,67 @@ public abstract class TestRendering<TCanvas, TColor, TMathPainter, TTextPainter>
protected abstract double FileSizeTolerance { get; }
protected abstract void DrawToStream<TContent>(Painter<TCanvas, TContent, TColor> painter,
Stream stream, float textPainterCanvasWidth, TextAlignment alignment) where TContent : class;

// These checks deliberately inspect the produced pixels instead of comparing snapshots.
// They are inherited by both frontends, which catches frontend-specific scaling regressions.
protected (int left, int top, int right, int bottom) RenderedAlphaBounds(string latex) {
using var stream = new MemoryStream();
var painter = new TTextPainter { FontSize = 40, LaTeX = latex };
painter.HighlightColor = painter.UnwrapColor(System.Drawing.Color.Transparent);
DrawToStream(painter, stream, 1000, TextAlignment.TopLeft);
Assert.Null(painter.ErrorMessage);
stream.Position = 0;
return DecodeAlphaBounds(stream);
}

protected (int left, int top, int right, int bottom) RenderedMathAlphaBounds(string latex) {
using var stream = new MemoryStream();
var painter = new TMathPainter { FontSize = 40, LaTeX = latex, LineStyle = Atom.LineStyle.Display };
painter.HighlightColor = painter.UnwrapColor(System.Drawing.Color.Transparent);
DrawToStream(painter, stream, 1000, TextAlignment.TopLeft);
Assert.Null(painter.ErrorMessage);
stream.Position = 0;
return DecodeAlphaBounds(stream);
}

static (int left, int top, int right, int bottom) DecodeAlphaBounds(Stream stream) {
using var bitmap = SKBitmap.Decode(stream);
Assert.NotNull(bitmap);
var left = bitmap.Width; var top = bitmap.Height; var right = -1; var bottom = -1;
for (var y = 0; y < bitmap.Height; y++) for (var x = 0; x < bitmap.Width; x++)
if (bitmap.GetPixel(x, y).Alpha > 10) {
left = Math.Min(left, x); top = Math.Min(top, y);
right = Math.Max(right, x); bottom = Math.Max(bottom, y);
}
Assert.True(right >= left && bottom >= top, "Rendered output has no occupied pixels.");
return (left, top, right, bottom);
}

[Fact]
public void RelativeSizePixelsChangeInExpectedDirection() {
var plain = RenderedAlphaBounds("a");
var small = RenderedAlphaBounds(@"\small a");
var large = RenderedAlphaBounds(@"\large a");
Assert.True(large.right - large.left > plain.right - plain.left);
Assert.True(small.right - small.left < plain.right - plain.left);
Assert.True(large.bottom - large.top > plain.bottom - plain.top);
}

[Fact]
public void RelativeSizeGroupedTransitionsRenderWithoutErrors() {
var plain = RenderedAlphaBounds("abc");
var bounds = RenderedAlphaBounds(@"{\small a}b{\large c}");
Assert.True(bounds.right > bounds.left && bounds.bottom > bounds.top);
Assert.True(bounds.right - bounds.left > plain.right - plain.left);
}

[Fact]
public void RelativeSizeNestedCompoundFormulaRendersWithoutErrors() {
var plain = RenderedMathAlphaBounds(@"\frac{\sqrt{x^2}}{y}");
var large = RenderedMathAlphaBounds(@"\large \frac{\sqrt{x^2}}{y}");
Assert.True(large.right - large.left > plain.right - plain.left);
Assert.True(large.bottom - large.top > plain.bottom - plain.top);
}
[Theory, ClassData(typeof(TestRenderingMathData))]
public void MathDisplay(string file, string latex) =>
Run(file, latex, new TMathPainter { LineStyle = Atom.LineStyle.Display });
Expand Down Expand Up @@ -202,4 +264,4 @@ public virtual void MathPainterSettings(string file, TMathPainter painter) =>
public void TextPainterSettings(string file, TTextPainter painter) =>
Run(file, @"Inline \color{red}{Maths}: $\int_{a_1^2}^{a_2^2}\color{green}\sqrt\frac x2dx$Display \color{red}{Maths}: $$\int_{a_1^2}^{a_2^2}\color{green}\sqrt\frac x2dx$$", painter);
}
}
}
108 changes: 108 additions & 0 deletions CSharpMath.Rendering.Text.Tests/RelativeSizeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Linq;
using CSharpMath.Rendering.BackEnd;
using CSharpMath.Rendering.Text;
using Xunit;

namespace CSharpMath.Rendering.Text.Tests {
public class RelativeSizeTests {
static readonly Fonts Font = new Fonts(Enumerable.Empty<Typography.OpenFont.Typeface>(), 20);

static (float width, float ascent, float descent) Layout(string latex) {
var atom = TextLaTeXParser.TextAtomFromLaTeX(latex)
.Match(value => value, error => throw new Xunit.Sdk.XunitException(error));
var display = TextTypesetter.Layout(atom, Font, float.PositiveInfinity).relative;
return (display.Width, display.Ascent, display.Descent);
}

[Theory]
[InlineData("tiny", .5f)]
[InlineData("scriptsize", .7f)]
[InlineData("footnotesize", .8f)]
[InlineData("small", .9f)]
[InlineData("normalsize", 1f)]
[InlineData("large", 1.2f)]
[InlineData("Large", 1.44f)]
[InlineData("LARGE", 1.728f)]
[InlineData("huge", 2.074f)]
[InlineData("Huge", 2.488f)]
public void AllDeclarationsPreserveCanonicalRatio(string command, float ratio) {
var atom = TextLaTeXParser.TextAtomFromLaTeX($@"\{command} a")
.Match(value => value, error => throw new Xunit.Sdk.XunitException(error));
var sized = Assert.IsType<TextAtom.RelativeSize>(atom);
Assert.Equal(command, sized.Declaration);
Assert.Equal(ratio, sized.Magnification);
Assert.Equal($@"\{command}{{a}}", TextLaTeXParser.TextAtomToLaTeX(atom).ToString());
}

[Fact]
public void GroupScopeAndSequentialTransitionsDoNotLeak() {
var atom = TextLaTeXParser.TextAtomFromLaTeX(@"{\small a}b\large c")
.Match(value => value, error => throw new Xunit.Sdk.XunitException(error));
var list = Assert.IsType<TextAtom.List>(atom);
Assert.IsType<TextAtom.RelativeSize>(list.Content[0]);
Assert.IsType<TextAtom.Text>(list.Content[1]);
Assert.IsType<TextAtom.RelativeSize>(list.Content[2]);
Assert.Equal(@"\small{a}b\large{c}", TextLaTeXParser.TextAtomToLaTeX(atom).ToString());
}

[Fact]
public void RelativeSizeEqualityIncludesMagnificationAndContent() {
var a = new TextAtom.RelativeSize(new TextAtom.Text("a"), "small");
var b = new TextAtom.RelativeSize(new TextAtom.Text("a"), "small");
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
Assert.NotEqual(a, new TextAtom.RelativeSize(new TextAtom.Text("b"), "small"));
}

[Fact]
public void RelativeSizesChangeActualGlyphGeometry() {
var normal = Layout("a");
foreach (var (command, ratio) in new[] {
("tiny", .5f), ("scriptsize", .7f), ("footnotesize", .8f), ("small", .9f),
("normalsize", 1f), ("large", 1.2f), ("Large", 1.44f), ("LARGE", 1.728f),
("huge", 2.074f), ("Huge", 2.488f) }) {
var sized = Layout($@"\{command} a");
Assert.InRange(sized.width / normal.width, ratio - .035f, ratio + .035f);
Assert.InRange(sized.ascent / normal.ascent, ratio - .06f, ratio + .06f);
Assert.InRange(sized.descent / normal.descent, ratio - .06f, ratio + .06f);
}
}

[Fact]
public void RelativeSizeRestoresAcrossLinesAndCoexistsWithFontSize() {
var normal = Layout("a\\\\a");
var mixed = Layout(@"\small a\\a \fontsize{40}{b}");
Assert.True(mixed.width > normal.width);
var serialized = TextLaTeXParser.TextAtomToLaTeX(
TextLaTeXParser.TextAtomFromLaTeX(@"\small a\\a \fontsize{40}{b}")
.Match(value => value, error => throw new Xunit.Sdk.XunitException(error))).ToString();
Assert.Contains(@"\small", serialized);
Assert.Contains(@"\fontsize", serialized);
}

[Fact]
public void RelativeAndAbsoluteFontSizeDeclarationsHaveDefinedPrecedence() {
var absolute = Layout(@"\fontsize{40}{a}");
var relativeThenAbsolute = Layout(@"\small\fontsize{40}{a}");
var absoluteThenRelative = Layout(@"\fontsize{40}{\small a}");
Assert.InRange(relativeThenAbsolute.width / absolute.width, .995f, 1.005f);
// Relative declarations are based on the externally supplied painter font,
// even when nested inside an arbitrary absolute \fontsize declaration.
Assert.InRange(absoluteThenRelative.width / absolute.width, .445f, .455f);
}

[Fact]
public void RelativeSizeScalesInlineAndDisplayMath() {
var normalInline = Layout("$x$");
var largeInline = Layout(@"\large $x$");
Assert.True(largeInline.width > normalInline.width);
var normalDisplay = TextTypesetter.Layout(
TextLaTeXParser.TextAtomFromLaTeX("$$\\frac{a}{b}$$")
.Match(value => value, error => throw new Xunit.Sdk.XunitException(error)), Font, float.PositiveInfinity).absolute;
var largeDisplay = TextTypesetter.Layout(
TextLaTeXParser.TextAtomFromLaTeX(@"\large $$\frac{a}{b}$$")
.Match(value => value, error => throw new Xunit.Sdk.XunitException(error)), Font, float.PositiveInfinity).absolute;
Assert.True(largeDisplay.Width > normalDisplay.Width);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CSharpMath.Rendering.Text.TextAtom.RelativeSize
CSharpMath.Rendering.Text.TextAtom.RelativeSize.RelativeSize(CSharpMath.Rendering.Text.TextAtom! content, string! declaration) -> void
CSharpMath.Rendering.Text.TextAtom.RelativeSize.Content.get -> CSharpMath.Rendering.Text.TextAtom!
CSharpMath.Rendering.Text.TextAtom.RelativeSize.Declaration.get -> string!
CSharpMath.Rendering.Text.TextAtom.RelativeSize.Magnification.get -> float
override CSharpMath.Rendering.Text.TextAtom.RelativeSize.SingleChar(CSharpMath.Atom.FontStyle style) -> int?
override CSharpMath.Rendering.Text.TextAtom.RelativeSize.Equals(CSharpMath.Rendering.Text.TextAtom! atom) -> bool
override CSharpMath.Rendering.Text.TextAtom.RelativeSize.GetHashCode() -> int
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CSharpMath.Rendering.Text.TextAtom.RelativeSize
CSharpMath.Rendering.Text.TextAtom.RelativeSize.RelativeSize(CSharpMath.Rendering.Text.TextAtom! content, string! declaration) -> void
CSharpMath.Rendering.Text.TextAtom.RelativeSize.Content.get -> CSharpMath.Rendering.Text.TextAtom!
CSharpMath.Rendering.Text.TextAtom.RelativeSize.Declaration.get -> string!
CSharpMath.Rendering.Text.TextAtom.RelativeSize.Magnification.get -> float
override CSharpMath.Rendering.Text.TextAtom.RelativeSize.SingleChar(CSharpMath.Atom.FontStyle style) -> int?
override CSharpMath.Rendering.Text.TextAtom.RelativeSize.Equals(CSharpMath.Rendering.Text.TextAtom! atom) -> bool
override CSharpMath.Rendering.Text.TextAtom.RelativeSize.GetHashCode() -> int
18 changes: 17 additions & 1 deletion CSharpMath.Rendering/Text/TextAtom.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ public sealed class Size : TextAtom {
public override bool Equals(TextAtom atom) => atom is Size s && s.PointSize == PointSize && s.Content.Equals(Content);
public override int GetHashCode() => (PointSize, Content).GetHashCode();
}
public sealed class RelativeSize : TextAtom {
public RelativeSize(TextAtom content, string declaration) {
if (!TextLaTeXSettings.RelativeSizes.ContainsKey(declaration))
throw new System.ArgumentException("Unknown relative size declaration", nameof(declaration));
Content = content;
Declaration = declaration;
Magnification = TextLaTeXSettings.RelativeSizes[declaration];
}
public TextAtom Content { get; }
public string Declaration { get; }
public float Magnification { get; }
public override int? SingleChar(FontStyle style) => Content.SingleChar(style);
public override bool Equals(TextAtom atom) => atom is RelativeSize s && s.Declaration == Declaration &&
s.Magnification == Magnification && s.Content.Equals(Content);
public override int GetHashCode() => (Declaration, Magnification, Content).GetHashCode();
}
public sealed class Colored : TextAtom {
public Colored(TextAtom content, System.Drawing.Color colour) => (Content, Colour) = (content, colour);
public TextAtom Content { get; }
Expand Down Expand Up @@ -107,4 +123,4 @@ public sealed class Comment : TextAtom {
public override int GetHashCode() => Content.GetHashCode();
}
}
}
}
Loading
Loading