From e236b54551cebe167a6e86a61019a4bfbf9a7f16 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 14:08:47 -0400 Subject: [PATCH 1/4] =?UTF-8?q?Implement=20CSS=202.1=20=C2=A712.4=20counte?= =?UTF-8?q?r-reset/counter-increment=20for=20content:=20counter()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit counter-reset/counter-increment parsed at the CSS-OM level but had zero effect anywhere - content: counter()/counters() were explicitly out of scope in CssContentEngine's own doc comment, and there was no counter-value tracking anywhere in Core. Adds CssCounterEngine: a forward-order tree walk (hooked into DomParser right after CascadeApplyStyles, before pseudo-element content gets resolved) that threads named counter values through document order - a box inherits its parent's counters for its first child, and each child's resulting counters feed the next sibling - covering ordinary sequential/nested counter usage (including the common case content:counter() needs). Deliberately not the full CSS 2.1 §12.4 algorithm: reversed() and counter-set aren't implemented, and a nested counter-reset for a name already in scope always starts a fresh value rather than the spec's more precise scoping rule - see the class's own remarks for the exact boundary. FormatCounterValue is the single counter-style resolver (decimal, decimal-leading-zero, roman, alpha, with CSS Counter Styles Level 3 §2's fallback-to-decimal for unknown/out-of-range styles), ported from PeachPDF's CssCounterEngine and reusing this fork's existing CommonUtils.ConvertToAlphaNumber for the alphabetic styles. Ports PeachPDF's CssCounterEngineTests.cs (pure formatting, 17 tests) and the counter-related subset of CssContentEngineTests.cs, adapted to run through the real box tree (LayoutHarness + a ::before/::after rule) rather than a hand-built box, since pseudo-element boxes here are only constructible via real selector matching. Adds two new integration tests proving the document-order threading itself works (sequential sibling increments, nested-ancestor visibility) beyond what PeachPDF's own box-local unit tests exercise. --- .../HtmlRenderer/Core/Dom/CssBoxProperties.cs | 23 +++ .../HtmlRenderer/Core/Dom/CssContentEngine.cs | 27 +++ .../HtmlRenderer/Core/Dom/CssCounterEngine.cs | 177 ++++++++++++++++++ Source/HtmlRenderer/Core/Parse/DomParser.cs | 2 + Source/HtmlRenderer/Core/Utils/CssUtils.cs | 13 +- .../CssContentEngineIntegrationTests.cs | 173 +++++++++++++++++ .../Dom/CssCounterEngineTests.cs | 58 ++++++ 7 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs create mode 100644 Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs index 1fe334ff9..0ea9cad2a 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs @@ -79,6 +79,8 @@ internal abstract class CssBoxProperties private string _marginRight = "0"; private string _marginTop = "0"; private string _left = "auto"; + private string _counterReset = CssConstants.None; + private string _counterIncrement = CssConstants.None; private string _lineHeight = "normal"; private string _listStyleType = "disc"; private string _listStyleImage = string.Empty; @@ -726,6 +728,27 @@ public string Right set { _right = value; } } + public string CounterReset + { + get { return _counterReset; } + set { _counterReset = value; } + } + + public string CounterIncrement + { + get { return _counterIncrement; } + set { _counterIncrement = value; } + } + + /// + /// 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 + /// , before layout runs. Consulted by + /// content: counter() (). + /// + internal System.Collections.Generic.Dictionary Counters { get; set; } + = new System.Collections.Generic.Dictionary(); + public string Bottom { get { return _bottom; } diff --git a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs index 5ca61083b..681abffd7 100644 --- a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs @@ -76,6 +76,33 @@ private static string Resolve(CssBox box, string content) sb.Append(attrValue); } } + else if (token is FunctionToken counterFunctionToken && + counterFunctionToken.Data.Equals(FunctionNames.Counter, StringComparison.OrdinalIgnoreCase)) + { + // counter( [, "); + var (root, _) = LayoutHarness.Layout(html); + var p = LayoutHarness.FindById(root, "p")!; + + Assert.IsFalse(p.Boxes.Any(b => b.IsBeforePseudoElement && b.Text != null)); + } + + [TestMethod] + public void ApplyContent_WithAttrFunction_RetrievesAttribute() + { + var html = LayoutHarness.Wrap( + "

text

"); + var (root, _) = LayoutHarness.Layout(html); + var p = LayoutHarness.FindById(root, "p")!; + var before = p.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("Chapter One", before.Text); + } + + [TestMethod] + public void ApplyContent_WithOpenAndCloseQuote_UsesCurlyQuotes() + { + var before = ResolveBeforeContent("open-quote \"quoted\" close-quote"); + Assert.AreEqual("“quoted”", before.Text); + } + + [TestMethod] + public void ApplyContent_WithCounterNoStyle_UsesDecimal() + { + var html = LayoutHarness.Wrap( + "

text

"); + var (root, _) = LayoutHarness.Layout(html); + var before = LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("7", before.Text); + } + + [TestMethod] + public void ApplyContent_WithCounterDecimalLeadingZero_PadsToTwoDigits() + { + var html = LayoutHarness.Wrap( + "

text

" + + ""); + var (root, _) = LayoutHarness.Layout(html); + var before = LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("01", before.Text); + } + + [TestMethod] + public void ApplyContent_WithCounterAlphabeticStyle_FormatsWithStyle() + { + var html = LayoutHarness.Wrap( + "

text

" + + ""); + var (root, _) = LayoutHarness.Layout(html); + var before = LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("IV", before.Text); + } + + [TestMethod] + public void ApplyContent_WithCounterUnknownStyle_FallsBackToDecimal() + { + // CSS Counter Styles Level 3 §2: unknown style must render as decimal, not empty. + var html = LayoutHarness.Wrap( + "

text

" + + ""); + var (root, _) = LayoutHarness.Layout(html); + var before = LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("5", before.Text); + } + + [TestMethod] + public void ApplyContent_WithCounterAndStyleAndLiteral_Concatenates() + { + var html = LayoutHarness.Wrap( + "

text

" + + ""); + var (root, _) = LayoutHarness.Layout(html); + var before = LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("03 Item", before.Text); + } + + [TestMethod] + public void CounterIncrement_ThreadsSequentiallyAcrossSiblings() + { + // CSS 2.1 §12.4: counter-increment accumulates through document order - three siblings each + // incrementing "item" should see 1, 2, 3 in turn. + var html = LayoutHarness.Wrap( + "
" + + "

text

" + + "

text

" + + "

text

" + + "
" + + ""); + var (root, _) = LayoutHarness.Layout(html); + + var a = LayoutHarness.FindById(root, "a")!.Boxes.First(b => b.IsBeforePseudoElement); + var b = LayoutHarness.FindById(root, "b")!.Boxes.First(b => b.IsBeforePseudoElement); + var c = LayoutHarness.FindById(root, "c")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("1", a.Text); + Assert.AreEqual("2", b.Text); + Assert.AreEqual("3", c.Text); + } + + [TestMethod] + public void CounterReset_OnNestedAncestor_IsVisibleToDescendant() + { + // A counter-reset on an ancestor is visible to descendants further down the tree, not just + // direct children. + var html = LayoutHarness.Wrap( + "

text

" + + ""); + var (root, _) = LayoutHarness.Layout(html); + var before = LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("5", before.Text); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + private static CssBox ResolveBeforeContent(string contentValue) + { + var html = LayoutHarness.Wrap( + $"

text

"); + var (root, _) = LayoutHarness.Layout(html); + return LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement); + } +} diff --git a/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs new file mode 100644 index 000000000..5992511ca --- /dev/null +++ b/Source/Test/HtmlRenderer.Test/Dom/CssCounterEngineTests.cs @@ -0,0 +1,58 @@ +using TheArtOfDev.HtmlRenderer.Core.Dom; + +namespace HtmlRenderer.Test.Dom; + +/// +/// Ported from PeachPDF.Tests/Html/Core/Dom/CssCounterEngineTests.cs. Unit tests for +/// , the counter-style resolver content: counter() +/// uses. Pins the CSS Counter Styles Level 3 §2 requirement that an unknown/invalid style falls back to +/// decimal rather than rendering nothing. +/// +[TestClass] +public sealed class CssCounterEngineTests +{ + [TestMethod] + [DataRow(1, "decimal", "1")] + [DataRow(12, "decimal", "12")] + [DataRow(1, "decimal-leading-zero", "01")] + [DataRow(9, "decimal-leading-zero", "09")] + [DataRow(12, "decimal-leading-zero", "12")] // already two digits - no over-padding + [DataRow(100, "decimal-leading-zero", "100")] + [DataRow(4, "lower-roman", "iv")] + [DataRow(4, "upper-roman", "IV")] + [DataRow(1, "lower-alpha", "a")] + [DataRow(3, "upper-alpha", "C")] + public void FormatCounterValue_KnownStyles_FormatAsExpected(int number, string style, string expected) + { + Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); + } + + [TestMethod] + [DataRow(1)] + [DataRow(7)] + [DataRow(42)] + public void FormatCounterValue_UnknownStyle_FallsBackToDecimal(int number) + { + // CSS Counter Styles Level 3 §2: an unknown/invalid counter style renders as decimal, never empty. + Assert.AreEqual(number.ToString(System.Globalization.CultureInfo.InvariantCulture), + CssCounterEngine.FormatCounterValue(number, "not-a-real-style")); + } + + [TestMethod] + [DataRow(0, "upper-roman", "0")] + [DataRow(-5, "lower-alpha", "-5")] + [DataRow(0, "lower-greek", "0")] + public void FormatCounterValue_AlphabeticStyleOutOfRange_FallsBackToDecimal(int number, string style, string expected) + { + // Alphabetic/symbolic styles can't represent 0 or negatives; CSS Counter Styles L3 §2 says such + // out-of-range values render with the fallback style (decimal), not empty. + Assert.AreEqual(expected, CssCounterEngine.FormatCounterValue(number, style)); + } + + [TestMethod] + public void FormatCounterValue_StyleMatchIsCaseInsensitive() + { + Assert.AreEqual("01", CssCounterEngine.FormatCounterValue(1, "DECIMAL-LEADING-ZERO")); + Assert.AreEqual("iv", CssCounterEngine.FormatCounterValue(4, "Lower-Roman")); + } +} From 9154a9e08613a245a8a66a627682f0223a159671 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 14:08:58 -0400 Subject: [PATCH 2/4] Add
  • support, fix list-style-type:square's wrong glyph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit
  • (a WHATWG HTML presentational hint) had no effect on GetIndexForList's numbering - only
      /reversed were consulted. Later items without their own explicit value now continue counting from a value-carrying item, matching every browser. Also fixes list-style-type:square rendering "♠" (U+2660 BLACK SPADE SUIT card-suit glyph) instead of an actual square - presumably a copy/paste typo, noticed while auditing this code for the counter port above. Re-approves the Bullets.png baseline for the corrected marker glyph. Ports the CSS2.1-relevant subset of PeachPDF's ListItemCounterIntegrationTests.cs (ol start/reversed were already correct; li value and the marker-glyph fix are new). --- Source/HtmlRenderer/Core/Dom/CssBox.cs | 14 +++- .../Baselines/Bullets.png | Bin 20998 -> 20827 bytes .../ListItemCounterIntegrationTests.cs | 70 ++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs diff --git a/Source/HtmlRenderer/Core/Dom/CssBox.cs b/Source/HtmlRenderer/Core/Dom/CssBox.cs index bff71cc06..ed14dc5ac 100644 --- a/Source/HtmlRenderer/Core/Dom/CssBox.cs +++ b/Source/HtmlRenderer/Core/Dom/CssBox.cs @@ -1228,6 +1228,15 @@ private int GetIndexForList() foreach (CssBox b in ParentBox.Boxes) { + // An explicit
    1. (WHATWG HTML presentational hint) sets the running count to + // N for that item; later items (without their own explicit value) continue incrementing + // from there, matching every browser's actual behavior. + int explicitValue; + if (b.Display == CssConstants.ListItem && int.TryParse(b.GetAttribute("value"), out explicitValue)) + { + index = explicitValue; + } + if (b.Equals(this)) return index; @@ -1263,7 +1272,10 @@ private void CreateListItemBox(RGraphics g) } else if (ListStyleType.Equals(CssConstants.Square, StringComparison.InvariantCultureIgnoreCase)) { - _listItemBox.Text = "♠"; + // Was "♠" (U+2660 BLACK SPADE SUIT) - not what CSS2.1 §12.5.1's list-style-type: + // square means at all (a filled square marker, the ♣/♦/♠ card-suit glyph was + // presumably a typo/copy-paste of a nearby symbol). + _listItemBox.Text = "▪"; // U+25AA BLACK SMALL SQUARE } else if (ListStyleType.Equals(CssConstants.Decimal, StringComparison.InvariantCultureIgnoreCase)) { diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Bullets.png b/Source/Test/HtmlRenderer.IntegrationTest/Baselines/Bullets.png index 1e2c8b6ded45f4b559c091d7255edf58dcc2f92a..c1ae566b519e238730b67c9cbce15ee1f3066573 100644 GIT binary patch literal 20827 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 L#+gi{sqg;>714Jl literal 20998 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 BjFJEV diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs new file mode 100644 index 000000000..6a3d84391 --- /dev/null +++ b/Source/Test/HtmlRenderer.IntegrationTest/Content/ListItemCounterIntegrationTests.cs @@ -0,0 +1,70 @@ +using System.Linq; +using HtmlRenderer.IntegrationTest.TestSupport; + +namespace HtmlRenderer.IntegrationTest.Content; + +/// +/// Ported from PeachPDF.Tests/Integration/ListItemCounterIntegrationTests.cs - the CSS2.1-relevant subset +/// this fork's simpler, non-CSS-counter-driven list-marker mechanism (CssBox.GetIndexForList) can +/// support: <ol start>/reversed (already implemented) and <li value> (newly +/// added by this port). CSS3 marker-styling extras from the source file are not ported. +/// +[DoNotParallelize] +[TestClass] +public sealed class ListItemCounterIntegrationTests +{ + [TestMethod] + public void OlStart_OffsetsEveryItem() + { + var html = LayoutHarness.Wrap("
      1. x
      2. y
      "); + var (root, _) = LayoutHarness.Layout(html); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + + Assert.AreEqual("5.", a.ListItemBox.Words[0].Text); + Assert.AreEqual("6.", b.ListItemBox.Words[0].Text); + } + + [TestMethod] + public void OlReversed_CountsDownFromItemCount() + { + var html = LayoutHarness.Wrap("
      1. x
      2. y
      3. z
      "); + var (root, _) = LayoutHarness.Layout(html); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual("3.", a.ListItemBox.Words[0].Text); + Assert.AreEqual("2.", b.ListItemBox.Words[0].Text); + Assert.AreEqual("1.", c.ListItemBox.Words[0].Text); + } + + [TestMethod] + public void LiValue_OverridesThatItemAndContinuesFromThere() + { + var html = LayoutHarness.Wrap( + "
      1. x
      2. y
      3. z
      "); + var (root, _) = LayoutHarness.Layout(html); + + var a = LayoutHarness.FindById(root, "a")!; + var b = LayoutHarness.FindById(root, "b")!; + var c = LayoutHarness.FindById(root, "c")!; + + Assert.AreEqual("1.", a.ListItemBox.Words[0].Text); + Assert.AreEqual("10.", b.ListItemBox.Words[0].Text); + Assert.AreEqual("11.", c.ListItemBox.Words[0].Text); + } + + [TestMethod] + public void ListStyleTypeSquare_UsesAFilledSquareGlyph_NotASpadeSuitSymbol() + { + // Regression: this used to render "♠" (U+2660 BLACK SPADE SUIT), not a square at all. + var html = LayoutHarness.Wrap("
      • x
      "); + var (root, _) = LayoutHarness.Layout(html); + var a = LayoutHarness.FindById(root, "a")!; + + Assert.AreEqual("▪", a.ListItemBox.Words[0].Text); + } +} From 3279f5b1f9cf24e4df34b3e511895bf3ec911c25 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 14:09:07 -0400 Subject: [PATCH 3/4] Fix a stale Ignore and its own test bug on an already-working ::after test CssContentWithCssEscapeInString_RendersLiterally was Ignore'd, citing "this fork has no pseudo-element support at all" - false: ::before already works (the CSS-escape test right above it in this same file passes), and CssContentEngine/CssData's pseudo-element creation predates this port entirely. Un-ignoring it surfaced a real bug in the TEST itself, not the engine: its box-finding predicate (HtmlTag == null && Text != null) matches the real text node "text" (which also has no HtmlTag) before it reaches the ::after box, since ::after is appended at the end of p.Boxes while ::before is inserted at index 0 - the sibling test just above happens to pass because ::before's insert-at-0 placement wins the FirstOrDefault race by coincidence. Fixed to key off IsAfterPseudoElement directly, and it passes. --- .../Text/HtmlEntityDecodingIntegrationTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs index ae7432465..890ab5d4d 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Text/HtmlEntityDecodingIntegrationTests.cs @@ -189,9 +189,6 @@ public void CssContentWithCssEscape_RendersLiterally() Assert.AreEqual("&", beforeBox!.Text); } - [Ignore("Requires ::before/::after pseudo-elements with a CSS content: property - this fork has no " + - "pseudo-element support at all (confirmed: no \"::before\"/\"::after\"/pseudo-element handling " + - "anywhere in Core, only :link/:hover pseudo-CLASSES are recognized).")] [TestMethod] public void CssContentWithCssEscapeInString_RendersLiterally() { @@ -200,7 +197,10 @@ public void CssContentWithCssEscapeInString_RendersLiterally() var (root, _) = LayoutHarness.Layout(html); var p = LayoutHarness.FindById(root, "p")!; - var afterBox = p.Boxes.FirstOrDefault(b => b.HtmlTag == null && b.Text != null); + // Unlike ::before (inserted at index 0), ::after is appended at the end of p.Boxes - so + // FirstOrDefault(HtmlTag == null) would instead match the real text node "text" (which also has + // no HtmlTag), not the pseudo box. IsAfterPseudoElement identifies it unambiguously. + var afterBox = p.Boxes.FirstOrDefault(b => b.IsAfterPseudoElement); Assert.IsNotNull(afterBox); Assert.IsTrue(afterBox!.Text!.Contains('<')); Assert.IsTrue(afterBox.Text!.Contains('>')); From 5c13e9a3b1d2f564cd0a447293ffef6c6f1bf52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20D=C3=BCrrenberger?= Date: Mon, 9 Nov 2026 08:44:23 +0100 Subject: [PATCH 4/4] 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 --- .../HtmlRenderer/Core/Dom/CssContentEngine.cs | 3 +- .../HtmlRenderer/Core/Dom/CssCounterEngine.cs | 105 +++++++++++------- Source/HtmlRenderer/Core/Parse/DomParser.cs | 2 +- .../CssContentEngineIntegrationTests.cs | 45 ++++++++ 4 files changed, 114 insertions(+), 41 deletions(-) diff --git a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs index 681abffd7..09ceba7b5 100644 --- a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs @@ -95,10 +95,11 @@ private static string Resolve(CssBox box, string content) // ResolveCounters walks this pseudo-element box as an ordinary child of its // generating element, so it already carries its own correct post-increment // counter snapshot (inherited from the generating element's, per §12.4). + // A counter that isn't in scope reads as 0 (§12.4). int counterValue; if (!box.Counters.TryGetValue(counterNameToken.Data, out counterValue)) { - counterValue = 1; + counterValue = 0; } sb.Append(CssCounterEngine.FormatCounterValue(counterValue, style)); } diff --git a/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs b/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs index 6d05f919a..e22dd6a33 100644 --- a/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs +++ b/Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs @@ -21,74 +21,101 @@ namespace TheArtOfDev.HtmlRenderer.Core.Dom /// Resolves CSS 2.1 §12.4 named counters (counter-reset/counter-increment) into a /// per-box snapshot of counter values, consulted by content: counter() /// ().
      - /// Deliberately a subset of the full spec algorithm: counters thread forward through document order - /// (a box inherits its parent's counters for its first child, and each child's resulting counters - /// feed the next sibling) which covers ordinary sequential/nested counter usage, but - /// reversed(<name>) and counter-set are not implemented, and a nested - /// counter-reset for a name already in scope always starts a fresh inner value rather than - /// the spec's more precise "new scope only among this element's own following siblings" rule - - /// in practice indistinguishable from the full rule for the common case of one counter-reset per - /// counter name in a document, which is what this port's test coverage exercises. + /// reversed(<name>) and counter-set are not implemented. /// internal static class CssCounterEngine { /// - /// Walks and its descendants in document order, applying each box's own + /// Walks and its descendants in document order, applying each box's own /// counter-reset/counter-increment (plus the implicit list-item counter a - /// display:list-item box always increments) on top of , and - /// storing the result on . Must run once, before layout, - /// after every box's CSS properties are resolved (counter-reset/counter-increment - /// need their final cascaded values). + /// display:list-item box always increments) and storing the values in scope on + /// . Must run once, before layout, after every box's CSS + /// properties are resolved. /// - /// - /// The counters visible immediately after and its whole subtree - what a - /// following sibling should inherit. - /// - public static Dictionary ResolveCounters(CssBox box, Dictionary inherited) + public static void ResolveCounters(CssBox root) { - var counters = new Dictionary(inherited); - - ApplyCounterReset(box, counters); - ApplyCounterIncrement(box, counters); + ResolveSiblings(new[] { root }, new Dictionary>()); + } - box.Counters = new Dictionary(counters); + /// + /// Each counter name maps to a stack of instances. A reset creates a new instance that stays in + /// scope for the box's following siblings and all their descendants, and goes out of scope again + /// once the parent ends (§12.4.1). Boxes with display:none don't touch counters (§12.4.3). + /// + private static void ResolveSiblings(IEnumerable boxes, Dictionary> counters) + { + var created = new HashSet(); - foreach (var child in box.Boxes) + foreach (var box in boxes) { - counters = ResolveCounters(child, counters); + if (box.Display == CssConstants.None) + continue; + + ApplyCounterReset(box, counters, created); + ApplyCounterIncrement(box, counters, created); + + var snapshot = new Dictionary(counters.Count); + foreach (var pair in counters) + snapshot[pair.Key] = pair.Value[pair.Value.Count - 1]; + box.Counters = snapshot; + + ResolveSiblings(box.Boxes, counters); } - return counters; + foreach (var name in created) + { + var instances = counters[name]; + instances.RemoveAt(instances.Count - 1); + if (instances.Count == 0) + counters.Remove(name); + } } - private static void ApplyCounterReset(CssBox box, Dictionary counters) + private static void ApplyCounterReset(CssBox box, Dictionary> counters, HashSet created) { if (string.IsNullOrEmpty(box.CounterReset) || box.CounterReset == CssConstants.None) return; - ForEachNameValuePair(box.CounterReset, defaultValue: 0, (name, value) => counters[name] = value); + ForEachNameValuePair(box.CounterReset, defaultValue: 0, (name, value) => Reset(counters, created, name, value)); } - private static void ApplyCounterIncrement(CssBox box, Dictionary counters) + private static void ApplyCounterIncrement(CssBox box, Dictionary> counters, HashSet created) { if (box.Display == CssConstants.ListItem && !CounterNameAppears(box.CounterIncrement, CssConstants.ListItem)) { - counters[CssConstants.ListItem] = GetOrDefault(counters, CssConstants.ListItem) + 1; + Increment(counters, created, CssConstants.ListItem, 1); } if (string.IsNullOrEmpty(box.CounterIncrement) || box.CounterIncrement == CssConstants.None) return; - ForEachNameValuePair(box.CounterIncrement, defaultValue: 1, - (name, value) => counters[name] = GetOrDefault(counters, name) + value); + ForEachNameValuePair(box.CounterIncrement, defaultValue: 1, (name, value) => Increment(counters, created, name, value)); } - /// - /// stand-in: this project also - /// targets netstandard2.0/net462, where that method doesn't exist. - /// - private static int GetOrDefault(Dictionary counters, string name) + private static void Reset(Dictionary> counters, HashSet created, string name, int value) { - int value; - return counters.TryGetValue(name, out value) ? value : 0; + // A second reset among the same siblings replaces that instance instead of nesting a new one. + if (created.Add(name)) + { + if (!counters.TryGetValue(name, out var instances)) + counters[name] = instances = new List(); + instances.Add(value); + } + else + { + var instances = counters[name]; + instances[instances.Count - 1] = value; + } + } + + private static void Increment(Dictionary> counters, HashSet created, string name, int value) + { + // Incrementing a counter that isn't in scope behaves as if this box had reset it to 0. + if (!counters.TryGetValue(name, out var instances)) + { + Reset(counters, created, name, 0); + instances = counters[name]; + } + + instances[instances.Count - 1] += value; } private static bool CounterNameAppears(string counterPropertyValue, string counterName) diff --git a/Source/HtmlRenderer/Core/Parse/DomParser.cs b/Source/HtmlRenderer/Core/Parse/DomParser.cs index 3ffe83f51..7ffb57384 100644 --- a/Source/HtmlRenderer/Core/Parse/DomParser.cs +++ b/Source/HtmlRenderer/Core/Parse/DomParser.cs @@ -80,7 +80,7 @@ public DomParser(CssParser cssParser) SetTextSelectionStyle(htmlContainer, cssData); - CssCounterEngine.ResolveCounters(root, new Dictionary()); + CssCounterEngine.ResolveCounters(root); CorrectTextBoxes(root); diff --git a/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs b/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs index 92f03975f..6b3082128 100644 --- a/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs +++ b/Source/Test/HtmlRenderer.IntegrationTest/Content/CssContentEngineIntegrationTests.cs @@ -161,6 +161,51 @@ public void CounterReset_OnNestedAncestor_IsVisibleToDescendant() Assert.AreEqual("5", before.Text); } + [TestMethod] + public void CounterReset_InANestedList_DoesNotLeakIntoTheOuterList() + { + // The inner list's reset creates a new counter that goes out of scope with the inner list, so + // the outer list continues at 2. + var html = LayoutHarness.Wrap( + "
      " + + "
      A" + + "

      x

      y

      z

      " + + "
      " + + "
      B
      " + + "
      " + + ""); + var (root, _) = LayoutHarness.Layout(html); + + var a = LayoutHarness.FindById(root, "a")!.Boxes.First(b => b.IsBeforePseudoElement); + var b = LayoutHarness.FindById(root, "b")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("1", a.Text); + Assert.AreEqual("2", b.Text); + } + + [TestMethod] + public void CounterIncrement_OnADisplayNoneElement_IsIgnored() + { + var html = LayoutHarness.Wrap( + "
      " + + "

      a

      " + + "

      hidden

      " + + "

      c

      " + + "
      " + + ""); + var (root, _) = LayoutHarness.Layout(html); + var before = LayoutHarness.FindById(root, "c")!.Boxes.First(b => b.IsBeforePseudoElement); + + Assert.AreEqual("2", before.Text); + } + + [TestMethod] + public void Counter_NotInScope_ReadsAsZero() + { + Assert.AreEqual("0", ResolveBeforeContent("counter(nothing)").Text); + } + // ── Helpers ──────────────────────────────────────────────────────────── private static CssBox ResolveBeforeContent(string contentValue)