Skip to content

Commit 33b4be6

Browse files
committed
Implement outline painting (CSS 2.1 §18.1) - was parsed but never painted
outline-style/-color/-width had CSS-OM parsing (and a shorthand) but no CssBoxProperties fields and no paint code anywhere - an authored outline had zero visual effect. Adds OutlineDrawHandler: unlike border, an outline isn't part of the box model and has no per-side bevel/corner-joining to account for, so it paints as four flat filled rectangles forming a ring around the border-box edge, hooked in right after DrawBoxBorders in FragmentPainter (matching CSS2.1 Appendix E's after-border painting order). Deliberately scoped to the common case: only solid/auto paint (every other style is a no-op, same as none/hidden), and outline-offset isn't implemented - this fork's CSS-OM has no OutlineOffsetProperty at all to read a value from, and adding outline-style:auto recognition would require a dedicated converter rather than a shared-map edit (Map.LineStyles is also border-style's own keyword table - adding "auto" there would wrongly make it a legal border-style value too). outline-color falls back to currentColor when unset, matching real browser behavior for the property's nominal (and here unimplemented) "invert" initial value. Ports the CSS2.1-relevant subset of PeachPDF's OutlineStylePaintIntegrationTests.cs against this simpler implementation - solid ring geometry, currentColor fallback, paint-order after border, none/hidden/zero-width no-op all pass; auto and outline-offset are ported [Ignore]d with the gaps above.
1 parent e5ec0fa commit 33b4be6

5 files changed

Lines changed: 268 additions & 0 deletions

File tree

Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ internal abstract class CssBoxProperties
8181
private string _left = "auto";
8282
private string _counterReset = CssConstants.None;
8383
private string _counterIncrement = CssConstants.None;
84+
private string _outlineStyle = CssConstants.None;
85+
private string _outlineColor = string.Empty;
86+
private string _outlineWidth = "medium";
8487
private string _lineHeight = "normal";
8588
private string _listStyleType = "disc";
8689
private string _listStyleImage = string.Empty;
@@ -740,6 +743,61 @@ public string CounterIncrement
740743
set { _counterIncrement = value; }
741744
}
742745

