Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Source/HtmlRenderer/Core/Dom/CssBox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,15 @@ private int GetIndexForList()

foreach (CssBox b in ParentBox.Boxes)
{
// An explicit <li value="N"> (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;

Expand Down Expand Up @@ -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))
{
Expand Down
23 changes: 23 additions & 0 deletions Source/HtmlRenderer/Core/Dom/CssBoxProperties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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; }
}

/// <summary>
/// This box's resolved named-counter values (CSS 2.1 §12.4), as of just after its own
/// <c>counter-reset</c>/<c>counter-increment</c> have been applied - populated once, by
/// <see cref="CssCounterEngine.ResolveCounters"/>, before layout runs. Consulted by
/// <c>content: counter()</c> (<see cref="CssContentEngine"/>).
/// </summary>
internal System.Collections.Generic.Dictionary<string, int> Counters { get; set; }
= new System.Collections.Generic.Dictionary<string, int>();

public string Bottom
{
get { return _bottom; }
Expand Down
28 changes: 28 additions & 0 deletions Source/HtmlRenderer/Core/Dom/CssContentEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(<name> [, <style>]?) - a bare counter() with no name argument (malformed,
// or the counters()-with-separator form this subset doesn't support) contributes no
// text, matching this engine's graceful-ignore-the-unsupported-part behavior.
// ArgumentTokens.ToList() (ValueExtensions' comma-splitting overload, not System.Linq's)
// groups the raw token stream into one List<Token> per comma-separated argument.
var counterArgs = counterFunctionToken.ArgumentTokens.ToList();
if (counterArgs.Count > 0 && counterArgs[0].FirstOrDefault() is KeywordToken counterNameToken)
{
var style = counterArgs.Count > 1 && counterArgs[1].FirstOrDefault() is KeywordToken styleToken
? styleToken.Data
: CssConstants.Decimal;

// Unlike attr() above, no parent fallback is needed here: CssCounterEngine.
// 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 = 0;
}
sb.Append(CssCounterEngine.FormatCounterValue(counterValue, style));
}
}
else if (token is KeywordToken keywordToken)
{
if (keywordToken.Data.Equals(Keywords.OpenQuote, StringComparison.OrdinalIgnoreCase))
Expand Down
204 changes: 204 additions & 0 deletions Source/HtmlRenderer/Core/Dom/CssCounterEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// "Therefore those skilled at the unorthodox
// are infinite as heaven and earth,
// inexhaustible as the great rivers.
// When they come to an end,
// they begin again,
// like the days and months;
// they die and are reborn,
// like the four seasons."
//
// - Sun Tsu,
// "The Art of War"

using System;
using System.Collections.Generic;
using System.Globalization;
using TheArtOfDev.HtmlRenderer.Core.Utils;

namespace TheArtOfDev.HtmlRenderer.Core.Dom
{
/// <summary>
/// Resolves CSS 2.1 §12.4 named counters (<c>counter-reset</c>/<c>counter-increment</c>) into a
/// per-box snapshot of counter values, consulted by <c>content: counter()</c>
/// (<see cref="CssContentEngine"/>).<br/>
/// <c>reversed(&lt;name&gt;)</c> and <c>counter-set</c> are not implemented.
/// </summary>
internal static class CssCounterEngine
{
/// <summary>
/// Walks <paramref name="root"/> and its descendants in document order, applying each box's own
/// <c>counter-reset</c>/<c>counter-increment</c> (plus the implicit <c>list-item</c> counter a
/// <c>display:list-item</c> box always increments) and storing the values in scope on
/// <see cref="CssBoxProperties.Counters"/>. Must run once, before layout, after every box's CSS
/// properties are resolved.
/// </summary>
public static void ResolveCounters(CssBox root)
{
ResolveSiblings(new[] { root }, new Dictionary<string, List<int>>());
}

/// <summary>
/// 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 <c>display:none</c> don't touch counters (§12.4.3).
/// </summary>
private static void ResolveSiblings(IEnumerable<CssBox> boxes, Dictionary<string, List<int>> counters)
{
var created = new HashSet<string>();

foreach (var box in boxes)
{
if (box.Display == CssConstants.None)
continue;

ApplyCounterReset(box, counters, created);
ApplyCounterIncrement(box, counters, created);

var snapshot = new Dictionary<string, int>(counters.Count);
foreach (var pair in counters)
snapshot[pair.Key] = pair.Value[pair.Value.Count - 1];
box.Counters = snapshot;

ResolveSiblings(box.Boxes, 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<string, List<int>> counters, HashSet<string> created)
{
if (string.IsNullOrEmpty(box.CounterReset) || box.CounterReset == CssConstants.None) return;

ForEachNameValuePair(box.CounterReset, defaultValue: 0, (name, value) => Reset(counters, created, name, value));
}

private static void ApplyCounterIncrement(CssBox box, Dictionary<string, List<int>> counters, HashSet<string> created)
{
if (box.Display == CssConstants.ListItem && !CounterNameAppears(box.CounterIncrement, CssConstants.ListItem))
{
Increment(counters, created, CssConstants.ListItem, 1);
}

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

ForEachNameValuePair(box.CounterIncrement, defaultValue: 1, (name, value) => Increment(counters, created, name, value));
}

private static void Reset(Dictionary<string, List<int>> counters, HashSet<string> created, string name, int value)
{
// 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<int>();
instances.Add(value);
}
else
{
var instances = counters[name];
instances[instances.Count - 1] = value;
}
}

private static void Increment(Dictionary<string, List<int>> counters, HashSet<string> 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)
{
if (string.IsNullOrEmpty(counterPropertyValue) || counterPropertyValue == CssConstants.None) return false;

var found = false;
ForEachNameValuePair(counterPropertyValue, 0, (name, _) => found |= name == counterName);
return found;
}

/// <summary>
/// Parses a <c>counter-reset</c>/<c>counter-increment</c> value - a space-separated sequence of
/// <c>&lt;name&gt; [&lt;integer&gt;]?</c> pairs - invoking <paramref name="apply"/> once per name,
/// with <paramref name="defaultValue"/> when no integer follows a given name.
/// </summary>
private static void ForEachNameValuePair(string value, int defaultValue, Action<string, int> apply)
{
var parts = value.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

for (var i = 0; i < parts.Length; i++)
{
var name = parts[i];
var next = i + 1 < parts.Length ? parts[i + 1] : null;

if (next != null && int.TryParse(next, NumberStyles.Integer, CultureInfo.InvariantCulture, out var explicitValue))
{
apply(name, explicitValue);
i++;
}
else
{
apply(name, defaultValue);
}
}
}

/// <summary>
/// Formats a resolved counter value as a string using the given CSS counter style (<c>decimal</c>,
/// <c>decimal-leading-zero</c>, <c>lower-roman</c>, <c>upper-alpha</c>, etc.) - the single
/// counter-style resolver <c>content: counter()</c> uses. Per
/// <see href="https://www.w3.org/TR/css-counter-styles-3/">CSS Counter Styles Level 3 §2</see>, an
/// unknown/invalid style, or a value an alphabetic/symbolic style cannot represent (0 or negative),
/// falls back to <c>decimal</c> rather than rendering nothing.
/// </summary>
public static string FormatCounterValue(int number, string style)
{
if (string.IsNullOrEmpty(style) || style.Equals(CssConstants.Decimal, StringComparison.OrdinalIgnoreCase))
{
return number.ToString(CultureInfo.InvariantCulture);
}

if (style.Equals(CssConstants.DecimalLeadingZero, StringComparison.OrdinalIgnoreCase))
{
return number.ToString("00", CultureInfo.InvariantCulture);
}

if (IsAlphabeticCounterStyle(style))
{
var formatted = CommonUtils.ConvertToAlphaNumber(number, style);

// Alphabetic/symbolic styles have no representation for values outside their range (e.g.
// 0 or negatives, for which ConvertToAlphaNumber yields the empty string) - such values
// fall back to decimal too, per CSS Counter Styles Level 3 §2.
return formatted.Length > 0 ? formatted : number.ToString(CultureInfo.InvariantCulture);
}

// Any other unknown/invalid style falls back to decimal.
return number.ToString(CultureInfo.InvariantCulture);
}

private static bool IsAlphabeticCounterStyle(string style)
{
return style.Equals(CssConstants.LowerRoman, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.UpperRoman, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.LowerAlpha, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.UpperAlpha, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.LowerLatin, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.UpperLatin, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.LowerGreek, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.Armenian, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.Georgian, StringComparison.OrdinalIgnoreCase)
|| style.Equals(CssConstants.Hebrew, StringComparison.OrdinalIgnoreCase);
}
}
}
2 changes: 2 additions & 0 deletions Source/HtmlRenderer/Core/Parse/DomParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ public DomParser(CssParser cssParser)

SetTextSelectionStyle(htmlContainer, cssData);

CssCounterEngine.ResolveCounters(root);

CorrectTextBoxes(root);

CorrectImgBoxes(root);
Expand Down
13 changes: 12 additions & 1 deletion Source/HtmlRenderer/Core/Utils/CssUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ internal static class CssUtils
"padding-bottom", "padding-left", "padding-right", "padding-top",
"page-break-inside", "break-inside", "break-before", "break-after", "page-break-before", "page-break-after",
"widows", "orphans", "page",
"left", "top", "right", "bottom", "width", "max-width", "height", "min-height", "max-height",
"left", "top", "right", "bottom", "counter-reset", "counter-increment",
"width", "max-width", "height", "min-height", "max-height",
"background-color", "background-image", "background-position", "background-repeat",
"content", "color", "display", "direction", "empty-cells", "float", "clear", "box-sizing", "position",
"line-height", "vertical-align", "text-indent", "text-align", "text-decoration-line",
Expand Down Expand Up @@ -175,6 +176,10 @@ public static string GetPropertyValue(CssBox cssBox, string propName)
return cssBox.Right;
case "bottom":
return cssBox.Bottom;
case "counter-reset":
return cssBox.CounterReset;
case "counter-increment":
return cssBox.CounterIncrement;
case "width":
return cssBox.Width;
case "max-width":
Expand Down Expand Up @@ -386,6 +391,12 @@ public static void SetPropertyValue(CssBox cssBox, string propName, string value
case "bottom":
cssBox.Bottom = value;
break;
case "counter-reset":
cssBox.CounterReset = value;
break;
case "counter-increment":
cssBox.CounterIncrement = value;
break;
case "width":
cssBox.Width = value;
break;
Expand Down
Binary file modified Source/Test/HtmlRenderer.IntegrationTest/Baselines/Bullets.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading