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/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/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" 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" },