diff --git a/docling/backend/html_backend.py b/docling/backend/html_backend.py
index da645055bc..5d0eb5da1a 100644
--- a/docling/backend/html_backend.py
+++ b/docling/backend/html_backend.py
@@ -276,6 +276,66 @@ def _parse_ol_start(value: str) -> Optional[int]:
return start if start <= _OL_START_MAX else None
+# The marker kinds of the `type` attribute of an `
` (case-sensitive).
+_OL_TYPES: Final = frozenset({"1", "a", "A", "i", "I"})
+# The CSS `list-style-type` keywords with the `type` of the same marker kind.
+_LIST_STYLE_TYPES: Final = {
+ "decimal": "1",
+ "lower-alpha": "a",
+ "lower-latin": "a",
+ "upper-alpha": "A",
+ "upper-latin": "A",
+ "lower-roman": "i",
+ "upper-roman": "I",
+}
+_LIST_STYLE_KEYWORD_RE: Final = re.compile(
+ r"list-style(?:-type)?\s*:\s*(?P[^;]*)", re.IGNORECASE
+)
+_ROMAN_NUMERALS: Final = (
+ (1000, "m"),
+ (900, "cm"),
+ (500, "d"),
+ (400, "cd"),
+ (100, "c"),
+ (90, "xc"),
+ (50, "l"),
+ (40, "xl"),
+ (10, "x"),
+ (9, "ix"),
+ (5, "v"),
+ (4, "iv"),
+ (1, "i"),
+)
+
+
+def _format_ol_marker(ordinal: int, list_type: str) -> str:
+ """Return the marker of an ordered list item, as a browser renders it.
+
+ Letters count a, ..., z, aa, ab, ... and Roman numerals cover 1 to 3999, as the
+ CSS `lower-alpha` and `lower-roman` counter styles. Outside that range, the
+ marker is a decimal number.
+
+ Args:
+ ordinal: The ordinal value of the list item.
+ list_type: The `type` attribute of the list, one of `_OL_TYPES`.
+
+ Returns:
+ The marker, e.g. `"c."` for the ordinal 3 and the type `"a"`.
+ """
+ label = str(ordinal)
+ if list_type in ("a", "A") and ordinal >= 1:
+ label = ""
+ while ordinal:
+ ordinal, rest = divmod(ordinal - 1, 26)
+ label = chr(ord("a") + rest) + label
+ elif list_type in ("i", "I") and 1 <= ordinal <= 3999:
+ label = ""
+ for value, numeral in _ROMAN_NUMERALS:
+ count, ordinal = divmod(ordinal, value)
+ label += numeral * count
+ return f"{label.upper() if list_type.isupper() else label}."
+
+
_CUSTOM_CHECKBOX_CLASSES: Final = {"checkbox", "checkbox-box", "checkbox-input"}
_CHECKBOX_MARK_TEXTS: Final = {"x", "✓", "✔", "☑"}
_CHECKBOX_CONTAINER_CLASSES: Final = {
@@ -3069,6 +3129,8 @@ def _handle_list( # noqa: C901
added_refs: list[RefItem] = []
tag_name = tag.name.lower()
start: Optional[int] = None
+ list_type = "1"
+ step = 1
name: str = ""
is_ordered = tag_name == "ol"
is_description = tag_name == "dl"
@@ -3079,6 +3141,17 @@ def _handle_list( # noqa: C901
start_attr = tag.get("start")
if isinstance(start_attr, str):
start = _parse_ol_start(start_attr)
+ list_type = self._get_ol_type(tag)
+ if tag.has_attr("reversed"):
+ step = -1
+ items = tag.find_all("li", recursive=False)
+ if start is None and (
+ list_type != "1"
+ or step == -1
+ or any(li.has_attr("value") for li in items)
+ ):
+ # A reversed list counts down from its number of items
+ start = len(items) if step == -1 else 1
name = "ordered list" + (f" start {start}" if start is not None else "")
else:
name = "list"
@@ -3120,6 +3193,8 @@ def open_list_group(group_start: Optional[int]) -> ListGroup:
# Track the number of list items added (not all children)
list_item_counter: int = 0
+ # The ordinal value of the next list item, if the list has numbers
+ ordinal = start
# Handle description lists ( with - and
- )
if is_description:
@@ -3222,17 +3297,25 @@ def open_list_group(group_start: Optional[int]) -> ListGroup:
else:
if list_group is None:
- if is_ordered and start is None:
- start = 1
- list_group = open_list_group(
- start + list_item_counter if start is not None else None
- )
+ if is_ordered and ordinal is None:
+ ordinal = list_item_counter + 1
+ list_group = open_list_group(ordinal)
+
+ # The `value` attribute of an item sets its ordinal value, and
+ # the items after it continue from there
+ value_attr = li.get("value")
+ if (
+ ordinal is not None
+ and isinstance(value_attr, str)
+ and (value := _parse_ol_start(value_attr)) is not None
+ ):
+ ordinal = value
# 1) determine the marker: an explicit marker set on the item
# wins, otherwise use the counter
marker: str = self._get_list_style_marker(li) or (
- f"{start + list_item_counter}."
- if is_ordered and start is not None
+ _format_ol_marker(ordinal, list_type)
+ if is_ordered and ordinal is not None
else ""
)
@@ -3288,6 +3371,8 @@ def open_list_group(group_start: Optional[int]) -> ListGroup:
# Increment counter only when a list item is actually added
if list_item:
list_item_counter += 1
+ if ordinal is not None:
+ ordinal += step
prev_item = list_item
if list_item or inputs_in_li or custom_checkboxes_in_li:
@@ -3325,6 +3410,28 @@ def open_list_group(group_start: Optional[int]) -> ListGroup:
self.level -= 1
return added_refs
+ @staticmethod
+ def _get_ol_type(tag: Tag) -> str:
+ """Return the kind of marker of the items of an `
`, one of `_OL_TYPES`.
+
+ A `list-style-type` keyword in the inline style wins over the `type`
+ attribute, as in browsers. Other keywords and stylesheets are ignored.
+ """
+ style = tag.get("style")
+ matches = (
+ list(_LIST_STYLE_KEYWORD_RE.finditer(style))
+ if isinstance(style, str)
+ else []
+ )
+ if matches:
+ for keyword in matches[-1].group("value").lower().split():
+ if keyword in _LIST_STYLE_TYPES:
+ return _LIST_STYLE_TYPES[keyword]
+ type_attr = tag.get("type")
+ return (
+ type_attr if isinstance(type_attr, str) and type_attr in _OL_TYPES else "1"
+ )
+
@staticmethod
def _get_list_style_marker(li: Tag) -> str:
"""Return the marker that an inline string `list-style-type` sets on `li`.
diff --git a/tests/test_backend_html.py b/tests/test_backend_html.py
index a1818b2cb5..ee0a3852de 100644
--- a/tests/test_backend_html.py
+++ b/tests/test_backend_html.py
@@ -797,6 +797,53 @@ def test_ordered_list_item_marker_from_list_style_string(items: str, expected: s
assert doc.export_to_markdown() == expected
+@pytest.mark.parametrize(
+ ("items", "expected"),
+ [
+ # The `type` attribute sets the kind of marker
+ ('- x
- y
', ["a.", "b."]),
+ ('- x
- y
', ["Z.", "AA."]),
+ ('- x
- y
', ["iii.", "iv."]),
+ (
+ '- x
- y
',
+ ["MMMCMXCIX.", "4000."],
+ ),
+ # A `list-style-type` keyword in the inline style wins over the `type`
+ ('- x
', ["a."]),
+ (
+ '- x
',
+ ["I."],
+ ),
+ # A reversed list counts down, by default from its number of items
+ ("- x
- y
- z
", ["3.", "2.", "1."]),
+ ('- x
- y
', ["j.", "i."]),
+ # The `value` attribute sets the number of an item and of the items after it
+ ('- x
- y
- z
', ["1.", "5.", "6."]),
+ (
+ '- x
- y
- z
',
+ ["3.", "10.", "9."],
+ ),
+ # The numbering continues after content that interrupts the list
+ ('- x
between
- y
', ["a.", "b."]),
+ # An unknown type is ignored, as in browsers
+ ('- x
- y
', ["", ""]),
+ ],
+)
+def test_ordered_list_item_marker_from_attributes(items: str, expected: list[str]):
+ html = f"{items}".encode()
+ in_doc = InputDocument(
+ path_or_stream=BytesIO(html),
+ format=InputFormat.HTML,
+ backend=HTMLDocumentBackend,
+ filename="test",
+ )
+ doc = in_doc._backend.convert()
+ markers = [
+ item.marker for item in doc.texts if item.label == DocItemLabel.LIST_ITEM
+ ]
+ assert markers == expected
+
+
def test_list_item_markers_survive_html_export_round_trip():
# Docling's HTML export writes the original list item markers as a CSS
# string list-style-type. Converting that HTML again must keep them.