diff --git a/CSharpMath.Rendering.Tests/TestConstrainedTextLayout.cs b/CSharpMath.Rendering.Tests/TestConstrainedTextLayout.cs new file mode 100644 index 00000000..0aae0d28 --- /dev/null +++ b/CSharpMath.Rendering.Tests/TestConstrainedTextLayout.cs @@ -0,0 +1,18 @@ +using CSharpMath.Rendering.FrontEnd; +using Xunit; + +namespace CSharpMath.Rendering.Tests { + public class TestConstrainedTextLayout { + [Theory] + [InlineData(880, 37, 113, 730)] + [InlineData(880, 0, 0, 880)] + public void ContentWidthSubtractsAsymmetricPadding(double available, double left, double right, float expected) => + Assert.Equal(expected, ConstrainedTextLayout.ContentWidth(available, left, right)); + + [Fact] + public void ContentWidthPreservesUnboundedConstraints() { + Assert.True(float.IsPositiveInfinity(ConstrainedTextLayout.ContentWidth(double.PositiveInfinity, 10, 20))); + Assert.True(float.IsNaN(ConstrainedTextLayout.ContentWidth(double.NaN, 10, 20))); + } + } +} diff --git a/CSharpMath.Rendering.Tests/TestTextBounds.cs b/CSharpMath.Rendering.Tests/TestTextBounds.cs new file mode 100644 index 00000000..fb5cdd47 --- /dev/null +++ b/CSharpMath.Rendering.Tests/TestTextBounds.cs @@ -0,0 +1,176 @@ +using System; +using System.Drawing; +using System.Linq; +using CSharpMath.Display; +using CSharpMath.Display.Displays; +using CSharpMath.Rendering.FrontEnd; +using SkiaSharp; +using Xunit; + +namespace CSharpMath.Rendering.Tests { + public class TestTextBounds { + const float Width = 880; + const string Italian = "Se di fronte a te si palesasse un acido grasso extra-large, lo riconosceresti immediatamente perché la sua natura fisica è data dalla lunghezza del tuo scheletro carbonioso"; + + [Fact] + public void RightAlignmentRetainsTypographicRightShiftFormula() { + var position = IPainterExtensions.GetDisplayPosition( + displayWidth: 240, displayAscent: 40, displayDescent: 10, + fontSize: 48, width: 880, height: 600, + alignment: TextAlignment.TopRight, + padding: new Thickness(17, 0, 23, 0), offsetX: 5, offsetY: 0); + Assert.Equal(622, position.X, precision: 4); + } + + static (SkiaSharp.TextPainter painter, RectangleF measure, SKBitmap bitmap) Draw(string text, TextAlignment alignment, Thickness padding = default, float offsetX = 0) { + var painter = new SkiaSharp.TextPainter { FontSize = 48, LaTeX = text }; + var measure = painter.Measure(ConstrainedTextLayout.ContentWidth(Width + 120, padding.Left, padding.Right)); + var bitmap = new SKBitmap((int)Width + 120, (int)Math.Ceiling(measure.Height + 100)); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + painter.Draw(canvas, new RectangleF(offsetX, 0, bitmap.Width, bitmap.Height), alignment, padding); + return (painter, measure, bitmap); + } + + static RectangleF InkBounds(SkiaSharp.TextPainter painter) { + var bounds = RectangleF.Empty; + foreach (var display in painter._relativeXCoordDisplay.Displays) { + var ink = display is TextRunDisplay run + ? run.InkBounds + : display.DisplayBounds(); + ink = ink.Plus(display.Position).Plus(painter._relativeXCoordDisplay.Position); + bounds = bounds.IsEmpty ? ink : bounds.Union(ink); + } + return bounds; + } + + [Fact] + public void CenteredBreakableTextKeepsInkInsideFiniteBoxAndMeasureCoversIt() { + var (_, measure, bitmap) = Draw(Italian, TextAlignment.Top, new Thickness(60, 0, 60, 0)); + var rendered = AssertRenderedRowsInside(bitmap, 60, Width + 60); + Assert.True(rendered.Width <= measure.Width + 2); + var rows = Enumerable.Range(0, bitmap.Height) + .Select(y => (y, xs: Enumerable.Range(60, (int)Width).Where(x => bitmap.GetPixel(x, y).Alpha > 0).ToArray())) + .Where(r => r.xs.Length > 0).ToList(); + for (var i = 0; i < rows.Count;) { + var j = i + 1; + while (j < rows.Count && rows[j].y <= rows[j - 1].y + 1) j++; + var line = rows.GetRange(i, j - i); + var min = line.Min(r => r.xs[0]); + var max = line.Max(r => r.xs[^1]); + Assert.InRange((min + max) / 2f, 480, 530); + i = j; + } + } + + [Theory] + [InlineData(TextAlignment.TopLeft)] + [InlineData(TextAlignment.TopRight)] + public void LeftAndRightAlignmentRemainWithinTheirExpectedFiniteBox(TextAlignment alignment) { + var (_, _, bitmap) = Draw("Italic overhangs should not alter line grouping.", alignment, new Thickness(60, 0, 60, 0)); + var bounds = AssertRenderedRowsInside(bitmap, 0, bitmap.Width); + if (alignment == TextAlignment.TopLeft) Assert.InRange(bounds.Left, 58, 62); + else Assert.InRange(bounds.Right, Width + 45, Width + 60); + } + + [Fact] + public void UnbreakableTextMayOverflowFiniteBox() { + var painter = new SkiaSharp.TextPainter { FontSize = 48, LaTeX = new string('W', 100) }; + var natural = painter.Measure(float.PositiveInfinity); + using var bitmap = new SKBitmap((int)Math.Ceiling(natural.Width) + 120, (int)Math.Ceiling(natural.Height) + 100); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + painter.Draw(canvas, top: 0, left: 60, right: 940); + var bounds = AssertRenderedRowsInside(bitmap, 0, bitmap.Width); + Assert.True(natural.Width > Width); + Assert.True(bounds.Right > 940); + Assert.True(bounds.Right < bitmap.Width - 10); + } + + [Fact] + public void CenteredTextHonorsAsymmetricPaddingAndOffset() { + var (_, _, bitmap) = Draw(Italian, TextAlignment.Top, + new Thickness(37, 0, 113, 0), 19); + AssertRenderedRowsInside(bitmap, 56, Width + 120 - 113 + 19); + } + + [Fact] + public void ExplicitBoundedDrawUsesTheSuppliedRegion() { + var painter = new SkiaSharp.TextPainter { FontSize = 48, LaTeX = Italian }; + using var bitmap = new SKBitmap(1000, 1000); + using var canvas = new SKCanvas(bitmap); + canvas.Clear(SKColors.Transparent); + painter.Draw(canvas, new RectangleF(60, 0, 880, 1000), TextAlignment.Top); + var bounds = AssertRenderedRowsInside(bitmap, 60, 940); + Assert.True(bounds.Width > 0); + } + + [Fact] + public void LegacyDrawRetainsOriginalPaddingGeometry() { + const string shortText = "short legacy text"; + var legacy = new SkiaSharp.TextPainter { FontSize = 48, LaTeX = shortText }; + var constrained = new SkiaSharp.TextPainter { FontSize = 48, LaTeX = shortText }; + using var legacyBitmap = new SKBitmap(1000, 1000); + using var constrainedBitmap = new SKBitmap(1000, 1000); + using var legacyCanvas = new SKCanvas(legacyBitmap); + using var constrainedCanvas = new SKCanvas(constrainedBitmap); + legacyCanvas.Clear(SKColors.Transparent); + constrainedCanvas.Clear(SKColors.Transparent); + // With no padding and top-left alignment, the explicit region is a + // semantic counterpart of the legacy canvas-width call. + legacy.Draw(legacyCanvas, TextAlignment.Top); + constrained.Draw(constrainedCanvas, new RectangleF(0, 0, 1000, 1000), TextAlignment.Top); + Assert.InRange(Math.Abs(legacy._relativeXCoordDisplay.Position.X - constrained._relativeXCoordDisplay.Position.X), 0, 1); + Assert.InRange(Math.Abs(legacy._relativeXCoordDisplay.Position.Y - constrained._relativeXCoordDisplay.Position.Y), 0, 1); + AssertInkBoundsClose(InkBounds(legacy), InkBounds(constrained)); + } + + static void AssertInkBoundsClose(RectangleF expected, RectangleF actual) { + Assert.InRange(Math.Abs(expected.Left - actual.Left), 0, 1); + Assert.InRange(Math.Abs(expected.Top - actual.Top), 0, 1); + Assert.InRange(Math.Abs(expected.Right - actual.Right), 0, 1); + Assert.InRange(Math.Abs(expected.Bottom - actual.Bottom), 0, 1); + } + + [Fact] + public void ExplicitRegionHonorsTranslatedOriginAndContentBox() { + var painter = new SkiaSharp.TextPainter { FontSize = 48, LaTeX = "translated centered text" }; + using var bitmap = new SKBitmap(1000, 1000); + using var displacedBitmap = new SKBitmap(1000, 1000); + using var canvas = new SKCanvas(bitmap); + using var displacedCanvas = new SKCanvas(displacedBitmap); + canvas.Clear(SKColors.Transparent); + displacedCanvas.Clear(SKColors.Transparent); + var region = new RectangleF(37, 53, 880, 700); + painter.Draw(canvas, new RectangleF(37, 53, 880, 700), TextAlignment.Top, + new Thickness(41, 17, 73, 29)); + painter.Draw(displacedCanvas, region, TextAlignment.Top, + new Thickness(41, 17, 73, 29), offsetY: 24); + var bounds = AssertRenderedRowsInside(bitmap, 78, 844); + var displacedBounds = AssertRenderedRowsInside(displacedBitmap, 78, 844); + Assert.True(bounds.Width <= painter.Measure(766).Width + 2); + Assert.True(bounds.Left >= 77 && bounds.Right <= 845); + Assert.Equal(bounds.Left, displacedBounds.Left); + Assert.Equal(bounds.Right, displacedBounds.Right); + Assert.InRange(displacedBounds.Top - bounds.Top, 23, 25); + Assert.InRange(displacedBounds.Bottom - bounds.Bottom, 23, 25); + } + + static RectangleF AssertRenderedRowsInside(SKBitmap bitmap, float left, float right) { + var result = RectangleF.Empty; + for (var y = 0; y < bitmap.Height; y++) { + var xs = Enumerable.Range(0, bitmap.Width) + .Where(x => bitmap.GetPixel(x, y).Alpha > 0) + .ToArray(); + if (xs.Length == 0) continue; + var row = new RectangleF(xs[0], y, xs[^1] - xs[0] + 1, 1); + result = result.IsEmpty ? row : result.Union(row); + Assert.True(xs[0] >= left - 1, $"row {y} starts at {xs[0]}"); + Assert.True(xs[^1] <= right + 1, $"row {y} ends at {xs[^1]}"); + } + Assert.False(result.IsEmpty); + Assert.True(result.Bottom < bitmap.Height - 2); + return result; + } + } +} diff --git a/CSharpMath.Rendering/FrontEnd/ConstrainedTextLayout.cs b/CSharpMath.Rendering/FrontEnd/ConstrainedTextLayout.cs new file mode 100644 index 00000000..517aee62 --- /dev/null +++ b/CSharpMath.Rendering/FrontEnd/ConstrainedTextLayout.cs @@ -0,0 +1,8 @@ +namespace CSharpMath.Rendering.FrontEnd { + internal static class ConstrainedTextLayout { + internal static float ContentWidth(double available, double left, double right) => + double.IsInfinity(available) || double.IsNaN(available) + ? (float)available + : (float)System.Math.Max(0, available - left - right); + } +} diff --git a/CSharpMath.Rendering/FrontEnd/TextPainter.cs b/CSharpMath.Rendering/FrontEnd/TextPainter.cs index 91e4164f..98edbc46 100644 --- a/CSharpMath.Rendering/FrontEnd/TextPainter.cs +++ b/CSharpMath.Rendering/FrontEnd/TextPainter.cs @@ -40,15 +40,29 @@ 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, alignment, padding, offsetX, offsetY); + DrawCore(canvas, null, null, alignment, padding, offsetX, offsetY, false); +#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); +#pragma warning restore RS0026 public void Draw(TCanvas canvas, float top, float left, float right) => - DrawCore(canvas, right - left, TextAlignment.TopLeft, default, left, top); + DrawCore(canvas, right - left, null, TextAlignment.TopLeft, default, left, top, false); public void Draw(TCanvas canvas, PointF position, float width) => - DrawCore(canvas, width, TextAlignment.TopLeft, default, position.X, position.Y); - private void DrawCore(TCanvas canvas, float? width, TextAlignment alignment, - Thickness padding, float offsetX, float offsetY) { + DrawCore(canvas, width, null, TextAlignment.TopLeft, default, position.X, position.Y, false); + private void DrawCore(TCanvas canvas, float? width, float? height, TextAlignment alignment, + Thickness padding, float offsetX, float offsetY, bool constrainCenteredInk) { var c = WrapCanvas(canvas); - UpdateDisplay(width ?? c.Width); + 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 + ? ConstrainedTextLayout.ContentWidth(regionWidth, padding.Left, padding.Right) + : regionWidth; + UpdateDisplay(layoutWidth); if (ErrorMessage == null) { _relativeXCoordDisplay.Position = _relativeXCoordDisplay.Position.Plus(IPainterExtensions.GetDisplayPosition( @@ -56,7 +70,7 @@ private void DrawCore(TCanvas canvas, float? width, TextAlignment alignment, System.Math.Max(_relativeXCoordDisplay.Ascent, _absoluteXCoordDisplay.Ascent), System.Math.Max(_relativeXCoordDisplay.Descent, _absoluteXCoordDisplay.Descent), FontSize, width ?? c.Width, - c.Height, alignment, padding, offsetX, offsetY + height ?? c.Height, alignment, padding, offsetX, offsetY )); var adjustedCanvasWidth = float.IsInfinity(c.Width) || float.IsNaN(c.Width) @@ -65,23 +79,68 @@ private void DrawCore(TCanvas canvas, float? width, TextAlignment alignment, : c.Width; // https://github.com/verybadcat/CSharpMath/issues/123 // Take into account padding, offset etc. on both sides - adjustedCanvasWidth -= _relativeXCoordDisplay.Position.X * 2; - float Δx = 0; - var y = float.NegativeInfinity; - var leftRightFlags = alignment & (TextAlignment.Left | TextAlignment.Right); - if (leftRightFlags != TextAlignment.Left) - 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.") - }; + if (!constrained) { + adjustedCanvasWidth -= _relativeXCoordDisplay.Position.X * 2; + float Δx = 0; + var y = float.NegativeInfinity; + var leftRightFlags = alignment & (TextAlignment.Left | TextAlignment.Right); + if (leftRightFlags != TextAlignment.Left) + 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.") + }; + } + relDisplay.Position = new PointF(relDisplay.Position.X + Δx, y); } - relDisplay.Position = new PointF(relDisplay.Position.X + Δx, y); - } + } else { + var contentLeft = regionWidth == float.PositiveInfinity || float.IsNaN(regionWidth) + ? _relativeXCoordDisplay.Position.X + : offsetX + padding.Left; + var contentRight = regionWidth == float.PositiveInfinity || float.IsNaN(regionWidth) + ? adjustedCanvasWidth + contentLeft + : offsetX + regionWidth - padding.Right; + // GetDisplayPosition has already placed the outer display using + // 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) + foreach (var relDisplay in _relativeXCoordDisplay.Displays.Reverse()) { + if (relDisplay.Position.Y > y) { + y = relDisplay.Position.Y; + 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."); + } + } + relDisplay.Position = new PointF(relDisplay.Position.X + Δx, y); + } + } + static float CenterShift(float oldShift, float minInk, float maxInk, + float contentLeft, float contentRight) { + if (minInk + oldShift >= contentLeft && maxInk + oldShift <= contentRight) + return oldShift; + if (maxInk - minInk > contentRight - contentLeft) + return oldShift; + return System.Math.Max(contentLeft - minInk, System.Math.Min(contentRight - maxInk, + (contentLeft + contentRight - minInk - maxInk) / 2)); + } //offsetY is already included in _relativeXCoordDisplay.Position, //no need to add it again below _absoluteXCoordDisplay.Position = @@ -120,4 +179,4 @@ public void DrawOneLine(TCanvas canvas, float x, float y) { } public new TextPainter ShallowClone() => (TextPainter)MemberwiseClone(); } -} \ No newline at end of file +} diff --git a/CSharpMath.Rendering/Properties/AssemblyInfo.cs b/CSharpMath.Rendering/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..9d8262bd --- /dev/null +++ b/CSharpMath.Rendering/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSharpMath.Uno")] +[assembly: InternalsVisibleTo("CSharpMath.Rendering.Tests")] diff --git a/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt b/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt index e69de29b..61a0d642 100644 --- a/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/DebugApi/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +CSharpMath.Rendering.FrontEnd.TextPainter.Draw(TCanvas canvas, System.Drawing.RectangleF region, CSharpMath.Rendering.FrontEnd.TextAlignment alignment = CSharpMath.Rendering.FrontEnd.TextAlignment.TopLeft, CSharpMath.Rendering.FrontEnd.Thickness padding = default(CSharpMath.Rendering.FrontEnd.Thickness), float offsetX = 0, float offsetY = 0) -> void diff --git a/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt b/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt index e69de29b..61a0d642 100644 --- a/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt +++ b/CSharpMath.Rendering/PublicAPI/ReleaseApi/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +CSharpMath.Rendering.FrontEnd.TextPainter.Draw(TCanvas canvas, System.Drawing.RectangleF region, CSharpMath.Rendering.FrontEnd.TextAlignment alignment = CSharpMath.Rendering.FrontEnd.TextAlignment.TopLeft, CSharpMath.Rendering.FrontEnd.Thickness padding = default(CSharpMath.Rendering.FrontEnd.Thickness), float offsetX = 0, float offsetY = 0) -> void diff --git a/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs b/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs index b0aea182..e6587859 100644 --- a/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs +++ b/CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Drawing; namespace CSharpMath.Rendering.Text { using Display = Display.IDisplay; @@ -35,4 +36,18 @@ public void Clear(float x, float y, ICollection accumulator, Ascent = Descent = Width = GapAfterLine = 0; } } -} \ No newline at end of file + 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 02552cd2..a61e92fc 100644 --- a/CSharpMath.Rendering/Text/TextTypesetter.cs +++ b/CSharpMath.Rendering/Text/TextTypesetter.cs @@ -216,4 +216,4 @@ void FinalizeInlineDisplay(float ascender, float rawDescender, return (new Display(relativePositionList), new Display(absolutePositionList)); } } -} \ No newline at end of file +} diff --git a/CSharpMath.Xaml/Views.cs b/CSharpMath.Xaml/Views.cs index 09bdd917..f25431b0 100644 --- a/CSharpMath.Xaml/Views.cs +++ b/CSharpMath.Xaml/Views.cs @@ -234,11 +234,12 @@ public BaseView() { _origin = point.Position; }; } - // TODO: How to stop a horizontally overflowing line for TextView from affecting line breaking for later lines, without breaking horizontal scrolling for MathView? - // Avalonia and MAUI don't exhibit this behavior. + // TextPainter wraps against the content box. Keep Uno's desired-size query + // on that same constraint so a padded TextView cannot lay out at the full + // viewport width and then clip its centered lines during drawing. protected override Windows.Foundation.Size MeasureOverride(Windows.Foundation.Size availableSize) => - Painter.Measure((float)availableSize.Width) is { } rect - ? new Windows.Foundation.Size(rect.Width, rect.Height) + Painter.Measure(ConstrainedTextLayout.ContentWidth(availableSize.Width, Padding.Left, Padding.Right)) is { } rect + ? new Windows.Foundation.Size(rect.Width + Padding.Left + Padding.Right, rect.Height) : base.MeasureOverride(availableSize); readonly struct ReadOnlyProperty< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TThis, @@ -262,6 +263,18 @@ protected override void RenderOverride(XCanvas canvas, Windows.Foundation.Size a #endif #endif var padding = Padding; +#if Uno + var painterPadding = new Thickness((float)padding.Left, (float)padding.Top, + (float)padding.Right, (float)padding.Bottom); + // TextPainter's finite-region overload is the only path that can keep + // centered ink inside the rendered view. MathPainter retains its legacy + // alignment contract. The concrete type check avoids selecting the + // overload through reflection; the Uno draw hook has no RectangleF API. + if (Painter is TextPainter textPainter) + textPainter.Draw(canvas, new RectangleF(0, 0, (float)ActualWidth, (float)ActualHeight), + TextAlignment, painterPadding, DisplacementX, DisplacementY); + else +#endif Painter.Draw(canvas, TextAlignment, new(left: (float)padding.Left, top: (float)padding.Top, right: (float)padding.Right, bottom: (float)padding.Bottom), DisplacementX, DisplacementY); } /// Requires touch events to be enabled in SkiaSharp/Xamarin.Forms @@ -321,4 +334,4 @@ protected override void RenderOverride(XCanvas canvas, Windows.Foundation.Size a } public partial class MathView : BaseView { } public partial class TextView : BaseView { } -} \ No newline at end of file +} diff --git a/CSharpMath/Display/Displays/TextRunDisplay.cs b/CSharpMath/Display/Displays/TextRunDisplay.cs index c44bf059..5242e880 100644 --- a/CSharpMath/Display/Displays/TextRunDisplay.cs +++ b/CSharpMath/Display/Displays/TextRunDisplay.cs @@ -15,12 +15,25 @@ public TextRunDisplay( Range = range; Width = context.GlyphBoundsProvider.GetTypographicWidth(font, run); // Compute ascent and descent - var rects = - context.GlyphBoundsProvider.GetBoundingRectsForGlyphs(font, Run.Glyphs, Run.GlyphInfos.Count); + var rects = context.GlyphBoundsProvider.GetBoundingRectsForGlyphs( + font, Run.Glyphs, Run.GlyphInfos.Count).ToArray(); Ascent = rects.IsEmpty() ? 0 : rects.Max(rect => rect.Bottom); // Convert to non-flipped naming here, Descent = rects.IsEmpty() ? 0 : rects.Max(rect => -rect.Y); + var advances = context.GlyphBoundsProvider.GetAdvancesForGlyphs( + font, Run.Glyphs, Run.GlyphInfos.Count).Advances.ToArray(); + var x = 0f; + var ink = RectangleF.Empty; + for (var i = 0; i < Run.GlyphInfos.Count; i++) { + var glyph = Run.GlyphInfos[i]; + var rect = rects[i]; + var positioned = new RectangleF(x + rect.X, rect.Y, rect.Width, rect.Height); + if (!rect.IsEmpty) ink = ink.IsEmpty ? positioned : ink.Union(positioned); + x += advances[i] + glyph.KernAfterGlyph; + } + InkBounds = ink; } public AttributedGlyphRun Run { get; } + internal RectangleF InkBounds { get; } public Range Range { get; } public float Width { get; } @@ -39,4 +52,4 @@ public void Draw(IGraphicsContext context) { public Color? BackColor { get; set; } public override string ToString() => Run.Text.ToString(); } -} \ No newline at end of file +} diff --git a/CSharpMath/Properties/AssemblyInfo.cs b/CSharpMath/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..cadab27d --- /dev/null +++ b/CSharpMath/Properties/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CSharpMath.Rendering")] +[assembly: InternalsVisibleTo("CSharpMath.Rendering.Tests")]