diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Abs.png b/CSharpMath.Rendering.Tests/MathDisplay/Abs.png index f3369c8c..e98bd6fc 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Abs.png and b/CSharpMath.Rendering.Tests/MathDisplay/Abs.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/Cases.png b/CSharpMath.Rendering.Tests/MathDisplay/Cases.png index e102fabf..3a6807a8 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/Cases.png and b/CSharpMath.Rendering.Tests/MathDisplay/Cases.png differ diff --git a/CSharpMath.Rendering.Tests/MathDisplay/SimpleShortProof.png b/CSharpMath.Rendering.Tests/MathDisplay/SimpleShortProof.png index eefae493..349c1155 100644 Binary files a/CSharpMath.Rendering.Tests/MathDisplay/SimpleShortProof.png and b/CSharpMath.Rendering.Tests/MathDisplay/SimpleShortProof.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Abs.png b/CSharpMath.Rendering.Tests/MathInline/Abs.png index f3369c8c..e98bd6fc 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Abs.png and b/CSharpMath.Rendering.Tests/MathInline/Abs.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/Cases.png b/CSharpMath.Rendering.Tests/MathInline/Cases.png index e102fabf..3a6807a8 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/Cases.png and b/CSharpMath.Rendering.Tests/MathInline/Cases.png differ diff --git a/CSharpMath.Rendering.Tests/MathInline/SimpleShortProof.png b/CSharpMath.Rendering.Tests/MathInline/SimpleShortProof.png index eefae493..349c1155 100644 Binary files a/CSharpMath.Rendering.Tests/MathInline/SimpleShortProof.png and b/CSharpMath.Rendering.Tests/MathInline/SimpleShortProof.png differ diff --git a/CSharpMath.Rendering.Tests/TestMeasure.cs b/CSharpMath.Rendering.Tests/TestMeasure.cs index b6a741e6..05415068 100644 --- a/CSharpMath.Rendering.Tests/TestMeasure.cs +++ b/CSharpMath.Rendering.Tests/TestMeasure.cs @@ -12,7 +12,7 @@ class D : Display.IDisplay { public float Descent => 3; public float Width => 10; - public PointF Position { get => PointF.Empty; set => throw new NotImplementedException(); } + public virtual PointF Position { get => PointF.Empty; set => throw new NotImplementedException(); } public Atom.Range Range => throw new NotImplementedException(); public Color? TextColor { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } public Color? BackColor { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } @@ -20,6 +20,10 @@ class D : Display.IDisplay { public void Draw(IGraphicsContext context) => throw new NotImplementedException(); public void SetTextColorRecursive(Color? textColor) => throw new NotImplementedException(); } + sealed class PositionedD : D { + PointF _position; + public override PointF Position { get => _position; set => _position = value; } + } class DKeyboard : Editor.MathKeyboard { public DKeyboard() : base(TypesettingContext.Instance, new Fonts(Enumerable.Empty(), 0.0f)) => Display = new Display.Displays.ListDisplay(new[] { new D() }); @@ -27,11 +31,13 @@ class DKeyboard : Editor.MathKeyboard { class DRenderingMath : SkiaSharp.MathPainter { public DRenderingMath() => Display = new Display.Displays.ListDisplay(new[] { new D() }); + public void SetTestDisplay(Display.IDisplay display) => Display = display; protected override void UpdateDisplayCore(float unused) { } } class DRenderingText : SkiaSharp.TextPainter { public DRenderingText() => Display = new Display.Displays.ListDisplay(new[] { new D() }); + public void SetTestDisplay(Display.IDisplay display) => Display = display; protected override void UpdateDisplayCore(float canvasWidth) { } } class DRenderingKeyboard : FrontEnd.MathKeyboard { @@ -57,5 +63,27 @@ public void RenderingMeasure_YIsNegAscent() { Assert.Equal(new RectangleF(0, -12, 10, 15), new DRenderingText().Measure(float.NaN)); Assert.Equal(new RectangleF(0, -12, 10, 15), new DRenderingKeyboard().Measure); } + + [Fact] + public void RenderingMeasure_RecursivelyUnionsPositionedRows() { + var left = new PositionedD { Position = new PointF(-4, 0) }; + var right = new PositionedD { Position = new PointF(12, 0) }; + var painter = new DRenderingText(); + painter.SetTestDisplay(new Display.Displays.ListDisplay(new Display.IDisplay[] { + new Display.Displays.ListDisplay(new Display.IDisplay[] { left }), + new Display.Displays.ListDisplay(new Display.IDisplay[] { right }) { + Position = new PointF(0, -20) + } + })); + Assert.Equal(new RectangleF(-4, -12, 26, 35), painter.Measure(float.NaN)); + var mathPainter = new DRenderingMath(); + mathPainter.SetTestDisplay(new Display.Displays.ListDisplay(new Display.IDisplay[] { + new Display.Displays.ListDisplay(new Display.IDisplay[] { left }), + new Display.Displays.ListDisplay(new Display.IDisplay[] { right }) { + Position = new PointF(0, -20) + } + })); + Assert.Equal(new RectangleF(-4, -12, 26, 35), mathPainter.Measure()); + } } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering.Tests/TestMultilineBounds.cs b/CSharpMath.Rendering.Tests/TestMultilineBounds.cs new file mode 100644 index 00000000..505afa50 --- /dev/null +++ b/CSharpMath.Rendering.Tests/TestMultilineBounds.cs @@ -0,0 +1,166 @@ +using System; +using System.Drawing; +using System.Linq; +using CSharpMath.Display; +using CSharpMath.Rendering; +using CSharpMath.Rendering.BackEnd; +using CSharpMath.Rendering.FrontEnd; +using CSharpMath.SkiaSharp; +using SkiaSharp; +using Xunit; + +namespace CSharpMath.Rendering.Tests { + public class TestMultilineBounds { + const string Maxwell = @"\begin{aligned}\nabla \cdot \vec{\bf E} & = \frac {\rho} {\varepsilon_0} \\ \nabla \cdot \vec{\bf B} & = 0 \\ \nabla \times \vec{\bf E} &= - \frac{\partial\vec{\bf B}}{\partial t} \\ \nabla \times \vec{\bf B} & = \mu_0\vec{\bf J} + \mu_0\varepsilon_0 \frac{\partial\vec{\bf E}}{\partial t}\end{aligned}"; + const string EqnArrayMath = @"\begin{eqnarray}a+b+c+d+e+f&=&a+b+c+d+e+f\\0 &=& a+b+c+d+e+f-a-b-c-d-e-f\end{eqnarray}"; + const string EqnArrayText = @"$$\begin{eqnarray}a+b+c+d+e+f&=&a+b+c+d+e+f\\0 &=& a+b+c+d+e+f-a-b-c-d-e-f\end{eqnarray}$$"; + + [Theory] + [InlineData(Maxwell)] + [InlineData(EqnArrayMath)] + [InlineData(@"\begin{array}{cc}a&b\\c&d\end{array}")] + [InlineData(@"\begin{matrix}1&2\\3&4\end{matrix}")] + public void MultilineSourcesHaveFiniteAggregateMeasure(string source) { + var painter = new SkiaSharp.MathPainter { FontSize = 32, LaTeX = source }; + var bounds = painter.Measure(600); + Assert.Null(painter.ErrorMessage); + Assert.True(bounds.Width > 0 && bounds.Height > 0); + Assert.True(float.IsFinite(bounds.Left) && float.IsFinite(bounds.Top)); + } + + [Fact] + public void MeasureDoesNotChangeLaterDrawLayoutWidth() { + var afterMeasure = new SkiaSharp.TextPainter { FontSize = 32, LaTeX = "line one line two" }; + afterMeasure.Measure(100); + using var first = new SKBitmap(1000, 1000); + using var firstCanvas = new SKCanvas(first); + afterMeasure.Draw(firstCanvas, TextAlignment.TopLeft); + + var direct = new SkiaSharp.TextPainter { FontSize = 32, LaTeX = "line one line two" }; + using var second = new SKBitmap(1000, 1000); + using var secondCanvas = new SKCanvas(second); + direct.Draw(secondCanvas, TextAlignment.TopLeft); + Assert.Equal(direct._relativeXCoordDisplay.Position, afterMeasure._relativeXCoordDisplay.Position); + } + + [Fact] + public void DrawIsStatelessAcrossRepeatedCalls() { + var painter = new SkiaSharp.TextPainter { FontSize = 32, LaTeX = "line one line two" }; + using var bitmap = new SKBitmap(1000, 1000); + using var canvas = new SKCanvas(bitmap); + painter.Draw(canvas, TextAlignment.TopLeft); + var first = painter._relativeXCoordDisplay.Position; + painter.Draw(canvas, TextAlignment.TopLeft); + Assert.Equal(first, painter._relativeXCoordDisplay.Position); + } + + [Theory] + [InlineData(TextAlignment.TopLeft)] + [InlineData(TextAlignment.Top)] + public void DrawAsStreamDimensionsAndEdgesContainInk(TextAlignment alignment) { + var painter = new SkiaSharp.MathPainter { FontSize = 32, LaTeX = Maxwell }; + using var stream = painter.DrawAsStream(600, alignmentForTests: alignment); + using var bitmap = SKBitmap.Decode(stream); + Assert.True(bitmap.Width > 0 && bitmap.Height > 0); + var pixels = Enumerable.Range(0, bitmap.Width).SelectMany(x => + Enumerable.Range(0, bitmap.Height).Select(y => (x, y, a: bitmap.GetPixel(x, y).Alpha))) + .Where(p => p.a > 0).ToArray(); + Assert.NotEmpty(pixels); + Assert.InRange(pixels.Min(p => p.x), 1, bitmap.Width - 2); + Assert.InRange(pixels.Max(p => p.x), 1, bitmap.Width - 2); + Assert.InRange(pixels.Min(p => p.y), 1, bitmap.Height - 2); + Assert.InRange(pixels.Max(p => p.y), 1, bitmap.Height - 2); + } + + [Fact] + public void EqnArrayStreamContainsTheCompleteComposite() { + var painter = new SkiaSharp.TextPainter { FontSize = 32, LaTeX = EqnArrayText }; + using var stream = painter.DrawAsStream(600); + using var bitmap = SKBitmap.Decode(stream); + var pixels = Enumerable.Range(0, bitmap.Width).SelectMany(x => + Enumerable.Range(0, bitmap.Height).Select(y => (x, y, a: bitmap.GetPixel(x, y).Alpha))) + .Where(p => p.a > 0).ToArray(); + Assert.NotEmpty(pixels); + Assert.InRange((pixels.Min(p => p.x) + pixels.Max(p => p.x)) / 2f, + bitmap.Width / 2f - 2, bitmap.Width / 2f + 2); + Assert.InRange(pixels.Min(p => p.x), 1, bitmap.Width - 2); + Assert.InRange(pixels.Max(p => p.x), 1, bitmap.Width - 2); + } + + [Theory] + [InlineData(600f)] + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + public void TextPainterCentersTheCompleteEqnArrayAtFiniteAndUnboundedWidths(float width) { + var painter = new SkiaSharp.TextPainter { FontSize = 32, LaTeX = EqnArrayText }; + var measure = painter.Measure(width); + Assert.True(float.IsFinite(measure.Left) && float.IsFinite(measure.Top)); + Assert.True(float.IsFinite(measure.Width) && float.IsFinite(measure.Height)); + var display = Assert.Single(painter._absoluteXCoordDisplay.Displays); + var bounds = DisplayInkBounds.Get(display).Plus(display.Position); + if (float.IsFinite(width)) + Assert.Equal(width / 2, bounds.Left + bounds.Width / 2, precision: 3); + else + Assert.Equal(0, bounds.Left, precision: 3); + } + + [Theory] + [InlineData(TextAlignment.TopLeft)] + [InlineData(TextAlignment.Center)] + public void MathPainterMeasureThenDrawUsesTheSameAggregateBounds(TextAlignment alignment) { + var painter = new SkiaSharp.MathPainter { FontSize = 32, LaTeX = Maxwell }; + var measured = painter.Measure(); + var width = Math.Max(1, (int)Math.Ceiling(measured.Width) + 4); + var height = Math.Max(1, (int)Math.Ceiling(measured.Height) + 4); + using var bitmap = new SKBitmap(width, height); + using var canvas = new SKCanvas(bitmap); + painter.Draw(canvas, alignment); + var pixels = Enumerable.Range(0, bitmap.Width).SelectMany(x => + Enumerable.Range(0, bitmap.Height).Select(y => (x, y, a: bitmap.GetPixel(x, y).Alpha))) + .Where(p => p.a > 0).ToArray(); + Assert.NotEmpty(pixels); + Assert.InRange(pixels.Min(p => p.x), 1, bitmap.Width - 2); + Assert.InRange(pixels.Max(p => p.x), 1, bitmap.Width - 2); + Assert.True(measured.Width > 0 && measured.Height > 0); + if (alignment == TextAlignment.Center) + Assert.InRange((pixels.Min(p => p.x) + pixels.Max(p => p.x)) / 2f, + bitmap.Width / 2f - 3, bitmap.Width / 2f + 3); + } + + [Fact] + public void OffsetAccentIsIncludedInAggregateInkBounds() { + var painter = new SkiaSharp.MathPainter { FontSize = 32, LaTeX = @"\widehat{AB}" }; + painter.Measure(); + var root = Assert.IsType>(painter.Display); + var accent = Assert.Single(root.Displays.OfType>()); + var bounds = DisplayInkBounds.Get(root); + var accentRight = accent.Position.X + accent.Accent.Position.X + accent.Accent.DisplayBounds().Width; + Assert.InRange(bounds.Right, accentRight - 0.01f, float.PositiveInfinity); + } + + [Fact] + public void NonMultilineTextHighlightRetainsLegacyBounds() { + var painter = new SkiaSharp.TextPainter { + FontSize = 32, LaTeX = "single line", HighlightColor = SKColors.Red + }; + painter.Measure(400); + Assert.False(DisplayInkBounds.RequiresAggregateBounds(painter.Display!)); + using var bitmap = new SKBitmap(400, 100); + using var canvas = new SKCanvas(bitmap); + painter.Draw(canvas, TextAlignment.TopLeft); + } + + [Fact] + public void MultilineHighlightUsesAggregateBounds() { + var painter = new SkiaSharp.MathPainter { + FontSize = 32, LaTeX = Maxwell, HighlightColor = SKColors.Red + }; + var measure = painter.Measure(); + Assert.True(DisplayInkBounds.RequiresAggregateBounds(painter.Display!)); + using var bitmap = new SKBitmap(Math.Max(1, (int)Math.Ceiling(measure.Width) + 4), + Math.Max(1, (int)Math.Ceiling(measure.Height) + 4)); + using var canvas = new SKCanvas(bitmap); + painter.Draw(canvas, TextAlignment.TopLeft); + } + } +} diff --git a/CSharpMath.Rendering.Tests/TextCenter/IntegrationByParts.png b/CSharpMath.Rendering.Tests/TextCenter/IntegrationByParts.png index 73d4b270..933d4bd1 100644 Binary files a/CSharpMath.Rendering.Tests/TextCenter/IntegrationByParts.png and b/CSharpMath.Rendering.Tests/TextCenter/IntegrationByParts.png differ diff --git a/CSharpMath.Rendering.Tests/TextCenterInfiniteWidth/IntegrationByParts.png b/CSharpMath.Rendering.Tests/TextCenterInfiniteWidth/IntegrationByParts.png index 61fe8c47..dc136779 100644 Binary files a/CSharpMath.Rendering.Tests/TextCenterInfiniteWidth/IntegrationByParts.png and b/CSharpMath.Rendering.Tests/TextCenterInfiniteWidth/IntegrationByParts.png differ diff --git a/CSharpMath.Rendering.Tests/TextLeft/IntegrationByParts.png b/CSharpMath.Rendering.Tests/TextLeft/IntegrationByParts.png index cf4a67d8..7e7336d1 100644 Binary files a/CSharpMath.Rendering.Tests/TextLeft/IntegrationByParts.png and b/CSharpMath.Rendering.Tests/TextLeft/IntegrationByParts.png differ diff --git a/CSharpMath.Rendering.Tests/TextLeftInfiniteWidth/IntegrationByParts.png b/CSharpMath.Rendering.Tests/TextLeftInfiniteWidth/IntegrationByParts.png index e5068285..63cfe212 100644 Binary files a/CSharpMath.Rendering.Tests/TextLeftInfiniteWidth/IntegrationByParts.png and b/CSharpMath.Rendering.Tests/TextLeftInfiniteWidth/IntegrationByParts.png differ diff --git a/CSharpMath.Rendering.Tests/TextRight/IntegrationByParts.png b/CSharpMath.Rendering.Tests/TextRight/IntegrationByParts.png index 7cd82d7d..715f573e 100644 Binary files a/CSharpMath.Rendering.Tests/TextRight/IntegrationByParts.png and b/CSharpMath.Rendering.Tests/TextRight/IntegrationByParts.png differ diff --git a/CSharpMath.Rendering.Tests/TextRightInfiniteWidth/IntegrationByParts.png b/CSharpMath.Rendering.Tests/TextRightInfiniteWidth/IntegrationByParts.png index c7b297cc..7c080ff6 100644 Binary files a/CSharpMath.Rendering.Tests/TextRightInfiniteWidth/IntegrationByParts.png and b/CSharpMath.Rendering.Tests/TextRightInfiniteWidth/IntegrationByParts.png differ diff --git a/CSharpMath.Rendering/FrontEnd/MathPainter.cs b/CSharpMath.Rendering/FrontEnd/MathPainter.cs index 4f33f970..98e61342 100644 --- a/CSharpMath.Rendering/FrontEnd/MathPainter.cs +++ b/CSharpMath.Rendering/FrontEnd/MathPainter.cs @@ -25,7 +25,16 @@ 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)); + if (Display is not { } display) { + DrawCore(c, null); + return; + } + var aggregate = DisplayInkBounds.RequiresAggregateBounds(display); + var bounds = aggregate ? DisplayInkBounds.Get(display) : display.DisplayBounds(); + var position = IPainterExtensions.GetDisplayPosition( + aggregate ? bounds.Width : display.Width, display.Ascent, display.Descent, FontSize, + c.Width, c.Height, alignment, padding, offsetX, offsetY); + DrawCore(c, display, aggregate ? new PointF(position.X - bounds.X, position.Y) : position); } public void Draw(TCanvas canvas, float x, float y) { var c = WrapCanvas(canvas); @@ -56,4 +65,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..325770fc 100644 --- a/CSharpMath.Rendering/FrontEnd/Painter.cs +++ b/CSharpMath.Rendering/FrontEnd/Painter.cs @@ -62,9 +62,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) return RectangleF.Empty; + if (!DisplayInkBounds.RequiresAggregateBounds(Display)) + return new RectangleF(0, -Display.Ascent, + Display.Width, Display.Ascent + Display.Descent); + var horizontalBounds = DisplayInkBounds.Get(Display); + return new RectangleF(horizontalBounds.Left, -Display.Ascent, + horizontalBounds.Width, Display.Ascent + Display.Descent); } protected abstract void UpdateDisplayCore(float textPainterCanvasWidth); protected void UpdateDisplay(float textPainterCanvasWidth) { @@ -118,9 +122,19 @@ protected void DrawCore(ICanvas canvas, IDisplay? display, PointF? canvas.DefaultColor = WrapColor(TextColor); canvas.CurrentColor = WrapColor(HighlightColor); canvas.CurrentStyle = PaintStyle; - var measure = Measure(canvas.Width); - canvas.FillRect(display.Position.X + measure.X, display.Position.Y - display.Descent, - measure.Width, measure.Height); + // The display has already been laid out by the caller. Calling the + // virtual Measure here would relayout TextPainter displays at the + // surface width and change their geometry while drawing. + var aggregateBounds = DisplayInkBounds.RequiresAggregateBounds(display); + var measure = aggregateBounds + ? DisplayInkBounds.Get(display) + : Measure(canvas.Width); + if (aggregateBounds) + canvas.FillRect(display.Position.X + measure.X, display.Position.Y + measure.Y, + measure.Width, measure.Height); + else + canvas.FillRect(display.Position.X + measure.X, display.Position.Y - display.Descent, + measure.Width, measure.Height); canvas.CurrentColor = null; static T? Nullable(T nonnull) where T : struct => new T?(nonnull); display.Draw(new GraphicsContext(canvas, @@ -132,4 +146,113 @@ GlyphBoxColor is var (glyph, textRun) ? Nullable((WrapColor(glyph), WrapColor(te public Painter ShallowClone() => (Painter)MemberwiseClone(); #endregion Methods } -} \ No newline at end of file + + internal static class DisplayInkBounds { + public static RectangleF GetInk(IDisplay display) => GetCore(display, false); + public static RectangleF Get(IDisplay display) { + return GetCore(display, true); + } + public static RectangleF GetTypographic(IDisplay display) => + GetCore(display, true, true); + public static bool ExtendsOwnAdvance(IDisplay display) { + var bounds = GetTypographic(display); + return bounds.Left < -0.01f || bounds.Right > display.Width + 0.01f; + } + public static bool RequiresAggregateBounds(IDisplay display) { + if (!ContainsMultipleRows(display)) return false; + var bounds = GetTypographic(display); + return bounds.Left < -0.01f || bounds.Right > display.Width + 0.01f; + } + public static bool ContainsMultipleRows(IDisplay display) { + if (display is Display.Displays.ListDisplay list) { + var rows = list.Displays + .OfType>() + .Where(row => row.LinePosition == Display.LinePosition.Regular) + .ToArray(); + if (rows.Length > 1 && rows.Any(row => + System.Math.Abs(row.Position.Y - rows[0].Position.Y) > 0.01f)) return true; + return list.Displays.Any(ContainsMultipleRows); + } + if (display is Display.Displays.TextLineDisplay line) + return line.Runs.Any(ContainsMultipleRows); + if (display is Display.Displays.InnerDisplay inner) + return ContainsMultipleRows(inner.Inner); + if (display is Display.Displays.FractionDisplay fraction) + return ContainsMultipleRows(fraction.Numerator) || ContainsMultipleRows(fraction.Denominator); + if (display is Display.Displays.RadicalDisplay radical) + return ContainsMultipleRows(radical.Radicand) + || (radical.Degree != null && ContainsMultipleRows(radical.Degree)); + if (display is Display.Displays.AccentDisplay accent) + return ContainsMultipleRows(accent.Accentee); + if (display is Display.Displays.LargeOpLimitsDisplay limits) + return ContainsMultipleRows(limits.NucleusDisplay) + || (limits.UpperLimit != null && ContainsMultipleRows(limits.UpperLimit)) + || (limits.LowerLimit != null && ContainsMultipleRows(limits.LowerLimit)); + if (display is Display.Displays.OverOrUnderlineDisplay overUnder) + return ContainsMultipleRows(overUnder.Inner); + if (display is Display.Displays.UnderAnnotationDisplay annotation) + return ContainsMultipleRows(annotation.Inner) || ContainsMultipleRows(annotation.UnderList); + return false; + } + static RectangleF GetCore(IDisplay display, bool includeOwn, + bool typographicOnly = false) { + if (display is Display.Displays.TextRunDisplay run) { + if (typographicOnly) return display.DisplayBounds(); + if (!includeOwn) return run.InkBounds; + var bounds = display.DisplayBounds(); + return run.InkBounds.IsEmpty ? bounds : bounds.Union(run.InkBounds); + } + if (display is Display.Displays.TextLineDisplay line) { + return WithChildren(line, line.Runs, false, includeOwn, false, typographicOnly); + } + if (display is Display.Displays.ListDisplay list) + return WithChildren(list, list.Displays, false, includeOwn, false, typographicOnly); + if (display is Display.Displays.InnerDisplay inner) + return WithChildren(inner, new IDisplay[] { inner.Left, inner.Inner, inner.Right }.Where(d => d != null)!, includeOwn, includeOwn, true, typographicOnly); + if (display is Display.Displays.AccentDisplay accent) { + var bounds = includeOwn ? display.DisplayBounds() : RectangleF.Empty; + var accentee = GetCore(accent.Accentee, includeOwn, typographicOnly); + if (!accentee.IsEmpty) { + var offset = new PointF(accent.Accentee.Position.X - accent.Position.X, + accent.Accentee.Position.Y - accent.Position.Y); + bounds = bounds.IsEmpty ? accentee.Plus(offset) : bounds.Union(accentee.Plus(offset)); + } + var glyph = GetCore(accent.Accent, includeOwn, typographicOnly); + if (!glyph.IsEmpty) + bounds = bounds.IsEmpty ? glyph.Plus(accent.Accent.Position) : bounds.Union(glyph.Plus(accent.Accent.Position)); + return bounds.IsEmpty && includeOwn ? display.DisplayBounds() : bounds; + } + if (display is Display.Displays.FractionDisplay fraction) + return WithChildren(fraction, new[] { fraction.Numerator, fraction.Denominator }, includeOwn, includeOwn, true, typographicOnly); + if (display is Display.Displays.RadicalDisplay radical) + return WithChildren(radical, new IDisplay[] { radical.Radicand, radical.Degree }.Where(d => d != null)!, includeOwn, includeOwn, true, typographicOnly); + if (display is Display.Displays.LargeOpLimitsDisplay limits) + return WithChildren(limits, new IDisplay[] { limits.NucleusDisplay, limits.UpperLimit, limits.LowerLimit }.Where(d => d != null)!, includeOwn, includeOwn, true, typographicOnly); + if (display is Display.Displays.OverOrUnderlineDisplay overUnder) + return WithChildren(overUnder, new[] { overUnder.Inner }, includeOwn, includeOwn, true, typographicOnly); + if (display is Display.Displays.UnderAnnotationDisplay annotation) + return WithChildren(annotation, new IDisplay[] { annotation.Inner, annotation.UnderList, annotation.AnnotationGlyph }.Where(d => d != null)!, includeOwn, includeOwn, true, typographicOnly); + return display.DisplayBounds(); + } + + static RectangleF WithChildren(IDisplay display, + IEnumerable?> children, + bool includeContainer, bool includeChildLayoutBounds, + bool normalizeChildPositions, bool typographicOnly = false) { + var bounds = includeContainer ? display.DisplayBounds() : RectangleF.Empty; + foreach (var child in children) { + if (child == null) continue; + var childBounds = GetCore(child, includeChildLayoutBounds, typographicOnly); + if (!childBounds.IsEmpty) { + var childPosition = normalizeChildPositions + ? new PointF(child.Position.X - display.Position.X, + child.Position.Y - display.Position.Y) + : child.Position; + var positioned = childBounds.Plus(childPosition); + bounds = bounds.IsEmpty ? positioned : bounds.Union(positioned); + } + } + return bounds.IsEmpty && includeChildLayoutBounds ? display.DisplayBounds() : bounds; + } + } +} diff --git a/CSharpMath.Rendering/FrontEnd/TextPainter.cs b/CSharpMath.Rendering/FrontEnd/TextPainter.cs index 98edbc46..b46e7103 100644 --- a/CSharpMath.Rendering/FrontEnd/TextPainter.cs +++ b/CSharpMath.Rendering/FrontEnd/TextPainter.cs @@ -40,37 +40,46 @@ protected override void UpdateDisplayCore(float canvasWidth) { public override void Draw(TCanvas canvas, TextAlignment alignment = TextAlignment.TopLeft, Thickness padding = default, float offsetX = 0, float offsetY = 0) => - DrawCore(canvas, null, null, alignment, padding, offsetX, offsetY, false); + DrawCore(canvas, null, null, alignment, padding, offsetX, offsetY, false, null); #pragma warning disable RS0026 // RectangleF is a required, disambiguating second parameter. public void Draw(TCanvas canvas, RectangleF region, TextAlignment alignment = TextAlignment.TopLeft, Thickness padding = default, float offsetX = 0, float offsetY = 0) => DrawCore(canvas, region.Width, region.Height, alignment, padding, - region.X + offsetX, region.Y + offsetY, true); + region.X + offsetX, region.Y + offsetY, true, null); #pragma warning restore RS0026 public void Draw(TCanvas canvas, float top, float left, float right) => - DrawCore(canvas, right - left, null, TextAlignment.TopLeft, default, left, top, false); + DrawCore(canvas, right - left, null, TextAlignment.TopLeft, default, left, top, false, null); public void Draw(TCanvas canvas, PointF position, float width) => - DrawCore(canvas, width, null, TextAlignment.TopLeft, default, position.X, position.Y, false); + DrawCore(canvas, width, null, TextAlignment.TopLeft, default, position.X, position.Y, false, null); + internal void DrawAtLayoutWidth(TCanvas canvas, float layoutWidth, + TextAlignment alignment = TextAlignment.TopLeft, float offsetX = 0, float offsetY = 0) => + DrawCore(canvas, null, null, alignment, default, offsetX, offsetY, false, layoutWidth); private void DrawCore(TCanvas canvas, float? width, float? height, TextAlignment alignment, - Thickness padding, float offsetX, float offsetY, bool constrainCenteredInk) { + Thickness padding, float offsetX, float offsetY, bool constrainCenteredInk, + float? explicitLayoutWidth) { var c = WrapCanvas(canvas); var regionWidth = width ?? c.Width; // The public legacy overloads intentionally retain their original // geometry. Only the explicit finite region opts into constrained text. var constrained = constrainCenteredInk && !float.IsInfinity(regionWidth) && !float.IsNaN(regionWidth); - var layoutWidth = constrained + var layoutWidth = explicitLayoutWidth ?? (constrained ? ConstrainedTextLayout.ContentWidth(regionWidth, padding.Left, padding.Right) - : regionWidth; + : regionWidth); UpdateDisplay(layoutWidth); if (ErrorMessage == null) { + var blockWidth = System.Math.Max(_relativeXCoordDisplay.Width, _absoluteXCoordDisplay.Width); + var inkBeforePosition = DisplayInkBounds.GetInk(_relativeXCoordDisplay); + var placementWidth = float.IsNaN(regionWidth) || float.IsInfinity(regionWidth) + ? System.Math.Max(blockWidth, inkBeforePosition.Width + padding.Left + padding.Right) + : regionWidth; _relativeXCoordDisplay.Position = _relativeXCoordDisplay.Position.Plus(IPainterExtensions.GetDisplayPosition( - System.Math.Max(_relativeXCoordDisplay.Width, _absoluteXCoordDisplay.Width), + blockWidth, System.Math.Max(_relativeXCoordDisplay.Ascent, _absoluteXCoordDisplay.Ascent), System.Math.Max(_relativeXCoordDisplay.Descent, _absoluteXCoordDisplay.Descent), - FontSize, width ?? c.Width, - height ?? c.Height, alignment, padding, offsetX, offsetY + FontSize, placementWidth, + FiniteHeight(height ?? c.Height, _relativeXCoordDisplay), alignment, padding, offsetX, offsetY )); var adjustedCanvasWidth = float.IsInfinity(c.Width) || float.IsNaN(c.Width) @@ -84,16 +93,21 @@ private void DrawCore(TCanvas canvas, float? width, float? height, TextAlignment float Δx = 0; var y = float.NegativeInfinity; var leftRightFlags = alignment & (TextAlignment.Left | TextAlignment.Right); - if (leftRightFlags != TextAlignment.Left) + if (leftRightFlags == TextAlignment.Center) foreach (var relDisplay in _relativeXCoordDisplay.Displays.Reverse()) { if (relDisplay.Position.Y > y) { y = relDisplay.Position.Y; var rightSpace = adjustedCanvasWidth - (relDisplay.Position.X + relDisplay.Width); - Δx = leftRightFlags switch { - TextAlignment.Center => rightSpace / 2, - TextAlignment.Right => rightSpace, - _ => throw new Atom.InvalidCodePathException("The left flag has been set. This foreach loop should have been skipped.") - }; + Δx = rightSpace / 2; + } + relDisplay.Position = new PointF(relDisplay.Position.X + Δx, y); + } + else if (leftRightFlags == TextAlignment.Right) + foreach (var relDisplay in _relativeXCoordDisplay.Displays.Reverse()) { + if (relDisplay.Position.Y > y) { + y = relDisplay.Position.Y; + var rightSpace = adjustedCanvasWidth - (relDisplay.Position.X + relDisplay.Width); + Δx = rightSpace; } relDisplay.Position = new PointF(relDisplay.Position.X + Δx, y); } @@ -108,29 +122,35 @@ private void DrawCore(TCanvas canvas, float? width, float? height, TextAlignment // typographic centering. Derive the same local right-space formula // used by the legacy path so the constrained path does not center // that outer display a second time. - var blockWidth = System.Math.Max(_relativeXCoordDisplay.Width, _absoluteXCoordDisplay.Width); float Δx = 0; - var y = float.NegativeInfinity; var leftRightFlags = alignment & (TextAlignment.Left | TextAlignment.Right); - if (leftRightFlags != TextAlignment.Left) + if (leftRightFlags == TextAlignment.Center) { + var y = float.NegativeInfinity; foreach (var relDisplay in _relativeXCoordDisplay.Displays.Reverse()) { if (relDisplay.Position.Y > y) { y = relDisplay.Position.Y; + var lineDisplays = _relativeXCoordDisplay.Displays.Where(d => d.Position.Y == y).ToArray(); + var minInk = lineDisplays.Min(d => LineCenterBounds(d).Left + d.Position.X + _relativeXCoordDisplay.Position.X); + var maxInk = lineDisplays.Max(d => LineCenterBounds(d).Right + d.Position.X + _relativeXCoordDisplay.Position.X); var rightSpace = blockWidth - (relDisplay.Position.X + relDisplay.Width); var oldShift = rightSpace / 2; - if (leftRightFlags == TextAlignment.Center) { - var lineDisplays = _relativeXCoordDisplay.Displays.Where(d => d.Position.Y == y).ToArray(); - var minInk = lineDisplays.Min(d => TextDisplayBounds.InkBounds(d).Left + d.Position.X + _relativeXCoordDisplay.Position.X); - var maxInk = lineDisplays.Max(d => TextDisplayBounds.InkBounds(d).Right + d.Position.X + _relativeXCoordDisplay.Position.X); - Δx = CenterShift(oldShift, minInk, maxInk, contentLeft, contentRight); - } else if (leftRightFlags == TextAlignment.Right) { - Δx = rightSpace; - } else { - throw new Atom.InvalidCodePathException("The left flag has been set. This foreach loop should have been skipped."); - } + Δx = CenterShift(oldShift, minInk, maxInk, contentLeft, contentRight); + } + relDisplay.Position = new PointF(relDisplay.Position.X + Δx, y); + } + } else if (leftRightFlags == TextAlignment.Right) { + var y = float.NegativeInfinity; + foreach (var relDisplay in _relativeXCoordDisplay.Displays.Reverse()) { + if (relDisplay.Position.Y > y) { + y = relDisplay.Position.Y; + var rightSpace = blockWidth - (relDisplay.Position.X + relDisplay.Width); + Δx = rightSpace; } relDisplay.Position = new PointF(relDisplay.Position.X + Δx, y); } + } else if (leftRightFlags != TextAlignment.Left) { + throw new Atom.InvalidCodePathException("The left flag has been set. This foreach loop should have been skipped."); + } } static float CenterShift(float oldShift, float minInk, float maxInk, float contentLeft, float contentRight) { @@ -141,6 +161,8 @@ static float CenterShift(float oldShift, float minInk, float maxInk, return System.Math.Max(contentLeft - minInk, System.Math.Min(contentRight - maxInk, (contentLeft + contentRight - minInk - maxInk) / 2)); } + static RectangleF LineCenterBounds(IDisplay display) => + DisplayInkBounds.GetInk(display); //offsetY is already included in _relativeXCoordDisplay.Position, //no need to add it again below _absoluteXCoordDisplay.Position = @@ -152,6 +174,8 @@ static float CenterShift(float oldShift, float minInk, float maxInk, } DrawCore(c, Display); } + static float FiniteHeight(float height, IDisplay display) => + (!float.IsNaN(height) && !float.IsInfinity(height)) ? height : System.Math.Max(1, display.Ascent + display.Descent); /// /// Draws with respect to the only baseline which coordinates are given - center display maths with respect to text instead of canvas width. /// The measure of the result drawn by this method is NOT Measure(float.PositiveInfinity) diff --git a/CSharpMath.Rendering/Properties/AssemblyInfo.cs b/CSharpMath.Rendering/Properties/AssemblyInfo.cs index 9d8262bd..38cefd02 100644 --- a/CSharpMath.Rendering/Properties/AssemblyInfo.cs +++ b/CSharpMath.Rendering/Properties/AssemblyInfo.cs @@ -2,3 +2,4 @@ [assembly: InternalsVisibleTo("CSharpMath.Uno")] [assembly: InternalsVisibleTo("CSharpMath.Rendering.Tests")] +[assembly: InternalsVisibleTo("CSharpMath.SkiaSharp")] diff --git a/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs b/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs index e6587859..bd23dbeb 100644 --- a/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs +++ b/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs @@ -36,18 +36,4 @@ public void Clear(float x, float y, ICollection accumulator, Ascent = Descent = Width = GapAfterLine = 0; } } - internal static class TextDisplayBounds { - public static RectangleF InkBounds(Display display) { - if (display is global::CSharpMath.Display.Displays.TextRunDisplay run) return run.InkBounds; - if (display is global::CSharpMath.Display.Displays.ListDisplay list) { - var bounds = RectangleF.Empty; - foreach (var child in list.Displays) { - var childBounds = InkBounds(child); - if (!childBounds.IsEmpty) bounds = bounds.IsEmpty ? childBounds.Plus(child.Position) : bounds.Union(childBounds.Plus(child.Position)); - } - return bounds; - } - return display.DisplayBounds(); - } - } } diff --git a/CSharpMath.Rendering/Text/TextTypesetter.cs b/CSharpMath.Rendering/Text/TextTypesetter.cs index a61e92fc..47b297e8 100644 --- a/CSharpMath.Rendering/Text/TextTypesetter.cs +++ b/CSharpMath.Rendering/Text/TextTypesetter.cs @@ -75,10 +75,7 @@ void AddDisplaysWithLineBreaks( var lastLineWidth = line.Width; BreakLine(line, displayList, displayMathList, false); display = Typesetter.CreateLine(m.Content, fonts, TypesettingContext.Instance, LineStyle.Display); - var displayX = IPainterExtensions.GetDisplayPosition - (display.Width, display.Ascent, display.Descent, - fonts.PointSize, canvasWidth, float.NaN, - TextAlignment.Top, default, default, default).X; + var displayX = CenteredDisplayX(display, fonts.PointSize, canvasWidth); //\because When displayList.LastOrDefault() is null, //the false condition is selected //\therefore Append abovedisplayshortskip which defaults @@ -200,20 +197,34 @@ void FinalizeInlineDisplay(float ascender, float rawDescender, BreakLine(globalLine, relativePositionList, absolutePositionList); //remember to finalize the last line var adjustedCanvasWidth = float.IsInfinity(canvasWidth) || float.IsNaN(canvasWidth) - ? Math.Max(relativePositionList.CollectionWidth(), - absolutePositionList.IsNonEmpty() ? absolutePositionList.Max(d => d.Width) : 0) + ? Math.Max(InkWidth(new Display(relativePositionList)), + absolutePositionList.IsNonEmpty() ? absolutePositionList.Max(InkWidth) : 0) : canvasWidth; if (float.IsInfinity(canvasWidth) || float.IsNaN(canvasWidth)) // In this case X of every display in absolutePositionList will be Infinity or NaN // Use max(width of relativePositionList, width of absolutePositionList) as canvasWidth instead foreach (var absDisplay in absolutePositionList) absDisplay.Position = new System.Drawing.PointF( - IPainterExtensions.GetDisplayPosition - (absDisplay.Width, absDisplay.Ascent, absDisplay.Descent, - inputFont.PointSize, adjustedCanvasWidth, float.NaN, - TextAlignment.Top, default, default, default).X, + CenteredDisplayX(absDisplay, inputFont.PointSize, adjustedCanvasWidth), absDisplay.Position.Y); return (new Display(relativePositionList), new Display(absolutePositionList)); + + static RectangleF InkBounds(IDisplay display) { + var aggregateLayout = DisplayInkBounds.GetTypographic(display); + if (!DisplayInkBounds.ContainsMultipleRows(display) + || (aggregateLayout.Left >= -0.01f && aggregateLayout.Right <= display.Width + 0.01f)) + return display.DisplayBounds(); + var bounds = DisplayInkBounds.Get(display); + return bounds.IsEmpty ? display.DisplayBounds() : bounds; + } + static float InkWidth(IDisplay display) => InkBounds(display).Width; + static float CenteredDisplayX(IDisplay display, float fontSize, float width) { + var bounds = InkBounds(display); + return IPainterExtensions.GetDisplayPosition + (bounds.Width, display.Ascent, display.Descent, + fontSize, width, float.NaN, + TextAlignment.Top, default, default, default).X - bounds.Left; + } } } } diff --git a/CSharpMath.SkiaSharp/Extensions.cs b/CSharpMath.SkiaSharp/Extensions.cs index d2d7c20a..d6b46e26 100644 --- a/CSharpMath.SkiaSharp/Extensions.cs +++ b/CSharpMath.SkiaSharp/Extensions.cs @@ -1,4 +1,5 @@ using System.Drawing; +using System.Linq; using CSharpMath.Rendering.FrontEnd; using SkiaSharp; @@ -14,14 +15,43 @@ public static System.IO.Stream? DrawAsStream SKEncodedImageFormat format = SKEncodedImageFormat.Png, int quality = 100, TextAlignment alignmentForTests = TextAlignment.TopLeft) where TContent : class { - var size = painter.Measure(textPainterCanvasWidth).Size; - // SKSurface does not support zero width/height. Null will be returned from SKSurface.Create. - if (size.Width is 0) size.Width = 1; - if (size.Height is 0) size.Height = 1; - using var surface = SKSurface.Create(new SKImageInfo((int)size.Width, (int)size.Height)); - painter.Draw(surface.Canvas, alignmentForTests); + var measure = painter.Measure(textPainterCanvasWidth); + var display = painter.Display; + var expandsLegacyBounds = painter is CSharpMath.Rendering.FrontEnd.TextPainter text + ? text._absoluteXCoordDisplay.Displays.Any(item => + DisplayInkBounds.ContainsMultipleRows(item) && DisplayInkBounds.ExtendsOwnAdvance(item)) + : display != null && DisplayInkBounds.ContainsMultipleRows(display) + && DisplayInkBounds.ExtendsOwnAdvance(display); + if (!expandsLegacyBounds) { + var legacyWidth = System.Math.Max(1, (int)(display?.Width ?? measure.Width)); + var legacyHeight = System.Math.Max(1, (int)(display is null + ? measure.Height : display.Ascent + display.Descent)); + using var legacySurface = SKSurface.Create(new SKImageInfo(legacyWidth, legacyHeight)); + painter.Draw(legacySurface.Canvas, alignmentForTests); + using var legacySnapshot = legacySurface.Snapshot(); + return legacySnapshot.Encode(format, quality).AsStream(); + } + + // Bounds-aware output is isolated to displays whose descendants extend + // outside the legacy root advance. Keep a one-pixel guard around that + // ink so antialiasing at an exact glyph edge is not clipped. + var width = System.Math.Max(1, (int)System.Math.Ceiling(measure.Width) + 8); + var height = System.Math.Max(1, (int)System.Math.Ceiling(measure.Height) + 4); + var origin = display is null ? default : IPainterExtensions.GetDisplayPosition( + display.Width, display.Ascent, display.Descent, painter.FontSize, + width, height, alignmentForTests, default, 0, 0); + // Measure uses mathematical coordinates while the canvas is vertically + // inverted. Translate both extrema into the allocated surface. + var offsetX = 4 - measure.Left - origin.X; + var offsetY = origin.Y - measure.Top + 1; + using var surface = SKSurface.Create(new SKImageInfo(width, height)); + if (painter is CSharpMath.Rendering.FrontEnd.TextPainter textPainter) + textPainter.DrawAtLayoutWidth(surface.Canvas, textPainterCanvasWidth, + alignmentForTests, offsetX, offsetY); + else + painter.Draw(surface.Canvas, alignmentForTests, offsetX: offsetX, offsetY: offsetY); using var snapshot = surface.Snapshot(); return snapshot.Encode(format, quality).AsStream(); } } -} \ No newline at end of file +}