docling-core 2.97.2__tar.gz → 2.98.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. {docling_core-2.97.2 → docling_core-2.98.1}/PKG-INFO +1 -1
  2. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/deserializer/doclang.py +74 -20
  3. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/doclang.py +247 -65
  4. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/markdown.py +82 -3
  5. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/__init__.py +1 -0
  6. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/base.py +7 -0
  7. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/origin.py +2 -0
  8. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/document.py +11 -0
  9. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/webvtt.py +38 -6
  10. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core.egg-info/PKG-INFO +1 -1
  11. {docling_core-2.97.2 → docling_core-2.98.1}/pyproject.toml +1 -1
  12. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_serialization.py +123 -0
  13. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_serialization_doclang.py +116 -0
  14. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_webvtt.py +30 -1
  15. {docling_core-2.97.2 → docling_core-2.98.1}/LICENSE +0 -0
  16. {docling_core-2.97.2 → docling_core-2.98.1}/README.md +0 -0
  17. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/__init__.py +0 -0
  18. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/cli/__init__.py +0 -0
  19. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/cli/serialize.py +0 -0
  20. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/cli/view.py +0 -0
  21. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/compat.py +0 -0
  22. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/experimental/__init__.py +0 -0
  23. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/experimental/serializer/__init__.py +0 -0
  24. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/experimental/serializer/outline.py +0 -0
  25. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/py.typed +0 -0
  26. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/search/__init__.py +0 -0
  27. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/search/package.py +0 -0
  28. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/__init__.py +0 -0
  29. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/__init__.py +0 -0
  30. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/base.py +0 -0
  31. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/chunk_expander.py +0 -0
  32. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/code_chunking/__init__.py +0 -0
  33. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/code_chunking/_language_code_chunkers.py +0 -0
  34. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/code_chunking/_utils.py +0 -0
  35. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/code_chunking/base_code_chunking_strategy.py +0 -0
  36. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/code_chunking/code_chunk.py +0 -0
  37. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/code_chunking/standard_code_chunking_strategy.py +0 -0
  38. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/doc_chunk.py +0 -0
  39. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/hierarchical_chunker.py +0 -0
  40. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/hybrid_chunker.py +0 -0
  41. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/line_chunker.py +0 -0
  42. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/page_chunker.py +0 -0
  43. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/tokenizer/__init__.py +0 -0
  44. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/tokenizer/base.py +0 -0
  45. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/tokenizer/huggingface.py +0 -0
  46. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/chunker/tokenizer/openai.py +0 -0
  47. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/deserializer/__init__.py +0 -0
  48. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/deserializer/base.py +0 -0
  49. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/deserializer/doclang_source_mapping.py +0 -0
  50. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/profiler/__init__.py +0 -0
  51. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/profiler/doc_profiler.py +0 -0
  52. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/__init__.py +0 -0
  53. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/_doclang_utils.py +0 -0
  54. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/azure.py +0 -0
  55. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/base.py +0 -0
  56. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/common.py +0 -0
  57. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/doctags.py +0 -0
  58. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/html.py +0 -0
  59. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/html_styles.py +0 -0
  60. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/latex.py +0 -0
  61. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/markdown_excel.py +0 -0
  62. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/plain_text.py +0 -0
  63. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/serializer/webvtt.py +0 -0
  64. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/visualizer/__init__.py +0 -0
  65. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/visualizer/base.py +0 -0
  66. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/visualizer/key_value_visualizer.py +0 -0
  67. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/visualizer/layout_visualizer.py +0 -0
  68. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/visualizer/reading_order_visualizer.py +0 -0
  69. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/transforms/visualizer/table_visualizer.py +0 -0
  70. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/__init__.py +0 -0
  71. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/base.py +0 -0
  72. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/__init__.py +0 -0
  73. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/annotations.py +0 -0
  74. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/constants.py +0 -0
  75. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/content_layer.py +0 -0
  76. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/formatting.py +0 -0
  77. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/meta.py +0 -0
  78. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/reference.py +0 -0
  79. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/scalars.py +0 -0
  80. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/common/source.py +0 -0
  81. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/doctags.py +0 -0
  82. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/__init__.py +0 -0
  83. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/code.py +0 -0
  84. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/content.py +0 -0
  85. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/form.py +0 -0
  86. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/group.py +0 -0
  87. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/key_value.py +0 -0
  88. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/node.py +0 -0
  89. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/picture/__init__.py +0 -0
  90. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/picture/charts.py +0 -0
  91. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/picture/classification.py +0 -0
  92. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/picture/meta.py +0 -0
  93. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/picture/molecule.py +0 -0
  94. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/picture/picture.py +0 -0
  95. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/table/__init__.py +0 -0
  96. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/table/table.py +0 -0
  97. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/table/table_data.py +0 -0
  98. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/items/text.py +0 -0
  99. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/labels.py +0 -0
  100. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/page.py +0 -0
  101. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/tokens.py +0 -0
  102. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/doc/utils.py +0 -0
  103. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/io/__init__.py +0 -0
  104. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/legacy_doc/__init__.py +0 -0
  105. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/legacy_doc/base.py +0 -0
  106. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/types/legacy_doc/document.py +0 -0
  107. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/__init__.py +0 -0
  108. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/alias.py +0 -0
  109. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/file.py +0 -0
  110. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/generate_docs.py +0 -0
  111. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/generate_jsonschema.py +0 -0
  112. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/legacy.py +0 -0
  113. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/settings.py +0 -0
  114. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core/utils/validators.py +0 -0
  115. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core.egg-info/SOURCES.txt +0 -0
  116. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core.egg-info/dependency_links.txt +0 -0
  117. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core.egg-info/entry_points.txt +0 -0
  118. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core.egg-info/requires.txt +0 -0
  119. {docling_core-2.97.2 → docling_core-2.98.1}/docling_core.egg-info/top_level.txt +0 -0
  120. {docling_core-2.97.2 → docling_core-2.98.1}/setup.cfg +0 -0
  121. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_azure_serializer.py +0 -0
  122. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_base.py +0 -0
  123. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_chunk_expander.py +0 -0
  124. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_cli_serialize.py +0 -0
  125. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_code_chunker.py +0 -0
  126. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_code_chunking_strategy.py +0 -0
  127. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_compat.py +0 -0
  128. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_data_gen_flag.py +0 -0
  129. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_deserializer_doclang.py +0 -0
  130. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_deserializer_doclang_source_mapping.py +0 -0
  131. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_doc_base.py +0 -0
  132. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_doclang_archive.py +0 -0
  133. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_docling_doc.py +0 -0
  134. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_doctags_load.py +0 -0
  135. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_hierarchical_chunker.py +0 -0
  136. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_hierarchy.py +0 -0
  137. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_hybrid_chunker.py +0 -0
  138. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_latex_serialization.py +0 -0
  139. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_line_chunker.py +0 -0
  140. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_metadata.py +0 -0
  141. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_otsl_table_export.py +0 -0
  142. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_page.py +0 -0
  143. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_page_chunker.py +0 -0
  144. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_plain_text_serialization.py +0 -0
  145. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_profiler.py +0 -0
  146. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_regions_to_table.py +0 -0
  147. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_serialization_doctag.py +0 -0
  148. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_serialization_outline.py +0 -0
  149. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_tokens.py +0 -0
  150. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_upath_support.py +0 -0
  151. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_utils.py +0 -0
  152. {docling_core-2.97.2 → docling_core-2.98.1}/tests/test_visualization.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docling-core
3
- Version: 2.97.2
3
+ Version: 2.98.1
4
4
  Summary: A python library to define and validate data types in Docling.
5
5
  Author-email: Cesar Berrospi Ramis <ceb@zurich.ibm.com>, Panos Vagenas <pva@zurich.ibm.com>, Michele Dolfi <dol@zurich.ibm.com>, Christoph Auer <cau@zurich.ibm.com>, Peter Staar <taa@zurich.ibm.com>
6
6
  Maintainer-email: Panos Vagenas <pva@zurich.ibm.com>, Michele Dolfi <dol@zurich.ibm.com>, Christoph Auer <cau@zurich.ibm.com>, Peter Staar <taa@zurich.ibm.com>, Cesar Berrospi Ramis <ceb@zurich.ibm.com>
@@ -353,11 +353,14 @@ class DocLangDocDeserializer(BaseDocDeserializer, BaseModel):
353
353
  # holding a picture or a table is not a float.
354
354
  if group_name := el.getAttribute(DocLangAttributeKey.NAME.value):
355
355
  self._parse_named_group(doc=doc, el=el, parent=parent, name=group_name)
356
- # Float + footnote siblings: parse as one unit (not a Docling GroupItem).
357
- elif self._first_child(el, DocLangToken.TABLE.value) or self._first_child(el, DocLangToken.INDEX.value):
358
- self._parse_table(doc=doc, el=el, parent=parent)
359
- elif self._first_child(el, DocLangToken.PICTURE.value):
360
- self._parse_picture(doc=doc, el=el, parent=parent)
356
+ # Float + satellites (extra captions, footnotes): parse as one unit (not a Docling GroupItem).
357
+ elif (host_el := self._float_group_host(el)) is not None:
358
+ if host_el.tagName == DocLangToken.PICTURE.value:
359
+ self._parse_picture(doc=doc, el=el, parent=parent)
360
+ elif host_el.tagName == DocLangToken.CODE.value:
361
+ self._parse_code_group(doc=doc, el=el, code_el=host_el, parent=parent)
362
+ else:
363
+ self._parse_table(doc=doc, el=el, parent=parent)
361
364
  else:
362
365
  self._walk_children(doc=doc, el=el, parent=parent)
363
366
  elif name in {DocLangToken.TABLE.value, DocLangToken.INDEX.value}:
@@ -482,6 +485,8 @@ class DocLangDocDeserializer(BaseDocDeserializer, BaseModel):
482
485
  content_layer=content_layer,
483
486
  )
484
487
  self._apply_initial_text_provenance(item, text=code_text, prov_list=prov_list)
488
+ if (caption := self._extract_caption(doc=doc, el=el)) is not None:
489
+ item.captions.append(caption.get_ref())
485
490
  if thread_id:
486
491
  self._register_thread(thread_id=thread_id, host=nm, item=item)
487
492
  self._apply_custom_meta_from_element(item=item, el=el)
@@ -606,6 +611,7 @@ class DocLangDocDeserializer(BaseDocDeserializer, BaseModel):
606
611
  DocLangToken.LOCATION.value,
607
612
  DocLangToken.LAYER.value,
608
613
  DocLangToken.LABEL.value,
614
+ DocLangToken.CAPTION.value,
609
615
  }:
610
616
  continue
611
617
  elif nm_child == DocLangToken.BR.value:
@@ -1463,23 +1469,23 @@ class DocLangDocDeserializer(BaseDocDeserializer, BaseModel):
1463
1469
  return DocItemLabel.TABLE
1464
1470
 
1465
1471
  def _parse_table(self, *, doc: DoclingDocument, el: Element, parent: NodeItem | None) -> None:
1466
- """Parse ``<table>``, ``<index>``, or a ``<group>`` wrapping them (with footnotes)."""
1472
+ """Parse ``<table>``, ``<index>``, or a ``<group>`` wrapping them (with satellites)."""
1467
1473
  otsl_el: Element | None
1474
+ extra_captions: list[TextItem] = []
1468
1475
  footnotes: list[TextItem] = []
1469
1476
  if el.tagName in {DocLangToken.TABLE.value, DocLangToken.INDEX.value}:
1470
1477
  caption = self._extract_caption(doc=doc, el=el)
1471
1478
  otsl_el = el
1472
1479
  table_label = self._table_label_from_otsl_element(el)
1473
1480
  else:
1474
- footnotes = self._extract_footnotes(doc=doc, el=el)
1481
+ extra_captions, footnotes = self._extract_satellites(doc=doc, el=el)
1475
1482
  otsl_el = self._first_child(el, DocLangToken.TABLE.value) or self._first_child(el, DocLangToken.INDEX.value)
1476
1483
  caption = self._extract_caption(doc=doc, el=el)
1477
1484
  if caption is None and otsl_el is not None:
1478
1485
  caption = self._extract_caption(doc=doc, el=otsl_el)
1479
1486
  if otsl_el is None:
1480
1487
  tbl = doc.add_table(data=TableData(), caption=caption, parent=parent)
1481
- for ftn in footnotes:
1482
- tbl.footnotes.append(ftn.get_ref())
1488
+ self._attach_satellites(item=tbl, captions=extra_captions, footnotes=footnotes)
1483
1489
  return
1484
1490
  table_label = self._table_label_from_otsl_element(otsl_el)
1485
1491
 
@@ -1543,18 +1549,18 @@ class DocLangDocDeserializer(BaseDocDeserializer, BaseModel):
1543
1549
  tbl.prov.append(p)
1544
1550
  if thread_id:
1545
1551
  self._register_thread(thread_id=thread_id, host=table_host, item=tbl)
1546
- for ftn in footnotes:
1547
- tbl.footnotes.append(ftn.get_ref())
1552
+ self._attach_satellites(item=tbl, captions=extra_captions, footnotes=footnotes)
1548
1553
 
1549
1554
  def _parse_picture(self, *, doc: DoclingDocument, el: Element, parent: NodeItem | None) -> None:
1550
- """Parse ``<picture>`` or a ``<group>`` wrapping it (with footnotes)."""
1555
+ """Parse ``<picture>`` or a ``<group>`` wrapping it (with satellites)."""
1551
1556
  picture_el: Element | None
1557
+ extra_captions: list[TextItem] = []
1552
1558
  footnotes: list[TextItem] = []
1553
1559
  if el.tagName == DocLangToken.PICTURE.value:
1554
1560
  caption = self._extract_caption(doc=doc, el=el)
1555
1561
  picture_el = el
1556
1562
  else:
1557
- footnotes = self._extract_footnotes(doc=doc, el=el)
1563
+ extra_captions, footnotes = self._extract_satellites(doc=doc, el=el)
1558
1564
  picture_el = self._first_child(el, DocLangToken.PICTURE.value)
1559
1565
  caption = self._extract_caption(doc=doc, el=el)
1560
1566
  if caption is None and picture_el is not None:
@@ -1576,8 +1582,7 @@ class DocLangDocDeserializer(BaseDocDeserializer, BaseModel):
1576
1582
  self._source_recorder.bind_item(picture_el, pic)
1577
1583
  for p in prov_list[1:]:
1578
1584
  pic.prov.append(p)
1579
- for ftn in footnotes:
1580
- pic.footnotes.append(ftn.get_ref())
1585
+ self._attach_satellites(item=pic, captions=extra_captions, footnotes=footnotes)
1581
1586
 
1582
1587
  if picture_el is not None:
1583
1588
  if label_val := self._extract_label_value(el=picture_el):
@@ -1763,23 +1768,72 @@ class DocLangDocDeserializer(BaseDocDeserializer, BaseModel):
1763
1768
  self._source_recorder.bind_item(cap_el, item)
1764
1769
  return item
1765
1770
 
1766
- def _extract_footnotes(self, *, doc: DoclingDocument, el: Element) -> list[TextItem]:
1771
+ def _float_group_host(self, el: Element) -> Element | None:
1772
+ """Return the host of a float group, or ``None`` if ``el`` is not one.
1773
+
1774
+ DocLang has no dedicated construct for a float's extra captions and footnotes
1775
+ yet, so by convention they are emitted as ``<text>`` and ``<footnote>`` siblings
1776
+ of the host inside an unnamed ``<group>``. Such a group wraps exactly one
1777
+ ``<picture>``, ``<table>``, ``<index>`` or ``<code>`` and nothing but satellites
1778
+ besides it.
1779
+ """
1780
+ host_tags = {
1781
+ DocLangToken.PICTURE.value,
1782
+ DocLangToken.TABLE.value,
1783
+ DocLangToken.INDEX.value,
1784
+ DocLangToken.CODE.value,
1785
+ }
1786
+ satellite_tags = {DocLangToken.TEXT.value, DocLangToken.FOOTNOTE.value, DocLangToken.CAPTION.value}
1787
+ hosts: list[Element] = []
1788
+ for node in el.childNodes:
1789
+ if not isinstance(node, Element):
1790
+ continue
1791
+ if node.tagName in host_tags:
1792
+ hosts.append(node)
1793
+ elif node.tagName not in satellite_tags:
1794
+ return None
1795
+ return hosts[0] if len(hosts) == 1 else None
1796
+
1797
+ def _extract_satellites(self, *, doc: DoclingDocument, el: Element) -> tuple[list[TextItem], list[TextItem]]:
1798
+ """Extract the satellites of a float group: ``<text>`` as captions, ``<footnote>`` as footnotes."""
1799
+ labels = {
1800
+ DocLangToken.TEXT.value: DocItemLabel.CAPTION,
1801
+ DocLangToken.FOOTNOTE.value: DocItemLabel.FOOTNOTE,
1802
+ }
1803
+ captions: list[TextItem] = []
1767
1804
  footnotes: list[TextItem] = []
1768
1805
  for node in el.childNodes:
1769
- if isinstance(node, Element) and node.tagName == DocLangToken.FOOTNOTE.value:
1806
+ if isinstance(node, Element) and (label := labels.get(node.tagName)) is not None:
1770
1807
  text = self._get_text(node).strip()
1771
1808
  if text:
1772
1809
  prov_list = self._extract_provenance(doc=doc, el=node)
1773
1810
  item = doc.add_text(
1774
- label=DocItemLabel.FOOTNOTE,
1811
+ label=label,
1775
1812
  text=text,
1776
1813
  prov=(prov_list[0] if prov_list else None),
1777
1814
  )
1778
1815
  for p in prov_list[1:]:
1779
1816
  item.prov.append(p)
1780
1817
  self._source_recorder.bind_item(node, item)
1781
- footnotes.append(item)
1782
- return footnotes
1818
+ (captions if label == DocItemLabel.CAPTION else footnotes).append(item)
1819
+ return captions, footnotes
1820
+
1821
+ def _parse_code_group(
1822
+ self, *, doc: DoclingDocument, el: Element, code_el: Element, parent: NodeItem | None
1823
+ ) -> None:
1824
+ """Parse a ``<group>`` wrapping a ``<code>`` with its satellites."""
1825
+ extra_captions, footnotes = self._extract_satellites(doc=doc, el=el)
1826
+ num_texts = len(doc.texts)
1827
+ self._parse_text_like(doc=doc, el=code_el, parent=parent)
1828
+ if (code := next((t for t in doc.texts[num_texts:] if isinstance(t, CodeItem)), None)) is not None:
1829
+ self._attach_satellites(item=code, captions=extra_captions, footnotes=footnotes)
1830
+
1831
+ @staticmethod
1832
+ def _attach_satellites(*, item: FloatingItem, captions: list[TextItem], footnotes: list[TextItem]) -> None:
1833
+ for cap in captions:
1834
+ item.captions.append(cap.get_ref())
1835
+ for ftn in footnotes:
1836
+ item.footnotes.append(ftn.get_ref())
1783
1837
 
1784
1838
  def _first_child(self, el: Element, tag_name: str) -> Element | None:
1785
1839
  for node in el.childNodes:
