Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 178 additions & 65 deletions docling/backend/xml/jats_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import logging
import traceback
from dataclasses import dataclass, replace
from io import BytesIO
from pathlib import Path
from typing import Final, cast
Expand All @@ -36,6 +37,8 @@
TableData,
TextItem,
)
from docling_core.types.doc.document import Formatting, Script
from lxml import etree
from typing_extensions import TypedDict, override

from docling.backend.abstract_backend import DeclarativeDocumentBackend
Expand Down Expand Up @@ -68,6 +71,34 @@
DEFAULT_HEADER_REFERENCES: Final[str] = "References"
DEFAULT_TEXT_ETAL: Final[str] = "et al."

# Maps JATS formatting tags to docling-core formatting attributes.
_JATS_FORMAT_TAG_MAP: Final[dict[str, dict[str, bool | Script]]] = {
"bold": {"bold": True},
"italic": {"italic": True},
"underline": {"underline": True},
"strike": {"strikethrough": True},
"sub": {"script": Script.SUB},
"sup": {"script": Script.SUPER},
}


@dataclass(slots=True)
class InlineSegment:
"""An ordered inline run of styled text or an inline formula.

Attributes:
label: Docling item label that classifies this inline run when it is
emitted as a text item.
text: Literal text carried by the run, or the LaTeX body when the run
is a formula.
formatting: Emphasis accumulated from the enclosing tags (bold, italic,
underline, strike, sub, sup), or ``None`` when the run is unstyled.
"""

label: DocItemLabel
text: str
formatting: Formatting | None = None


class Abstract(TypedDict):
label: str
Expand Down Expand Up @@ -507,21 +538,6 @@ def _add_citation(self, doc: DoclingDocument, parent: NodeItem, text: str) -> No

return

def _handle_inline_formula(
self,
child: etree._Element,
node: etree._Element,
node_text: str,
inline_segments: list[tuple[DocItemLabel, str]],
) -> str:
formula = self._get_inline_equation(child) if node.tag == "p" else None
if formula is not None:
if node_text.strip():
inline_segments.append((DocItemLabel.TEXT, node_text.strip()))
node_text = ""
inline_segments.append((DocItemLabel.FORMULA, formula))
return node_text

