diff --git a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs index 9d122367..b3d54062 100644 --- a/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs +++ b/CSharpMath.Core.Tests/Atom/LaTeXParserTest.cs @@ -96,6 +96,61 @@ public void ControlWordDoesNotConsumeFollowingStar() { Assert.Equal(@"\sin *", LaTeXParser.MathListToLaTeX(list).ToString()); } + [Fact] + public void MathRelAndJoinRelAreStructuralAndRoundTrip() { + var list = ParseLaTeX(@"a\mathrel{|}\joinrel=b"); + Assert.Collection(list, + atom => Assert.IsType(atom), + atom => { + var rel = Assert.IsType(atom); + Assert.Collection(rel.InnerList, inner => Assert.IsType(inner)); + }, + atom => Assert.IsType(atom), + atom => Assert.IsType(atom), + atom => Assert.IsType(atom)); + Assert.Equal(@"a\mathrel{|}\joinrel =b", LaTeXParser.MathListToLaTeX(list).ToString()); + var reparsed = ParseLaTeX(LaTeXParser.MathListToLaTeX(list).ToString()); + Assert.True(list.NullCheckingStructuralEquality(reparsed)); + } + + [Theory] + [InlineData(@"\mathrel{", "Missing closing brace")] + public void MathRelReportsMalformedArguments(string input, string expected) { + var (_, error) = new LaTeXParser(input).Build(); + Assert.Equal(expected, error); + } + + [Fact] + public void MathRelPreservesNestedContentAndScripts() { + var list = ParseLaTeX(@"\mathrel{\left( x^2 \right)}_i"); + var rel = Assert.IsType(Assert.Single(list)); + Assert.Single(rel.Subscript); + var inner = Assert.IsType(Assert.Single(rel.InnerList)); + Assert.Equal("(", inner.LeftBoundary.Nucleus); + Assert.Equal(")", inner.RightBoundary.Nucleus); + var canonical = LaTeXParser.MathListToLaTeX(list).ToString(); + Assert.True(list.NullCheckingStructuralEquality(ParseLaTeX(canonical))); + } + + [Fact] + public void JoinRelIsNotADisplayedNodeDuringCloneNormalization() { + var list = ParseLaTeX(@"a+\joinrel=b").Clone(true); + Assert.Collection(list, + atom => Assert.IsType(atom), + atom => Assert.IsType(atom), + atom => Assert.IsType(atom), + atom => Assert.IsType(atom), + atom => Assert.IsType(atom)); + } + + [Fact] + public void JoinRelPreservesScriptsStructurally() { + var list = ParseLaTeX(@"\joinrel^x"); + var join = Assert.IsType(Assert.Single(list)); + Assert.Single(join.Superscript); + Assert.True(list.NullCheckingStructuralEquality(ParseLaTeX(LaTeXParser.MathListToLaTeX(list).ToString()))); + } + /// new[] { Base list }, new[] { Script of first atom }, new[] { Script of first atom inside script of first atom } [Theory] [InlineData("x^2", "x^2", new[] { typeof(Variable) }, new[] { typeof(Number) })] diff --git a/CSharpMath.Core.Tests/Display/TypesetterTests.cs b/CSharpMath.Core.Tests/Display/TypesetterTests.cs index 23e3d77b..7531bcba 100644 --- a/CSharpMath.Core.Tests/Display/TypesetterTests.cs +++ b/CSharpMath.Core.Tests/Display/TypesetterTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Drawing; using System.Linq; using CSharpMath.Atom; @@ -10,6 +11,105 @@ namespace CSharpMath.Core.DisplayTests { public class TypesetterTests { + [Theory] + [InlineData(LineStyle.Display)] + [InlineData(LineStyle.Text)] + [InlineData(LineStyle.Script)] + [InlineData(LineStyle.ScriptScript)] + public void JoinRelContributesExactlyNegativeThreeMu(LineStyle style) { + var plain = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX("xx"), _font, _context, style); + var joined = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"x\joinrel x"), _font, _context, style); + var styleFont = new TFont(_context.MathTable.GetStyleSize(style, _font)); + Approximately.Equal(-3 * _context.MathTable.MuUnit(styleFont), joined.Width - plain.Width); + } + + [Fact] + public void MathRelGetsRelationSpacingExactlyOnceAndKeepsInternalLayout() { + var ordinary = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX("a=b"), _font, _context, LineStyle.Display); + var wrapped = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"a\mathrel{x}b"), _font, _context, LineStyle.Display); + Approximately.Equal(ordinary.Width, wrapped.Width); + var inner = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX("x+y"), _font, _context, LineStyle.Display); + var wrappedInner = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"\mathrel{x+y}"), _font, _context, LineStyle.Display); + Approximately.Equal(inner.Width, wrappedInner.Width); + } + + [Fact] + public void MathRelAtFormulaBoundaryAndWithScriptsRemainsMeasured() { + var standalone = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"\mathrel{x}"), _font, _context, LineStyle.Display); + var x = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX("x"), _font, _context, LineStyle.Display); + Approximately.Equal(x.Width, standalone.Width); + var scripted = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"\mathrel{x}^2"), _font, _context, LineStyle.Display); + Assert.Contains(scripted.Displays, d => d.HasScript); + Assert.True(scripted.Width >= standalone.Width); + } + + [Fact] + public void JoinRelScriptHasAVisibleScriptAnchor() { + var display = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"\joinrel^x"), _font, _context, LineStyle.Display); + Assert.Contains(display.Displays, d => d.HasScript); + Assert.True(display.Width > 0); + } + + [Theory] + [InlineData(@"\joinrel x", 6.6666667f)] + [InlineData(@"x\joinrel", 6.6666667f)] + [InlineData(@"\joinrel", -3.3333333f)] + public void JoinRelKeepsLogicalAdvanceAtBoundaries(string latex, float expectedLogicalWidth) { + var display = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(latex), _font, _context, LineStyle.Display); + Approximately.Equal(expectedLogicalWidth, display.LogicalWidth); + Assert.True(display.Width >= 0); + if (latex == @"\joinrel x") + Approximately.Equal(-10f / 3f, display.InkLeft); + } + + [Fact] + public void NestedMathRelPropagatesJoinRelLogicalAdvance() { + var display = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"\mathrel{\joinrel x}"), _font, _context, LineStyle.Display); + Approximately.Equal(6.6666667, display.LogicalWidth); + Assert.True(display.Width >= display.LogicalWidth); + } + + [Fact] + public void JoinRelInkIsPreservedThroughOverlineWrapper() { + var display = ParseLaTeXToDisplay(@"\overline{\joinrel x}"); + Assert.Contains(display.Displays, d => d is OverOrUnderlineDisplay); + Assert.True(display.InkLeft < 0); + } + + [Fact] + public void JoinRelInkIsPreservedThroughLargeOperatorLimits() { + var display = ParseLaTeXToDisplay(@"\sum\limits_{\joinrel x}"); + Assert.Contains(display.Displays, d => d is LargeOpLimitsDisplay); + Assert.True(display.InkLeft < 0); + } + + [Fact] + public void JoinRelInkIsPreservedThroughTableContainer() { + var display = ParseLaTeXToDisplay(@"\begin{matrix}\joinrel x & y\\ z & w\end{matrix}"); + Assert.True(display.HasJoinRel()); + Assert.True(display.InkLeft < 0); + } + + [Fact] + public void ListDisplaySnapshotsMutableChildrenForGeometryAndProvenance() { + var joined = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(@"\joinrel x"), _font, _context, LineStyle.Display); + var ordinary = Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX("x"), _font, _context, LineStyle.Display); + var children = new List> { joined }; + var snapshot = new ListDisplay(children); + var width = snapshot.Width; + + children[0] = ordinary; + children.Add(ordinary); + + Assert.Single(snapshot.Displays); + Assert.Same(joined, snapshot.Displays[0]); + Approximately.Equal(width, snapshot.Width); + Assert.True(snapshot.HasJoinRel()); + Assert.False(snapshot.Displays is System.Array); + Assert.False(snapshot.Displays is List>); + var readOnlyView = Assert.IsAssignableFrom>>(snapshot.Displays); + Assert.Throws(() => readOnlyView[0] = ordinary); + } internal static ListDisplay ParseLaTeXToDisplay(string latex) => Typesetter.CreateLine(AtomTests.LaTeXParserTest.ParseLaTeX(latex), _font, _context, LineStyle.Display); @@ -673,4 +773,4 @@ public void SpacingBetweenNumbers() { }); } } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering.Tests/TestAngouriMathForms.cs b/CSharpMath.Rendering.Tests/TestAngouriMathForms.cs index 23452f75..dfc4506a 100644 --- a/CSharpMath.Rendering.Tests/TestAngouriMathForms.cs +++ b/CSharpMath.Rendering.Tests/TestAngouriMathForms.cs @@ -1,3 +1,6 @@ +using System; +using System.Linq; +using SkiaSharp; using Xunit; namespace CSharpMath.Rendering.Tests { @@ -8,6 +11,7 @@ namespace CSharpMath.Rendering.Tests { /// ; what is new is the layout, so this /// measures rather than comparing against a baseline image. /// + [Collection(nameof(TestRenderingFixture))] public class TestAngouriMathForms { static System.Drawing.RectangleF Measure(string latex) { var painter = new SkiaSharp.MathPainter { LaTeX = latex }; @@ -33,5 +37,72 @@ public void TheyLayOut(string latex) { [Fact] public void ModuloTakesUpRoom() => Assert.True(Measure(@"x\bmod y").Width > Measure(@"xy").Width * 2); + + [Fact] + public void MathRelAndJoinRelMeasureWithoutClipping() { + var wrapped = Measure(@"a\mathrel{|}\joinrel=b"); + var relation = Measure("a=b"); + Assert.True(wrapped.Width > 0 && wrapped.Height > 0); + Assert.True(relation.Width > 0 && relation.Height > 0); + Assert.Null(new SkiaSharp.MathPainter { LaTeX = @"\mathrel{\left( x\right)}" }.ErrorMessage); + } + + [Fact] + public void UnaffectedMathKeepsLegacyMeasureOriginAndAdvance() { + var relation = Measure("a=b"); + var wrapped = Measure(@"\mathrel{x+y}"); + Assert.Equal(0, relation.X); + Assert.Equal(0, wrapped.X); + Assert.True(relation.Width > 0 && wrapped.Width > 0); + } + + [Theory] + [InlineData(@"\joinrel x")] + [InlineData(@"x\joinrel")] + [InlineData(@"\mathrel{\left(\joinrel x\right)}")] + [InlineData(@"\frac{\joinrel x}{x}")] + [InlineData(@"\sqrt{\joinrel x}")] + [InlineData(@"\bar{\joinrel x}")] + public void SkiaTightCanvasContainsInkAtBothEdges(string latex) { + var painter = new SkiaSharp.MathPainter { LaTeX = latex }; + var measure = painter.Measure(1000); + // Allocate only the measured ink span, with a one-pixel safety edge. This catches + // both clipping and accidental double compensation of a negative ink origin. + var width = Math.Max(1, (int)Math.Ceiling(measure.Width) + 2); + var height = Math.Max(1, (int)Math.Ceiling(measure.Height) + 2); + using var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + painter.Draw(canvas, 1, height - 1 - painter.Display!.Descent); + var occupied = Enumerable.Range(0, width).Where(x => + Enumerable.Range(0, height).Any(y => bitmap.GetPixel(x, y).Alpha > 0)).ToArray(); + Assert.NotEmpty(occupied); + Assert.True(occupied.First() >= 1); + Assert.True(occupied.Last() <= width - 2); + Assert.True(occupied.Last() > occupied.First()); + } + + [Theory] + [InlineData(@"\joinrel x")] + [InlineData(@"x\joinrel")] + public void DirectBoundaryJoinRelMatchesPlainX(string latex) { + static (int width, int height, int left, int right, int top, int bottom) Render(string source) { + var painter = new SkiaSharp.MathPainter { LaTeX = source }; + var measure = painter.Measure(1000); + var width = Math.Max(1, (int)Math.Ceiling(measure.Width) + 2); + var height = Math.Max(1, (int)Math.Ceiling(measure.Height) + 2); + using var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + painter.Draw(canvas, 1, height - 1 - painter.Display!.Descent); + var pixels = Enumerable.Range(0, width) + .SelectMany(x => Enumerable.Range(0, height).Select(y => (x, y))) + .Where(p => bitmap.GetPixel(p.x, p.y).Alpha > 0).ToArray(); + Assert.NotEmpty(pixels); + return (width, height, pixels.Min(p => p.x), pixels.Max(p => p.x), + pixels.Min(p => p.y), pixels.Max(p => p.y)); + } + Assert.Equal(Render("x"), Render(latex)); + } } } diff --git a/CSharpMath.Rendering.Tests/TestRendering.cs b/CSharpMath.Rendering.Tests/TestRendering.cs index e281aa21..a712c52e 100644 --- a/CSharpMath.Rendering.Tests/TestRendering.cs +++ b/CSharpMath.Rendering.Tests/TestRendering.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using SkiaSharp; using Xunit; namespace CSharpMath.Rendering.Tests { @@ -74,6 +75,95 @@ public void MathDisplay(string file, string latex) => [Theory, ClassData(typeof(TestRenderingMathData))] public void MathInline(string file, string latex) => Run(file, latex, new TMathPainter { LineStyle = Atom.LineStyle.Text }); + + [Theory] + [InlineData(@"\joinrel x")] + [InlineData(@"x\joinrel")] + [InlineData(@"\mathrel{\left(\joinrel x\right)}")] + [InlineData(@"\bar{\joinrel x}")] + public void JoinRelInkFitsInMeasuredImage(string latex) { + var painter = new TMathPainter { LaTeX = latex }; + Assert.Null(painter.ErrorMessage); + var measure = painter.Measure(1000); + Assert.True(measure.Width > 0 && measure.Height > 0); + + using var stream = new MemoryStream(); + DrawToStream(painter, stream, 1000, TextAlignment.TopLeft); + stream.Position = 0; + using var bitmap = SKBitmap.Decode(stream); + Assert.NotNull(bitmap); + var pixels = Enumerable.Range(0, bitmap.Width) + .SelectMany(x => Enumerable.Range(0, bitmap.Height).Select(y => (x, y))) + .Where(p => bitmap.GetPixel(p.x, p.y).Alpha > 0) + .ToArray(); + Assert.NotEmpty(pixels); + Assert.InRange(pixels.Min(p => p.x), 0, bitmap.Width - 1); + Assert.InRange(pixels.Max(p => p.x), 0, bitmap.Width - 1); + Assert.InRange(pixels.Min(p => p.y), 0, bitmap.Height - 1); + Assert.InRange(pixels.Max(p => p.y), 0, bitmap.Height - 1); + // DrawAsStream/DrawAsPng use the measured dimensions cast to int (floor). + Assert.Equal(Math.Max(1, (int)measure.Width), bitmap.Width); + Assert.Equal(Math.Max(1, (int)measure.Height), bitmap.Height); + } + + [Theory] + [InlineData(@"\joinrel x")] + [InlineData(@"x\joinrel")] + public void BoundaryJoinRelHasSameRasterAsPlainX(string latex) { + (int width, int height, int left, int right, int top, int bottom) Render(string source) { + var painter = new TMathPainter { LaTeX = source }; + using var stream = new MemoryStream(); + DrawToStreamForContract(painter, stream); + stream.Position = 0; + using var bitmap = SKBitmap.Decode(stream); + Assert.NotNull(bitmap); + var pixels = Enumerable.Range(0, bitmap!.Width) + .SelectMany(x => Enumerable.Range(0, bitmap.Height).Select(y => (x, y))) + .Where(p => bitmap.GetPixel(p.x, p.y).Alpha > 0).ToArray(); + Assert.NotEmpty(pixels); + return (bitmap.Width, bitmap.Height, pixels.Min(p => p.x), pixels.Max(p => p.x), + pixels.Min(p => p.y), pixels.Max(p => p.y)); + } + var plain = Render("x"); + var joined = Render(latex); + Assert.Equal(plain, joined); + } + + [Theory] + [InlineData(TextAlignment.TopLeft)] + [InlineData(TextAlignment.Top)] + [InlineData(TextAlignment.TopRight)] + public void TextPainterJoinRelInkSurvivesTextTypesetterRoots(TextAlignment alignment) { + const float canvasWidth = 240; + var painter = new TTextPainter { LaTeX = "prefix $\\joinrel x$ suffix" }; + Assert.Null(painter.ErrorMessage); + var measure = painter.Measure(canvasWidth); + Assert.True(measure.Width > 0 && measure.Height > 0); + Assert.True(measure.Width >= painter.Display!.Width); + + using var stream = new MemoryStream(); + DrawToStream(painter, stream, canvasWidth, alignment); + stream.Position = 0; + using var bitmap = SKBitmap.Decode(stream); + Assert.NotNull(bitmap); + var pixels = Enumerable.Range(0, bitmap!.Width) + .SelectMany(x => Enumerable.Range(0, bitmap.Height).Select(y => (x, y))) + .Where(p => bitmap.GetPixel(p.x, p.y).Alpha > 0) + .ToArray(); + Assert.NotEmpty(pixels); + Assert.InRange(pixels.Min(p => p.x), 0, bitmap.Width - 1); + Assert.InRange(pixels.Max(p => p.x), 0, bitmap.Width - 1); + } + + [Fact] + public void TextPainterWithoutJoinRelKeepsLegacyMeasuredWidth() { + var painter = new TTextPainter { LaTeX = "prefix $x$ suffix" }; + var measure = painter.Measure(240); + Assert.Equal(painter.Display!.Width, measure.Width); + } + + private void DrawToStreamForContract(TMathPainter painter, Stream stream) => + DrawToStream(painter, stream, 1000, TextAlignment.TopLeft); [Theory, ClassData(typeof(TestRenderingTextData))] public void TextLeft(string file, string latex) => Run(file, latex, new TTextPainter()); @@ -202,4 +292,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); } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/FrontEnd/MathPainter.cs b/CSharpMath.Rendering/FrontEnd/MathPainter.cs index 4f33f970..85b45a12 100644 --- a/CSharpMath.Rendering/FrontEnd/MathPainter.cs +++ b/CSharpMath.Rendering/FrontEnd/MathPainter.cs @@ -1,4 +1,5 @@ using System.Drawing; +using CSharpMath; namespace CSharpMath.Rendering.FrontEnd { using System.Runtime; @@ -25,12 +26,25 @@ protected override void UpdateDisplayCore(float unused) { public override void Draw(TCanvas canvas, TextAlignment alignment = TextAlignment.Center, Thickness padding = default, float offsetX = 0, float offsetY = 0) { var c = WrapCanvas(canvas); UpdateDisplay(float.NaN); - DrawCore(c, Display, Display == null ? new PointF?() : IPainterExtensions.GetDisplayPosition(Display.Width, Display.Ascent, Display.Descent, FontSize, c.Width, c.Height, alignment, padding, offsetX, offsetY)); + var position = Display == null ? new PointF?() : Display.HasJoinRel() + ? GetAlignedPosition(c, alignment, padding, offsetX, offsetY) + : IPainterExtensions.GetDisplayPosition(Display.Width, Display.Ascent, Display.Descent, + FontSize, c.Width, c.Height, alignment, padding, offsetX, offsetY); + DrawCore(c, Display, position); } public void Draw(TCanvas canvas, float x, float y) { var c = WrapCanvas(canvas); UpdateDisplay(float.NaN); - DrawCore(c, Display, new PointF(x, -y)); // Invert the canvas + var inkLeft = Display?.HasJoinRel() == true ? Display.InkBounds().Left : 0; + DrawCore(c, Display, new PointF(x - inkLeft, -y)); // x is the ink bounding-box origin; invert the canvas + } + private PointF GetAlignedPosition(ICanvas canvas, TextAlignment alignment, Thickness padding, + float offsetX, float offsetY) { + var bounds = Display!.InkBounds(); + var aligned = IPainterExtensions.GetDisplayPosition( + bounds.Right - bounds.Left, Display.Ascent, Display.Descent, FontSize, + canvas.Width, canvas.Height, alignment, padding, offsetX, offsetY); + return new PointF(aligned.X - bounds.Left, aligned.Y); } /// /// Directly draw the given . Repositions the . @@ -56,4 +70,4 @@ public void DrawDisplay(IDisplay? display, TCanvas canvas, } public new MathPainter ShallowClone() => (MathPainter)MemberwiseClone(); } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/FrontEnd/Painter.cs b/CSharpMath.Rendering/FrontEnd/Painter.cs index 02acaa6f..59b842a8 100644 --- a/CSharpMath.Rendering/FrontEnd/Painter.cs +++ b/CSharpMath.Rendering/FrontEnd/Painter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Specialized; using System.Drawing; +using CSharpMath; using CSharpMath.Display; using Typography.OpenFont; @@ -62,9 +63,13 @@ public Painter() { public abstract ICanvas WrapCanvas(TCanvas canvas); public virtual RectangleF Measure(float textPainterCanvasWidth) { UpdateDisplay(textPainterCanvasWidth); - if (Display != null) - return new RectangleF(0, -Display.Ascent, Display.Width, Display.Ascent + Display.Descent); - else return RectangleF.Empty; + if (Display != null) { + if (!Display.HasJoinRel()) + return new RectangleF(0, -Display.Ascent, Display.Width, Display.Ascent + Display.Descent); + var inkBounds = Display.InkBounds(); + return new RectangleF(inkBounds.Left, -Display.Ascent, + inkBounds.Right - inkBounds.Left, Display.Ascent + Display.Descent); + } else return RectangleF.Empty; } protected abstract void UpdateDisplayCore(float textPainterCanvasWidth); protected void UpdateDisplay(float textPainterCanvasWidth) { @@ -132,4 +137,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/Atom/Atoms/JoinRel.cs b/CSharpMath/Atom/Atoms/JoinRel.cs new file mode 100644 index 00000000..ba0b8597 --- /dev/null +++ b/CSharpMath/Atom/Atoms/JoinRel.cs @@ -0,0 +1,13 @@ +namespace CSharpMath.Atom.Atoms { + /// The plain-TeX relation joiner, which contributes -3mu. + public sealed class JoinRel : MathAtom { + public JoinRel() : base() { } + // Plain TeX's joiner is an invisible spacing atom. Scripts are rejected by + // the parser rather than being silently discarded by the typesetter. + public override bool ScriptsAllowed => true; + public new JoinRel Clone(bool finalize) => (JoinRel)base.Clone(finalize); + protected override MathAtom CloneInside(bool finalize) => new JoinRel(); + public override string DebugString => @"\joinrel" + + new System.Text.StringBuilder().AppendDebugStringOfScripts(this).ToString(); + } +} diff --git a/CSharpMath/Atom/Atoms/MathRel.cs b/CSharpMath/Atom/Atoms/MathRel.cs new file mode 100644 index 00000000..864365cf --- /dev/null +++ b/CSharpMath/Atom/Atoms/MathRel.cs @@ -0,0 +1,19 @@ +using System.Text; + +namespace CSharpMath.Atom.Atoms { + /// A complete subformula treated as one relation atom. + public sealed class MathRel : MathAtom, IMathListContainer { + public MathRel(MathList innerList) : base() => InnerList = innerList; + public MathList InnerList { get; } + System.Collections.Generic.IEnumerable IMathListContainer.InnerLists => + new[] { InnerList }; + public override bool ScriptsAllowed => true; + public new MathRel Clone(bool finalize) => (MathRel)base.Clone(finalize); + protected override MathAtom CloneInside(bool finalize) => new MathRel(InnerList.Clone(finalize)); + public override string DebugString => new StringBuilder(@"\mathrel{") + .Append(InnerList.DebugString).Append('}').AppendDebugStringOfScripts(this).ToString(); + public override bool Equals(object obj) => obj is MathRel other && + EqualsAtom(other) && InnerList.NullCheckingStructuralEquality(other.InnerList); + public override int GetHashCode() => (base.GetHashCode(), InnerList).GetHashCode(); + } +} diff --git a/CSharpMath/Atom/LaTeXParser.cs b/CSharpMath/Atom/LaTeXParser.cs index 1250d8e7..815bc972 100644 --- a/CSharpMath/Atom/LaTeXParser.cs +++ b/CSharpMath/Atom/LaTeXParser.cs @@ -557,6 +557,14 @@ static bool MathAtomToLaTeX(MathAtom atom, StringBuilder builder, MathListToLaTeX(list, builder, currentFontStyle); builder.Append(@"\right").Append(BoundaryToLaTeX(right)).Append(' '); break; + case MathRel rel: + builder.Append(@"\mathrel{"); + MathListToLaTeX(rel.InnerList, builder, currentFontStyle); + builder.Append('}'); + break; + case JoinRel: + builder.Append(@"\joinrel "); + break; case Table table: if (table.Environment != null) { builder.Append(@"\begin{" + table.Environment + "}"); @@ -731,4 +739,4 @@ public static StringBuilder MathListToLaTeX(MathList mathList, StringBuilder? sb return sb; } } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/LaTeXSettings.cs b/CSharpMath/Atom/LaTeXSettings.cs index 3ef5599a..ea262b77 100644 --- a/CSharpMath/Atom/LaTeXSettings.cs +++ b/CSharpMath/Atom/LaTeXSettings.cs @@ -183,6 +183,9 @@ public static class LaTeXSettings { parser.TextMode ? parser.ReadSpace().Bind(skip => Ok(new Space(skip))) : @"\hskip is not allowed in math mode" }, { @"\mkern", (parser, accumulate, stopChar) => !parser.TextMode ? parser.ReadSpace().Bind(kern => Ok(new Space(kern))) : @"\mkern is not allowed in text mode" }, + { @"\mathrel", (parser, accumulate, stopChar) => + parser.ReadArgument().Bind(innerList => Ok(new MathRel(innerList))) }, + { @"\joinrel", (parser, accumulate, stopChar) => Ok(new JoinRel()) }, { @"\mskip", (parser, accumulate, stopChar) => !parser.TextMode ? parser.ReadSpace().Bind(skip => Ok(new Space(skip))) : @"\mskip is not allowed in text mode" }, { @"\raisebox", (parser, accumulate, stopChar) => { @@ -1189,4 +1192,4 @@ atom is Accent accent // \varsupsetneqq -> ⫌ + U+FE00 (Variation Selector 1) Not dealing with variation selectors, thank you very much }; } -} \ No newline at end of file +} diff --git a/CSharpMath/Atom/MathList.cs b/CSharpMath/Atom/MathList.cs index d3869673..8eda114b 100644 --- a/CSharpMath/Atom/MathList.cs +++ b/CSharpMath/Atom/MathList.cs @@ -72,7 +72,7 @@ public MathList Clone(bool finalize) { n.Fuse(newNode); continue; // do not add the new node; we fused it instead. } - if (newNode is not (Comment or Space or Style)) prevDisplayedIndex = newList.Count; // Corresponds to atom types that use continue; in Typesetter.CreateLine + if (newNode is not (Comment or Space or Style or JoinRel)) prevDisplayedIndex = newList.Count; // Corresponds to atom types that use continue; in Typesetter.CreateLine newList.Add(newNode); prevNode = newNode; } @@ -122,4 +122,4 @@ public virtual void Add(MathAtom item) { public bool Remove(MathAtom item) => Atoms.Remove(item); public MathList Slice(int index, int count) => new MathList { Atoms = Atoms.GetRange(index, count) }; } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Displays/AccentDisplay.cs b/CSharpMath/Display/Displays/AccentDisplay.cs index 0d4ff196..628e3698 100644 --- a/CSharpMath/Display/Displays/AccentDisplay.cs +++ b/CSharpMath/Display/Displays/AccentDisplay.cs @@ -47,4 +47,4 @@ public void SetTextColorRecursive(Color? textColor) { public override string ToString() => $@"\accent{{{Accent}}}{{{Accentee}}}"; } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Displays/FractionDisplay.cs b/CSharpMath/Display/Displays/FractionDisplay.cs index 9d13f795..4ea871b4 100644 --- a/CSharpMath/Display/Displays/FractionDisplay.cs +++ b/CSharpMath/Display/Displays/FractionDisplay.cs @@ -69,4 +69,4 @@ public void SetTextColorRecursive(Color? textColor) { public override string ToString() => $@"\frac{{{Numerator}}}{{{Denominator}}}"; } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Displays/InnerDisplay.cs b/CSharpMath/Display/Displays/InnerDisplay.cs index b176366d..c975fb23 100644 --- a/CSharpMath/Display/Displays/InnerDisplay.cs +++ b/CSharpMath/Display/Displays/InnerDisplay.cs @@ -58,4 +58,4 @@ public void SetTextColorRecursive(Color? textColor) { public override string ToString() => $@"\inner[{Left}][{Right}]{{{Inner}}}"; } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Displays/ListDisplay.cs b/CSharpMath/Display/Displays/ListDisplay.cs index c8b2cef1..91fcc12f 100644 --- a/CSharpMath/Display/Displays/ListDisplay.cs +++ b/CSharpMath/Display/Displays/ListDisplay.cs @@ -21,14 +21,28 @@ public void SetTextColorRecursive(Color? textColor) { /// For a subscript or superscript, this is the index in the /// parent list. For a regular list, it is int.MinValue. public int IndexInParent { get; set; } + /// Internal provenance marker for JoinRel-aware ink normalization. + internal bool HasJoinRelDirect { get; set; } + internal bool HasJoinRelDescendant { get; set; } public ListDisplay(IReadOnlyList> displays) { - Displays = displays; + // Take a snapshot: provenance, width, and drawing must continue to + // describe the same children even when the caller supplied a mutable + // IReadOnlyList such as List. + Displays = System.Array.AsReadOnly(displays.ToArray()); LinePosition = LinePosition.Regular; IndexInParent = int.MinValue; + // Children are fully constructed before their containing list. Cache + // provenance here so manually composed lists and table containers are + // covered without a later Measure/Draw traversal. + HasJoinRelDescendant = Displays.Any(d => d.HasJoinRel()); + LogicalWidth = displays.CollectionWidth(); } public float Ascent => Displays.CollectionAscent(); public float Descent => Displays.CollectionDescent(); public PointF Position { get; set; } + internal float LogicalWidth { get; set; } + internal float InkLeft => this.InkBounds().Left; + internal float InkRight => this.InkBounds().Right; public Range Range => Range.Combine( @@ -48,4 +62,4 @@ public void Draw(IGraphicsContext context) { /// The string returned is NOT real TeX! It's for debugging purposes only. public override string ToString() => string.Concat(Displays); } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Displays/RadicalDisplay.cs b/CSharpMath/Display/Displays/RadicalDisplay.cs index 13939496..bc647a09 100644 --- a/CSharpMath/Display/Displays/RadicalDisplay.cs +++ b/CSharpMath/Display/Displays/RadicalDisplay.cs @@ -79,4 +79,4 @@ public void SetTextColorRecursive(Color? textColor) { public Color? BackColor { get; set; } public override string ToString() => $@"\sqrt[{Degree}]{{{Radicand}}}"; } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/IDisplay.cs b/CSharpMath/Display/IDisplay.cs index 0e0a1bf9..e84888bc 100644 --- a/CSharpMath/Display/IDisplay.cs +++ b/CSharpMath/Display/IDisplay.cs @@ -46,4 +46,4 @@ public static void DrawBackground } } } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/InterElementSpaces.cs b/CSharpMath/Display/InterElementSpaces.cs index 62808b9c..3305376e 100644 --- a/CSharpMath/Display/InterElementSpaces.cs +++ b/CSharpMath/Display/InterElementSpaces.cs @@ -36,6 +36,8 @@ static int GetInterElementSpaceArrayIndexForType(MathAtom atomType, bool row) => Atoms.LargeOperator _ => 1, Atoms.BinaryOperator _ => 2, Atoms.Relation _ => 3, + Atoms.MathRel _ => 3, + Atoms.JoinRel _ => 3, Atoms.Open _ => 4, Atoms.Close _ => 5, Atoms.Punctuation _ => 6, @@ -64,4 +66,4 @@ static int GetInterElementSpaceArrayIndexForType(MathAtom atomType, bool row) => return multiplier > 0 ? multiplier * mathTable.MuUnit(styleFont) : 0; } } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Typesetter.cs b/CSharpMath/Display/Typesetter.cs index 8c58f6bf..f759914f 100644 --- a/CSharpMath/Display/Typesetter.cs +++ b/CSharpMath/Display/Typesetter.cs @@ -158,8 +158,14 @@ List _PreprocessMathList() { } var typesetter = new Typesetter(font, context, style, cramped, spaced); typesetter.CreateDisplayAtoms(_PreprocessMathList()); - return new ListDisplay(typesetter._displayAtoms.ToArray()); + var listDisplay = new ListDisplay(typesetter._displayAtoms.ToArray()) { + HasJoinRelDirect = typesetter._hasJoinRel, + HasJoinRelDescendant = typesetter._hasJoinRel || typesetter._displayAtoms.Any(d => d.HasJoinRel()) + }; + listDisplay.LogicalWidth = typesetter._currentPosition.X; + return listDisplay; } + private bool _hasJoinRel; private void CreateDisplayAtoms(List preprocessedAtoms) { MathAtom? prevAtom = null; foreach (var atom in preprocessedAtoms) { @@ -174,6 +180,18 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { AddDisplayLine(false); _currentPosition.X += space.ActualLength(_mathTable, _font); continue; + case JoinRel joinRel: + _hasJoinRel = true; + AddDisplayLine(false); + if (joinRel.Superscript.IsNonEmpty() || joinRel.Subscript.IsNonEmpty()) { + var scriptAnchor = AddDisplayLine(true); + if (scriptAnchor is null) throw new InvalidCodePathException("script anchor was not created"); + var logicalPosition = _currentPosition.X; + MakeScripts(joinRel, scriptAnchor, joinRel.IndexRange.Location, 0); + _currentPosition.X = logicalPosition; + } + _currentPosition.X -= 3 * _mathTable.MuUnit(_styleFont); + continue; case Style style: // stash the existing layout AddDisplayLine(false); @@ -244,6 +262,17 @@ private void CreateDisplayAtoms(List preprocessedAtoms) { MakeScripts(atom, innerDisplay, atom.IndexRange.Location, 0); } break; + case MathRel mathRel: + AddDisplayLine(false); + AddInterElementSpace(prevAtom, mathRel); + var mathRelDisplay = CreateLine(mathRel.InnerList, _font, _context, _style, _cramped); + mathRelDisplay.Position = _currentPosition; + _currentPosition.X += mathRelDisplay is ListDisplay nestedMathRel + ? nestedMathRel.LogicalWidth : mathRelDisplay.Width; + _displayAtoms.Add(mathRelDisplay); + if (atom.Subscript.IsNonEmpty() || atom.Superscript.IsNonEmpty()) + MakeScripts(atom, mathRelDisplay, atom.IndexRange.Location, 0); + break; case Underline underline: AddDisplayLine(false); AddInterElementSpace(prevAtom, underline); @@ -1133,4 +1162,4 @@ private IDisplay AddLimitsToDisplay(IDisplay displ return display; } } -} \ No newline at end of file +} diff --git a/CSharpMath/Extensions.cs b/CSharpMath/Extensions.cs index d2cb4c4c..39fcb9d9 100644 --- a/CSharpMath/Extensions.cs +++ b/CSharpMath/Extensions.cs @@ -65,6 +65,87 @@ public static float CollectionWidth displays.IsNonEmpty() ? displays.Max(d => d.Position.X + d.Width) - displays.Min(d => d.Position.X) : 0; + internal static (float Left, float Right) InkBounds + (this IDisplay display) where TFont : IFont { + if (display is Display.Displays.ListDisplay list && list.Displays.IsNonEmpty()) { + var bounds = list.Displays.Select(d => { + var child = d.InkBounds(); + return (Left: d.Position.X + child.Left, Right: d.Position.X + child.Right); + }); + return (bounds.Min(b => b.Left), bounds.Max(b => b.Right)); + } + IEnumerable<(float Left, float Right)> Children() { + float Offset(IDisplay child) => child.Position.X - display.Position.X; + (float Left, float Right) At(IDisplay child) { + var b = child.InkBounds(); + var x = Offset(child); + return (x + b.Left, x + b.Right); + } + switch (display) { + case Display.Displays.InnerDisplay inner: + if (inner.Left is { } left) yield return (left.Position.X - display.Position.X, left.Position.X - display.Position.X + left.Width); + yield return At(inner.Inner); + if (inner.Right is { } right) yield return (right.Position.X - display.Position.X, right.Position.X - display.Position.X + right.Width); + break; + case Display.Displays.FractionDisplay fraction: + yield return At(fraction.Numerator); yield return At(fraction.Denominator); + yield return (0, fraction.Width); break; + case Display.Displays.RadicalDisplay radical: + yield return At(radical.Radicand); + if (radical.Degree is { } degree) yield return At(degree); + yield return (0, radical.Width); break; + case Display.Displays.AccentDisplay accent: + yield return At(accent.Accentee); + yield return (accent.Accent.Position.X, accent.Accent.Position.X + accent.Accent.Width); break; + case Display.Displays.UnderAnnotationDisplay under: + yield return At(under.Inner); + if (under.UnderList is { } list2) yield return At(list2); + yield return (under.AnnotationGlyph.Position.X, under.AnnotationGlyph.Position.X + under.AnnotationGlyph.Width); break; + case Display.Displays.OverOrUnderlineDisplay over: + yield return At(over.Inner); + // The horizontal rule is owned by the wrapper, not Inner. + yield return (over.Position.X - display.Position.X, + over.Position.X - display.Position.X + over.Width); + break; + case Display.Displays.LargeOpLimitsDisplay limits: + yield return At(limits.NucleusDisplay); + if (limits.UpperLimit is { } upper) yield return At(upper); + if (limits.LowerLimit is { } lower) yield return At(lower); + // Include the wrapper's logical advance as well as child ink. + yield return (0, limits.Width); + break; + } + } + var children = Children().ToArray(); + if (children.Length > 0) + return (children.Min(b => b.Left), children.Max(b => b.Right)); + return (0, display.Width); + } + internal static bool HasJoinRel(this IDisplay display) + where TFont : IFont { + switch (display) { + case Display.Displays.ListDisplay list: + return list.HasJoinRelDescendant; + case Display.Displays.InnerDisplay inner: + return inner.Inner.HasJoinRel() || (inner.Left?.HasJoinRel() ?? false) || (inner.Right?.HasJoinRel() ?? false); + case Display.Displays.FractionDisplay fraction: + return fraction.Numerator.HasJoinRel() || fraction.Denominator.HasJoinRel(); + case Display.Displays.RadicalDisplay radical: + return radical.Radicand.HasJoinRel() || (radical.Degree?.HasJoinRel() ?? false); + case Display.Displays.AccentDisplay accent: + return accent.Accentee.HasJoinRel(); + case Display.Displays.OverOrUnderlineDisplay over: + return over.Inner.HasJoinRel(); + case Display.Displays.UnderAnnotationDisplay under: + return under.Inner.HasJoinRel() || (under.UnderList?.HasJoinRel() ?? false); + case Display.Displays.LargeOpLimitsDisplay limits: + return limits.NucleusDisplay.HasJoinRel() + || (limits.UpperLimit?.HasJoinRel() ?? false) + || (limits.LowerLimit?.HasJoinRel() ?? false); + default: + return false; + } + } public static PointF Plus(this PointF point1, PointF point2) => new PointF(point1.X + point2.X, point1.Y + point2.Y); public static bool Is(this ReadOnlySpan span, char c) => @@ -142,4 +223,4 @@ internal static StringBuilder AppendDebugStringOfScripts(this StringBuilder buil return builder; } } -} \ No newline at end of file +} diff --git a/CSharpMath/Properties/AssemblyInfo.cs b/CSharpMath/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..add16518 --- /dev/null +++ b/CSharpMath/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSharpMath.Rendering")] +[assembly: InternalsVisibleTo("CSharpMath.Core.Tests")] diff --git a/CSharpMath/PublicAPI.Unshipped.txt b/CSharpMath/PublicAPI.Unshipped.txt index e69de29b..8a47875d 100644 --- a/CSharpMath/PublicAPI.Unshipped.txt +++ b/CSharpMath/PublicAPI.Unshipped.txt @@ -0,0 +1,13 @@ +CSharpMath.Atom.Atoms.JoinRel +CSharpMath.Atom.Atoms.JoinRel.Clone(bool finalize) -> CSharpMath.Atom.Atoms.JoinRel! +CSharpMath.Atom.Atoms.JoinRel.JoinRel() -> void +CSharpMath.Atom.Atoms.MathRel +CSharpMath.Atom.Atoms.MathRel.Clone(bool finalize) -> CSharpMath.Atom.Atoms.MathRel! +CSharpMath.Atom.Atoms.MathRel.InnerList.get -> CSharpMath.Atom.MathList! +CSharpMath.Atom.Atoms.MathRel.MathRel(CSharpMath.Atom.MathList! innerList) -> void +override CSharpMath.Atom.Atoms.JoinRel.DebugString.get -> string! +override CSharpMath.Atom.Atoms.JoinRel.ScriptsAllowed.get -> bool +override CSharpMath.Atom.Atoms.MathRel.DebugString.get -> string! +override CSharpMath.Atom.Atoms.MathRel.Equals(object! obj) -> bool +override CSharpMath.Atom.Atoms.MathRel.GetHashCode() -> int +override CSharpMath.Atom.Atoms.MathRel.ScriptsAllowed.get -> bool