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
64 changes: 63 additions & 1 deletion CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1633,5 +1633,67 @@ public void TestErrorSurrogates() {
Assert.Equal(expected.Replace("\r", null), actual);
}
}

[Fact]
public void MiddleDelimiterSplitsInnerAndRoundTrips() {
var list = ParseLaTeX(@"\left\{a\middle|b\right\}");
var inner = Assert.IsType<Inner>(Assert.Single(list));
Assert.Equal(2, inner.Segments.Count);
Assert.Equal("|", Assert.Single(inner.MiddleBoundaries).Nucleus);
Assert.Equal(@"\left\{ a\middle| b\right\} ",
LaTeXParser.MathListToLaTeX(list).ToString());
}

[Fact]
public void MiddleDelimiterOutsideInnerIsRejected() {
var (_, error) = LaTeXParser.MathListFromLaTeX(@"a\middle|b");
Assert.NotNull(error);
Assert.Contains("Missing \\left", error);
}

[Theory]
[InlineData(@"\left(a\middle|b\middle|c\right)", 3, 2)]
[InlineData(@"\left.\frac{a}{b}\middle.\begin{matrix}x\\y\end{matrix}\right.", 2, 1)]
[InlineData(@"\left(\left\{a\middle|b\right\}\middle|c\right)", 2, 1)]
[InlineData(@"\left. a\middle. b\right.", 2, 1)]
public void MiddleDelimiterSupportsMultipleNestedNullAndCompositeSegments(string input,
int segmentCount, int middleCount) {
var parsed = ParseLaTeX(input);
var inner = Assert.IsType<Inner>(Assert.Single(parsed));
Assert.Equal(segmentCount, inner.Segments.Count);
Assert.Equal(middleCount, inner.MiddleBoundaries.Count);
Assert.Equal(segmentCount, ((IMathListContainer)inner).InnerLists.Count());
var roundTrip = LaTeXParser.MathListToLaTeX(new MathList(inner)).ToString();
var reparsed = Assert.IsType<Inner>(Assert.Single(ParseLaTeX(roundTrip)));
Assert.Equal(inner, reparsed);
}

[Fact]
public void MiddleDelimiterErrorsIdentifySourceAndMissingRight() {
var (_, unsupported) = LaTeXParser.MathListFromLaTeX(@"\left(a\middle\bogus b\right)");
Assert.NotNull(unsupported);
Assert.Contains("pos", unsupported);
var (_, missingRight) = LaTeXParser.MathListFromLaTeX(@"\left(a\middle|b");
Assert.Contains("Missing \\right", missingRight);
var (_, missingDelimiter) = LaTeXParser.MathListFromLaTeX(@"\left(a\middle");
Assert.Contains("Missing delimiter", missingDelimiter);
}

[Fact]
public void MiddleDelimiterInnerConstructorEnforcesInvariantAndSnapshots() {
Assert.Throws<ArgumentException>(() => new Inner(Boundary.Empty,
new[] { new MathList() }, new[] { new Boundary("|") }, Boundary.Empty));
var segments = new List<MathList> { new MathList(), new MathList(new Number("2")) };
var middles = new List<Boundary> { new Boundary("|") };
var inner = new Inner(Boundary.Empty, segments, middles, Boundary.Empty);
segments.Clear();
middles.Clear();
Assert.Equal(2, inner.Segments.Count);
Assert.Equal(inner, inner.Clone(true));
Assert.Throws<NotSupportedException>(() => ((IList<MathList>)inner.Segments).Add(new MathList()));
Assert.Throws<NotSupportedException>(() => ((IList<Boundary>)inner.MiddleBoundaries).Clear());
var set = new HashSet<Inner> { inner };
Assert.Contains(inner.Clone(true), set);
}
}
}
}
39 changes: 38 additions & 1 deletion CSharpMath.Core.Tests/Display/TypesetterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,43 @@ public void TestInner(string left, string right) =>
Assert.False(glyph2.HasScript);
Assert.Equal(right.EnumerateRunes().Last(), glyph2.Glyph);
});

