From 2f39e89292559bcb15e07e47ea563e1df7cad169 Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Fri, 23 Oct 2026 18:54:33 +0200 Subject: [PATCH 1/2] build: require docling-core 2.104.0 docling-core 2.104.0 escapes CodeItem text in the HTML serializer. Older versions let the Markdown backend's HTML round trip decode code text a second time, so `&` inside code became `&`. The Markdown groundtruth already expects the literal code text, which only 2.104.0 produces, and the lock already resolves 2.104.0. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Christoph Auer --- pyproject.toml | 6 +++--- uv.lock | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8a73a417fe..a9efb8af72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ requires-python = '>=3.10,<4.0' # MINIMAL BASE (8 packages) - ~50MB dependencies = [ 'pydantic>=2.0.0,<3.0.0', - 'docling-core>=2.103.1,<3.0.0', + 'docling-core>=2.104.0,<3.0.0', 'pydantic-settings>=2.3.0,<3.0.0', 'filetype>=1.2.0,<2.0.0', 'requests>=2.32.2,<3.0.0', @@ -288,7 +288,7 @@ models-vlm-inline = [ feat-chunking = [ # >=2.94.1 relaxed its transformers pin; older releases cap transformers<5.9.0 # on macOS, which conflicts with the mlx-vlm floor in models-vlm-inline. - 'docling-core[chunking]>=2.103.1,<3.0.0', + 'docling-core[chunking]>=2.104.0,<3.0.0', ] service-client = [ @@ -340,7 +340,7 @@ typecheck = [ ] pr-fast-checks = [ { include-group = "typecheck" }, - "docling-core[chunking] (>=2.103.1,<3.0.0)", + "docling-core[chunking] (>=2.104.0,<3.0.0)", "pydantic (>=2.0.0,<3.0.0)", "pydantic-settings (>=2.3.0,<3.0.0)", "ruff==0.15.12", diff --git a/uv.lock b/uv.lock index dc898a3e82..07a2c69592 100644 --- a/uv.lock +++ b/uv.lock @@ -2539,8 +2539,8 @@ requires-dist = [ { name = "defusedxml", marker = "extra == 'format-iwork'", specifier = ">=0.7.1,<0.8.0" }, { name = "defusedxml", marker = "extra == 'format-xml-uspto'", specifier = ">=0.7.1,<0.8.0" }, { name = "defusedxml", marker = "extra == 'models-local'", specifier = ">=0.7.1,<0.8.0" }, - { name = "docling-core", specifier = ">=2.103.1,<3.0.0" }, - { name = "docling-core", extras = ["chunking"], marker = "extra == 'feat-chunking'", specifier = ">=2.103.1,<3.0.0" }, + { name = "docling-core", specifier = ">=2.104.0,<3.0.0" }, + { name = "docling-core", extras = ["chunking"], marker = "extra == 'feat-chunking'", specifier = ">=2.104.0,<3.0.0" }, { name = "docling-ibm-models", marker = "extra == 'models-local'", specifier = ">=4.0.2,<5" }, { name = "docling-parse", marker = "extra == 'format-pdf-docling'", specifier = ">=7.22.2,<8.0.0" }, { name = "docling-slim", extras = ["convert-core"], marker = "extra == 'extract-core'" }, @@ -2682,7 +2682,7 @@ examples = [ ] pr-fast-checks = [ { name = "boto3-stubs", specifier = "~=1.37" }, - { name = "docling-core", extras = ["chunking"], specifier = ">=2.103.1,<3.0.0" }, + { name = "docling-core", extras = ["chunking"], specifier = ">=2.104.0,<3.0.0" }, { name = "pandas-stubs", specifier = "~=2.1" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.3.0,<3.0.0" }, From a561ec188c515f3ff4ce39d8365d3323e43d546e Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Fri, 23 Oct 2026 18:54:39 +0200 Subject: [PATCH 2/2] fix(md): splice raw HTML blocks back instead of decoding them The Markdown backend puts raw HTML blocks into CodeItem text, serializes the document to HTML, and strips the
 wrapper to splice the
HTML back. Since docling-core 2.104.0 escapes code text, convert()
unescapes each block once. That assumes the serializer escaped it exactly
once: with docling-core 2.103.1, which the floor allowed, entities in the
block were decoded twice (`&` became `&`).

Store the raw blocks on the backend and put only an index between the
markers. convert() replaces each marked 
 block with the stored
HTML, so the result no longer depends on how the serializer treats code
text.

