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( + "

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