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
(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/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..09ceba7b5 100644
--- a/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs
+++ b/Source/HtmlRenderer/Core/Dom/CssContentEngine.cs
@@ -76,6 +76,34 @@ 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(
+ ""
+ + "");
+ var (root, _) = LayoutHarness.Layout(html);
+ var before = LayoutHarness.FindById(root, "p")!.Boxes.First(b => b.IsBeforePseudoElement);
+
+ 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(
+ ""
+ + "");
+ 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)
+ {
+ 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.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("- x
- 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("- x
- y
- 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(
+ "- x
- y
- 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("");
+ var (root, _) = LayoutHarness.Layout(html);
+ var a = LayoutHarness.FindById(root, "a")!;
+
+ Assert.AreEqual("▪", a.ListItemBox.Words[0].Text);
+ }
+}
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('>'));
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"));
+ }
+}