Skip to content

fix(latex): number the items of enumerate lists - #4518

Open
pandasuwu wants to merge 9 commits into
docling-project:mainfrom
pandasuwu:fix/latex-enumerate
Open

pandasuwu wants to merge 9 commits into
docling-project:mainfrom
pandasuwu:fix/latex-enumerate

Conversation

@pandasuwu

@pandasuwu pandasuwu commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

enumerate lists were converted to unordered lists (#4515). The LaTeX backend adds list items through the generic text path (doc.add_text(label=DocItemLabel.LIST_ITEM)), which creates them with enumerated=False, and nothing in the backend set it for enumerate. Markdown export therefore rendered - bullets, and the repository's ground truth recorded it: example_01.tex.md shows the items "First item in ordered list" and "Second item in ordered list" as bullets.

Input:

\begin{enumerate}
\item First step
\item Second step
\end{enumerate}

Before:

- First step
- Second step

After:

1. First step
2. Second step

Fix. The items of an enumerate are created with enumerated=True; a nested list keeps its own kind.

That alone is not enough, because docling-core numbers an item by its position among the children of its list group, and the backend could add more than one child per \item. Anything that flushes the text buffer started a new sibling in the list:

  • a nested list,
  • a blank line inside an item,
  • display math (\[...\], equation), a quote, or a link (\href), together with the text after it.

With numbering on, each of these would take a number, e.g. 1. First, the formula, 3. continuation, 4. Second for \item First \[x=1\] continuation. The new _process_list_item therefore creates the list item first and adds the whole content of the \item inside it; the leading text then becomes the text of the item itself. Every \item now adds exactly one child to its list, whatever it contains. This is the structure the Markdown and HTML backends already produce for nested lists.

The first paragraph of an item is written into the list item as it is added, whether it comes from the text buffer or is a paragraph between blank lines, and whichever call adds it. So it also becomes the text of the item after a leading blank line or when it is nested in a quote, another environment or a group, and no temporary item is created and deleted. Text added to a list item is split at blank lines, since a blank line inside the item text would end the list in a Markdown export. doc.delete_items() rescans the whole document, and calling it per item made list conversion quadratic; the list code no longer deletes items. backend.convert() on an enumerate of 600 items takes 0.03 s, the same as on main.

An empty \item (no content, or only a \label) used to add no list item, so every later item would take a number one lower than in the typeset document. Every \item now adds a list item, even an empty one. The content before the first \item is processed as before: whitespace, comments and macros such as \setlength add nothing, and stray text becomes a numbered item.

An item that starts with \footnote{...} or \newline keeps its text: a footnote is attached to the text and stays inside the item, and a \newline before any content has nothing to break and is dropped.

A display formula that starts an item, as in \item \[x=1\] First, goes on the line of the item: an inline group that also takes the text up to the next blank line or other content, as the Word backend does for an equation in a paragraph. The Markdown export is 1. $x=1$ First instead of 1. followed by the formula on its own line, which a CommonMark parser read as an empty item.

Tests. test_latex_list_enumerate only asserted len(list_items) >= 2; it now checks the items are enumerated and the Markdown is 1. Alpha\n2. Beta. Nine new tests, all failing on main:

  • test_latex_enumerate_numbering_with_nested_content covers a nested list, text after it, a second paragraph and a nested enumerate, and checks the outer list is numbered 1 to 4.
  • test_latex_enumerate_item_with_block_content covers an item containing display math, an equation, an \href and a quote, and checks the list has two direct items numbered 1 and 2.
  • test_latex_enumerate_item_starting_with_footnote_or_newline checks that an item starting with a footnote or a \newline keeps its text, with the footnote inside the item.
  • test_latex_enumerate_item_starting_with_nested_text covers an item starting with a quote, a quote in a group and a center, and checks the Markdown is 1. First\n2. Second.
  • test_latex_enumerate_item_starting_with_paragraph_break covers an item whose first paragraph follows a blank line, on its own, in a quote and in a group, and checks the item text, its children and the Markdown 1. First\nContinuation\n2. Second.
  • test_latex_enumerate_item_starting_with_formula covers an item starting with \[...\] and with an equation, and checks the line of the item and the Markdown 1. $x=1$ First\n2. Second.
  • test_latex_enumerate_line_of_item_starting_with_formula checks that a nested list ends the line of such an item and a footnote does not.
  • test_latex_enumerate_empty_item_keeps_numbering covers an empty \item and one with only a \label, and checks the item after them is numbered 4.
  • test_latex_enumerate_content_before_first_item covers a comment, \setlength, a \label and stray text before the first \item.

Ground truth. Regenerated with DOCLING_GEN_TEST_DATA=1; only list structure and numbering change:

  • example_01.tex: the ordered list is numbered (1., 2.).
  • 2305.03393 (OTSL): the six syntax rules are numbered 1 to 6. The "Cross cell rule" item has a blank line between its title and its text, which used to produce a separate list item; that text is now inside rule 3, so the rules are not numbered 1 to 7.

pytest tests/test_latex: all other tests pass, and test_e2e_latex_conversions regenerates the committed ground truth unchanged, also when merged with current main. The core test suite has the same results as on main apart from the new tests. prek run on the changed files: passed.

Issue resolved by this Pull Request:
Resolves #4515

Checklist:

  • Documentation has been updated, if necessary.
  • Examples have been added, if necessary.
  • Tests have been added, if necessary.

enumerate lists were converted to unordered lists: the items are added
through the generic text path, which never sets ListItem.enumerated, so
Markdown export rendered "- " bullets instead of "1.", "2.".

Mark the direct items of an enumerate as enumerated. Numbering follows an
item's position in its list, so each \item now yields exactly one list item:
a nested list, and any paragraph after the first one or after a nested list,
is added inside the item instead of beside it, as in the other backends.

Resolves docling-project#4515

Signed-off-by: pandasuwu <suhani.m024@gmail.com>
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

✅ DCO Check Passed

Thanks @pandasuwu, all your commits are properly signed off. 🎉

@mergify

mergify Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 2 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 Require two reviewer for test updates 👀 reviews
🟢 Enforce conventional commit —

🔴 Require two reviewer for test updates

Waiting for

  • #approved-reviews-by >= 2
This rule is failing.

When test data is updated, we require two reviewers

  • #approved-reviews-by >= 2

Show 1 satisfied protection

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\(.+\))?(!)?:

@codecov

codecov Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.89041% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
docling/backend/latex/handlers/environments.py 92.30% 0 Missing and 2 partials ⚠️
docling/backend/latex/utils/text.py 97.36% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please keep each source item as one list item when it contains a display equation or other content that flushes the text buffer. I confirmed the case below on c147215. All 124 LaTeX tests pass, but they do not cover this case.

Comment thread docling/backend/latex/handlers/environments.py Outdated
@PeterStaar-IBM

Copy link
Copy Markdown
Member

@pandasuwu can you please follow up here?

Display math, equation and quote environments, and links flush the text
buffer, so an \item containing one was still split into several siblings
in the list group, each taking a number in an enumerate: "1. First", the
formula, "3. continuation", "4. Second".

Create the list item first and add the whole \item content inside it;
the leading text then becomes the text of the item. Every \item now adds
exactly one child to its list, whatever it contains.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>
@pandasuwu

pandasuwu commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor Author

@PeterStaar-IBM Done, could u please see my reply to @geoHeil's review above

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The earlier block-content case is fixed, and all 128 LaTeX tests pass on 4181905. The new per-item deletion causes a large speed regression; see below.

Comment thread docling/backend/latex/handlers/environments.py Outdated
The previous commit promoted the leading text of an item by adding it as a
child and then deleting it, and dropped empty items the same way.
`doc.delete_items()` rescans the whole document, so list conversion took
quadratic time: an enumerate of 600 items took about 3 s instead of 0.03 s,
and larger documents hit the parse timeout.

