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("" + html); private static BrowserDisplayList Layout(IDocument page) => new BrowserLayoutEngine().Layout(page, new(128, 96)); [Fact] public void NestedRowFlexIntrinsicWidthIncludesBothBlockItemsAndTheGap() { var page = Page("
Site
domain.test / download

Title

", "#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("
domain.test / download

Title

", "#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("
Half
", "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("
", "#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("
abc
", "#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("
Hidden
", "#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("
", "#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("
", "#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 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() { } } }