746+
public string OutlineStyle
747+
{
748+
get { return _outlineStyle; }
749+
set { _outlineStyle = value; }
750+
}
751+
752+
public string OutlineColor
753+
{
754+
get { return _outlineColor; }
755+
set { _outlineColor = value; }
756+
}
757+
758+
public string OutlineWidth
759+
{
760+
get { return _outlineWidth; }
761+
set { _outlineWidth = value; }
762+
}
763+
764+
/// <summary>
765+
/// The resolved <c>outline-width</c> in pixels ("thin"/"medium"/"thick" keywords resolved, like
766+
/// <see cref="ActualBorderTopWidth"/>), or 0 when <c>outline-style</c> is <c>none</c>/<c>hidden</c>
767+
/// (an outline with a style of none/hidden never paints, regardless of width - CSS 2.1 §18.1's
768+
/// invert/no-op default matches border's own "no style, no width" rule).
769+
/// </summary>
770+
public double ActualOutlineWidth
771+
{
772+
get
773+
{
774+
if (string.IsNullOrEmpty(OutlineStyle) || OutlineStyle == CssConstants.None || OutlineStyle == CssConstants.Hidden)
775+
{
776+
return 0;
777+
}
778+
return CssValueParser.GetActualBorderWidth(OutlineWidth, this);
779+
}
780+
}
781+
782+
/// <summary>
783+
/// The resolved paint color for <c>outline-color</c> - falls back to this box's own
784+
/// <see cref="ActualColor"/> (i.e. <c>currentColor</c>) when <c>outline-color</c> is unset, which
785+
/// is what every real browser does today for the property's nominal <c>invert</c> initial value
786+
/// (true color inversion is not implemented here).
787+
/// </summary>
788+
public RColor ActualOutlineColor
789+
{
790+
get
791+
{
792+
// "transparent" is OutlineColorProperty's own cascaded initial value (Color.Transparent) -
793+
// treated the same as unset, since an invisible-by-default outline would defeat the point
794+
// of the property entirely.
795+
return string.IsNullOrEmpty(OutlineColor) || OutlineColor == "transparent"
796+
? ActualColor
797+
: GetActualColor(OutlineColor);
798+
}
799+
}
800+
743801
/// <summary>
744802
/// This box's resolved named-counter values (CSS 2.1 §12.4), as of just after its own
745803
/// <c>counter-reset</c>/<c>counter-increment</c> have been applied - populated once, by
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// "Therefore those skilled at the unorthodox
2+
// are infinite as heaven and earth,
3+
// inexhaustible as the great rivers.
4+
// When they come to an end,
5+
// they begin again,
6+
// like the days and months;
7+
// they die and are reborn,
8+
// like the four seasons."
9+
//
10+
// - Sun Tsu,
11+
// "The Art of War"
12+
13+
using TheArtOfDev.HtmlRenderer.Adapters;
14+
using TheArtOfDev.HtmlRenderer.Adapters.Entities;
15+
using TheArtOfDev.HtmlRenderer.Core.Dom;
16+
using TheArtOfDev.HtmlRenderer.Core.Utils;
17+
18+
namespace TheArtOfDev.HtmlRenderer.Core.Handlers
19+
{
20+
/// <summary>
21+
/// Paints <c>outline</c> (CSS 2.1 §18.1) as a plain rectangular ring around the box's own border-box
22+
/// edge - unlike <see cref="BordersDrawHandler"/>, an outline has no per-side bevel/corner-joining to
23+
/// account for (it isn't part of the box model and doesn't affect layout), so this is a single flat
24+
/// four-rectangle fill rather than a per-side polygon.<br/>
25+
/// Deliberately a subset of the full property: only <c>solid</c> (and <c>auto</c>, treated the same
26+
/// as <c>solid</c> per spec) is painted - <c>dotted</c>/<c>dashed</c>/<c>double</c>/<c>groove</c>/
27+
/// <c>ridge</c>/<c>inset</c>/<c>outset</c> are not (a box with one of those styles simply paints no
28+
/// outline, same as <c>none</c>). <c>outline-offset</c> is not implemented either (the CSS-OM here has
29+
/// no <c>outline-offset</c> property at all to read a value from), so the ring always sits flush
30+
/// against the border-box edge.
31+
/// </summary>
32+
internal static class OutlineDrawHandler
33+
{
34+
/// <summary>
35+
/// Paints <paramref name="box"/>'s outline around <paramref name="borderBoxRect"/> (the same
36+
/// border-box rectangle <see cref="BordersDrawHandler.DrawBoxBorders"/> was just given for this
37+
/// line) if it has a paintable one.
38+
/// </summary>
39+
public static void Draw(RGraphics g, CssBox box, RRect borderBoxRect)
40+
{
41+
var width = box.ActualOutlineWidth;
42+
if (width <= 0 || borderBoxRect.Width <= 0 || borderBoxRect.Height <= 0) return;
43+
44+
var style = box.OutlineStyle;
45+
var isSolid = style == CssConstants.Solid || style == CssConstants.Auto;
46+
if (!isSolid) return;
47+
48+
var brush = g.GetSolidBrush(box.ActualOutlineColor);
49+
50+
// Top band (full width, including the corners) ...
51+
g.DrawRectangle(brush, borderBoxRect.X - width, borderBoxRect.Y - width, borderBoxRect.Width + 2 * width, width);
52+
// ... bottom band ...
53+
g.DrawRectangle(brush, borderBoxRect.X - width, borderBoxRect.Bottom, borderBoxRect.Width + 2 * width, width);
54+
// ... left band (between the top/bottom bands, not overlapping their corners) ...
55+
g.DrawRectangle(brush, borderBoxRect.X - width, borderBoxRect.Y, width, borderBoxRect.Height);
56+
// ... right band.
57+
g.DrawRectangle(brush, borderBoxRect.Right, borderBoxRect.Y, width, borderBoxRect.Height);
58+
}
59+
}
60+
}

Source/HtmlRenderer/Core/Paint/FragmentPainter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ private void PaintFragmentContent(RGraphics g, BoxFragment fragment)
216216
{
217217
box.PaintBackground(g, actualRect, i == 0, i == lines.Count - 1);
218218
BordersDrawHandler.DrawBoxBorders(g, box, actualRect, i == 0, i == lines.Count - 1);
219+
OutlineDrawHandler.Draw(g, box, actualRect);
219220
}
220221
}
221222

