diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 0ea9cad2a..0167d22a4 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -81,6 +81,9 @@ internal abstract class CssBoxProperties private string _left = "auto"; private string _counterReset = CssConstants.None; private string _counterIncrement = CssConstants.None; + private string _outlineStyle = CssConstants.None; + private string _outlineColor = string.Empty; + private string _outlineWidth = "medium"; private string _lineHeight = "normal"; private string _listStyleType = "disc"; private string _listStyleImage = string.Empty; @@ -740,6 +743,62 @@ public string CounterIncrement set { _counterIncrement = value; } } + public string OutlineStyle + { + get { return _outlineStyle; } + set { _outlineStyle = value; } + } + + public string OutlineColor + { + get { return _outlineColor; } + set { _outlineColor = value; } + } + + public string OutlineWidth + { + get { return _outlineWidth; } + set { _outlineWidth = value; } + } + + /// + /// The resolved outline-width in pixels ("thin"/"medium"/"thick" keywords resolved, like + /// ), or 0 when outline-style is none/hidden + /// (an outline with a style of none/hidden never paints, regardless of width - CSS 2.1 §18.1's + /// invert/no-op default matches border's own "no style, no width" rule). + /// + public double ActualOutlineWidth + { + get + { + if (string.IsNullOrEmpty(OutlineStyle) || OutlineStyle == CssConstants.None || OutlineStyle == CssConstants.Hidden) + { + return 0; + } + return CssValueParser.GetActualBorderWidth(OutlineWidth, this); + } + } + + /// + /// The resolved paint color for outline-color - falls back to this box's own + /// (i.e. currentColor) when outline-color is unset, which + /// is what every real browser does today for the property's nominal invert initial value + /// (true color inversion is not implemented here). + /// + public RColor ActualOutlineColor + { + get + { + // "transparent" is OutlineColorProperty's own cascaded initial value (Color.Transparent) - + // treated the same as unset, since an invisible-by-default outline would defeat the point + // of the property entirely. + return string.IsNullOrEmpty(OutlineColor) || OutlineColor == "transparent" + || OutlineColor.Equals("invert", StringComparison.OrdinalIgnoreCase) + ? ActualColor + : GetActualColor(OutlineColor); + } + } + /// /// This box's resolved named-counter values (CSS 2.1 §12.4), as of just after its own /// counter-reset/counter-increment have been applied - populated once, by @@ -1939,6 +1998,9 @@ protected void InheritStyle(CssBox p, bool everything) _borderRightStyle = p._borderRightStyle; _borderBottomStyle = p._borderBottomStyle; _borderLeftStyle = p._borderLeftStyle; + _outlineStyle = p._outlineStyle; + _outlineColor = p._outlineColor; + _outlineWidth = p._outlineWidth; _bottom = p._bottom; _borderTopLeftRadius = p._borderTopLeftRadius; _borderTopRightRadius = p._borderTopRightRadius; diff --git a/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs b/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs new file mode 100644 index 000000000..87002924f --- /dev/null +++ b/Source/HtmlRenderer/Core/Handlers/OutlineDrawHandler.cs @@ -0,0 +1,60 @@ +// "Therefore those skilled at the unorthodox +// are infinite as heaven and earth, +// inexhaustible as the great rivers. +// When they come to an end, +// they begin again, +// like the days and months; +// they die and are reborn, +// like the four seasons." +// +// - Sun Tsu, +// "The Art of War" + +using TheArtOfDev.HtmlRenderer.Adapters; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; +using TheArtOfDev.HtmlRenderer.Core.Dom; +using TheArtOfDev.HtmlRenderer.Core.Utils; + +namespace TheArtOfDev.HtmlRenderer.Core.Handlers +{ + /// + /// Paints outline (CSS 2.1 §18.1) as a plain rectangular ring around the box's own border-box + /// edge - unlike , an outline has no per-side bevel/corner-joining to + /// account for (it isn't part of the box model and doesn't affect layout), so this is a single flat + /// four-rectangle fill rather than a per-side polygon.
+ /// Deliberately a subset of the full property: only solid (and auto, treated the same + /// as solid per spec) is painted - dotted/dashed/double/groove/ + /// ridge/inset/outset are not (a box with one of those styles simply paints no + /// outline, same as none). outline-offset is not implemented either (the CSS-OM here has + /// no outline-offset property at all to read a value from), so the ring always sits flush + /// against the border-box edge. + ///
+ internal static class OutlineDrawHandler + { + /// + /// Paints 's outline around (the same + /// border-box rectangle was just given for this + /// line) if it has a paintable one. + /// + public static void Draw(RGraphics g, CssBox box, RRect borderBoxRect) + { + var width = box.ActualOutlineWidth; + if (width <= 0 || borderBoxRect.Width <= 0 || borderBoxRect.Height <= 0) return; + + var style = box.OutlineStyle; + var isSolid = style == CssConstants.Solid || style == CssConstants.Auto; + if (!isSolid) return; + + var brush = g.GetSolidBrush(box.ActualOutlineColor); + + // Top band (full width, including the corners) ... + g.DrawRectangle(brush, borderBoxRect.X - width, borderBoxRect.Y - width, borderBoxRect.Width + 2 * width, width); + // ... bottom band ... + g.DrawRectangle(brush, borderBoxRect.X - width, borderBoxRect.Bottom, borderBoxRect.Width + 2 * width, width); + // ... left band (between the top/bottom bands, not overlapping their corners) ... + g.DrawRectangle(brush, borderBoxRect.X - width, borderBoxRect.Y, width, borderBoxRect.Height); + // ... right band. + g.DrawRectangle(brush, borderBoxRect.Right, borderBoxRect.Y, width, borderBoxRect.Height); + } + } +} diff --git a/Source/HtmlRenderer/Core/Paint/Content/HrFragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/HrFragmentPainter.cs index adc9e8037..4170b09c9 100644 --- a/Source/HtmlRenderer/Core/Paint/Content/HrFragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/Content/HrFragmentPainter.cs @@ -2,6 +2,7 @@ using TheArtOfDev.HtmlRenderer.Adapters.Entities; using TheArtOfDev.HtmlRenderer.Core.Dom; using TheArtOfDev.HtmlRenderer.Core.Fragments; +using TheArtOfDev.HtmlRenderer.Core.Handlers; namespace TheArtOfDev.HtmlRenderer.Core.Paint.Content { @@ -25,6 +26,7 @@ public void Paint(FragmentPainter painter, RGraphics g, BoxFragment fragment) var rect = fragment.PrimaryRect; rect.Offset(offset); box.DrawHrContent(g, rect); + OutlineDrawHandler.Draw(g, box, rect); } } } diff --git a/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs index 00396d457..36e65527f 100644 --- a/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/Content/ReplacedFragmentPainter.cs @@ -31,6 +31,7 @@ public void Paint(FragmentPainter painter, RGraphics g, BoxFragment fragment) box.PaintBackground(g, rect, true, true); BordersDrawHandler.DrawBoxBorders(g, box, rect, true, true); + OutlineDrawHandler.Draw(g, box, rect); DrawContent(g, fragment, painter.LiveTreeOffset(box.IsFixed)); diff --git a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs index 18b53bc1e..517b9dd32 100644 --- a/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs +++ b/Source/HtmlRenderer/Core/Paint/FragmentPainter.cs @@ -216,6 +216,7 @@ private void PaintFragmentContent(RGraphics g, BoxFragment fragment) { box.PaintBackground(g, actualRect, i == 0, i == lines.Count - 1); BordersDrawHandler.DrawBoxBorders(g, box, actualRect, i == 0, i == lines.Count - 1); + OutlineDrawHandler.Draw(g, box, actualRect); } } diff --git a/Source/HtmlRenderer/Core/Utils/CssUtils.cs b/Source/HtmlRenderer/Core/Utils/CssUtils.cs index 6893c6078..1d8c0b437 100644 --- a/Source/HtmlRenderer/Core/Utils/CssUtils.cs +++ b/Source/HtmlRenderer/Core/Utils/CssUtils.cs @@ -50,6 +50,7 @@ internal static class CssUtils "page-break-inside", "break-inside", "break-before", "break-after", "page-break-before", "page-break-after", "widows", "orphans", "page", "left", "top", "right", "bottom", "counter-reset", "counter-increment", + "outline-style", "outline-color", "outline-width", "width", "max-width", "height", "min-height", "max-height", "background-color", "background-image", "background-position", "background-repeat", "content", "color", "display", "direction", "empty-cells", "float", "clear", "box-sizing", "position", @@ -180,6 +181,12 @@ public static string GetPropertyValue(CssBox cssBox, string propName) return cssBox.CounterReset; case "counter-increment": return cssBox.CounterIncrement; + case "outline-style": + return cssBox.OutlineStyle; + case "outline-color": + return cssBox.OutlineColor; + case "outline-width": + return cssBox.OutlineWidth; case "width": return cssBox.Width; case "max-width": @@ -397,6 +404,15 @@ public static void SetPropertyValue(CssBox cssBox, string propName, string value case "counter-increment": cssBox.CounterIncrement = value; break; + case "outline-style": + cssBox.OutlineStyle = value; + break; + case "outline-color": + cssBox.OutlineColor = value; + break; + case "outline-width": + cssBox.OutlineWidth = value; + break; case "width": cssBox.Width = value; break; diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs new file mode 100644 index 000000000..07d5c5784 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/BackgroundPaintIntegrationTests.cs @@ -0,0 +1,77 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +/// +/// CSS 2.1 §14.2.1: background-color paints a solid fill behind a box's content, covering the box's +/// padding + content area (border-box minus the border itself). No dedicated background paint-call test +/// existed anywhere in this repo before this - only CSS-OM parsing (BackgroundPropertyTests.cs). +/// +[DoNotParallelize] +[TestClass] +public sealed class BackgroundPaintIntegrationTests +{ + [TestMethod] + public void BackgroundColor_PaintsASolidFillRect() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + var fills = g.Log.OfType() + .Where(r => r.Color == RColor.FromArgb(10, 20, 30)).ToList(); + + Assert.AreEqual(1, fills.Count); + Assert.AreEqual(100, fills[0].Width, 0.1); + Assert.AreEqual(50, fills[0].Height, 0.1); + } + + [TestMethod] + public void BackgroundColorTransparent_PaintsNothing() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + + Assert.IsFalse(g.Log.OfType().Any()); + } + + [TestMethod] + public void BackgroundColorDefault_PaintsNothing() + { + // The initial value of background-color is "transparent" - a box with no background-color set at + // all must not paint a fill either. + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + + Assert.IsFalse(g.Log.OfType().Any()); + } + + [TestMethod] + public void BackgroundColor_PaintsBeforeBorderAndOutline() + { + // CSS 2.1 Appendix E: background paints before border/outline for the same box. + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + + var backgroundIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == RColor.FromArgb(10, 20, 30)); + var borderIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawLineCall l && l.Color == RColor.FromArgb(1, 2, 3)); + var outlineIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall r && r.Color == RColor.FromArgb(4, 5, 6)); + + Assert.IsTrue(backgroundIndex >= 0 && borderIndex >= 0 && outlineIndex >= 0); + Assert.IsTrue(backgroundIndex < borderIndex, "background must paint before the border"); + Assert.IsTrue(borderIndex < outlineIndex, "border must paint before the outline"); + } +} diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs new file mode 100644 index 000000000..45db4d71b --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Painting/OutlineStylePaintIntegrationTests.cs @@ -0,0 +1,174 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; +using TheArtOfDev.HtmlRenderer.Adapters.Entities; + +namespace HtmlRenderer.IntegrationTest.Painting; + +/// +/// Ported from PeachPDF.Tests' OutlineStylePaintIntegrationTests.cs, adapted to this fork's new (and more +/// limited) OutlineDrawHandler - see that class's remarks for exactly what's implemented: a plain +/// rectangular ring for solid/auto only, no outline-offset (this fork's CSS-OM has no +/// such property to read a value from), and every other style (dotted/dashed/double/ +/// groove/ridge/inset/outset) paints nothing, same as none. +/// +[DoNotParallelize] +[TestClass] +public sealed class OutlineStylePaintIntegrationTests +{ + [TestMethod] + public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + var rects = g.Log.OfType() + .Where(r => r.Color == RColor.FromArgb(51, 51, 51)).ToList(); + + Assert.AreEqual(4, rects.Count, "expected one filled rect per side of the outline ring"); + + // Two of the four bands are 5px tall (top/bottom, spanning the full outer width including + // corners) and two are 5px wide (left/right, spanning just the box's own height) - regardless of + // which order OutlineDrawHandler emits them in. + Assert.AreEqual(2, rects.Count(r => System.Math.Abs(r.Height - 5) < 0.1)); + Assert.AreEqual(2, rects.Count(r => System.Math.Abs(r.Width - 5) < 0.1)); + } + + [Ignore("outline-style:auto cannot be parsed at all on this fork: Map.LineStyles (Core/CssEngine/Model/" + + "Map.cs), the shared keyword table outline-style's converter reuses from border-style, has no " + + "\"auto\" entry - adding one there would also make it a legal (but spec-invalid) border-style " + + "value, so this needs its own dedicated converter rather than a shared-map edit, which is out of " + + "scope for this pass. OutlineDrawHandler.Draw's own \"style == Auto\" check is consequently dead " + + "code today - reachable only by setting the property programmatically, never via CSS text.")] + [TestMethod] + public void OutlineAuto_PaintsTheSameAsSolid() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + var rects = g.Log.OfType() + .Where(r => r.Color == RColor.FromArgb(51, 51, 51)).ToList(); + + Assert.AreEqual(4, rects.Count); + } + + [TestMethod] + public void OutlineColorUnset_FallsBackToCurrentColor() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + var rects = g.Log.OfType().ToList(); + + Assert.AreEqual(4, rects.Count); + Assert.IsTrue(rects.All(r => r.Color == RColor.FromArgb(10, 20, 30))); + } + + [TestMethod] + public void Outline_PaintsAfterBorder() + { + // Paint-order: DrawBoxBorders runs, then OutlineDrawHandler - the outline's draw calls must come + // after the border's in the recording. + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + + var borderIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawLineCall line && line.Color == RColor.FromArgb(1, 2, 3)); + var outlineIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall rect && rect.Color == RColor.FromArgb(4, 5, 6)); + + Assert.IsTrue(borderIndex >= 0 && outlineIndex >= 0); + Assert.IsTrue(outlineIndex > borderIndex, "outline must paint after (on top of) the border"); + } + + [TestMethod] + [DataRow("none")] + [DataRow("hidden")] + public void OutlineStyleNoneOrHidden_PaintsNothing(string style) + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + $"
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + + Assert.IsFalse(g.Log.OfType().Any(r => r.Color == RColor.FromArgb(51, 51, 51))); + } + + [TestMethod] + public void ZeroWidthOutline_PaintsNothing() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + + Assert.IsFalse(g.Log.OfType().Any(r => r.Color == RColor.FromArgb(51, 51, 51))); + } + + [Ignore("outline-offset is not implemented on this fork: the CSS-OM here has no OutlineOffsetProperty at " + + "all (confirmed - no PropertyNames.OutlineOffset, no case in PropertyFactory), so there is no value " + + "for OutlineDrawHandler to read; the ring always sits flush against the border-box edge instead of " + + "the requested 10px further out.")] + [TestMethod] + public void OutlineOffset_PushesTheRingFurtherFromTheBorderBox() + { + var (root, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
x
")); + var div = PaintHarness.FindById(root, "b")!; + + var g = PaintHarness.PaintBox(container, div); + var borderBox = div.Rectangles.Values.Single(); + var top = g.Log.OfType().First(r => r.Color == RColor.FromArgb(51, 51, 51)); + + Assert.AreEqual(borderBox.Y - 10 - 5, top.Y, 0.1); + } + + [TestMethod] + public void OutlineColorInvert_FallsBackToCurrentColor() + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "
")); + + var g = PaintHarness.PaintPage(container); + var rects = g.Log.OfType() + .Where(r => r.Color == RColor.FromArgb(51, 51, 51)).ToList(); + + Assert.AreEqual(4, rects.Count); + } + + [TestMethod] + [DataRow("")] + [DataRow("
")] + public void ReplacedAndRuleElements_PaintTheirOutline(string element) + { + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap(element)); + + var g = PaintHarness.PaintPage(container); + var rects = g.Log.OfType() + .Where(r => r.Color == RColor.FromArgb(51, 51, 51)).ToList(); + + Assert.AreEqual(4, rects.Count); + } + + [TestMethod] + public void InlineSplitAroundABlock_KeepsTheOutlineOnEveryPart() + { + // The span gets split into a part before and a part after the block, and both parts are copies. + var (_, container) = PaintHarness.Layout(PaintHarness.Wrap( + "before
block
after
")); + + var g = PaintHarness.PaintPage(container); + var rects = g.Log.OfType() + .Where(r => r.Color == RColor.FromArgb(51, 51, 51)).ToList(); + + Assert.IsGreaterThanOrEqualTo(8, rects.Count, "both the part before and the part after the block need a ring"); + } +}