Skip to content

Commit b361bb7

Browse files
committed
feat(markdown): include table image in Markdown export when available
MarkdownTableSerializer previously ignored TableItem.image entirely, only emitting the text grid, unlike MarkdownPictureSerializer which honors image_mode for pictures. Extract the shared image-emission logic into a module-level _serialize_image_part() usable by both serializers, and move _image_to_base64 up from PictureItem to the shared FloatingItem base so TableItem can call it too. MarkdownTableSerializer now appends the table image alongside the text table when item.image is set and image_mode is not PLACEHOLDER. Tables without a generated image (the common case) see no output change. Reworked onto the post-#664 module split (items/node.py, items/picture, transforms/serializer/markdown) after the original branch went stale. Closes #474 Signed-off-by: Max Hsu <maxmilian@gmail.com>
1 parent 0215808 commit b361bb7

4 files changed

Lines changed: 143 additions & 57 deletions

File tree

docling_core/transforms/serializer/markdown.py

Lines changed: 53 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,46 @@ def serialize(
427427
)
428428

429429

430+
def _serialize_image_part(
431+
item: FloatingItem,
432+
doc: DoclingDocument,
433+
image_mode: ImageRefMode,
434+
image_placeholder: str,
435+
**kwargs: Any,
436+
) -> SerializationResult:
437+
"""Render a floating item's image per the image mode.
438+
439+
Shared by the picture and table serializers so both honor image_mode
440+
identically for any FloatingItem that carries an image.
441+
"""
442+
error_response = "<!-- 🖼️❌ Image not available. Please use `PdfPipelineOptions(generate_picture_images=True)` -->"
443+
if image_mode == ImageRefMode.PLACEHOLDER:
444+
text_res = image_placeholder
445+
elif image_mode == ImageRefMode.EMBEDDED:
446+
# short-cut: we already have the image in base64
447+
if isinstance(item.image, ImageRef) and isinstance(item.image.uri, AnyUrl) and item.image.uri.scheme == "data":
448+
text_res = f"![Image]({item.image.uri})"
449+
else:
450+
# get the item.image._pil or crop it out of the page-image
451+
img = item.get_image(doc=doc)
452+
if img is not None:
453+
imgb64 = item._image_to_base64(img)
454+
text_res = f"![Image](data:image/png;base64,{imgb64})"
455+
else:
456+
text_res = error_response
457+
elif image_mode == ImageRefMode.REFERENCED:
458+
if not isinstance(item.image, ImageRef) or (
459+
isinstance(item.image.uri, AnyUrl) and item.image.uri.scheme == "data"
460+
):
461+
text_res = image_placeholder
462+
else:
463+
text_res = f"![Image]({item.image.uri!s})"
464+
else:
465+
text_res = image_placeholder
466+
467+
return create_ser_result(text=text_res, span_source=item)
468+
469+
430470
class MarkdownTableSerializer(BaseTableSerializer):
431471
"""Markdown-specific table item serializer."""
432472