@@ -89,6 +89,7 @@ from docling_core.types.doc import (
89
89
  PictureItem,
90
90
  PictureMeta,
91
91
  ProvenanceItem,
92
+ RefItem,
92
93
  Script,
93
94
  SectionHeaderItem,
94
95
  Size,
@@ -397,13 +398,158 @@ def _serialize_floating_caption_head(
397
398
  params: DocLangParams,
398
399
  **kwargs: Any,
399
400
  ) -> str:
400
- """Serialize referenced caption(s) for inclusion in the host element head."""
401
- if not params.add_referenced_caption or not item.captions:
401
+ """Serialize the first referenced caption for inclusion in the host element head.
402
+
403
+ The element head holds at most one ``<caption>``; any further captions are emitted
404
+ next to the host by ``_serialize_floating_satellites``.
405
+ """
406
+ if not params.add_referenced_caption:
407
+ return ""
408
+ captions = _resolve_satellites(refs=item.captions, doc_serializer=doc_serializer, doc=doc, **kwargs)
409
+ if not captions:
402
410
  return ""
403
- cap_res = doc_serializer.serialize_captions(item=item, **kwargs)
411
+ head_item = item.model_copy(update={"captions": [captions[0].get_ref()]})
412
+ cap_res = doc_serializer.serialize_captions(item=head_item, **kwargs)
404
413
  return cap_res.text or ""
405
414
 
406
415
 
416
+ def _resolve_satellites(
417
+ *,
418
+ refs: list[RefItem],
419
+ doc_serializer: BaseDocSerializer,
420
+ doc: DoclingDocument,
421
+ **kwargs: Any,
422
+ ) -> list[TextItem]:
423
+ """Resolve caption or footnote references, skipping excluded ones."""
424
+ excluded = doc_serializer.get_excluded_refs(**kwargs)
425
+ return [sat for ref in refs if ref.cref not in excluded and isinstance(sat := ref.resolve(doc), TextItem)]
426
+
427
+
428
+ def _serialize_satellite(
429
+ *,
430
+ item: TextItem,
431
+ tag: DocLangToken,
432
+ content_type: ContentType,
433
+ doc: DoclingDocument,
434
+ params: DocLangParams,
435
+ ) -> str:
436
+ """Serialize a caption or footnote of a floating item as a standalone element."""
437
+ head = _element_head_prefix(item=item, doc=doc, params=params)
438
+ content = _escape_text(item.text, params) if item.text and content_type in params.content_types else ""
439
+ text = f"{head}{content}"
440
+ return _wrap(text=text, wrap_tag=tag.value) if text else ""
441
+
442
+
443
+ def _top_left_bbox(prov: ProvenanceItem, doc: DoclingDocument) -> BoundingBox | None:
444
+ """Return the provenance bbox in top-left origin, or ``None`` if the page height is unknown."""
445
+ if prov.bbox.coord_origin == CoordOrigin.TOPLEFT:
446
+ return prov.bbox
447
+ page = doc.pages.get(prov.page_no)
448
+ if page is None:
449
+ return None
450
+ return prov.bbox.to_top_left_origin(page_height=page.size.height)
451
+
452
+
453
+ def _satellite_placement(
454
+ *,
455
+ sat: DocItem,
456
+ host: FloatingItem,
457
+ doc: DoclingDocument,
458
+ ) -> tuple[bool, tuple[int, float, float]] | None:
459
+ """Place a satellite (extra caption or footnote) relative to its host by layout.
460
+
461
+ The satellite goes after the host if it sits on a later page, entirely below the
462
+ host, or (when overlapping it vertically) entirely to its right; if it overlaps
463
+ the host in both directions, if its bbox center is lower. Returns this decision
464
+ together with the satellite's reading-order sort key, or ``None`` if the position
465
+ cannot be determined.
466
+ """
467
+ if not sat.prov or not host.prov:
468
+ return None
469
+ sat_prov = sat.prov[0]
470
+ if (sat_bbox := _top_left_bbox(sat_prov, doc)) is None:
471
+ return None
472
+ key = (sat_prov.page_no, sat_bbox.t, sat_bbox.l)
473
+ host_pages = [p.page_no for p in host.prov]
474
+ if sat_prov.page_no < min(host_pages):
475
+ return False, key
476
+ if sat_prov.page_no > max(host_pages):
477
+ return True, key
478
+ host_prov = next((p for p in host.prov if p.page_no == sat_prov.page_no), None)
479
+ if host_prov is None or (host_bbox := _top_left_bbox(host_prov, doc)) is None:
480
+ return None
481
+ # clearly above or below the host
482
+ if sat_bbox.b <= host_bbox.t:
483
+ return False, key
484
+ if sat_bbox.t >= host_bbox.b:
485
+ return True, key
486
+ # beside the host
487
+ if sat_bbox.r <= host_bbox.l:
488
+ return False, key
489
+ if sat_bbox.l >= host_bbox.r:
490
+ return True, key
491
+ # overlapping the host
492
+ sat_y = (sat_bbox.t + sat_bbox.b) / 2
493
+ host_y = (host_bbox.t + host_bbox.b) / 2
494
+ return sat_y > host_y, key
495
+
496
+
497
+ def _serialize_floating_satellites(
498
+ *,
499
+ item: FloatingItem,
500
+ doc_serializer: BaseDocSerializer,
501
+ doc: DoclingDocument,
502
+ params: DocLangParams,
503
+ **kwargs: Any,
504
+ ) -> tuple[list[SerializationResult], list[SerializationResult]]:
505
+ """Serialize the extra captions and the footnotes of a floating item.
506
+
507
+ Captions beyond the first one (which goes in the host element head) become
508
+ ``<text>`` elements, footnotes become ``<footnote>`` elements. Each is placed
509
+ before or after the host by layout (see ``_satellite_placement``), and sorted in
510
+ reading order (page, top, left) on either side. Satellites without a determinable
511
+ position fall back to extra captions before and footnotes after the host.
512
+
513
+ Returns the ``(before, after)`` serialization results.
514
+ """
515
+ sats: list[tuple[TextItem, str, bool]] = []
516
+ if params.add_referenced_caption:
517
+ captions = _resolve_satellites(refs=item.captions, doc_serializer=doc_serializer, doc=doc, **kwargs)
518
+ for cap in captions[1:]:
519
+ text = _serialize_satellite(
520
+ item=cap, tag=DocLangToken.TEXT, content_type=ContentType.REF_CAPTION, doc=doc, params=params
521
+ )
522
+ sats.append((cap, text, False))
523
+ if params.add_referenced_footnote:
524
+ footnotes = _resolve_satellites(refs=item.footnotes, doc_serializer=doc_serializer, doc=doc, **kwargs)
525
+ for ftn in footnotes:
526
+ text = _serialize_satellite(
527
+ item=ftn, tag=DocLangToken.FOOTNOTE, content_type=ContentType.REF_FOOTNOTE, doc=doc, params=params
528
+ )
529
+ sats.append((ftn, text, True))
530
+
531
+ before_unplaced: list[SerializationResult] = []
532
+ after_unplaced: list[SerializationResult] = []
533
+ before: list[tuple[tuple[int, float, float], SerializationResult]] = []
534
+ after: list[tuple[tuple[int, float, float], SerializationResult]] = []
535
+ for sat, text, default_after in sats:
536
+ if not text:
537
+ continue
538
+ res = create_ser_result(text=text, span_source=sat)
539
+ if (placement := _satellite_placement(sat=sat, host=item, doc=doc)) is None:
540
+ (after_unplaced if default_after else before_unplaced).append(res)
541
+ else:
542
+ goes_after, key = placement
543
+ (after if goes_after else before).append((key, res))
544
+
545
+ before.sort(key=lambda x: x[0])
546
+ after.sort(key=lambda x: x[0])
547
+ return (
548
+ before_unplaced + [res for _, res in before],
549
+ [res for _, res in after] + after_unplaced,
550
+ )
551
+
552
+
407
553
  def _element_label_for_serialization(
408
554
  *,
409
555
  raw_label: str | None,
@@ -723,6 +869,7 @@ class DocLangTextSerializer(BaseModel, BaseTextSerializer):
723
869
  visited=visited,
724
870
  is_inline_scope=is_inline_scope,
725
871
  thread_id=thread_id,
872
+ include_caption_head=idp == 0,
726
873
  **kwargs,
727
874
  )
728
875
  res.append(tres)
@@ -814,14 +961,17 @@ class DocLangTextSerializer(BaseModel, BaseTextSerializer):
814
961
  is_inline_scope: bool = False,
815
962
  visited: set[str] | None = None,
816
963
  thread_id: str | None = None,
964
+ include_caption_head: bool = True,
817
965
  **kwargs: Any,
818
966
  ) -> SerializationResult:
