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
37 changes: 23 additions & 14 deletions src/Html2OpenXml/Expressions/Numbering/ListExpression.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,9 @@ private static string GetListName(IElement listNode, string? parentName = null)
bool orderedList = listNode.NodeName.Equals("ol", StringComparison.OrdinalIgnoreCase);
string? type = styleAttributes["list-style-type"].ToString();

if(orderedList && string.IsNullOrEmpty(type))
{
type = ListTypeToListStyleType(listNode.GetAttribute("type"));
}
// parse node "type" attribute, if inline style "list-style-type" attribute is null.
if (string.IsNullOrEmpty(type))
type = ListTypeToListStyleType(listNode.GetAttribute("type"), orderedList);

if (string.IsNullOrEmpty(type) || !supportedListTypes.Contains(type!))
{
Expand All @@ -233,18 +232,28 @@ private static string GetListName(IElement listNode, string? parentName = null)

/// <summary>
/// Map ordered list style attribute values to css list-style-type.
/// Valid types are "1|a|A|i|I": https://w3schools.com/tags/att_ol_type.asp
/// Ordered lists types are "1|a|A|i|I|α" (https://w3schools.com/tags/att_ol_type.asp).
/// Unordered lists types are "disc|circle|square" (https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/ul#type).
/// </summary>
private static string? ListTypeToListStyleType(string? type) => type switch
private static string? ListTypeToListStyleType(string? type, bool orderedList)
{
"1" => "decimal",
"a" => "lower-alpha",
"A" => "upper-alpha",
"i" => "lower-roman",
"I" => "upper-roman",
"lower-greek" or "upper-greek" => type,
_ => null
};
return (orderedList, type) switch
{
// un-ordered list marker style resolution
(false, string t) when t.Equals("disc", StringComparison.OrdinalIgnoreCase) => "disc",
(false, string t) when t.Equals("circle", StringComparison.OrdinalIgnoreCase) => "circle",
(false, string t) when t.Equals("square", StringComparison.OrdinalIgnoreCase) => "square",

// ordered list marker style resolution
(true, "1") => "decimal",
(true, "a") => "lower-alpha",
(true, "A") => "upper-alpha",
(true, "i") => "lower-roman",
(true, "I") => "upper-roman",

_ => null
};
}

/// <summary>
/// Resolve the <see cref="ParagraphStyleId"/> of a list element node,
Expand Down
56 changes: 56 additions & 0 deletions test/HtmlToOpenXml.Tests/NumberingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -731,5 +731,61 @@ await converter.ParseBody(@"<ul style='list-style-type:""😀""'>

AssertThatOpenXmlDocumentIsValid();
}

/// <summary>
/// A nested ul type attribute must use its own bullet, not the parent marker
/// </summary>
/// <param name="listType"></param>
/// <param name="nestedListType"></param>
/// <returns></returns>
[TestCase("disc", "square", ExpectedResult = "•,▪")]
[TestCase("disc", "circle", ExpectedResult = "•,\u25CB")]
public async Task<string> NestedUnorderedList_TypeAttribute_UsesOwnBullet(string listType, string nestedListType)
{
await converter.ParseBody($@"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify the HTML to the minimum. The goal is not to test consecutive list. So please remove the 2nd <ul>
The [Test] is no longer applicable (as you now accept params) so convert it to /// <summary>
and rename Ingredients to List Item1 and so on.
Thanks!

@AyUsH18102001 AyUsH18102001 Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

<ul type='{listType}'>
<li>Ingredients
<ul type='{nestedListType}'>
<li>Rice</li>
</ul>
</li>
</ul>");

var paragraphs = mainPart.Document!.Body!.Elements<Paragraph>().ToList();
Assert.That(paragraphs, Has.Count.EqualTo(2));
AssertThatOpenXmlDocumentIsValid();

return string.Join(",", paragraphs.Select(GetBulletText).Select(BulletTextUnicode));
}

[Test(Description = "CSS list-style-type takes precedence over the ul type attribute")]
public async Task UnorderedList_CssListStyleType_OverridesTypeAttribute()
{
await converter.ParseBody(@"<ul type='disc' style='list-style-type: circle'><li>Item</li></ul>");

var paragraph = mainPart.Document!.Body!.Elements<Paragraph>().Single();
Assert.That(GetBulletText(paragraph), Is.EqualTo("o"));
AssertThatOpenXmlDocumentIsValid();
}

private string? GetBulletText(Paragraph paragraph)
{
var numberingProperties = paragraph.ParagraphProperties?.NumberingProperties;
var numId = numberingProperties?.NumberingId?.Val?.Value;
var level = numberingProperties?.NumberingLevelReference?.Val?.Value;
var numbering = mainPart.NumberingDefinitionsPart!.Numbering!;
var instance = numbering.Elements<NumberingInstance>().Single(i => i.NumberID?.Value == numId);
var abstractNum = numbering.Elements<AbstractNum>().Single(a => a.AbstractNumberId?.Value == instance.AbstractNumId!.Val!.Value);
return abstractNum.Elements<Level>().First(l => l.LevelIndex?.Value == level).LevelText?.Val?.Value;
}

private string? BulletTextUnicode(string? bulletText)
{
return bulletText switch
{
"o" => "\u25CB", // ○ circle
_ => bulletText
};
}
}
}
Loading