using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using XFESystem.Desktop;
namespace XFESystem.Browser.Tests;
public sealed class CssAffineLayoutTests
{
private static IDocument Page(string html, string css) => new HtmlParser().ParseDocument("<style>body{margin:0}" + css + "</style>" + html);
private static BrowserDisplayList Layout(IDocument page) => new BrowserLayoutEngine().Layout(page, new(128, 96));
[Fact]
public void NestedRowFlexIntrinsicWidthIncludesBothBlockItemsAndTheGap()
{
var page = Page("<div id=outer><a id=link><div id=icon></div><div><div>Site</div><div class=meta><cite id=url>domain.test / download</cite></div></div></a></div><h2 id=title>Title</h2>",
"#outer,#link,.meta{display:flex}#outer{width:400px}#link{column-gap:8px}#icon{width:28px;height:28px}cite{font:14px monospace;line-height:20px}.meta{height:20px}h2{margin:0}");
var list = Layout(page); var link = list.GetBoundingClientRect(page.GetElementById("link")!);
var icon = list.GetBoundingClientRect(page.GetElementById("icon")!); var url = list.GetBoundingClientRect(page.GetElementById("url")!);
Assert.Equal(28, icon.Width, 5);
Assert.Equal(icon.Right + 8, url.X, 5);
Assert.Equal("domain.test / download".Length * 14 * .6, url.Width, 5);
Assert.Equal(url.Right, link.Right, 5);
var runs = list.Commands.Where(c => c.Kind == BrowserDrawKind.Text && (c.Node?.ParentElement?.LocalName == "cite")).ToArray();
Assert.Single(runs.Select(c => c.Bounds.Y).Distinct());
Assert.True(runs.Max(c => c.Bounds.Bottom) <= list.GetBoundingClientRect(page.GetElementById("title")!).Top);
}
[Fact]
public void IntrinsicCitationUsesTheSameSeparateGlyphRunsAsActualPaint()
{
var page = Page("<div id=outer><div id=meta><cite id=url>domain.<strong>test</strong> / download</cite></div></div><h2 id=title>Title</h2>",
"#outer,#meta{display:flex}#outer{width:400px}cite,strong{font:14px monospace}#meta{height:20px}h2{margin:0}");
var list = new BrowserLayoutEngine().Layout(page, new(500, 200), textMetrics: new ShapedMetrics());
var runs = list.Commands.Where(c => c.Kind == BrowserDrawKind.Text && c.Node?.ParentElement?.Closest("cite") is not null).ToArray();
Assert.Single(runs.Select(c => c.Bounds.Y).Distinct());
Assert.True(runs.Max(c => c.Bounds.Bottom) <= list.GetBoundingClientRect(page.GetElementById("title")!).Top);
}
private sealed class ShapedMetrics : IBrowserTextMetrics
{
public double Measure(string text, BrowserFont font) => text.Length * 8 + (text == " " ? 1 : 0);
}
[Fact]
public void PercentageUnderlineDoesNotInflateAutoInlineTabsButExplicitPercentageStillResolves()
{
var page = Page("<nav><ul><li id=first><a>Web</a></li><li id=second><a>Images</a></li></ul></nav><div id=explicit>Half</div>",
"nav{height:40px;overflow:hidden}ul{margin:0;padding:0}li{display:inline-block;line-height:30px;margin-right:12px}li::after{content:'';display:block;width:100%;height:3px;background:red}#explicit{display:inline-block;width:50%}");
var list = new BrowserLayoutEngine().Layout(page, new(500, 200));
var first = list.GetBoundingClientRect(page.GetElementById("first")!); var second = list.GetBoundingClientRect(page.GetElementById("second")!);
Assert.Equal(first.Y, second.Y, 5); Assert.InRange(first.Width, 20, 40); Assert.InRange(second.Width, 40, 65);
Assert.Equal(250, list.GetBoundingClientRect(page.GetElementById("explicit")!).Width, 5);
var underline = Assert.Single(list.Commands, c => c.Kind == BrowserDrawKind.Fill && c.Color == 0xFF0000 && c.Node == page.GetElementById("first"));
Assert.Equal(first.Width, underline.Bounds.Width, 5);
}
[Theory]
[InlineData("rotate(90deg)", 30, 0, 20, 40)]
[InlineData("matrix(0,1,-1,0,0,0)", 30, 0, 20, 40)]
[InlineData("scale(.5) translateX(50%)", 40, 15, 20, 10)]
public void TransformOrderAndDefaultBorderBoxOriginMatchCss(string transform, double x, double y, double width, double height)
{
var page = Page("<div id=A></div><div id=B></div>", "#A{position:relative;margin:10px 20px;width:40px;height:20px;background:red;transform:" + transform + "}#B{height:5px}");
var list = Layout(page); var r = list.GetBoundingClientRect(page.GetElementById("A")!);
Assert.Equal(x, r.X, 5); Assert.Equal(y, r.Y, 5); Assert.Equal(width, r.Width, 5); Assert.Equal(height, r.Height, 5);
Assert.Equal(40, list.GetBoundingClientRect(page.GetElementById("B")!).Top, 5); // Flow remains untransformed.
}
[Fact]
public void NestedTransformsApplyOnceToTextRangesAndInverseHitTesting()
{
var page = Page("<div id=A><div id=B>abc</div></div>", "#A{position:absolute;left:40px;top:20px;width:40px;height:40px;transform-origin:0 0;transform:rotate(90deg)}#B{width:20px;height:10px;transform:translateX(10px)}");
var list = Layout(page); var child = page.GetElementById("B")!; var r = list.GetBoundingClientRect(child);
Assert.Equal(30, r.X, 5); Assert.Equal(30, r.Y, 5); Assert.Equal(10, r.Width, 5); Assert.Equal(20, r.Height, 5);
Assert.Same(child, list.HitTest(35, 35)!.Element);
var text = child.FirstChild!; var range = Assert.Single(list.GetRangeRects(text, 0, text, 3));
Assert.True(range.Height > range.Width);
Assert.Contains(list.Commands, c => c.Kind == BrowserDrawKind.Text && c.Transform is not null);
}
[Theory]
[InlineData("scale(0)")]
[InlineData("matrix(1,2,2,4,0,0)")]
public void SingularTransformsHaveNoPixelsOrHitTarget(string transform)
{
var page = Page("<div id=A>Hidden</div>", "#A{width:50px;height:50px;transform:" + transform + ";background:red}");
var list = Layout(page);
Assert.DoesNotContain(list.Commands, c => c.Node?.ParentElement?.Id == "A" || (c.Node as IElement)?.Id == "A");
Assert.NotEqual("A", list.HitTest(10, 10)?.Element?.Id);
}
[Fact]
public void RotatedBorderChevronPaintsAndFreezesAtItsTransformedLocation()
{
var page = Page("<div id=A></div>", "#A{position:absolute;left:40px;top:20px;width:40px;height:40px;box-sizing:border-box;border-left:6px solid red;border-bottom:6px solid red;transform:scale(.25) translateX(25%) rotate(45deg)}");
var list = Layout(page); var original = new Pixels(); var frozen = new Pixels();
BrowserRasterPainter.Paint(original, new(0, 0, 128, 96), list);
BrowserPaintSnapshot.Capture(list).Paint(frozen, new(0, 0, 128, 96));
Assert.Equal(original.Data, frozen.Data);
var ink = original.Data.Select((p, i) => (p, i)).Where(p => ((p.p >> 8) & 255) < 220).ToArray();
Assert.InRange(ink.Length, 15, 80);
Assert.All(ink, p => { Assert.InRange(p.i % 128, 54, 70); Assert.InRange(p.i / 128, 32, 48); });
Assert.Equal(0xFFFFFFu, original.Data[20 * 128 + 40]); // Old untransformed L is gone.
}
[Fact]
public void RotatedOverflowClipMasksEscapingStackedDescendants()
{
var page = Page("<div id=A><div id=B></div></div>", "#A{position:absolute;left:40px;top:20px;width:20px;height:20px;overflow:hidden;transform:rotate(45deg)}#B{position:relative;left:-10px;top:-10px;width:40px;height:40px;background:red;z-index:3}");
var list = Layout(page); var pixels = new Pixels(); BrowserRasterPainter.Paint(pixels, new(0, 0, 128, 96), list);
Assert.Equal(0xFF0000u, pixels.Data[30 * 128 + 50]);
Assert.Equal(0xFFFFFFu, pixels.Data[17 * 128 + 37]); // Inside bounding rectangle, outside rotated diamond.
Assert.NotEqual("B", list.HitTest(37, 17)?.Element?.Id);
Assert.Equal("B", list.HitTest(50, 30)?.Element?.Id);
}
private sealed class Pixels : IDesktopCoverageCanvas
{
public uint[] Data = new uint[128 * 96];
public int Width => 128;
public int Height => 96;
public void Fill(uint rgb, int x, int y, int width, int height)
{ for (int yy = Math.Max(0, y); yy < Math.Min(Height, y + height); yy++) for (int xx = Math.Max(0, x); xx < Math.Min(Width, x + width); xx++) Data[yy * Width + xx] = rgb; }
public void BlendCoverage(uint rgb, int x, int y, int width, int height, ReadOnlySpan<byte> coverage)
{
for (int yy = 0; yy < height; yy++) for (int xx = 0; xx < width; xx++)
{
if (x + xx < 0 || x + xx >= Width || y + yy < 0 || y + yy >= Height) continue;
uint old = Data[(y + yy) * Width + x + xx], a = coverage[yy * width + xx], result = 0;
for (int shift = 0; shift < 24; shift += 8) result |= ((((rgb >> shift) & 255) * a + ((old >> shift) & 255) * (255 - a) + 127) / 255) << shift;
Data[(y + yy) * Width + x + xx] = result;
}
}
public void Line(uint rgb, int x1, int y1, int x2, int y2) => throw new NotSupportedException();
public void Text(string text, uint rgb, int x, int y) => throw new NotSupportedException();
public void Present() { }
}
}
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
using XFESystem.Desktop;
namespace XFESystem.Browser.Tests;
public sealed class CssAffineLayoutTests
{
private static IDocument Page(string html, string css) => new HtmlParser().ParseDocument("<style>body{margin:0}" + css + "</style>" + html);
private static BrowserDisplayList Layout(IDocument page) => new BrowserLayoutEngine().Layout(page, new(128, 96));
[Fact]
public void NestedRowFlexIntrinsicWidthIncludesBothBlockItemsAndTheGap()
{
var page = Page("<div id=outer><a id=link><div id=icon></div><div><div>Site</div><div class=meta><cite id=url>domain.test / download</cite></div></div></a></div><h2 id=title>Title</h2>",
"#outer,#link,.meta{display:flex}#outer{width:400px}#link{column-gap:8px}#icon{width:28px;height:28px}cite{font:14px monospace;line-height:20px}.meta{height:20px}h2{margin:0}");
var list = Layout(page); var link = list.GetBoundingClientRect(page.GetElementById("link")!);
var icon = list.GetBoundingClientRect(page.GetElementById("icon")!); var url = list.GetBoundingClientRect(page.GetElementById("url")!);
Assert.Equal(28, icon.Width, 5);
Assert.Equal(icon.Right + 8, url.X, 5);
Assert.Equal("domain.test / download".Length * 14 * .6, url.Width, 5);
Assert.Equal(url.Right, link.Right, 5);
var runs = list.Commands.Where(c => c.Kind == BrowserDrawKind.Text && (c.Node?.ParentElement?.LocalName == "cite")).ToArray();
Assert.Single(runs.Select(c => c.Bounds.Y).Distinct());
Assert.True(runs.Max(c => c.Bounds.Bottom) <= list.GetBoundingClientRect(page.GetElementById("title")!).Top);
}
[Fact]
public void IntrinsicCitationUsesTheSameSeparateGlyphRunsAsActualPaint()
{
var page = Page("<div id=outer><div id=meta><cite id=url>domain.<strong>test</strong> / download</cite></div></div><h2 id=title>Title</h2>",
"#outer,#meta{display:flex}#outer{width:400px}cite,strong{font:14px monospace}#meta{height:20px}h2{margin:0}");
var list = new BrowserLayoutEngine().Layout(page, new(500, 200), textMetrics: new ShapedMetrics());
var runs = list.Commands.Where(c => c.Kind == BrowserDrawKind.Text && c.Node?.ParentElement?.Closest("cite") is not null).ToArray();
Assert.Single(runs.Select(c => c.Bounds.Y).Distinct());
Assert.True(runs.Max(c => c.Bounds.Bottom) <= list.GetBoundingClientRect(page.GetElementById("title")!).Top);
}
private sealed class ShapedMetrics : IBrowserTextMetrics
{
public double Measure(string text, BrowserFont font) => text.Length * 8 + (text == " " ? 1 : 0);
}
[Fact]
public void PercentageUnderlineDoesNotInflateAutoInlineTabsButExplicitPercentageStillResolves()
{
var page = Page("<nav><ul><li id=first><a>Web</a></li><li id=second><a>Images</a></li></ul></nav><div id=explicit>Half</div>",
"nav{height:40px;overflow:hidden}ul{margin:0;padding:0}li{display:inline-block;line-height:30px;margin-right:12px}li::after{content:'';display:block;width:100%;height:3px;background:red}#explicit{display:inline-block;width:50%}");
var list = new BrowserLayoutEngine().Layout(page, new(500, 200));
var first = list.GetBoundingClientRect(page.GetElementById("first")!); var second = list.GetBoundingClientRect(page.GetElementById("second")!);
Assert.Equal(first.Y, second.Y, 5); Assert.InRange(first.Width, 20, 40); Assert.InRange(second.Width, 40, 65);
Assert.Equal(250, list.GetBoundingClientRect(page.GetElementById("explicit")!).Width, 5);
var underline = Assert.Single(list.Commands, c => c.Kind == BrowserDrawKind.Fill && c.Color == 0xFF0000 && c.Node == page.GetElementById("first"));
Assert.Equal(first.Width, underline.Bounds.Width, 5);
}
[Theory]
[InlineData("rotate(90deg)", 30, 0, 20, 40)]
[InlineData("matrix(0,1,-1,0,0,0)", 30, 0, 20, 40)]
[InlineData("scale(.5) translateX(50%)", 40, 15, 20, 10)]
public void TransformOrderAndDefaultBorderBoxOriginMatchCss(string transform, double x, double y, double width, double height)
{
var page = Page("<div id=A></div><div id=B></div>", "#A{position:relative;margin:10px 20px;width:40px;height:20px;background:red;transform:" + transform + "}#B{height:5px}");
var list = Layout(page); var r = list.GetBoundingClientRect(page.GetElementById("A")!);
Assert.Equal(x, r.X, 5); Assert.Equal(y, r.Y, 5); Assert.Equal(width, r.Width, 5); Assert.Equal(height, r.Height, 5);
Assert.Equal(40, list.GetBoundingClientRect(page.GetElementById("B")!).Top, 5); // Flow remains untransformed.
}
[Fact]
public void NestedTransformsApplyOnceToTextRangesAndInverseHitTesting()
{
var page = Page("<div id=A><div id=B>abc</div></div>", "#A{position:absolute;left:40px;top:20px;width:40px;height:40px;transform-origin:0 0;transform:rotate(90deg)}#B{width:20px;height:10px;transform:translateX(10px)}");
var list = Layout(page); var child = page.GetElementById("B")!; var r = list.GetBoundingClientRect(child);
Assert.Equal(30, r.X, 5); Assert.Equal(30, r.Y, 5); Assert.Equal(10, r.Width, 5); Assert.Equal(20, r.Height, 5);
Assert.Same(child, list.HitTest(35, 35)!.Element);
var text = child.FirstChild!; var range = Assert.Single(list.GetRangeRects(text, 0, text, 3));
Assert.True(range.Height > range.Width);
Assert.Contains(list.Commands, c => c.Kind == BrowserDrawKind.Text && c.Transform is not null);
}
[Theory]
[InlineData("scale(0)")]
[InlineData("matrix(1,2,2,4,0,0)")]
public void SingularTransformsHaveNoPixelsOrHitTarget(string transform)
{
var page = Page("<div id=A>Hidden</div>", "#A{width:50px;height:50px;transform:" + transform + ";background:red}");
var list = Layout(page);
Assert.DoesNotContain(list.Commands, c => c.Node?.ParentElement?.Id == "A" || (c.Node as IElement)?.Id == "A");
Assert.NotEqual("A", list.HitTest(10, 10)?.Element?.Id);
}
[Fact]
public void RotatedBorderChevronPaintsAndFreezesAtItsTransformedLocation()
{
var page = Page("<div id=A></div>", "#A{position:absolute;left:40px;top:20px;width:40px;height:40px;box-sizing:border-box;border-left:6px solid red;border-bottom:6px solid red;transform:scale(.25) translateX(25%) rotate(45deg)}");
var list = Layout(page); var original = new Pixels(); var frozen = new Pixels();
BrowserRasterPainter.Paint(original, new(0, 0, 128, 96), list);
BrowserPaintSnapshot.Capture(list).Paint(frozen, new(0, 0, 128, 96));
Assert.Equal(original.Data, frozen.Data);
var ink = original.Data.Select((p, i) => (p, i)).Where(p => ((p.p >> 8) & 255) < 220).ToArray();
Assert.InRange(ink.Length, 15, 80);
Assert.All(ink, p => { Assert.InRange(p.i % 128, 54, 70); Assert.InRange(p.i / 128, 32, 48); });
Assert.Equal(0xFFFFFFu, original.Data[20 * 128 + 40]); // Old untransformed L is gone.
}
[Fact]
public void RotatedOverflowClipMasksEscapingStackedDescendants()
{
var page = Page("<div id=A><div id=B></div></div>", "#A{position:absolute;left:40px;top:20px;width:20px;height:20px;overflow:hidden;transform:rotate(45deg)}#B{position:relative;left:-10px;top:-10px;width:40px;height:40px;background:red;z-index:3}");
var list = Layout(page); var pixels = new Pixels(); BrowserRasterPainter.Paint(pixels, new(0, 0, 128, 96), list);
Assert.Equal(0xFF0000u, pixels.Data[30 * 128 + 50]);
Assert.Equal(0xFFFFFFu, pixels.Data[17 * 128 + 37]); // Inside bounding rectangle, outside rotated diamond.
Assert.NotEqual("B", list.HitTest(37, 17)?.Element?.Id);
Assert.Equal("B", list.HitTest(50, 30)?.Element?.Id);
}
private sealed class Pixels : IDesktopCoverageCanvas
{
public uint[] Data = new uint[128 * 96];
public int Width => 128;
public int Height => 96;
public void Fill(uint rgb, int x, int y, int width, int height)
{ for (int yy = Math.Max(0, y); yy < Math.Min(Height, y + height); yy++) for (int xx = Math.Max(0, x); xx < Math.Min(Width, x + width); xx++) Data[yy * Width + xx] = rgb; }
public void BlendCoverage(uint rgb, int x, int y, int width, int height, ReadOnlySpan<byte> coverage)
{
for (int yy = 0; yy < height; yy++) for (int xx = 0; xx < width; xx++)
{
if (x + xx < 0 || x + xx >= Width || y + yy < 0 || y + yy >= Height) continue;
uint old = Data[(y + yy) * Width + x + xx], a = coverage[yy * width + xx], result = 0;
for (int shift = 0; shift < 24; shift += 8) result |= ((((rgb >> shift) & 255) * a + ((old >> shift) & 255) * (255 - a) + 127) / 255) << shift;
Data[(y + yy) * Width + x + xx] = result;
}
}
public void Line(uint rgb, int x1, int y1, int x2, int y2) => throw new NotSupportedException();
public void Text(string text, uint rgb, int x, int y) => throw new NotSupportedException();
public void Present() { }
}
}