819
967
  """Serialize a ``TextItem`` into DocLang markup.
820
968
 
821
969
  Depending on parameters, emits meta blocks, location tokens, and the
822
970
  item's textual content (prefixing code language for ``CodeItem``). For
823
- floating items, captions may be appended. The result can be wrapped in a
824
- tag derived from the item's label when applicable.
971
+ floating items, the first caption goes in the element head and further
972
+ captions and footnotes are emitted around it (see
973
+ ``_serialize_floating_satellites``). The result can be wrapped in a tag
974
+ derived from the item's label when applicable.
825
975
 
826
976
  Args:
827
977
  item: The text-like item to serialize.
@@ -935,6 +1085,12 @@ class DocLangTextSerializer(BaseModel, BaseTextSerializer):
935
1085
  params=params,
936
1086
  )
937
1087
 
1088
+ caption_head = ""
1089
+ if include_caption_head and isinstance(item, FloatingItem):
1090
+ caption_head = _serialize_floating_caption_head(
1091
+ item=item, doc_serializer=doc_serializer, doc=doc, params=params, **kwargs
1092
+ )
1093
+
938
1094
  include_href = not is_inline_scope
939
1095
  if not skip_location:
940
1096
  parts.append(
@@ -943,6 +1099,7 @@ class DocLangTextSerializer(BaseModel, BaseTextSerializer):
943
1099
  doc=doc,
944
1100
  params=params,
945
1101
  label_value=code_label,
1102
+ caption_text=caption_head or None,
946
1103
  custom_text=custom_head or None,
947
1104
  include_href=include_href,
948
1105
  thread_id=thread_id,
@@ -966,24 +1123,46 @@ class DocLangTextSerializer(BaseModel, BaseTextSerializer):
966
1123
  or (isinstance(item, FormulaItem) and ContentType.TEXT_FORMULA in params.content_types)
967
1124
  or (not isinstance(item, CodeItem | FormulaItem) and ContentType.TEXT_OTHER in params.content_types)
968
1125
  ):
969
- if item.children and not item.text:
970
- # Check if first child is InlineGroup - if so, only serialize that as text content
971
- first_child_ref = item.children[0]
972
- first_child_item = first_child_ref.resolve(doc)
973
-
974
- if isinstance(first_child_item, InlineGroup):
975
- # Only serialize the first child (InlineGroup) as the text content
976
- # Other children are hierarchical subordinates and will be serialized separately
977
- text_part = doc_serializer.serialize(item=first_child_item, visited=my_visited, **kwargs).text
1126
+ first_child_item = item.children[0].resolve(doc) if item.children else None
1127
+ if isinstance(first_child_item, InlineGroup):
1128
+ # Only serialize the first child (InlineGroup) as the text content
1129
+ # Other children are hierarchical subordinates and will be serialized separately.
1130
+ # The item's own text, when present, precedes the runs within the same element:
1131
+ # emitting the runs as siblings instead yields invalid DocLang and loses content.
1132
+ inline_part = doc_serializer.serialize(item=first_child_item, visited=my_visited, **kwargs).text
1133
+ if item.text:
1134
+ own_part = _escape_text(item.text, params)
1135
+ own_part = doc_serializer.post_process(
1136
+ text=own_part,
1137
+ formatting=item.formatting,
1138
+ hyperlink=None,
1139
+ )
1140
+ if item.label == DocItemLabel.HANDWRITTEN_TEXT:
1141
+ own_part = _wrap(text=own_part, wrap_tag=DocLangToken.HANDWRITING.value)
1142
+ elif item.label in [
1143
+ DocItemLabel.CHECKBOX_SELECTED,
1144
+ DocItemLabel.CHECKBOX_UNSELECTED,
1145
+ ]:
1146
+ # Add checkbox token before the text
1147
+ checkbox_token = DocLangVocabulary._create_checkbox_token(
1148
+ selected=(item.label == DocItemLabel.CHECKBOX_SELECTED)
1149
+ )
1150
+ own_part = checkbox_token + own_part
1151
+ # Join with a plain space rather than the pretty-printing delimiter:
1152
+ # own_part and inline_part are two runs of the same inline flow, so
1153
+ # they need a real word boundary even in minimized (non-pretty) output.
1154
+ text_part = " ".join([p for p in (own_part, inline_part) if p])
978
1155
  else:
979
- # Serialize all children as text content
980
- sub_parts: list[str] = []
981
- for child_ref in item.children:
982
- child_item = child_ref.resolve(doc)
983
- if isinstance(item, ListItem) and _list_item_segment_sibling(child_item):
984
- continue
985
- sub_parts.append(doc_serializer.serialize(item=child_item, visited=my_visited, **kwargs).text)
986
- text_part = _get_delim(params=params).join(sub_parts)
1156
+ text_part = inline_part
1157
+ elif item.children and not item.text:
1158
+ # Serialize all children as text content
1159
+ sub_parts: list[str] = []
1160
+ for child_ref in item.children:
1161
+ child_item = child_ref.resolve(doc)
1162
+ if isinstance(item, ListItem) and _list_item_segment_sibling(child_item):
1163
+ continue
1164
+ sub_parts.append(doc_serializer.serialize(item=child_item, visited=my_visited, **kwargs).text)
1165
+ text_part = _get_delim(params=params).join(sub_parts)
987
1166
  else:
988
1167
  text_part = _escape_text(item.text, params)
989
1168
  text_part = doc_serializer.post_process(
@@ -1006,18 +1185,6 @@ class DocLangTextSerializer(BaseModel, BaseTextSerializer):
1006
1185
  if text_part:
1007
1186
  parts.append(text_part)
1008
1187
 
1009
- if params.add_referenced_caption and isinstance(item, FloatingItem):
1010
- cap_text = doc_serializer.serialize_captions(item=item, **kwargs).text
1011
- if cap_text:
1012
- cap_text = _escape_text(cap_text, params)
1013
- parts.append(cap_text)
1014
-
1015
- if params.add_referenced_footnote and isinstance(item, FloatingItem):
1016
- ftn_text = doc_serializer.serialize_footnotes(item=item, **kwargs).text
1017
- if ftn_text:
1018
- ftn_text = _escape_text(ftn_text, params)
1019
- parts.append(ftn_text)
1020
-
1021
1188
  text_res = "".join(parts)
1022
1189
 
1023
1190
  # Special handling for ListItems with suppress_empty_elements
@@ -1043,6 +1210,17 @@ class DocLangTextSerializer(BaseModel, BaseTextSerializer):
1043
1210
  elif item.label in (DocItemLabel.FIELD_KEY, DocItemLabel.FIELD_VALUE):
1044
1211
  text_res = _wrap_field_kv_markup_if_needed(text=text_res, item=item, doc=doc)
1045
1212
 
1213
+ if include_caption_head and isinstance(item, FloatingItem) and not is_inline_scope:
1214
+ before, after = _serialize_floating_satellites(
1215
+ item=item, doc_serializer=doc_serializer, doc=doc, params=params, **kwargs
1216
+ )
1217
+ if before or after:
1218
+ # Wrap the host with its satellites so that they can be re-attached on deserialization.
1219
+ text_res = _wrap(
1220
+ text="".join(r.text for r in before) + text_res + "".join(r.text for r in after),
1221
+ wrap_tag=DocLangToken.GROUP.value,
1222
+ )
1223
+
1046
1224
  # Prepend ldiv element for ListItems (it's a delimiter, not a wrapper)
1047
1225
  if ldiv_element:
1048
1226
  text_res = ldiv_element + text_res
@@ -1254,19 +1432,21 @@ class DocLangPictureSerializer(BasePictureSerializer):
1254
1432
  picture_open += ">"
1255
1433
  picture_text = f"{picture_open}{inner}</{DocLangToken.PICTURE.value}>"
1256
1434
 
1257
- footnote_text = ""
1258
- if params.add_referenced_footnote:
1259
- ftn_res = doc_serializer.serialize_footnotes(item=item, **kwargs)
1260
- if ftn_res.text:
1261
- footnote_text = ftn_res.text
1262
- res_parts.append(ftn_res)
1435
+ before, after = _serialize_floating_satellites(
1436
+ item=item, doc_serializer=doc_serializer, doc=doc, params=params, **kwargs
1437
+ )
1438
+ res_parts.extend(before + after)
1263
1439
 
1264
- if not inner and not footnote_text:
1440
+ if not inner and not (before or after):
1265
1441
  if params.suppress_empty_elements:
1266
1442
  return create_ser_result()
1267
1443
  text_res = f"<{DocLangToken.PICTURE.value}></{DocLangToken.PICTURE.value}>"
1268
- elif footnote_text:
1269
- text_res = _wrap(text=picture_text + footnote_text, wrap_tag=DocLangToken.GROUP.value)
1444
+ elif before or after:
1445
+ # Wrap the host with its satellites so that they can be re-attached on deserialization.
1446
+ text_res = _wrap(
1447
+ text="".join(r.text for r in before) + picture_text + "".join(r.text for r in after),
1448
+ wrap_tag=DocLangToken.GROUP.value,
1449
+ )
1270
1450
  else:
1271
1451
  text_res = picture_text
1272
1452
 
@@ -1488,19 +1668,24 @@ class DocLangTableSerializer(BaseTableSerializer):
1488
1668
  table_text = _wrap(text=head + "".join(inner_parts), wrap_tag=host_token.value)
1489
1669
  res_parts.append(create_ser_result(text=head + "".join(inner_parts), span_source=item))
1490
1670
 
1491
- footnote_text = ""
1492
- if include_caption_head and params.add_referenced_footnote:
1493
- ftn_res = doc_serializer.serialize_footnotes(item=item, **kwargs)
1494
- if ftn_res.text:
1495
- footnote_text = ftn_res.text
1496
- res_parts.append(ftn_res)
1671
+ before: list[SerializationResult] = []
1672
+ after: list[SerializationResult] = []
1673
+ if include_caption_head:
1674
+ before, after = _serialize_floating_satellites(
1675
+ item=item, doc_serializer=doc_serializer, doc=doc, params=params, **kwargs
1676
+ )
1677
+ res_parts.extend(before + after)
1497
1678
 
1498
- if not (head or inner_parts) and not footnote_text:
1679
+ if not (head or inner_parts) and not (before or after):
1499
1680
  if params.suppress_empty_elements:
1500
1681
  return create_ser_result()
1501
1682
  text_res = f"<{host_token.value}></{host_token.value}>"
1502
- elif footnote_text:
1503
- text_res = _wrap(text=table_text + footnote_text, wrap_tag=DocLangToken.GROUP.value)
1683
+ elif before or after:
1684
+ # Wrap the host with its satellites so that they can be re-attached on deserialization.
1685
+ text_res = _wrap(
1686
+ text="".join(r.text for r in before) + table_text + "".join(r.text for r in after),
1687
+ wrap_tag=DocLangToken.GROUP.value,
1688
+ )
1504
1689
  else:
1505
1690
  text_res = table_text
1506
1691
 
@@ -1863,19 +2048,16 @@ class DocLangDocSerializer(DocSerializer):
1863
2048
  """Serialize the item's footnotes with DocLang location tokens."""