Source/HtmlRenderer/Core/Utils/CssUtils.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ internal static class CssUtils
5050
"page-break-inside", "break-inside", "break-before", "break-after", "page-break-before", "page-break-after",
5151
"widows", "orphans", "page",
5252
"left", "top", "right", "bottom", "counter-reset", "counter-increment",
53+
"outline-style", "outline-color", "outline-width",
5354
"width", "max-width", "height", "min-height", "max-height",
5455
"background-color", "background-image", "background-position", "background-repeat",
5556
"content", "color", "display", "direction", "empty-cells", "float", "clear", "box-sizing", "position",
@@ -180,6 +181,12 @@ public static string GetPropertyValue(CssBox cssBox, string propName)
180181
return cssBox.CounterReset;
181182
case "counter-increment":
182183
return cssBox.CounterIncrement;
184+
case "outline-style":
185+
return cssBox.OutlineStyle;
186+
case "outline-color":
187+
return cssBox.OutlineColor;
188+
case "outline-width":
189+
return cssBox.OutlineWidth;
183190
case "width":
184191
return cssBox.Width;
185192
case "max-width":
@@ -397,6 +404,15 @@ public static void SetPropertyValue(CssBox cssBox, string propName, string value
397404
case "counter-increment":
398405
cssBox.CounterIncrement = value;
399406
break;
407+
case "outline-style":
408+
cssBox.OutlineStyle = value;
409+
break;
410+
case "outline-color":
411+
cssBox.OutlineColor = value;
412+
break;
413+
case "outline-width":
414+
cssBox.OutlineWidth = value;
415+
break;
400416
case "width":
401417
cssBox.Width = value;
402418
break;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System.Linq;
2+
using HtmlRenderer.IntegrationTest.TestSupport;
3+
using TheArtOfDev.HtmlRenderer.Adapters.Entities;
4+
5+
namespace HtmlRenderer.IntegrationTest.Painting;
6+
7+
/// <summary>
8+
/// Ported from PeachPDF.Tests' OutlineStylePaintIntegrationTests.cs, adapted to this fork's new (and more
9+
/// limited) <c>OutlineDrawHandler</c> - see that class's remarks for exactly what's implemented: a plain
10+
/// rectangular ring for <c>solid</c>/<c>auto</c> only, no <c>outline-offset</c> (this fork's CSS-OM has no
11+
/// such property to read a value from), and every other style (<c>dotted</c>/<c>dashed</c>/<c>double</c>/
12+
/// <c>groove</c>/<c>ridge</c>/<c>inset</c>/<c>outset</c>) paints nothing, same as <c>none</c>.
13+
/// </summary>
14+
[DoNotParallelize]
15+
[TestClass]
16+
public sealed class OutlineStylePaintIntegrationTests
17+
{
18+
[TestMethod]
19+
public void SolidOutline_PaintsAFourSidedRingAroundTheBorderBox()
20+
{
21+
var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(
22+
"<div id='b' style='width:100px;height:50px;outline:5px solid rgb(51,51,51)'>x</div>"));
23+
var div = PaintHarness.FindById(root, "b")!;
24+
25+
var g = PaintHarness.PaintBox(container, div);
26+
var rects = g.Log.OfType<RecordingGraphics.DrawRectCall>()
27+
.Where(r => r.Color == RColor.FromArgb(51, 51, 51)).ToList();
28+
29+
Assert.AreEqual(4, rects.Count, "expected one filled rect per side of the outline ring");
30+
31+
// Two of the four bands are 5px tall (top/bottom, spanning the full outer width including
32+
// corners) and two are 5px wide (left/right, spanning just the box's own height) - regardless of
33+
// which order OutlineDrawHandler emits them in.
34+
Assert.AreEqual(2, rects.Count(r => System.Math.Abs(r.Height - 5) < 0.1));
35+
Assert.AreEqual(2, rects.Count(r => System.Math.Abs(r.Width - 5) < 0.1));
36+
}
37+
38+
[Ignore("outline-style:auto cannot be parsed at all on this fork: Map.LineStyles (Core/CssEngine/Model/" +
39+
"Map.cs), the shared keyword table outline-style's converter reuses from border-style, has no " +
40+
"\"auto\" entry - adding one there would also make it a legal (but spec-invalid) border-style " +
41+
"value, so this needs its own dedicated converter rather than a shared-map edit, which is out of " +
42+
"scope for this pass. OutlineDrawHandler.Draw's own \"style == Auto\" check is consequently dead " +
43+
"code today - reachable only by setting the property programmatically, never via CSS text.")]
44+
[TestMethod]
45+
public void OutlineAuto_PaintsTheSameAsSolid()
46+
{
47+
var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(
48+
"<div id='b' style='width:100px;height:50px;outline:5px auto rgb(51,51,51)'>x</div>"));
49+
var div = PaintHarness.FindById(root, "b")!;
50+
51+
var g = PaintHarness.PaintBox(container, div);
52+
var rects = g.Log.OfType<RecordingGraphics.DrawRectCall>()
53+
.Where(r => r.Color == RColor.FromArgb(51, 51, 51)).ToList();
54+
55+
Assert.AreEqual(4, rects.Count);
56+
}
57+
58+
[TestMethod]
59+
public void OutlineColorUnset_FallsBackToCurrentColor()
60+
{
61+
var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(
62+
"<div id='b' style='width:100px;height:50px;color:rgb(10,20,30);outline-style:solid;outline-width:5px'>x</div>"));
63+
var div = PaintHarness.FindById(root, "b")!;
64+
65+
var g = PaintHarness.PaintBox(container, div);
66+
var rects = g.Log.OfType<RecordingGraphics.DrawRectCall>().ToList();
67+
68+
Assert.AreEqual(4, rects.Count);
69+
Assert.IsTrue(rects.All(r => r.Color == RColor.FromArgb(10, 20, 30)));
70+
}
71+
72+
[TestMethod]
73+
public void Outline_PaintsAfterBorder()
74+
{
75+
// Paint-order: DrawBoxBorders runs, then OutlineDrawHandler - the outline's draw calls must come
76+
// after the border's in the recording.
77+
var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(
78+
"<div id='b' style='width:100px;height:50px;border:3px solid rgb(1,2,3);outline:5px solid rgb(4,5,6)'>x</div>"));
79+
var div = PaintHarness.FindById(root, "b")!;
80+
81+
var g = PaintHarness.PaintBox(container, div);
82+
83+
var borderIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawLineCall line && line.Color == RColor.FromArgb(1, 2, 3));
84+
var outlineIndex = g.Log.FindIndex(c => c is RecordingGraphics.DrawRectCall rect && rect.Color == RColor.FromArgb(4, 5, 6));
85+
86+
Assert.IsTrue(borderIndex >= 0 && outlineIndex >= 0);
87+
Assert.IsTrue(outlineIndex > borderIndex, "outline must paint after (on top of) the border");
88+
}
89+
90+
[TestMethod]
91+
[DataRow("none")]
92+
[DataRow("hidden")]
93+
public void OutlineStyleNoneOrHidden_PaintsNothing(string style)
94+
{
95+
var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(
96+
$"<div id='b' style='width:100px;height:50px;outline:5px {style} rgb(51,51,51)'>x</div>"));
97+
var div = PaintHarness.FindById(root, "b")!;
98+
99+
var g = PaintHarness.PaintBox(container, div);
100+
101+
Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == RColor.FromArgb(51, 51, 51)));
102+
}
103+
104+
[TestMethod]
105+
public void ZeroWidthOutline_PaintsNothing()
106+
{
107+
var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(
108+
"<div id='b' style='width:100px;height:50px;outline:0 solid rgb(51,51,51)'>x</div>"));
109+
var div = PaintHarness.FindById(root, "b")!;
110+
111+
var g = PaintHarness.PaintBox(container, div);
112+
113+
Assert.IsFalse(g.Log.OfType<RecordingGraphics.DrawRectCall>().Any(r => r.Color == RColor.FromArgb(51, 51, 51)));
114+
}
115+
116+
[Ignore("outline-offset is not implemented on this fork: the CSS-OM here has no OutlineOffsetProperty at " +
117+
"all (confirmed - no PropertyNames.OutlineOffset, no case in PropertyFactory), so there is no value " +
118+
"for OutlineDrawHandler to read; the ring always sits flush against the border-box edge instead of " +
119+
"the requested 10px further out.")]
120+
[TestMethod]
121+
public void OutlineOffset_PushesTheRingFurtherFromTheBorderBox()
122+
{
123+
var (root, container) = PaintHarness.Layout(PaintHarness.Wrap(
124+
"<div id='b' style='width:100px;height:50px;outline:5px solid rgb(51,51,51);outline-offset:10px'>x</div>"));
125+
var div = PaintHarness.FindById(root, "b")!;
126+
127+
var g = PaintHarness.PaintBox(container, div);
128+
var borderBox = div.Rectangles.Values.Single();
129+
var top = g.Log.OfType<RecordingGraphics.DrawRectCall>().First(r => r.Color == RColor.FromArgb(51, 51, 51));
130+
131+
Assert.AreEqual(borderBox.Y - 10 - 5, top.Y, 0.1);
132+
}
133+
}

0 commit comments

Comments
 (0)