Skip to content

Commit 9377e7f

Browse files
committed
Fix counter scoping for nested resets and hidden elements
- End a counter-reset's scope with its parent instead of leaking it to later siblings of the parent - Skip display:none subtrees when resolving counters - Read counters that aren't in scope as 0
1 parent 6657597 commit 9377e7f

4 files changed

Lines changed: 114 additions & 41 deletions

File tree

‎Source/HtmlRenderer/Core/Dom/CssContentEngine.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,11 @@ private static string Resolve(CssBox box, string content)
9595
// ResolveCounters walks this pseudo-element box as an ordinary child of its
9696
// generating element, so it already carries its own correct post-increment
9797
// counter snapshot (inherited from the generating element's, per §12.4).
98+
// A counter that isn't in scope reads as 0 (§12.4).
9899
int counterValue;
99100
if (!box.Counters.TryGetValue(counterNameToken.Data, out counterValue))
100101
{
101-
counterValue = 1;
102+
counterValue = 0;
102103
}
103104
sb.Append(CssCounterEngine.FormatCounterValue(counterValue, style));
104105
}

‎Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs‎

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -21,74 +21,101 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom
2121
/// Resolves CSS 2.1 §12.4 named counters (<c>counter-reset</c>/<c>counter-increment</c>) into a
2222
/// per-box snapshot of counter values, consulted by <c>content: counter()</c>
2323
/// (<see cref="CssContentEngine"/>).<br/>
24-
/// Deliberately a subset of the full spec algorithm: counters thread forward through document order
25-
/// (a box inherits its parent's counters for its first child, and each child's resulting counters
26-
/// feed the next sibling) which covers ordinary sequential/nested counter usage, but
27-
/// <c>reversed(&lt;name&gt;)</c> and <c>counter-set</c> are not implemented, and a nested
28-
/// <c>counter-reset</c> for a name already in scope always starts a fresh inner value rather than
29-
/// the spec's more precise "new scope only among this element's own following siblings" rule -
30-
/// in practice indistinguishable from the full rule for the common case of one counter-reset per
31-
/// counter name in a document, which is what this port's test coverage exercises.
24+
/// <c>reversed(&lt;name&gt;)</c> and <c>counter-set</c> are not implemented.
3225
/// </summary>
3326
internal static class CssCounterEngine
3427
{
3528
/// <summary>
36-
/// Walks <paramref name="box"/> and its descendants in document order, applying each box's own
29+
/// Walks <paramref name="root"/> and its descendants in document order, applying each box's own
3730
/// <c>counter-reset</c>/<c>counter-increment</c> (plus the implicit <c>list-item</c> counter a
38-
/// <c>display:list-item</c> box always increments) on top of <paramref name="inherited"/>, and
39-
/// storing the result on <see cref="CssBoxProperties.Counters"/>. Must run once, before layout,
40-
/// after every box's CSS properties are resolved (<c>counter-reset</c>/<c>counter-increment</c>
41-
/// need their final cascaded values).
31+
/// <c>display:list-item</c> box always increments) and storing the values in scope on
32+
/// <see cref="CssBoxProperties.Counters"/>. Must run once, before layout, after every box's CSS
33+
/// properties are resolved.
4234
/// </summary>
43-
/// <returns>
44-
/// The counters visible immediately after <paramref name="box"/> and its whole subtree - what a
45-
/// following sibling should inherit.
46-
/// </returns>
47-
public static Dictionary<string, int> ResolveCounters(CssBox box, Dictionary<string, int> inherited)
35+
public static void ResolveCounters(CssBox root)
4836
{
49-
var counters = new Dictionary<string, int>(inherited);
50-
51-
ApplyCounterReset(box, counters);
52-
ApplyCounterIncrement(box, counters);
37+
ResolveSiblings(new[] { root }, new Dictionary<string, List<int>>());
38+
}
5339

54-
box.Counters = new Dictionary<string, int>(counters);
40+
/// <summary>
41+
/// Each counter name maps to a stack of instances. A reset creates a new instance that stays in
42+
/// scope for the box's following siblings and all their descendants, and goes out of scope again
43+
/// once the parent ends (§12.4.1). Boxes with <c>display:none</c> don't touch counters (§12.4.3).
44+
/// </summary>
45+
private static void ResolveSiblings(IEnumerable<CssBox> boxes, Dictionary<string, List<int>> counters)
46+
{
47+
var created = new HashSet<string>();
5548

56-
foreach (var child in box.Boxes)
49+
foreach (var box in boxes)
5750
{
58-
counters = ResolveCounters(child, counters);
51+
if (box.Display == CssConstants.None)
52+
continue;
53+
54+
ApplyCounterReset(box, counters, created);
55+
ApplyCounterIncrement(box, counters, created);
56+
57+
var snapshot = new Dictionary<string, int>(counters.Count);
58+
foreach (var pair in counters)
59+
snapshot[pair.Key] = pair.Value[pair.Value.Count - 1];
60+
box.Counters = snapshot;
61+
62+
ResolveSiblings(box.Boxes, counters);
5963
}
6064

61-
return counters;
65+
foreach (var name in created)
66+
{
67+
var instances = counters[name];
68+
instances.RemoveAt(instances.Count - 1);
69+
if (instances.Count == 0)
70+
counters.Remove(name);
71+
}
6272
}
6373

64-
private static void ApplyCounterReset(CssBox box, Dictionary<string, int> counters)
74+
private static void ApplyCounterReset(CssBox box, Dictionary<string, List<int>> counters, HashSet<string> created)
6575
{
6676
if (string.IsNullOrEmpty(box.CounterReset) || box.CounterReset == CssConstants.None) return;
6777

68-
ForEachNameValuePair(box.CounterReset, defaultValue: 0, (name, value) => counters[name] = value);
78+
ForEachNameValuePair(box.CounterReset, defaultValue: 0, (name, value) => Reset(counters, created, name, value));
6979
}
7080

71-
private static void ApplyCounterIncrement(CssBox box, Dictionary<string, int> counters)
81+
private static void ApplyCounterIncrement(CssBox box, Dictionary<string, List<int>> counters, HashSet<string> created)
7282
{
7383
if (box.Display == CssConstants.ListItem && !CounterNameAppears(box.CounterIncrement, CssConstants.ListItem))
7484
{
75-
counters[CssConstants.ListItem] = GetOrDefault(counters, CssConstants.ListItem) + 1;
85+
Increment(counters, created, CssConstants.ListItem, 1);
7686
}
7787

7888
if (string.IsNullOrEmpty(box.CounterIncrement) || box.CounterIncrement == CssConstants.None) return;
7989

80-
ForEachNameValuePair(box.CounterIncrement, defaultValue: 1,
81-
(name, value) => counters[name] = GetOrDefault(counters, name) + value);
90+
ForEachNameValuePair(box.CounterIncrement, defaultValue: 1, (name, value) => Increment(counters, created, name, value));
8291
}
8392

84-
/// <summary>
85-
/// <see cref="Dictionary{TKey,TValue}.GetValueOrDefault(TKey)"/> stand-in: this project also
86-
/// targets netstandard2.0/net462, where that method doesn't exist.
87-
/// </summary>
88-
private static int GetOrDefault(Dictionary<string, int> counters, string name)
93+
private static void Reset(Dictionary<string, List<int>> counters, HashSet<string> created, string name, int value)
8994
{
90-
int value;
91-
return counters.TryGetValue(name, out value) ? value : 0;
95+
// A second reset among the same siblings replaces that instance instead of nesting a new one.
96+
if (created.Add(name))
97+
{
98+
if (!counters.TryGetValue(name, out var instances))
99+
counters[name] = instances = new List<int>();
100+
instances.Add(value);
101+
}
102+
else
103+
{
104+
var instances = counters[name];
105+
instances[instances.Count - 1] = value;
106+
}
107+
}
108+
109+
private static void Increment(Dictionary<string, List<int>> counters, HashSet<string> created, string name, int value)
110+
{
111+
// Incrementing a counter that isn't in scope behaves as if this box had reset it to 0.
112+
if (!counters.TryGetValue(name, out var instances))
113+
{
114+
Reset(counters, created, name, 0);
115+
instances = counters[name];
116+
}
117+
118+
instances[instances.Count - 1] += value;
92119
}
93120

94121
private static bool CounterNameAppears(string counterPropertyValue, string counterName)

‎Source/HtmlRenderer/Core/Parse/DomParser.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public DomParser(CssParser cssParser)
8080

8181
SetTextSelectionStyle(htmlContainer, cssData);
8282

83-
CssCounterEngine.ResolveCounters(root, new Dictionary<string, int>());
83+
CssCounterEngine.ResolveCounters(root);
8484

8585
CorrectTextBoxes(root);
8686

‎Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,51 @@ public void CounterReset_OnNestedAncestor_IsVisibleToDescendant()
161161
Assert.AreEqual("5", before.Text);
162162
}
163163