[Theory]
[InlineData(LineStyle.Display)]
[InlineData(LineStyle.Text)]
[InlineData(LineStyle.Script)]
public void TestMiddleDelimitersShareExtentAndRetainAssemblies(LineStyle style) {
var line = Typesetter.CreateLine(
AtomTests.LaTeXParserTest.ParseLaTeX(
@"\left\{\frac{1}{2}\middle|\begin{matrix}x\\y\end{matrix}\middle\Vert z\right\}^2"),
_font, _context, style);
var inner = line.Displays.OfType<InnerDisplay<TFont, TGlyph>>().Single();
Assert.True(inner.HasScript);
Assert.Equal(5, inner.Inner.Displays.Count);
var middle = Assert.IsType<GlyphDisplay<TFont, TGlyph>>(inner.Inner.Displays[1]);
var middle2 = Assert.IsType<GlyphDisplay<TFont, TGlyph>>(inner.Inner.Displays[3]);
Assert.Equal('|', middle.Glyph.ToString()[0]);
Assert.Equal('‖', middle2.Glyph.ToString()[0]);
Assert.NotEqual(middle.Glyph, middle2.Glyph);
Assert.NotEqual(0, middle.Ascent + middle.Descent);
Assert.NotEqual(0, middle2.Ascent + middle2.Descent);
Assert.True(inner.Ascent >= inner.Inner.Ascent);
Assert.True(inner.Descent >= inner.Inner.Descent);
Assert.NotNull(inner.Left);
Assert.NotNull(inner.Right);
Assert.True(inner.Left.Ascent + inner.Left.Descent > 0);
Assert.True(inner.Right.Ascent + inner.Right.Descent > 0);
var outerExtent = System.Math.Max(inner.Left.Ascent + inner.Left.Descent, inner.Right.Ascent + inner.Right.Descent);
Assert.InRange(middle.Ascent + middle.Descent, outerExtent * .75f, outerExtent * 1.25f);
Assert.InRange(middle2.Ascent + middle2.Descent, outerExtent * .75f, outerExtent * 1.25f);
var nullMiddle = Typesetter.CreateLine(
AtomTests.LaTeXParserTest.ParseLaTeX(@"\left. a\middle. b\right."),
_font, _context, style).Displays.OfType<InnerDisplay<TFont, TGlyph>>().Single();
Assert.Equal(2, nullMiddle.Inner.Displays.Count);
Assert.DoesNotContain(nullMiddle.Inner.Displays, d => d is GlyphDisplay<TFont, TGlyph>);
// All parts are laid out in one shared group coordinate system.
Assert.Equal(inner.Inner.Position.Y, middle.Position.Y);
}
[Theory, InlineData("\\sqrt2", "", "2"), InlineData("\\sqrt[3]2", "3", "2")]
public void TestRadical(string latex, string degree, string radicand) =>
TestOuter(latex, 1, 18.56, 4, degree.IsEmpty() ? 20 : 21.44, d => {
Expand Down Expand Up @@ -673,4 +710,4 @@ public void SpacingBetweenNumbers() {
});
}
}
}
}
66 changes: 65 additions & 1 deletion CSharpMath.Rendering.Tests/TestRendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using CSharpMath.Display;
using CSharpMath.Display.Displays;
using CSharpMath.Rendering.BackEnd;
using Xunit;