Co-Authored-By: Claude Opus 5.5 
Signed-off-by: Christoph Auer 
---
 docling/backend/md_backend.py  | 31 +++++++++++++++----------------
 tests/test_backend_markdown.py | 15 +++++++++++++++
 2 files changed, 30 insertions(+), 16 deletions(-)

diff --git a/docling/backend/md_backend.py b/docling/backend/md_backend.py
index b7c2f03961..4e608bb16e 100644
--- a/docling/backend/md_backend.py
+++ b/docling/backend/md_backend.py
@@ -268,7 +268,7 @@ def __init__(
 
         self._pending_hard_line_break = False
         self._pending_soft_line_break = False
-        self._html_blocks: int = 0
+        self._html_blocks: list[str] = []
         self._image_loader: ImageResourceLoader | None = None
 
         # A leading BOM is dropped. Kept, it prefixes the first line, so a
@@ -998,15 +998,17 @@ def _iterate_elements(  # noqa: C901
                 self._pending_hard_line_break = True
 
         elif isinstance(element, marko.block.HTMLBlock):
-            self._html_blocks += 1
             _log.debug("HTML Block: %s", element)
             if (
                 len(element.body) > 0
             ):  # If Marko doesn't return any content for HTML block, skip it
-                html_block = element.body.strip()
+                self._html_blocks.append(element.body.strip())
 
-                # wrap in markers to enable post-processing in convert()
-                text_to_add = f"{_START_MARKER}{html_block}{_STOP_MARKER}"
+                # Keep only an index in the code text: the HTML serializer may
+                # escape code text, so convert() splices the raw block back.
+                text_to_add = (
+                    f"{_START_MARKER}{len(self._html_blocks) - 1}{_STOP_MARKER}"
+                )
                 doc.add_code(
                     parent=parent_item,
                     text=text_to_add,
@@ -1182,30 +1184,27 @@ def convert(self) -> DoclingDocument:
                 list_last_item_by_ref={},
             )
 
-            if self._html_blocks > 0:
+            if self._html_blocks:
                 html_backend_cls = MarkdownHTMLDocumentBackend
                 html_payload = serialize_markdown_for_html(
                     doc, source_hash=self.document_hash
                 )
-                html_str = html_payload.text
-
-                # Restore only the marked HTML blocks. The HTML serializer
-                # escapes code content, so decode each block before parsing it.
+                html_blocks = self._html_blocks
+                # restore the raw HTML blocks in place of their marked code blocks
                 pattern = (
                     rf"
\s*\s*{re.escape(_START_MARKER)}"
-                    rf"(.*?){re.escape(_STOP_MARKER)}\s*\s*
" + rf"(\d+){re.escape(_STOP_MARKER)}\s*
\s*
" ) html_str, count = re.subn( pattern, - lambda match: unescape(match.group(1)), - html_str, - flags=re.DOTALL, + lambda match: html_blocks[int(match.group(1))], + html_payload.text, ) - if count != self._html_blocks: + if count != len(html_blocks): raise RuntimeError( "An internal error has occurred during Markdown conversion." ) - self._html_blocks = 0 + self._html_blocks = [] # delegate to HTML backend stream = BytesIO(bytes(html_str, encoding="utf-8")) md_options = cast(MarkdownBackendOptions, self.options) diff --git a/tests/test_backend_markdown.py b/tests/test_backend_markdown.py index f766e58833..d1f975b8de 100644 --- a/tests/test_backend_markdown.py +++ b/tests/test_backend_markdown.py @@ -1067,6 +1067,21 @@ def test_ordered_list_numbers_survive_html_block(): ) +def test_html_block_entities_are_decoded_exactly_once(): + """Raw HTML must reach the HTML backend unchanged, whether or not the + docling-core HTML serializer escapes code text. + + Double escaping keeps `&lt;` in the text; double unescaping turns + `<b>` into ``. + """ + markdown = "Intro.\n\n
Write &lt;b&gt; for bold & more
\n" + conv_result = get_converter().convert_string(markdown, format=InputFormat.MD) + assert conv_result.status == ConversionStatus.SUCCESS + + texts = [item.text for item in conv_result.document.texts] + assert texts == ["Intro.", "Write <b> for bold & more"] + + def test_standard_ordered_list_still_starts_at_one(): """Ordinary 1-based ordered lists must continue to export as 1-based.""" markdown = "1. alpha\n2. beta\n3. gamma\n"