From b441869591d8f5ccf24b96ad53d654327a1e5538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20D=C3=A6hli=20Aslesen?= Date: Mon, 19 Oct 2026 11:10:10 +0200 Subject: [PATCH] fix(html): number ordered list items by their type, reversed and value attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML backend numbered the items of an `
    ` only from its `start` attribute. A list with `type="a"`, `"A"`, `"i"` or `"I"`, or with the same kind of marker set by a `list-style-type` keyword in its inline style, got decimal numbers. A `reversed` list counted up, and the `value` attribute of an `
  1. ` had no effect. Legal texts refer to list items by their letter ("bokstav a"), so the letters were lost. The marker of each item now follows the ordinal values of the HTML specification and the counter styles that browsers render. A list without these attributes is unchanged. Signed-off-by: Morten Dæhli Aslesen --- docling/backend/html_backend.py | 121 ++++++++++++++++++++++++++++++-- tests/test_backend_html.py | 47 +++++++++++++ 2 files changed, 161 insertions(+), 7 deletions(-) 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 + ('
        1. x
        2. y
        ', ["a.", "b."]), + ('
        1. x
        2. y
        ', ["Z.", "AA."]), + ('
        1. x
        2. y
        ', ["iii.", "iv."]), + ( + '
        1. x
        2. y
        ', + ["MMMCMXCIX.", "4000."], + ), + # A `list-style-type` keyword in the inline style wins over the `type` + ('
        1. x
        ', ["a."]), + ( + '
        1. x
        ', + ["I."], + ), + # A reversed list counts down, by default from its number of items + ("
        1. x
        2. y
        3. z
        ", ["3.", "2.", "1."]), + ('
        1. x
        2. y
        ', ["j.", "i."]), + # The `value` attribute sets the number of an item and of the items after it + ('
        1. x
        2. y
        3. z
        ', ["1.", "5.", "6."]), + ( + '
        1. x
        2. y
        3. z
        ', + ["3.", "10.", "9."], + ), + # The numbering continues after content that interrupts the list + ('
        1. x
        2. between

        3. y
        ', ["a.", "b."]), + # An unknown type is ignored, as in browsers + ('
        1. x
        2. 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.