@@ -584,6 +624,18 @@ def serialize(
584624
if table_text:
585625
res_parts.append(create_ser_result(text=table_text, span_source=item))
586626

627+
# Emit the table image alongside the text grid when one was generated.
628+
# Tables without an image (the common case) stay a strict no-op.
629+
if item.image is not None and params.image_mode != ImageRefMode.PLACEHOLDER:
630+
img_res = _serialize_image_part(
631+
item=item,
632+
doc=doc,
633+
image_mode=params.image_mode,
634+
image_placeholder=params.image_placeholder,
635+
)
636+
if img_res.text:
637+
res_parts.append(img_res)
638+
587639
text_res = "\n\n".join([r.text for r in res_parts])
588640

589641
return create_ser_result(text=text_res, span_source=res_parts)
@@ -622,7 +674,7 @@ def serialize(
622674
if ann_res.text:
623675
res_parts.append(ann_res)
624676

625-
img_res = self._serialize_image_part(
677+
img_res = _serialize_image_part(
626678
item=item,
627679
doc=doc,
628680
image_mode=params.image_mode,
@@ -648,51 +700,6 @@ def serialize(
648700

649701
return create_ser_result(text=text_res, span_source=res_parts)
650702

651-
def _serialize_image_part(
652-
self,
653-
item: PictureItem,
654-
doc: DoclingDocument,
655-
image_mode: ImageRefMode,
656-
image_placeholder: str,
657-
**kwargs: Any,
658-
) -> SerializationResult:
659-
error_response = (
660-
"<!-- 🖼️❌ Image not available. Please use `PdfPipelineOptions(generate_picture_images=True)` -->"
661-
)
662-
if image_mode == ImageRefMode.PLACEHOLDER:
663-
text_res = image_placeholder
664-
elif image_mode == ImageRefMode.EMBEDDED:
665-
# short-cut: we already have the image in base64
666-
if (
667-
isinstance(item.image, ImageRef)
668-
and isinstance(item.image.uri, AnyUrl)
669-
and item.image.uri.scheme == "data"
670-
):
671-
text = f"![Image]({item.image.uri})"
672-
text_res = text
673-
else:
674-
# get the item.image._pil or crop it out of the page-image
675-
img = item.get_image(doc=doc)
676-
677-
if img is not None:
678-
imgb64 = item._image_to_base64(img)
679-
text = f"![Image](data:image/png;base64,{imgb64})"
680-
681-
text_res = text
682-
else:
683-
text_res = error_response
684-
elif image_mode == ImageRefMode.REFERENCED:
685-
if not isinstance(item.image, ImageRef) or (
686-
isinstance(item.image.uri, AnyUrl) and item.image.uri.scheme == "data"
687-
):
688-
text_res = image_placeholder
689-
else:
690-
text_res = f"![Image]({item.image.uri!s})"
691-
else:
692-
text_res = image_placeholder
693-
694-
return create_ser_result(text=text_res, span_source=item)
695-
696703

697704
class MarkdownKeyValueSerializer(BaseKeyValueSerializer):
698705
"""Markdown-specific key-value item serializer."""

docling_core/types/doc/items/node.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Base document-tree node items: NodeItem, DocItem, FloatingItem."""
22

3+
import base64
34
from collections.abc import Sequence
5+
from io import BytesIO
46
from typing import TYPE_CHECKING, Annotated, Optional
57

68
from PIL import Image as PILImage
@@ -231,3 +233,12 @@ def get_image(self, doc: "DoclingDocument", prov_index: int = 0) -> Optional[PIL
231233
if self.image is not None:
232234
return self.image.pil_image
233235
return super().get_image(doc=doc, prov_index=prov_index)
236+
237+
# Convert the image to Base64
238+
def _image_to_base64(self, pil_image, format="PNG"):
239+
"""Base64 representation of the image."""
240+
buffered = BytesIO()
241+
pil_image.save(buffered, format=format) # Save the image to the byte stream
242+
img_bytes = buffered.getvalue() # Get the byte data
243+
img_base64 = base64.b64encode(img_bytes).decode("utf-8") # Encode to Base64 and decode to string
244+
return img_base64

docling_core/types/doc/items/picture/picture.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
"""Picture item and its annotation-data union."""
22

3-
import base64
43
import hashlib
54
import logging
65
import typing
76
import warnings
87
from collections.abc import Sequence
9-
from io import BytesIO
108
from typing import TYPE_CHECKING, Annotated, Optional, Union
119

1210
from PIL import Image as PILImage
@@ -143,15 +141,6 @@ def _migrate_annotations_to_meta(self) -> Self:
143141

144142
return self
145143

146-
# Convert the image to Base64
147-
def _image_to_base64(self, pil_image, format="PNG"):
148-
"""Base64 representation of the image."""
149-
buffered = BytesIO()
150-
pil_image.save(buffered, format=format) # Save the image to the byte stream
151-
img_bytes = buffered.getvalue() # Get the byte data
152-
img_base64 = base64.b64encode(img_bytes).decode("utf-8") # Encode to Base64 and decode to string
153-
return img_base64
154-
155144
@staticmethod
156145
def _image_to_hexhash(img: Optional[PILImage.Image]) -> Optional[str]:
157146
"""Hexash from the image."""

test/test_serialization.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from xml.etree import ElementTree as ET
77

88
import pytest
9+
from PIL import Image
910

1011
from docling_core.transforms.serializer.common import _DEFAULT_LABELS
1112
from docling_core.transforms.serializer.html import (
@@ -32,12 +33,14 @@
3233
DescriptionAnnotation,
3334
EntitiesMetaField,
3435
EntityMention,
36+
ImageRef,
3537
LanguageMetaField,
3638
PictureClassificationMetaField,
3739
PictureClassificationPrediction,
3840
PictureMeta,
3941
RefItem,
4042
RichTableCell,
43+
Size,
4144
SummaryMetaField,
4245
TableCell,
4346
TableData,
@@ -352,6 +355,82 @@ def test_md_single_row_table():
352355
verify(exp_file=exp_file, actual=actual)
353356

354357

358+
def test_md_table_with_image_embedded():
359+
doc = DoclingDocument(name="")
360+
table = doc.add_table(data=TableData(num_rows=1, num_cols=1))
361+
doc.add_table_cell(
362+
table_item=table,
363+
cell=TableCell(
364+
start_row_offset_idx=0,
365+
end_row_offset_idx=1,
366+
start_col_offset_idx=0,
367+
end_col_offset_idx=1,
368+
text="foo",
369+
),
370+
)
371+
table.image = ImageRef.from_pil(image=Image.new("RGB", (2, 2)), dpi=72)
372+
373+
ser = MarkdownDocSerializer(
374+
doc=doc,
375+
params=MarkdownParams(image_mode=ImageRefMode.EMBEDDED),
376+
)
377+
actual = ser.serialize().text
378+
assert "| foo |" in actual
379+
assert "![Image](data:image/png;base64," in actual
380+
381+
382+
def test_md_table_with_image_referenced():
383+
doc = DoclingDocument(name="")
384+
table = doc.add_table(data=TableData(num_rows=1, num_cols=1))
385+
doc.add_table_cell(
386+
table_item=table,
387+
cell=TableCell(
388+
start_row_offset_idx=0,
389+
end_row_offset_idx=1,
390+
start_col_offset_idx=0,
391+
end_col_offset_idx=1,
392+
text="foo",
393+
),
394+
)
395+
table.image = ImageRef(
396+
mimetype="image/png",
397+
dpi=72,
398+
size=Size(width=2, height=2),
399+
uri="table_0.png",
400+
)
401+
402+
ser = MarkdownDocSerializer(
403+
doc=doc,
404+
params=MarkdownParams(image_mode=ImageRefMode.REFERENCED),
405+
)
406+
actual = ser.serialize().text
407+
assert actual == "| foo |\n|-------|\n\n![Image](table_0.png)"
408+
409+
410+
def test_md_table_without_image_unaffected_by_image_mode():
411+
"""A table with no generated image must not gain an image line, even if
412+
image_mode is set — this must remain a strict no-op for the common case."""
413+
doc = DoclingDocument(name="")
414+
table = doc.add_table(data=TableData(num_rows=1, num_cols=1))
415+
doc.add_table_cell(
416+
table_item=table,
417+
cell=TableCell(
418+
start_row_offset_idx=0,
419+
end_row_offset_idx=1,
420+
start_col_offset_idx=0,
421+
end_col_offset_idx=1,
422+
text="foo",
423+
),
424+
)
425+
426+
ser = MarkdownDocSerializer(
427+
doc=doc,
428+
params=MarkdownParams(image_mode=ImageRefMode.EMBEDDED),
429+
)
430+
actual = ser.serialize().text
431+
assert actual == "| foo |\n|-------|"
432+
433+
355434
def test_md_pipe_in_table():
356435
doc = DoclingDocument(name="Pipe in Table")
357436
table = doc.add_table(data=TableData(num_rows=1, num_cols=1))

0 commit comments

Comments
 (0)