164+
[TestMethod]
165+
public void CounterReset_InANestedList_DoesNotLeakIntoTheOuterList()
166+
{
167+
// The inner list's reset creates a new counter that goes out of scope with the inner list, so
168+
// the outer list continues at 2.
169+
var html = LayoutHarness.Wrap(
170+
"<div class='list'>"
171+
+ "<div class='item'><span id='a'>A</span>"
172+
+ "<div class='list'><p class='item'>x</p><p class='item'>y</p><p class='item'>z</p></div>"
173+
+ "</div>"
174+
+ "<div class='item'><span id='b'>B</span></div>"
175+
+ "</div>"
176+
+ "<style>.list { counter-reset: item; } .item { counter-increment: item; }"
177+
+ " #a::before, #b::before { content: counter(item); }</style>");
178+
var (root, _) = LayoutHarness.Layout(html);
179+
180+
var a = LayoutHarness.FindById(root, "a")!.Boxes.First(b => b.IsBeforePseudoElement);
181+
var b = LayoutHarness.FindById(root, "b")!.Boxes.First(b => b.IsBeforePseudoElement);
182+
183+
Assert.AreEqual("1", a.Text);
184+
Assert.AreEqual("2", b.Text);
185+
}
186+
187+
[TestMethod]
188+
public void CounterIncrement_OnADisplayNoneElement_IsIgnored()
189+
{
190+
var html = LayoutHarness.Wrap(
191+
"<div style='counter-reset:item'>"
192+
+ "<p style='counter-increment:item'>a</p>"
193+
+ "<p style='counter-increment:item; display:none'>hidden</p>"
194+
+ "<p id='c' style='counter-increment:item'>c</p>"
195+
+ "</div>"
196+
+ "<style>#c::before { content: counter(item); }</style>");
197+
var (root, _) = LayoutHarness.Layout(html);
198+
var before = LayoutHarness.FindById(root, "c")!.Boxes.First(b => b.IsBeforePseudoElement);
199+
200+
Assert.AreEqual("2", before.Text);
201+
}
202+
203+
[TestMethod]
204+
public void Counter_NotInScope_ReadsAsZero()
205+
{
206+
Assert.AreEqual("0", ResolveBeforeContent("counter(nothing)").Text);
207+
}
208+
164209
// ── Helpers ────────────────────────────────────────────────────────────
165210

166211
private static CssBox ResolveBeforeContent(string contentValue)

0 commit comments

Comments
 (0)