def _parse_element_citation(self, node: etree._Element) -> str:
citation: Citation = {
"author_names": "",
Expand Down Expand Up @@ -651,22 +667,130 @@ def _parse_element_citation(self, node: etree._Element) -> str:
def _add_equation(
self, doc: DoclingDocument, parent: NodeItem, node: etree._Element
) -> None:
math_text = node.text
if not math_text:
return
math_parts = math_text.split("$$")
if len(math_parts) == 3:
math_formula = math_parts[1]
doc.add_text(label=DocItemLabel.FORMULA, text=math_formula, parent=parent)
formula = JatsDocumentBackend._extract_tex_math(node)
if formula:
doc.add_text(label=DocItemLabel.FORMULA, text=formula, parent=parent)

return

def _get_inline_equation(self, node: etree._Element) -> str | None:
tex_math = node.find("tex-math")
if tex_math is None or not tex_math.text:
@staticmethod
def _extract_tex_math(node: etree._Element) -> str | None:
if not node.text:
return None
math_parts = tex_math.text.split("$$")
return math_parts[1] if len(math_parts) == 3 else tex_math.text.strip()
text = node.text.strip()
for delimiter in ("$$", "$"):
if (
len(text) > 2 * len(delimiter)
and text.startswith(delimiter)
and text.endswith(delimiter)
):
text = text[len(delimiter) : -len(delimiter)].strip()
break
return text or None

@staticmethod
def _merge_formatting(formatting: Formatting | None, tag: str) -> Formatting | None:
if tag not in _JATS_FORMAT_TAG_MAP:
return formatting
base = formatting if formatting else Formatting()
return base.model_copy(update=_JATS_FORMAT_TAG_MAP[tag])

@staticmethod
def _strip_segments(segments: list[InlineSegment]) -> list[InlineSegment]:
stripped: list[InlineSegment] = []
for segment in segments:
text = segment.text.strip()
if text:
stripped.append(replace(segment, text=text))
return stripped

@staticmethod
def _walk_inline_formula(
node: etree._Element, formatting: Formatting | None = None
) -> list[InlineSegment]:
current = JatsDocumentBackend._merge_formatting(formatting, node.tag)
segments: list[InlineSegment] = []
if node.text:
text = node.text.replace("\n", " ")
if text:
segments.append(
InlineSegment(
label=DocItemLabel.TEXT, text=text, formatting=current
)
)
for child in node:
tag = child.tag
if not isinstance(tag, str) or tag.endswith("}math"):
# Skip comments, processing instructions, and MathML.
pass
elif tag == "tex-math":
formula = JatsDocumentBackend._extract_tex_math(child)
if formula is not None:
segments.append(
InlineSegment(label=DocItemLabel.FORMULA, text=formula)
)
else:
segments.extend(
JatsDocumentBackend._walk_inline_formula(child, current)
)
if child.tail:
tail = child.tail.replace("\n", " ")
if tail:
segments.append(
InlineSegment(
label=DocItemLabel.TEXT, text=tail, formatting=current
)
)
return segments

@staticmethod
def _append_run(
segments: list[InlineSegment], text: str, formatting: Formatting | None
) -> None:
"""Append a text run, coalescing into the previous run when formatting matches."""
text = text.replace("\n", " ")
if not text:
return
if (
segments
and segments[-1].label == DocItemLabel.TEXT
and segments[-1].formatting == formatting
):
segments[-1] = replace(segments[-1], text=segments[-1].text + text)
else:
segments.append(
InlineSegment(label=DocItemLabel.TEXT, text=text, formatting=formatting)
)

@staticmethod
def _extend_segments(
segments: list[InlineSegment], more: list[InlineSegment]
) -> None:
"""Extend ``segments`` with ``more``, coalescing adjacent equal-format text."""
for segment in more:
if segment.label == DocItemLabel.TEXT:
JatsDocumentBackend._append_run(
segments, segment.text, segment.formatting
)
else:
segments.append(segment)

@staticmethod
def _emit_inline(
doc: DoclingDocument, parent: NodeItem, segments: list[InlineSegment]
) -> None:
"""Emit inline segments under ``parent``, wrapping many in an inline group."""
segments = JatsDocumentBackend._strip_segments(segments)
if not segments:
return
container = doc.add_inline_group(parent=parent) if len(segments) > 1 else parent
for segment in segments:
doc.add_text(
label=segment.label,
text=segment.text,
formatting=segment.formatting,
parent=container,
)

def _add_figure_captions(
self, doc: DoclingDocument, parent: NodeItem, node: etree._Element
Expand Down Expand Up @@ -920,27 +1044,27 @@ def _add_footnote_group(
)

def _walk_linear(
self, doc: DoclingDocument, parent: NodeItem, node: etree._Element
) -> str:
self,
doc: DoclingDocument,
parent: NodeItem,
node: etree._Element,
formatting: Formatting | None = None,
) -> list[InlineSegment]:
skip_tags = ["term"]
flush_tags = ["ack", "sec", "list", "boxed-text", "disp-formula", "fig"]
new_parent: NodeItem = parent
inline_segments: list[tuple[DocItemLabel, str]] = []
node_text: str = (
node.text.replace("\n", " ")
if (node.tag not in skip_tags and node.text)
else ""
)
current = JatsDocumentBackend._merge_formatting(formatting, node.tag)
inline_segments: list[InlineSegment] = []
if node.tag not in skip_tags and node.text:
JatsDocumentBackend._append_run(inline_segments, node.text, current)

for child in list(node):
stop_walk: bool = False

# flush text into TextItem for some tags in paragraph nodes
if node.tag == "p" and node_text.strip() and child.tag in flush_tags:
doc.add_text(
label=DocItemLabel.TEXT, text=node_text.strip(), parent=parent
)
node_text = ""
# flush pending inline content before a block child in a paragraph
if node.tag == "p" and child.tag in flush_tags:
JatsDocumentBackend._emit_inline(doc, parent, inline_segments)
inline_segments = []

# add elements and decide whether to stop walking
if child.tag in ("sec", "ack"):
Expand Down Expand Up @@ -1022,40 +1146,29 @@ def _walk_linear(
self._add_equation(doc, parent, child)
stop_walk = True
elif child.tag == "inline-formula":
node_text = self._handle_inline_formula(
child,
node,
node_text,
# Inline formula: tex-math stays inline, unlike block <disp-formula>.
JatsDocumentBackend._extend_segments(
inline_segments,
JatsDocumentBackend._walk_inline_formula(child, current),
)
stop_walk = True

# step into child
if not stop_walk:
new_text = self._walk_linear(doc, new_parent, child)
child_segments = self._walk_linear(doc, new_parent, child, current)
if not (node.getparent().tag == "p" and node.tag in flush_tags):
node_text += new_text
JatsDocumentBackend._extend_segments(
inline_segments, child_segments
)
if child.tag in ("sec", "ack") and text:
self.hlevel -= 1

# pick up the tail text
node_text += child.tail.replace("\n", " ") if child.tail else ""
if child.tail:
JatsDocumentBackend._append_run(inline_segments, child.tail, current)

# create paragraph
# emit the paragraph, or backpropagate inline content to the parent
if node.tag == "p":
if node_text.strip():
inline_segments.append((DocItemLabel.TEXT, node_text.strip()))
if inline_segments:
# Wrap in an inline group only when several segments flow together
# (e.g. text + formula); a single segment is added directly.
container = (
doc.add_inline_group(parent=parent)
if len(inline_segments) > 1
else parent
)
for label, text in inline_segments:
doc.add_text(label=label, text=text, parent=container)
return ""
else:
# backpropagate the text
return node_text
JatsDocumentBackend._emit_inline(doc, parent, inline_segments)
return []
return inline_segments
Loading
Loading