1864
2049
  params = DocLangParams(**kwargs)
1865
2050
  results: list[SerializationResult] = []
1866
- for footnote in item.footnotes:
1867
- if footnote.cref not in self.get_excluded_refs(**kwargs):
1868
- if isinstance(ftn := footnote.resolve(self.doc), TextItem):
1869
- head = _element_head_prefix(item=ftn, doc=self.doc, params=params)
1870
-
1871
- content = ""
1872
- if ftn.text and ContentType.REF_FOOTNOTE in params.content_types:
1873
- content = _escape_text(ftn.text, params)
1874
-
1875
- text_res = f"{head}{content}"
1876
- if text_res:
1877
- text_res = _wrap(text_res, wrap_tag=DocLangToken.FOOTNOTE.value)
1878
- results.append(create_ser_result(text=text_res))
2051
+ for ftn in _resolve_satellites(refs=item.footnotes, doc_serializer=self, doc=self.doc, **kwargs):
2052
+ text_res = _serialize_satellite(
2053
+ item=ftn,
2054
+ tag=DocLangToken.FOOTNOTE,
2055
+ content_type=ContentType.REF_FOOTNOTE,
2056
+ doc=self.doc,
2057
+ params=params,
2058
+ )
2059
+ if text_res:
2060
+ results.append(create_ser_result(text=text_res))
1879
2061
 
1880
2062
  text_res = "".join([r.text for r in results])
1881
2063