`_process_nodes` now takes an optional `text_target`: the first text flushed
while the target is still empty becomes its text, so no temporary item is
created. Whitespace before the first `\item` no longer creates an item, and
the remaining empty items are deleted in one call per list.

The output is unchanged.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>
An empty `\item` (no content, or only a `\label`) added no list item, so
every later item of an enumerate took a number one lower than in the
typeset document: `1. One`, `2. Three`.

Every `\item` now adds a list item, even an empty one. The content before
the first `\item` is processed as before: whitespace, comments and macros
such as `\setlength` add nothing, and stray text becomes a numbered item.
The list code no longer deletes items.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>
@pandasuwu
pandasuwu requested a review from geoHeil October 3, 2026 22:17
…ine break

An item starting with `\footnote{...}` or `\newline` added that content as a
child before any text, so the item got no text of its own: `1. ` followed
by the note or an empty line and then the text.

A footnote is attached to the text, so the leading text still becomes the
text of the item and the footnote stays inside it. A `\newline` before any
content has nothing to break and is dropped.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The speed regression is fixed: 600 items take 0.016 s on this head, versus 0.016 s on the base. All 135 LaTeX tests pass. One Markdown regression remains for an item that starts with a quote; see below.

Comment thread docling/backend/latex/handlers/environments.py Outdated
Only the outer `_process_nodes` call of an item put the leading text into the
list item. A quote, a `center`, another environment or a group processes its
content in its own call, so an item starting with one got no text: it was
exported as `1. ` followed by `First` on its own line, which a CommonMark
parser reads as an empty item and a paragraph that takes in `2. Second`.

The text buffer now fills any list item that has no text yet, and no children
other than footnotes, whichever call flushes it, so the leading text becomes
the text of the item however deeply it is nested. The `text_target` argument
is no longer needed.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The simple quote case is fixed. The block-content and speed fixes also hold. All 138 LaTeX tests pass on 464562c. A leading paragraph break still leaves a text-only item empty; see below.

Ruff, pinned ty 0.0.33 (existing warning policy), Tach, and the lock check pass. The local line check fails on 10 unchanged files. CI fast checks pass; the merge protection still needs two approvals.

Comment thread docling/backend/latex/backend.py Outdated
A paragraph between blank lines is added by `_process_chars_node` without
going through the text buffer, so an item starting with a blank line kept no
text: `\item`, a blank line, `First` exported as `1. ` followed by `First`,
which a CommonMark parser reads as an empty item and a paragraph that takes
in `2. Second`. The same happened inside a leading quote.

Both paths now add text through `_add_text`, which makes the first paragraph
the text of a list item that has none yet, without a document delete. Text
added to a list item is also split at blank lines, so a group such as
`{First\n\nContinuation}` does not put a blank line into the item text.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>
@pandasuwu
pandasuwu requested a review from geoHeil October 5, 2026 17:42

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Re-review of a9f31eb: the paragraph-break case is fixed and all 141 LaTeX tests pass. One P2 remains. See the inline comment.

)
nodes.append(n)

