From c341f411b72cf25ab458f7135c467b2f6686e5cb Mon Sep 17 00:00:00 2001 From: Avanish Garg Date: Fri, 9 Oct 2026 14:56:46 +0530 Subject: [PATCH 1/2] fix(md): keep Markdown content intact around HTML blocks The Markdown backend exported the whole document to HTML and parsed it again as soon as the file had one HTML block, even a comment. The export does not escape code text, so ``, `Dict` or `` were read as tags and dropped, newlines in code collapsed, adjacent paragraphs merged and pictures were lost. Convert each HTML block on its own with the HTML backend and append the result at the position of the block. Markdown content is no longer exported and parsed again. The document origin is now text/markdown. Signed-off-by: Avanish Garg --- docling/backend/html_backend.py | 7 +- docling/backend/md_backend.py | 108 ++++-------- .../md/groundtruth/escaped_characters.md.json | 118 ++++++------- .../md/groundtruth/mixed_without_h1.md.yaml | 82 ++++++--- .../md/groundtruth/signature_stamp_01.md.json | 4 +- tests/test_backend_markdown.py | 5 +- tests/test_backend_markdown_html_blocks.py | 158 ++++++++++++++++++ 7 files changed, 312 insertions(+), 170 deletions(-) create mode 100644 tests/test_backend_markdown_html_blocks.py diff --git a/docling/backend/html_backend.py b/docling/backend/html_backend.py index 9794a8718f..132c4af35e 100644 --- a/docling/backend/html_backend.py +++ b/docling/backend/html_backend.py @@ -928,7 +928,11 @@ def convert(self) -> DoclingDocument: binary_hash=self.document_hash, ) doc = DoclingDocument(name=self.file.stem or "file", origin=origin) + self.convert_into(doc) + return doc + def convert_into(self, doc: DoclingDocument) -> None: + """Append the converted content to the body of `doc`.""" if cast(HTMLBackendOptions, self.options).render_page: self._render_with_browser() if self._rendered_html: @@ -1011,7 +1015,6 @@ def convert(self) -> DoclingDocument: self.ctx = _Context() self._render_visibility_cache.clear() self._walk(content, doc) - return doc @staticmethod def _is_header_in_cell_or_item(header: Tag) -> bool: @@ -3333,7 +3336,7 @@ def _get_list_style_marker(li: Tag) -> str: Docling's HTML export writes the original marker of a list item this way, so reading it back keeps markers such as `3.` when the HTML is converted - again (the Markdown backend does this for files with HTML blocks). + again. """ style = li.get("style") if not isinstance(style, str): diff --git a/docling/backend/md_backend.py b/docling/backend/md_backend.py index b7c2f03961..bcefeeb0bf 100644 --- a/docling/backend/md_backend.py +++ b/docling/backend/md_backend.py @@ -37,11 +37,8 @@ from docling.backend.abstract_backend import ( DeclarativeDocumentBackend, ) +from docling.backend.html_backend import HTMLDocumentBackend from docling.backend.utils.image_resource_loader import ImageResourceLoader -from docling.backend.utils.markdown_html import ( - MarkdownHTMLDocumentBackend, - serialize_markdown_for_html, -) from docling.datamodel.backend_options import ( HTMLBackendOptions, MarkdownBackendOptions, @@ -108,10 +105,6 @@ class _GfmStrikethrough(marko.inline.InlineElement): "Install it with `pip install 'docling-slim[format-markdown]'`." ) -_MARKER_BODY = "DOCLING_DOC_MD_HTML_EXPORT" -_START_MARKER = f"#_#_{_MARKER_BODY}_START_#_#" -_STOP_MARKER = f"#_#_{_MARKER_BODY}_STOP_#_#" - class _PendingCreationType(str, Enum): """CoordOrigin.""" @@ -268,7 +261,6 @@ def __init__( self._pending_hard_line_break = False self._pending_soft_line_break = False - self._html_blocks: int = 0 self._image_loader: ImageResourceLoader | None = None # A leading BOM is dropped. Kept, it prefixes the first line, so a @@ -998,21 +990,9 @@ 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() - - # wrap in markers to enable post-processing in convert() - text_to_add = f"{_START_MARKER}{html_block}{_STOP_MARKER}" - doc.add_code( - parent=parent_item, - text=text_to_add, - formatting=formatting, - hyperlink=hyperlink, - ) + if html_block := element.body.strip(): + self._add_html_block(doc, parent_item, html_block) elif isinstance(element, _gfm_el.Table): _log.debug(" - GFM Table") @@ -1137,6 +1117,38 @@ def _load_image_ref(self, dest: str) -> ImageRef | None: warnings.warn(f"Could not process an image from {dest}: {exc}") return None + def _add_html_block( + self, doc: DoclingDocument, parent_item: NodeItem | None, html_block: str + ) -> None: + """Convert one HTML block with the HTML backend into `parent_item`.""" + md_options = cast(MarkdownBackendOptions, self.options) + html_options = HTMLBackendOptions( + enable_local_fetch=md_options.enable_local_fetch, + enable_remote_fetch=md_options.enable_remote_fetch, + fetch_images=md_options.fetch_images, + source_uri=md_options.source_uri, + infer_furniture=False, + add_title=False, + ) + stream = BytesIO(html_block.encode("utf-8")) + in_doc = InputDocument( + path_or_stream=stream, + format=InputFormat.HTML, + backend=HTMLDocumentBackend, + filename=self.file.name, + backend_options=html_options, + ) + first_new = len(doc.body.children) + HTMLDocumentBackend( + in_doc=in_doc, path_or_stream=stream, options=html_options + ).convert_into(doc) + if parent_item is not None: + new_refs = doc.body.children[first_new:] + del doc.body.children[first_new:] + for ref in new_refs: + ref.resolve(doc).parent = parent_item.get_ref() + parent_item.children.extend(new_refs) + def is_valid(self) -> bool: return self.valid @@ -1181,56 +1193,6 @@ def convert(self) -> DoclingDocument: list_item_counter_by_ref={}, list_last_item_by_ref={}, ) - - if self._html_blocks > 0: - 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. - pattern = ( - rf"
\s*\s*{re.escape(_START_MARKER)}"
-                    rf"(.*?){re.escape(_STOP_MARKER)}\s*\s*
" - ) - html_str, count = re.subn( - pattern, - lambda match: unescape(match.group(1)), - html_str, - flags=re.DOTALL, - ) - if count != self._html_blocks: - raise RuntimeError( - "An internal error has occurred during Markdown conversion." - ) - self._html_blocks = 0 - # delegate to HTML backend - stream = BytesIO(bytes(html_str, encoding="utf-8")) - md_options = cast(MarkdownBackendOptions, self.options) - html_options = HTMLBackendOptions( - enable_local_fetch=md_options.enable_local_fetch, - enable_remote_fetch=md_options.enable_remote_fetch, - fetch_images=md_options.fetch_images, - source_uri=md_options.source_uri, - infer_furniture=False, - add_title=False, - ) - in_doc = InputDocument( - path_or_stream=stream, - format=InputFormat.HTML, - backend=html_backend_cls, - filename=self.file.name, - backend_options=html_options, - ) - html_backend_obj = html_backend_cls( - in_doc=in_doc, - path_or_stream=stream, - options=html_options, - picture_refs=html_payload.picture_refs, - ) - doc = html_backend_obj.convert() else: raise RuntimeError( f"Cannot convert md with {self.document_hash} because the backend failed to init." diff --git a/tests/data/md/groundtruth/escaped_characters.md.json b/tests/data/md/groundtruth/escaped_characters.md.json index 826912662f..7d59b0633f 100644 --- a/tests/data/md/groundtruth/escaped_characters.md.json +++ b/tests/data/md/groundtruth/escaped_characters.md.json @@ -3,8 +3,8 @@ "version": "1.10.0", "name": "escaped_characters", "origin": { - "mimetype": "text/html", - "binary_hash": 3824621944112159534, + "mimetype": "text/markdown", + "binary_hash": 14089559831613693230, "filename": "escaped_characters.md" }, "furniture": { @@ -20,20 +20,53 @@ { "$ref": "#/texts/0" }, + { + "$ref": "#/texts/1" + }, + { + "$ref": "#/texts/2" + }, { "$ref": "#/texts/3" }, + { + "$ref": "#/groups/0" + }, + { + "$ref": "#/groups/1" + }, { "$ref": "#/texts/6" }, + { + "$ref": "#/texts/7" + }, { "$ref": "#/texts/8" }, + { + "$ref": "#/texts/9" + }, { "$ref": "#/texts/10" }, + { + "$ref": "#/tables/0" + }, + { + "$ref": "#/groups/2" + }, { "$ref": "#/texts/14" + }, + { + "$ref": "#/texts/15" + }, + { + "$ref": "#/texts/16" + }, + { + "$ref": "#/texts/17" } ], "content_layer": "body", @@ -44,7 +77,7 @@ { "self_ref": "#/groups/0", "parent": { - "$ref": "#/texts/3" + "$ref": "#/body" }, "children": [ { @@ -52,13 +85,13 @@ } ], "content_layer": "body", - "name": "ordered list", + "name": "list", "label": "list" }, { "self_ref": "#/groups/1", "parent": { - "$ref": "#/texts/3" + "$ref": "#/body" }, "children": [ { @@ -72,7 +105,7 @@ { "self_ref": "#/groups/2", "parent": { - "$ref": "#/texts/10" + "$ref": "#/body" }, "children": [ { @@ -96,11 +129,7 @@ "parent": { "$ref": "#/body" }, - "children": [ - { - "$ref": "#/texts/1" - } - ], + "children": [], "content_layer": "body", "label": "title", "prov": [], @@ -110,13 +139,9 @@ { "self_ref": "#/texts/1", "parent": { - "$ref": "#/texts/0" + "$ref": "#/body" }, - "children": [ - { - "$ref": "#/texts/2" - } - ], + "children": [], "content_layer": "body", "label": "section_header", "prov": [], @@ -127,7 +152,7 @@ { "self_ref": "#/texts/2", "parent": { - "$ref": "#/texts/1" + "$ref": "#/body" }, "children": [], "content_layer": "body", @@ -141,14 +166,7 @@ "parent": { "$ref": "#/body" }, - "children": [ - { - "$ref": "#/groups/0" - }, - { - "$ref": "#/groups/1" - } - ], + "children": [], "content_layer": "body", "label": "title", "prov": [], @@ -188,11 +206,7 @@ "parent": { "$ref": "#/body" }, - "children": [ - { - "$ref": "#/texts/7" - } - ], + "children": [], "content_layer": "body", "label": "title", "prov": [], @@ -202,7 +216,7 @@ { "self_ref": "#/texts/7", "parent": { - "$ref": "#/texts/6" + "$ref": "#/body" }, "children": [], "content_layer": "body", @@ -220,11 +234,7 @@ "parent": { "$ref": "#/body" }, - "children": [ - { - "$ref": "#/texts/9" - } - ], + "children": [], "content_layer": "body", "label": "title", "prov": [], @@ -234,7 +244,7 @@ { "self_ref": "#/texts/9", "parent": { - "$ref": "#/texts/8" + "$ref": "#/body" }, "children": [], "content_layer": "body", @@ -252,14 +262,7 @@ "parent": { "$ref": "#/body" }, - "children": [ - { - "$ref": "#/tables/0" - }, - { - "$ref": "#/groups/2" - } - ], + "children": [], "content_layer": "body", "label": "title", "prov": [], @@ -311,14 +314,7 @@ "parent": { "$ref": "#/body" }, - "children": [ - { - "$ref": "#/texts/15" - }, - { - "$ref": "#/texts/16" - } - ], + "children": [], "content_layer": "body", "label": "title", "prov": [], @@ -328,7 +324,7 @@ { "self_ref": "#/texts/15", "parent": { - "$ref": "#/texts/14" + "$ref": "#/body" }, "children": [], "content_layer": "body", @@ -340,13 +336,9 @@ { "self_ref": "#/texts/16", "parent": { - "$ref": "#/texts/14" + "$ref": "#/body" }, - "children": [ - { - "$ref": "#/texts/17" - } - ], + "children": [], "content_layer": "body", "label": "section_header", "prov": [], @@ -357,7 +349,7 @@ { "self_ref": "#/texts/17", "parent": { - "$ref": "#/texts/16" + "$ref": "#/body" }, "children": [], "content_layer": "body", @@ -373,7 +365,7 @@ { "self_ref": "#/tables/0", "parent": { - "$ref": "#/texts/10" + "$ref": "#/body" }, "children": [], "content_layer": "body", diff --git a/tests/data/md/groundtruth/mixed_without_h1.md.yaml b/tests/data/md/groundtruth/mixed_without_h1.md.yaml index b3037f9d61..79f4c612a8 100644 --- a/tests/data/md/groundtruth/mixed_without_h1.md.yaml +++ b/tests/data/md/groundtruth/mixed_without_h1.md.yaml @@ -1,7 +1,9 @@ body: children: - $ref: '#/texts/0' + - $ref: '#/texts/1' - $ref: '#/groups/0' + - $ref: '#/texts/9' content_layer: body label: unspecified name: _root_ @@ -14,14 +16,6 @@ furniture: name: _root_ self_ref: '#/furniture' groups: -- children: - - $ref: '#/texts/1' - content_layer: body - label: section - name: header-1 - parent: - $ref: '#/body' - self_ref: '#/groups/0' - children: - $ref: '#/texts/2' - $ref: '#/texts/4' @@ -30,8 +24,8 @@ groups: label: list name: list parent: - $ref: '#/texts/1' - self_ref: '#/groups/1' + $ref: '#/body' + self_ref: '#/groups/0' - children: - $ref: '#/texts/3' content_layer: body @@ -39,13 +33,23 @@ groups: name: list parent: $ref: '#/texts/2' + self_ref: '#/groups/1' +- children: + - $ref: '#/texts/6' + - $ref: '#/texts/7' + - $ref: '#/texts/8' + content_layer: body + label: inline + name: group + parent: + $ref: '#/texts/5' self_ref: '#/groups/2' key_value_items: [] name: mixed_without_h1 origin: - binary_hash: 11100987453172996924 + binary_hash: 407652015547384603 filename: mixed_without_h1.md - mimetype: text/html + mimetype: text/markdown pages: {} pictures: [] schema_name: DoclingDocument @@ -60,27 +64,25 @@ texts: prov: [] self_ref: '#/texts/0' text: Content before first heading -- children: - - $ref: '#/groups/1' - - $ref: '#/texts/6' +- children: [] content_layer: body label: section_header level: 1 orig: Some heading parent: - $ref: '#/groups/0' + $ref: '#/body' prov: [] self_ref: '#/texts/1' text: Some heading - children: - - $ref: '#/groups/2' + - $ref: '#/groups/1' content_layer: body enumerated: false label: list_item marker: '' orig: A. first parent: - $ref: '#/groups/1' + $ref: '#/groups/0' prov: [] self_ref: '#/texts/2' text: A. first @@ -91,7 +93,7 @@ texts: marker: '' orig: subitem parent: - $ref: '#/groups/2' + $ref: '#/groups/1' prov: [] self_ref: '#/texts/3' text: subitem @@ -102,28 +104,56 @@ texts: marker: '' orig: B. second parent: - $ref: '#/groups/1' + $ref: '#/groups/0' prov: [] self_ref: '#/texts/4' text: B. second -- children: [] +- children: + - $ref: '#/groups/2' content_layer: body enumerated: false label: list_item marker: '' - orig: 2 . strange + orig: '' parent: - $ref: '#/groups/1' + $ref: '#/groups/0' prov: [] self_ref: '#/texts/5' - text: 2 . strange + text: '' - children: [] content_layer: body label: text - orig: The end! + orig: '2' parent: - $ref: '#/texts/1' + $ref: '#/groups/2' prov: [] self_ref: '#/texts/6' + text: '2' +- children: [] + content_layer: body + label: text + orig: . + parent: + $ref: '#/groups/2' + prov: [] + self_ref: '#/texts/7' + text: . +- children: [] + content_layer: body + label: text + orig: strange + parent: + $ref: '#/groups/2' + prov: [] + self_ref: '#/texts/8' + text: strange +- children: [] + content_layer: body + label: text + orig: The end! + parent: + $ref: '#/body' + prov: [] + self_ref: '#/texts/9' text: The end! version: 1.10.0 diff --git a/tests/data/md/groundtruth/signature_stamp_01.md.json b/tests/data/md/groundtruth/signature_stamp_01.md.json index 513db072c3..24a6aea4e3 100644 --- a/tests/data/md/groundtruth/signature_stamp_01.md.json +++ b/tests/data/md/groundtruth/signature_stamp_01.md.json @@ -3,8 +3,8 @@ "version": "1.10.0", "name": "signature_stamp_01", "origin": { - "mimetype": "text/html", - "binary_hash": 3703610756227907514, + "mimetype": "text/markdown", + "binary_hash": 5357081687404952837, "filename": "signature_stamp_01.md" }, "furniture": { diff --git a/tests/test_backend_markdown.py b/tests/test_backend_markdown.py index f766e58833..37835335ab 100644 --- a/tests/test_backend_markdown.py +++ b/tests/test_backend_markdown.py @@ -1052,10 +1052,7 @@ def test_ordered_list_split_by_prose_preserves_numbers(): def test_ordered_list_numbers_survive_html_block(): - """A Markdown file with an HTML block is converted again through HTML. - - The list numbers must survive that round trip, like in a file without HTML. - """ + """The list numbers are the same as in a file without HTML blocks.""" markdown = "5. foo\n6. bar\n\nRestart the shell.\n\n7. baz\n\n
note
\n" conv_result = get_converter().convert_string(markdown, format=InputFormat.MD) assert conv_result.status == ConversionStatus.SUCCESS diff --git a/tests/test_backend_markdown_html_blocks.py b/tests/test_backend_markdown_html_blocks.py new file mode 100644 index 0000000000..556a3acd21 --- /dev/null +++ b/tests/test_backend_markdown_html_blocks.py @@ -0,0 +1,158 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +import pytest +from docling_core.types.doc import CodeItem, DoclingDocument, GroupItem, RichTableCell + +from docling.datamodel.backend_options import MarkdownBackendOptions +from tests.test_backend_markdown import _convert_markdown, _png_data_uri + +pytestmark = pytest.mark.cross_platform + + +_HTML_BLOCK_BODY = """\ +```cpp +#include +Dict d; +printf("
"); +``` + +```bash +conda create -n +conda activate +``` + +Use `` here. + +| a | b | +|---|---| +| x | 2 | + +See [one](https://a.example) and **bold** +See [two](https://b.example) and *italic* + +5. first +6. second +""" + + +def _items(doc: DoclingDocument) -> list[tuple[str, str]]: + return [(str(t.label), t.text) for t in doc.texts] + + +@pytest.mark.parametrize( + ("before", "after"), + [ + ("", ""), + ("\n\n", ""), + ("", "\n\n"), + ("\n\n", "\n\n\n
\n"), + ], + ids=["no_html_block", "leading", "trailing", "both"], +) +def test_html_comment_does_not_change_surrounding_content(before: str, after: str): + """An HTML block leaves the Markdown content around it unchanged.""" + options = MarkdownBackendOptions() + expected = _convert_markdown(_HTML_BLOCK_BODY, options) + doc = _convert_markdown(f"{before}{_HTML_BLOCK_BODY}{after}", options) + + codes = [t.text for t in doc.texts if isinstance(t, CodeItem)] + assert codes == [ + '#include \nDict d;\nprintf("
");', + "conda create -n \nconda activate ", + "", + ] + assert [c.text for c in doc.tables[0].data.table_cells] == ["a", "b", "x", "2"] + assert [t.marker for t in doc.texts if t.label == "list_item"] == ["5.", "6."] + assert _items(doc) == _items(expected) + assert doc.export_to_markdown() == expected.export_to_markdown() + + +def test_adjacent_paragraphs_stay_separate_around_html_block(): + markdown = ( + "\n\n" + "See [one](https://a.example) and **bold**\n\n" + "See [two](https://b.example)\n" + ) + doc = _convert_markdown(markdown, MarkdownBackendOptions()) + + assert [ + [child.resolve(doc).text for child in group.children] for group in doc.groups + ] == [["See", "one", "and", "bold"], ["See", "two"]] + + +def test_markdown_picture_survives_html_block(): + markdown = f"\n\n![alt]({_png_data_uri(7, 5)})\n" + doc = _convert_markdown(markdown, MarkdownBackendOptions(fetch_images=True)) + + assert len(doc.pictures) == 1 + image = doc.pictures[0].get_image(doc) + assert image is not None + assert image.size == (7, 5) + + +def test_html_block_content_is_converted(): + markdown = ( + "# Title\n\n" + "Intro ``\n\n" + "

Section

\n\n" + "
H1H2
12
\n\n" + "
Moredetail
\n\n" + f'

logo

\n\n' + "
open tag\n\n" + "after\n" + ) + doc = _convert_markdown(markdown, MarkdownBackendOptions(fetch_images=True)) + + assert [(str(t.label), t.text) for t in doc.texts] == [ + ("title", "Title"), + ("text", "Intro"), + ("code", ""), + ("section_header", "Section"), + ("text", "More"), + ("text", "detail"), + ("caption", "logo"), + ("text", "open"), + ("text", "tag"), + ("text", "after"), + ] + assert [[c.text for c in row] for row in doc.tables[0].data.grid] == [ + ["H1", "H2"], + ["1", "2"], + ] + assert len(doc.pictures) == 1 + assert doc.pictures[0].image is not None + + +def test_html_block_inside_list_item(): + markdown = "- a\n
block
\n- b\n\n \n- ``\n" + doc = _convert_markdown(markdown, MarkdownBackendOptions()) + + assert _items(doc) == [ + ("list_item", "a"), + ("text", "block"), + ("list_item", "b"), + ("list_item", ""), + ] + assert len(doc.groups) == 1 + assert doc.groups[0].label == "list" + + +def test_html_block_references_stay_valid(): + """Items an HTML block refers to by reference still resolve after conversion.""" + markdown = ( + "first\n\nsecond\n\n" + '
Cap
\n\n' + "

x

y

z
\n" + ) + doc = _convert_markdown(markdown, MarkdownBackendOptions()) + + assert doc.validate_tree(doc.body) + assert [c.resolve(doc).text for c in doc.pictures[0].captions] == ["Cap"] + table = doc.tables[0] + rich_cells = [c for c in table.data.table_cells if isinstance(c, RichTableCell)] + assert len(rich_cells) == 1 + cell_group = rich_cells[0].ref.resolve(doc) + assert isinstance(cell_group, GroupItem) + assert cell_group.parent == table.get_ref() + assert [c.resolve(doc).text for c in cell_group.children] == ["x", "y"] From d3ac58b144697ab59d1220a984ee07e3551ee742 Mon Sep 17 00:00:00 2001 From: Avanish Garg Date: Thu, 15 Oct 2026 14:52:18 +0530 Subject: [PATCH 2/2] refactor(md): drop the picture relay that the HTML round trip needed The Markdown backend no longer exports the whole document to HTML and parses it again, so nothing relays Markdown pictures through that round trip. Remove `docling/backend/utils/markdown_html.py`, which has no remaining users. The picture tests added with it keep passing unchanged. Signed-off-by: Avanish Garg --- docling/backend/utils/markdown_html.py | 101 ------------------------- 1 file changed, 101 deletions(-) delete mode 100644 docling/backend/utils/markdown_html.py diff --git a/docling/backend/utils/markdown_html.py b/docling/backend/utils/markdown_html.py deleted file mode 100644 index 9e41e2b3bf..0000000000 --- a/docling/backend/utils/markdown_html.py +++ /dev/null @@ -1,101 +0,0 @@ -# SPDX-FileCopyrightText: The Docling Contributors -# SPDX-License-Identifier: MIT - -"""Keep Markdown pictures through the internal HTML round trip.""" - -import html -from dataclasses import dataclass -from io import BytesIO -from pathlib import Path -from typing import Any - -from docling_core.transforms.serializer.base import ( - BaseDocSerializer, - SerializationResult, -) -from docling_core.transforms.serializer.common import create_ser_result -from docling_core.transforms.serializer.html import ( - HTMLDocSerializer, - HTMLParams, - HTMLPictureSerializer, -) -from docling_core.types.doc import DoclingDocument, ImageRef, PictureItem -from docling_core.types.doc.common.constants import DOCUMENT_TOKENS_EXPORT_LABELS -from docling_core.types.doc.common.content_layer import DEFAULT_CONTENT_LAYERS -from typing_extensions import override - -from docling.backend.html_backend import HTMLDocumentBackend -from docling.datamodel.backend_options import HTMLBackendOptions -from docling.datamodel.document import InputDocument - - -@dataclass(frozen=True) -class MarkdownHTMLPayload: - text: str - picture_refs: dict[str, ImageRef | None] - - -class _MarkdownPictureSerializer(HTMLPictureSerializer): - def __init__(self, reference_prefix: str) -> None: - self._reference_prefix = reference_prefix - - @override - def serialize( - self, - *, - item: PictureItem, - doc_serializer: BaseDocSerializer, - doc: DoclingDocument, - **kwargs: Any, - ) -> SerializationResult: - # The HTML backend must see an img even when fetching was disabled or - # failed. Its source is a document-local key, not a new fetch request. - reference = html.escape(self._reference_prefix + item.self_ref, quote=True) - parts = [ - doc_serializer.serialize_captions(item=item, tag="figcaption", **kwargs), - create_ser_result(text=f'', span_source=item), - ] - text = "".join(part.text for part in parts) - return create_ser_result(text=f"
{text}
", span_source=parts) - - -class MarkdownHTMLDocumentBackend(HTMLDocumentBackend): - def __init__( - self, - in_doc: InputDocument, - *, - path_or_stream: BytesIO | Path, - options: HTMLBackendOptions | None, - picture_refs: dict[str, ImageRef | None] | None = None, - ) -> None: - # InputDocument constructs a backend to inspect it before conversion; - # that instance does not yet need the already parsed Markdown pictures. - super().__init__(in_doc=in_doc, path_or_stream=path_or_stream, options=options) - self._picture_refs = picture_refs if picture_refs is not None else {} - - @override - def _create_image_ref(self, src_url: str) -> ImageRef | None: - if src_url in self._picture_refs: - return self._picture_refs[src_url] - # Images in the original HTML retain the normal loader and its guards. - return super()._create_image_ref(src_url) - - -def serialize_markdown_for_html( - doc: DoclingDocument, *, source_hash: str -) -> MarkdownHTMLPayload: - # Include the source hash so original HTML cannot collide with a reference - # merely by using the same picture index. The namespace is deterministic. - prefix = f"data:application/x-docling-md-picture,{source_hash}/" - picture_refs = { - prefix + picture.self_ref: picture.image for picture in doc.pictures - } - serializer = HTMLDocSerializer( - doc=doc, - params=HTMLParams( - labels=DOCUMENT_TOKENS_EXPORT_LABELS, - layers=DEFAULT_CONTENT_LAYERS, - ), - picture_serializer=_MarkdownPictureSerializer(prefix), - ) - return MarkdownHTMLPayload(serializer.serialize().text, picture_refs)