Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CSharpMath.Rendering.Tests/TestConstrainedTextLayout.cs
Original file line number Diff line number Diff line change
@@ -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)));
}
}
}
176 changes: 176 additions & 0 deletions CSharpMath.Rendering.Tests/TestTextBounds.cs
Original file line number Diff line number Diff line change
@@ -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<BackEnd.Fonts, BackEnd.Glyph> 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;
}
}
}
8 changes: 8 additions & 0 deletions CSharpMath.Rendering/FrontEnd/ConstrainedTextLayout.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
107 changes: 83 additions & 24 deletions CSharpMath.Rendering/FrontEnd/TextPainter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,37 @@ 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(
System.Math.Max(_relativeXCoordDisplay.Width, _absoluteXCoordDisplay.Width),
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)
Expand All @@ -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 =
Expand Down Expand Up @@ -120,4 +179,4 @@ public void DrawOneLine(TCanvas canvas, float x, float y) {
}
public new TextPainter<TCanvas, TColor> ShallowClone() => (TextPainter<TCanvas, TColor>)MemberwiseClone();
}
}
}
4 changes: 4 additions & 0 deletions CSharpMath.Rendering/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("CSharpMath.Uno")]
[assembly: InternalsVisibleTo("CSharpMath.Rendering.Tests")]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CSharpMath.Rendering.FrontEnd.TextPainter<TCanvas, TColor>.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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CSharpMath.Rendering.FrontEnd.TextPainter<TCanvas, TColor>.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
17 changes: 16 additions & 1 deletion CSharpMath.Rendering/Text/TextLayoutLineBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Drawing;

namespace CSharpMath.Rendering.Text {
using Display = Display.IDisplay<BackEnd.Fonts, BackEnd.Glyph>;
Expand Down Expand Up @@ -35,4 +36,18 @@ public void Clear(float x, float y, ICollection<Display> accumulator,
Ascent = Descent = Width = GapAfterLine = 0;
}
}
}
internal static class TextDisplayBounds {
public static RectangleF InkBounds(Display display) {
if (display is global::CSharpMath.Display.Displays.TextRunDisplay<BackEnd.Fonts, BackEnd.Glyph> run) return run.InkBounds;
if (display is global::CSharpMath.Display.Displays.ListDisplay<BackEnd.Fonts, BackEnd.Glyph> 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();
}
}
}
Loading
Loading