list_item = doc.add_list_item(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve valid Markdown when a block starts an item.

Reproduce with:
\begin{enumerate}
\item [x=1] First
\item Second
\end{enumerate}

On this head, the first ListItem has the formula and First as children, but Markdown is:

1. 
$$x=1$$
First
2. Second

A CommonMark parser reads an empty item, so the item content and numbering are not preserved. Please add a regression test and fix the serializer path, or link the required docling-core fix and keep this PR blocked until the output is valid.

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.

Thanks @geoHeil, fixed in 75db32d. A display formula that starts an item now goes on the line of the item: an inline group, first in the item, that also takes the text up to the next blank line or other content, as the Word backend does for an equation in a paragraph. \item \[x=1\] First now exports as 1. $x=1$ First\n2. Second, which a CommonMark parser reads as one list with two items.

test_latex_enumerate_item_starting_with_formula covers \[...\] and equation, and checks the line of the item and that Markdown. test_latex_enumerate_line_of_item_starting_with_formula checks that a nested list ends that line and a footnote does not. Both fail on a9f31eb.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the update, @pandasuwu. I checked 75db32dd. The reported formula case now exports correctly. All 145 LaTeX tests pass. The speed fix also holds: 600 items take 0.023 s on this head and 0.022 s on the base, in the same environment.

Blocker (P2): this issue still affects other block content at the start of an item. For example:

\begin{enumerate}
\item \begin{theorem}First\end{theorem}
\item Second
\end{enumerate}

The Markdown export is:

1. 
**Theorem.**
First
2. Second

marko reads one empty list item. It puts the theorem text and 2. Second in a paragraph outside the list. Thus, the second item loses its numbering. A check for two parsed list items passes on base a1c5ff2a and fails on this head. A leading verbatim block also leaves an empty item and puts the code outside it.

The document tree and HTML export keep the content inside the first item. The Markdown export must keep that structure too. Please cover these block types and add a test that parses the Markdown and checks both list items. A shared serializer fix may cover these cases. I still recommend changes before merge.

Validation: Python 3.10.19, locked docling-core 2.99.0; pytest -q -p no:tach tests/test_latex passes. Ruff, ty (with the current warning policy), Tach, dprint, and the lock check pass. make check stops at the line limit check on 10 unchanged files; the same check fails on the base. make validate reports no changed files. Hosted CI jobs pass; the merge rule still needs two approvals.

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.

Thanks @geoHeil. The cause is in the docling-core Markdown serializer: it indents only nested lists, so any other block child of a list item starts at column 0 and ends the item. A backend change can only work around this for content that fits on the line of the item, as for a formula. It cannot do that for a theorem, code or a table. So I opened docling-project/docling-core#888, which indents the block children of a list item by the width of the marker.

With that branch, each of these parses with marko (GFM) as one ordered list with two items, with the block inside item 1:

  • \item \begin{theorem}First\end{theorem}
  • \item \begin{verbatim}code\end{verbatim}
  • \item \begin{lstlisting}code\end{lstlisting}
  • \item \begin{tabular}{c}a\\b\end{tabular}
  • \item First \begin{verbatim}code\end{verbatim}

For example, the theorem case now exports as:

1. 
   **Theorem.**
   First
2. Second

cdb2ee1 adds test_latex_enumerate_markdown_of_item_with_block. It parses the Markdown and checks one ordered list starting at 1, the blocks of the first item, and 2. Second. With the locked docling-core 2.99.0, all five cases fail, so the test is xfail(strict=True) and refers to the core PR. This PR stays blocked on that fix. When docling-core is bumped, I will:

  • remove the xfail marker (strict mode makes the bump fail until I do),
  • update the exact Markdown in five list tests, where the continuation line of an item becomes indented (for example 1. First\n Continuation\n2. Second),
  • regenerate 2305.03393_main.tex.md, where the "Cross cell rule" text becomes indented inside rule 3.

…item

An item whose first content is a display formula, such as `\item \[x=1\]
First`, had the formula and the text as block children of an item without
text. The Markdown export showed `1. ` followed by `$$x=1$$` and `First` on
their own lines, which a CommonMark parser reads as an empty item; the next
item then loses its numbering.

Such a formula now opens the line of the item: an inline group, before any
footnotes, that also takes the text up to the next blank line or the next
other content, as the Word backend does for an equation in a paragraph. The
export is `1. $x=1$ First`. Items that start with text are unchanged.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>
A theorem, verbatim, lstlisting or tabular at the start of an enumerate
item is exported as an empty item followed by the block at column 0, so
a CommonMark parser ends the list. The fix is in docling-core
(docling-project/docling-core#888); the test is xfail until it is bumped.

Signed-off-by: pandasuwu <suhani.m024@gmail.com>
@github-actions github-actions Bot added ai:ci-safe AI triage found no CI-safety concern (advisory) bug Something isn't working markdown issue related to markdown backend labels Oct 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

AI triage (advisory)

Checked commit cdb2ee1680. The analysis read the diff only. It did not run code from this pull request.

The PR fixes the LaTeX backend to set enumerated=True on \item nodes inside enumerate environments, and restructures list-item processing so each \item adds exactly one child to its list group regardless of nested content.

Topic labels added: bug, markdown.

CI safety: no concern found. A maintainer must still approve CI.

2 related pull request(s) in the same code
PR State Signal Assessment
#4817 open changes the same code related: PR 4817 also rewrites _process_list in environments.py and modifies math.py and backend.py, but its goal is inline formula rendering and arXiv paper improvements, not the enumerate-numbering bug; the heavy overlap would cause merge conflicts but the two PRs solve different problems.
#4517 open changes the same code related: PR 4517 also modifies text.py but adds accent/letter macro conversion in _macro_node_to_text; PR 4518 adds new list-item helpers in the same file, so they touch different hunks for different features.

Reference data changes

Change Files
structure 1
text 4
  • structure tests/data/latex/groundtruth/2305.03393_main.tex.json: list_item: 12 → 11; text: 37 → 38
  • text tests/data/latex/groundtruth/2305.03393_main.tex.itxt: +1 / -1 lines
  • text tests/data/latex/groundtruth/2305.03393_main.tex.md: +7 / -7 lines
  • text tests/data/latex/groundtruth/example_01.tex.json: item attributes changed
  • text tests/data/latex/groundtruth/example_01.tex.md: +2 / -2 lines

Assessment: expected. The example_01.tex.md and .json changes reflect list items switching from unordered bullets to numbered entries with enumerated=True, and the 2305.03393_main.tex changes reflect the item-count reduction from consolidating nested content inside single list items — both are direct outcomes of the described fix.

The ai:* labels are advisory. They do not approve CI and they do not add tests:full. A new push runs this check again.

@PeterStaar-IBM

Copy link
Copy Markdown
Member

@geoHeil Is this ready to be merged, would like to merge this before mine (#4817)

@geoHeil

geoHeil commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Thanks for the updates. I rechecked cdb2ee16 against a1c5ff2a. The numbering, formula, and speed fixes hold. The LaTeX suite gives 145 passed and 5 expected failures on Python 3.10.19 with locked docling-core 2.99.0.

One existing blocker remains: block content can leave its list item in Markdown. I ran the five cases in test_latex_enumerate_markdown_of_item_with_block with --runxfail; all five fail. They cover theorem, verbatim, lstlisting, tabular, and text followed by verbatim. The document tree keeps the content, but the Markdown parser does not keep the same list structure.

docling-core #888 is still open. Please keep this PR on hold until that fix is released and required here. Then remove the expected-failure marker and update the affected tests and reference output. The backend changes otherwise look good in the cases I checked.

Ruff, ty with the current warning policy, Tach, dprint, and the lock check pass. make check stops at 10 unchanged files over the line limit; the base has the same failures. These results cover the LaTeX suite and targeted reproductions, not the full local test suite.

@geoHeil geoHeil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

see the comment

@pandasuwu

pandasuwu commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor Author

Hello @geoHeil, I saw that docling-project/docling-core#888 will be dropped for docling-project/docling-core#830 casue the latter handles two more inline cases. So this PR now has to wait until #830 is merged and released.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai:ci-safe AI triage found no CI-safety concern (advisory) bug Something isn't working markdown issue related to markdown backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LaTeX backend converts enumerate lists to unordered (bullet) lists

4 participants