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(
+ "beforeblock
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");
+ }
+}