namespace CSharpMath.Rendering.Tests {
Expand Down Expand Up @@ -68,12 +71,73 @@ 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;
private static InnerDisplay<Fonts, Glyph>? FindInner(IDisplay<Fonts, Glyph> display) => display switch {
InnerDisplay<Fonts, Glyph> inner when inner.Inner.Displays.Count > 1
&& (inner.Inner.Displays.Any(d => d is GlyphDisplay<Fonts, Glyph>)
|| inner.Inner.Displays.Any(d => d is ListDisplay<Fonts, Glyph>)) => inner,
InnerDisplay<Fonts, Glyph> inner => FindInner(inner.Inner),
ListDisplay<Fonts, Glyph> list => list.Displays.Select(FindInner).FirstOrDefault(i => i != null),
_ => null
};
private static InnerDisplay<Fonts, Glyph>? FindAnyInner(IDisplay<Fonts, Glyph> display) => display switch {
InnerDisplay<Fonts, Glyph> inner => inner,
ListDisplay<Fonts, Glyph> list => list.Displays.Select(FindAnyInner).FirstOrDefault(i => i != null),
_ => null
};
[Theory, ClassData(typeof(TestRenderingMathData))]
public void MathDisplay(string file, string latex) =>
Run(file, latex, new TMathPainter { LineStyle = Atom.LineStyle.Display });
[Theory, ClassData(typeof(TestRenderingMathData))]
public void MathInline(string file, string latex) =>
Run(file, latex, new TMathPainter { LineStyle = Atom.LineStyle.Text });

[Theory]
[InlineData(Atom.LineStyle.Display)]
[InlineData(Atom.LineStyle.Text)]
[InlineData(Atom.LineStyle.Script)]
public void MiddleDelimiterRenderingSmoke(Atom.LineStyle lineStyle) {
// Inspect the backend display tree and independently resolved delimiter glyphs.
foreach (var latex in new[] {
@"\left\{\frac{x_i}{i}\middle|i\in\mathcal{I}\right\}",
@"\left. a\middle|b\right.",
@"\left. a\middle. b\right.",
@"\left( a\middle|b\middle\Vert c\right)",
@"\left\{\left[\frac{a}{b}\middle|\begin{pmatrix}x&y\\z&w\end{pmatrix}\right]\right\}",
@"\left(\middle|x\right)^2"
}) {
var painter = new TMathPainter { LineStyle = lineStyle, LaTeX = latex };
var measure = painter.Measure();
Assert.True(measure.Width > 0 && measure.Height > 0, latex);
var inner = Assert.IsType<InnerDisplay<Fonts, Glyph>>(FindInner(painter.Display!));
var parts = inner.Inner.Displays;
if (latex.Contains(@"\middle.")) {
Assert.Equal(2, parts.Count);
Assert.DoesNotContain(parts, part => part is GlyphDisplay<Fonts, Glyph>);
} else {
Assert.Contains(parts, part => part is GlyphDisplay<Fonts, Glyph>);
}
if (latex.Contains(@"\middle|b\middle\Vert")) {
var middles = parts.OfType<GlyphDisplay<Fonts, Glyph>>().ToArray();
Assert.Equal(2, middles.Length);
var barPainter = new TMathPainter { LineStyle = lineStyle, LaTeX = @"\left|a\right|" };
_ = barPainter.Measure();
var bar = Assert.IsType<InnerDisplay<Fonts, Glyph>>(FindAnyInner(barPainter.Display!));
var vertPainter = new TMathPainter { LineStyle = lineStyle, LaTeX = @"\left\|a\right\|" };
_ = vertPainter.Measure();
var vert = Assert.IsType<InnerDisplay<Fonts, Glyph>>(FindAnyInner(vertPainter.Display!));
Assert.Equal(bar.Left!.GetType(), middles[0].GetType());
Assert.Equal(vert.Left!.GetType(), middles[1].GetType());
Assert.Equal(((GlyphDisplay<Fonts, Glyph>)bar.Left).Glyph.Info.GlyphIndex, middles[0].Glyph.Info.GlyphIndex);
Assert.Equal(((GlyphDisplay<Fonts, Glyph>)vert.Left).Glyph.Info.GlyphIndex, middles[1].Glyph.Info.GlyphIndex);
var outerExtent = Math.Max(inner.Left!.Ascent + inner.Left.Descent, inner.Right!.Ascent + inner.Right.Descent);
Assert.All(middles, glyph => Assert.InRange(glyph.Ascent + glyph.Descent, outerExtent * 0.75f, outerExtent * 1.25f));
Assert.All(middles, glyph => Assert.True(glyph.Ascent + glyph.Descent > 0));
Assert.InRange(Math.Abs(middles[0].ShiftDown - middles[1].ShiftDown), 0, 1e-4f);
Assert.InRange(Math.Abs(inner.Left!.ShiftDown - middles[0].ShiftDown), 0, 1e-4f);
Assert.InRange(Math.Abs(inner.Right!.ShiftDown - middles[1].ShiftDown), 0, 1e-4f);
}
}
}
[Theory, ClassData(typeof(TestRenderingTextData))]
public void TextLeft(string file, string latex) =>
Run(file, latex, new TTextPainter());
Expand Down Expand Up @@ -202,4 +266,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);
}
}
}
53 changes: 38 additions & 15 deletions CSharpMath/Atom/Atoms/Inner.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CSharpMath.Atom.Atoms {
/// <summary>An inner atom, i.e. embedded math list</summary>
public sealed class Inner : MathAtom, IMathListContainer {
public Inner(Boundary left, MathList innerList, Boundary right) =>
(LeftBoundary, InnerList, RightBoundary) = (left, innerList, right);
public MathList InnerList { get; }
public Inner(Boundary left, MathList innerList, Boundary right)
: this(left, new[] { innerList }, Array.Empty<Boundary>(), right) { }
public Inner(Boundary left, IReadOnlyList<MathList> segments,
IReadOnlyList<Boundary> middleBoundaries, Boundary right) {
if (segments is null) throw new ArgumentNullException(nameof(segments));
if (middleBoundaries is null) throw new ArgumentNullException(nameof(middleBoundaries));
if (segments.Count != middleBoundaries.Count + 1)
throw new ArgumentException("An inner atom must have one more segment than middle delimiters.");
(LeftBoundary, Segments, MiddleBoundaries, RightBoundary) =
(left, Array.AsReadOnly(segments.ToArray()),
Array.AsReadOnly(middleBoundaries.ToArray()), right);
}
public MathList InnerList => Segments[0];
public IReadOnlyList<MathList> Segments { get; }
public IReadOnlyList<Boundary> MiddleBoundaries { get; }
public Boundary LeftBoundary { get; }
public Boundary RightBoundary { get; }
System.Collections.Generic.IEnumerable<MathList> IMathListContainer.InnerLists =>
new[] { InnerList };
System.Collections.Generic.IEnumerable<MathList> IMathListContainer.InnerLists => Segments;
public override bool ScriptsAllowed => true;
public new Inner Clone(bool finalize) => (Inner)base.Clone(finalize);
protected override MathAtom CloneInside(bool finalize) =>
new Inner(LeftBoundary, InnerList.Clone(finalize), RightBoundary);
new Inner(LeftBoundary, Segments.Select(s => s.Clone(finalize)).ToArray(),
MiddleBoundaries, RightBoundary);
public bool EqualsInner(Inner otherInner) =>
EqualsAtom(otherInner)
&& InnerList.NullCheckingStructuralEquality(otherInner.InnerList)
&& Segments.Count == otherInner.Segments.Count
&& Segments.Zip(otherInner.Segments, (a, b) => a.NullCheckingStructuralEquality(b)).All(x => x)
&& MiddleBoundaries.SequenceEqual(otherInner.MiddleBoundaries)
&& LeftBoundary.NullCheckingStructuralEquality(otherInner.LeftBoundary)
&& RightBoundary.NullCheckingStructuralEquality(otherInner.RightBoundary);
public override bool Equals(object obj) => obj is Inner i ? EqualsInner(i) : false;
public override int GetHashCode() =>
(base.GetHashCode(), InnerList, LeftBoundary, RightBoundary).GetHashCode();
public override string DebugString =>
new StringBuilder(@"\inner")
.AppendInBracesOrEmptyBraces(LeftBoundary.Nucleus)
.AppendInBracesOrLiteralNull(InnerList.DebugString)
.AppendInBracesOrEmptyBraces(RightBoundary.Nucleus)
.AppendDebugStringOfScripts(this).ToString();
(base.GetHashCode(), string.Join("\u001f", Segments.Select(s => s.DebugString)),
string.Join("\u001f", MiddleBoundaries.Select(b => b.Nucleus)),
LeftBoundary, RightBoundary).GetHashCode();
public override string DebugString => MiddleBoundaries.Count == 0
? new StringBuilder(@"\inner").AppendInBracesOrEmptyBraces(LeftBoundary.Nucleus)
.AppendInBracesOrLiteralNull(InnerList.DebugString)
.AppendInBracesOrEmptyBraces(RightBoundary.Nucleus)
.AppendDebugStringOfScripts(this).ToString()
: new StringBuilder(@"\inner").AppendInBracesOrEmptyBraces(LeftBoundary.Nucleus)
.AppendInBracesOrLiteralNull(string.Join("|", Segments.Select(s => s.DebugString)))
.AppendInBracesOrLiteralNull(string.Join(",", MiddleBoundaries.Select(b => b.Nucleus)))
.AppendInBracesOrEmptyBraces(RightBoundary.Nucleus)
.AppendDebugStringOfScripts(this).ToString();
}
}
}
16 changes: 15 additions & 1 deletion CSharpMath/Atom/LaTeXParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public class TableEnvironment : IEnvironment {
}
public class InnerEnvironment : IEnvironment {
public Boundary? RightBoundary { get; set; }
public List<MathList> Segments { get; } = new List<MathList>();
public List<Boundary> MiddleBoundaries { get; } = new List<Boundary>();
}
#pragma warning restore CA1034 // Nested types should not be visible
public string Chars { get; }
Expand Down Expand Up @@ -539,6 +541,18 @@ static bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder,
MathListToLaTeX(radical.Radicand, builder, currentFontStyle);
builder.Append('}');
break;
case Inner {
MiddleBoundaries.Count: > 0, LeftBoundary: var left,
Segments: var segments, RightBoundary: var right
}:
builder.Append(@"\left").Append(BoundaryToLaTeX(left)).Append(' ');
for (var i = 0; i < segments.Count; i++) {
MathListToLaTeX(segments[i], builder, currentFontStyle);
if (i < segments.Count - 1)
builder.Append(@"\middle").Append(BoundaryToLaTeX(((Inner)atom).MiddleBoundaries[i])).Append(' ');
}
builder.Append(@"\right").Append(BoundaryToLaTeX(right)).Append(' ');
break;
case Inner { LeftBoundary: { Nucleus: null }, InnerList: var list, RightBoundary: { Nucleus: null } }:
MathListToLaTeX(list, builder, currentFontStyle);
break;
Expand Down Expand Up @@ -731,4 +745,4 @@ public static StringBuilder MathListToLaTeX(MathList mathList, StringBuilder? sb
return sb;
}
}
}
}
37 changes: 29 additions & 8 deletions CSharpMath/Atom/LaTeXSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,28 @@ public static class LaTeXSettings {
Ok(new Radical(degree ?? new MathList(), radicand)))) },
{ @"\left", (parser, accumulate, stopChar) =>
parser.ReadDelimiter("left").Bind(left => {
parser.Environments.Push(new LaTeXParser.InnerEnvironment());
return parser.ReadUntil(stopChar).Bind(innerList => {
if (!(parser.Environments.PeekOrDefault() is
LaTeXParser.InnerEnvironment { RightBoundary: { } right })) {
var environment = new LaTeXParser.InnerEnvironment();
parser.Environments.Push(environment);
var completed = false;
try {
while (true) {
var result = parser.ReadUntil(stopChar);
if (result.Error != null) return result.Error;
environment.Segments.Add(result._value);
if (environment.RightBoundary is Boundary right) {
parser.Environments.Pop();
completed = true;
return Ok(new Inner(left, environment.Segments.ToArray(),
environment.MiddleBoundaries.ToArray(), right));
}
if (environment.MiddleBoundaries.Count == environment.Segments.Count)
continue;
return Err($@"Missing \right for \left with delimiter {left}");
}
parser.Environments.Pop();
return Ok(new Inner(left, innerList, right));
});
} finally {
if (!completed && parser.Environments.PeekOrDefault() == environment)
parser.Environments.Pop();
}
}) },
{ @"\overline", (parser, accumulate, stopChar) =>
parser.ReadArgument().Bind(mathList => Ok(new Overline(mathList))) },
Expand Down Expand Up @@ -321,6 +334,14 @@ public static class LaTeXSettings {
inner.RightBoundary = boundary;
return OkStop(accumulate);
} },
{ @"\middle", (parser, accumulate, stopChar) => {
if (!(parser.Environments.PeekOrDefault() is LaTeXParser.InnerEnvironment inner))
return "Missing \\left";
var (boundary, error) = parser.ReadDelimiter("middle");
if (error != null) return error;
inner.MiddleBoundaries.Add(boundary);
return OkStop(accumulate);
} },
{ @"\\", @"\cr", (parser, accumulate, stopChar) => {
if (!(parser.Environments.PeekOrDefault() is LaTeXParser.TableEnvironment environment)) {
return parser.ReadTable(null, accumulate, true, stopChar).Bind(table => OkStop(new MathList(table)));
Expand Down Expand Up @@ -1189,4 +1210,4 @@ atom is Accent accent
// \varsupsetneqq -> ⫌ + U+FE00 (Variation Selector 1) Not dealing with variation selectors, thank you very much
};
}
}
}
Loading
Loading