diff --git a/docling_core/transforms/serializer/html.py b/docling_core/transforms/serializer/html.py index 862ee184..0db93b47 100644 --- a/docling_core/transforms/serializer/html.py +++ b/docling_core/transforms/serializer/html.py @@ -443,6 +443,11 @@ def serialize( text_res = "".join([r.text for r in res_parts]) text_res = f"{text_res}
" if text_res else "" + ftn_res = doc_serializer.serialize_footnotes(item=item, **kwargs) + if ftn_res.text: + text_res = f"{text_res}{ftn_res.text}" if text_res else ftn_res.text + res_parts.append(ftn_res) + return create_ser_result(text=text_res, span_source=res_parts) @override @@ -1276,6 +1281,33 @@ def serialize_captions( text_res = f"<{tag}>{text_res}" return create_ser_result(text=text_res, span_source=results) + @override + def serialize_footnotes( + self, + item: FloatingItem, + **kwargs: Any, + ) -> SerializationResult: + """Serialize the item's footnotes.""" + params = self.params.merge_with_patch(patch=kwargs) + results: list[SerializationResult] = [] + excluded_refs = self.get_excluded_refs(**kwargs) + + if DocItemLabel.FOOTNOTE in params.labels: + for ftn in item.footnotes: + if isinstance(it := ftn.resolve(self.doc), TextItem) and it.self_ref not in excluded_refs: + text_ftn = it.text + text_dir = get_text_direction(text_ftn) + dir_str = f' dir="{text_dir}"' if text_dir == "rtl" else "" + results.append( + create_ser_result( + text=(f'
{html.escape(text_ftn)}
'), + span_source=it, + ) + ) + + text_res = params.caption_delim.join([r.text for r in results]) + return create_ser_result(text=text_res, span_source=results) + def _generate_head(self) -> str: """Generate the HTML head section with metadata and styles.""" params = self.params diff --git a/docling_core/transforms/serializer/markdown.py b/docling_core/transforms/serializer/markdown.py index 066788d2..e852e6c2 100644 --- a/docling_core/transforms/serializer/markdown.py +++ b/docling_core/transforms/serializer/markdown.py @@ -576,6 +576,10 @@ def serialize( if table_text: res_parts.append(create_ser_result(text=table_text, span_source=item)) + ftn_res = doc_serializer.serialize_footnotes(item=item, **kwargs) + if ftn_res.text: + res_parts.append(ftn_res) + text_res = "\n\n".join([r.text for r in res_parts]) return create_ser_result(text=text_res, span_source=res_parts) diff --git a/test/test_serialization.py b/test/test_serialization.py index b6bd4b1d..0ed17403 100644 --- a/test/test_serialization.py +++ b/test/test_serialization.py @@ -1103,3 +1103,36 @@ def test_html_meta_emits_xhtml_compatible_attributes(): assert 'data-meta-name="entities"' in html_out # Output must be parseable by a strict XML parser. ET.fromstring(html_out) + + +def test_table_footnotes_serialized_to_markdown_and_html(): + """Regression test for #496: table footnotes must not be dropped by the + Markdown and HTML serializers (they are delegated to the parent TableItem).""" + doc = DoclingDocument(name="t") + + td = TableData(num_rows=0, num_cols=2) + td.add_row(["Header 1", "Header 2"]) + td.add_row(["Data 1", "Data 2"]) + table = doc.add_table(data=td) + + fn1 = doc.add_text(label=DocItemLabel.FOOTNOTE, text="First footnote.") + fn2 = doc.add_text(label=DocItemLabel.FOOTNOTE, text="Second footnote.") + table.footnotes.append(fn1.get_ref()) + table.footnotes.append(fn2.get_ref()) + + # footnotes are stored on the table in the document model ... + assert [fn.resolve(doc).text for fn in table.footnotes] == [ + "First footnote.", + "Second footnote.", + ] + + # ... and must survive Markdown serialization + md_out = doc.export_to_markdown() + assert "First footnote." in md_out + assert "Second footnote." in md_out + + # ... and HTML serialization (as escaped footnote divs after the table) + html_out = doc.export_to_html() + assert "First footnote." in html_out + assert "Second footnote." in html_out + assert '
First footnote.
' in html_out