diff --git a/docling/.agents/skills/docling/references/cli.md b/docling/.agents/skills/docling/references/cli.md
index 7b8ebbb31c..3021e5eabb 100644
--- a/docling/.agents/skills/docling/references/cli.md
+++ b/docling/.agents/skills/docling/references/cli.md
@@ -52,6 +52,7 @@ docling report.pdf --pipeline vlm --vlm-model granite_docling --output /tmp/
docling report.pdf --pipeline vlm --vlm-model smoldocling --output /tmp/
docling report.pdf --pipeline vlm --vlm-model nemotron_parse_v2 --output /tmp/
docling report.pdf --pipeline vlm --vlm-model nemotron_parse_v3_5 --trust-remote-code --output /tmp/
+docling report.pdf --pipeline vlm --vlm-model lightonocr3_08b --output /tmp/
docling chart.png --from image --to dclx --pipeline vlm --enrich-chart-extraction
docling report.pdf --pipeline native --from pdf --output /tmp/
```
diff --git a/docling/datamodel/pipeline_options.py b/docling/datamodel/pipeline_options.py
index 910b11cca4..36f6ea95b5 100644
--- a/docling/datamodel/pipeline_options.py
+++ b/docling/datamodel/pipeline_options.py
@@ -1232,6 +1232,9 @@ class CodeFormulaVlmOptions(StagePresetMixin, VlmEngineOptionsMixin, BaseModel):
VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_DOLPHIN)
VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_GLMOCR)
VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_LIGHTONOCR)
+VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_LIGHTONOCR_3_08B)
+VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_LIGHTONOCR_3_1B)
+VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_LIGHTONOCR_3_4B)
VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_FALCON_OCR)
VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_CHANDRA_OCR2)
VlmConvertOptions.register_preset(stage_model_specs.VLM_CONVERT_UNLIMITED_OCR)
diff --git a/docling/datamodel/pipeline_options_vlm_model.py b/docling/datamodel/pipeline_options_vlm_model.py
index 5a3f4b918c..dfe01d1946 100644
--- a/docling/datamodel/pipeline_options_vlm_model.py
+++ b/docling/datamodel/pipeline_options_vlm_model.py
@@ -107,6 +107,7 @@ class ResponseFormat(str, Enum):
MARKDOWN = "markdown"
DEEPSEEKOCR_MARKDOWN = "deepseekocr_markdown"
UNLIMITED_OCR_MARKDOWN = "unlimited_ocr_markdown"
+ LIGHTONOCR_MARKDOWN = "lightonocr_markdown"
HTML = "html"
OTSL = "otsl"
PLAINTEXT = "plaintext"
diff --git a/docling/datamodel/stage_model_specs.py b/docling/datamodel/stage_model_specs.py
index 26fe32c1a3..ffe12d1c03 100644
--- a/docling/datamodel/stage_model_specs.py
+++ b/docling/datamodel/stage_model_specs.py
@@ -1697,6 +1697,50 @@ def from_preset(
default_engine_type=VlmEngineType.AUTO_INLINE,
)
+
+def _lightonocr3_preset(size: str, preset_id: str) -> StageModelPreset:
+ name = f"LightOnOCR-3-{size}"
+ repo_id = f"lightonai/{name}"
+ return StageModelPreset(
+ preset_id=preset_id,
+ name=name,
+ description=f"LightOnOCR-3 OCR with block grounding ({size} parameters)",
+ model_spec=VlmModelSpec(
+ name=name,
+ default_repo_id=repo_id,
+ prompt="grounding",
+ response_format=ResponseFormat.LIGHTONOCR_MARKDOWN,
+ max_new_tokens=4096,
+ engine_overrides={
+ VlmEngineType.TRANSFORMERS: EngineModelConfig(
+ min_engine_version="5.5.4",
+ torch_dtype="bfloat16",
+ extra_config={
+ "transformers_model_type": TransformersModelType.AUTOMODEL_IMAGETEXTTOTEXT,
+ "transformers_prompt_style": TransformersPromptStyle.CHAT,
+ "extra_generation_config": {"skip_special_tokens": True},
+ },
+ ),
+ },
+ api_overrides={
+ VlmEngineType.API: ApiModelConfig(
+ params={"model": repo_id, "max_tokens": 4096}
+ ),
+ VlmEngineType.API_OPENAI: ApiModelConfig(
+ params={"model": repo_id, "max_tokens": 4096}
+ ),
+ },
+ ),
+ scale=2.0,
+ max_size=1540,
+ default_engine_type=VlmEngineType.AUTO_INLINE,
+ )
+
+
+VLM_CONVERT_LIGHTONOCR_3_08B = _lightonocr3_preset("0.8B", "lightonocr3_08b")
+VLM_CONVERT_LIGHTONOCR_3_1B = _lightonocr3_preset("1B", "lightonocr3_1b")
+VLM_CONVERT_LIGHTONOCR_3_4B = _lightonocr3_preset("4B", "lightonocr3_4b")
+
# -----------------------------------------------------------------------------
# PICTURE_DESCRIPTION PRESETS (for image captioning/description)
# -----------------------------------------------------------------------------
diff --git a/docling/pipeline/vlm_pipeline.py b/docling/pipeline/vlm_pipeline.py
index 0bf6cc0bba..e83954b077 100644
--- a/docling/pipeline/vlm_pipeline.py
+++ b/docling/pipeline/vlm_pipeline.py
@@ -79,6 +79,7 @@
ResponseFormat.CHANDRA_HTML,
ResponseFormat.DEEPSEEKOCR_MARKDOWN,
ResponseFormat.DOTS_JSON,
+ ResponseFormat.LIGHTONOCR_MARKDOWN,
ResponseFormat.NEMOTRON_PARSE_V2,
ResponseFormat.NEMOTRON_PARSE_V3_5,
ResponseFormat.UNLIMITED_OCR_MARKDOWN,
@@ -559,6 +560,17 @@ def _finalize_page_document(
page_image=page.image,
normalize_heading_levels=False,
)
+ elif response_format == ResponseFormat.LIGHTONOCR_MARKDOWN:
+ from docling.utils.lightonocr_utils import parse_lightonocr_markdown
+
+ document = parse_lightonocr_markdown(
+ content=predicted_text,
+ original_page_size=page.size,
+ page_no=page.page_no,
+ filename=conv_res.input.file.name or "file",
+ page_image=page.image,
+ normalize_heading_levels=False,
+ )
elif response_format == ResponseFormat.MARKDOWN:
document = self._convert_text_page(
conv_res, page, InputFormat.MD, MarkdownDocumentBackend
diff --git a/docling/utils/lightonocr_utils.py b/docling/utils/lightonocr_utils.py
new file mode 100644
index 0000000000..ac529d763b
--- /dev/null
+++ b/docling/utils/lightonocr_utils.py
@@ -0,0 +1,155 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""Parse LightOnOCR-3 Markdown with normalized block bounding boxes."""
+
+from __future__ import annotations
+
+import html
+import re
+from collections.abc import Iterator
+from pathlib import Path
+
+from docling_core.types.doc import (
+ ContentLayer,
+ DocItemLabel,
+ DoclingDocument,
+ DocumentOrigin,
+ ImageRef,
+ Size,
+)
+from PIL import Image as PILImage
+
+from docling.utils.chandra_utils import _add_html_fragment
+from docling.utils.markdown_math import create_markdown_parser
+from docling.utils.vlm_utils import (
+ normalize_section_header_levels,
+ strip_math_delimiters,
+)
+
+_MARKER = re.compile(
+ r"^ {0,3}!\[([a-z_]+)\+?\]\(([^)\n]*,[^)\n]*,[^)\n]*,[^)\n]*)\)[ \t]*(.*)$"
+)
+_LABELS = {
+ "title": "Title",
+ "header": "Page-Header",
+ "footer": "Page-Footer",
+ "page_number": "Page-Footer",
+ "footnote": "Footnote",
+ "caption": "Caption",
+ "formula": "Equation-Block",
+ "code": "Code-Block",
+ "table": "Table",
+ "list": "List-Group",
+ "image": "Image",
+ "chart": "Figure",
+ "header_image": "Image",
+ "footer_image": "Image",
+}
+
+
+def _blocks(content: str) -> Iterator[tuple[str, str, str]]:
+ label, bbox = "text", ""
+ lines: list[str] = []
+ fence = ""
+ for line in content.splitlines(keepends=True):
+ marker = _MARKER.fullmatch(line.rstrip("\r\n")) if not fence else None
+ if marker is not None:
+ if lines or bbox:
+ yield label, bbox, "".join(lines)
+ label, bbox, inline_body = marker.groups()
+ lines = []
+ line = inline_body + "\n" if inline_body else ""
+
+ # Check the block body too: a fence can start on the marker's line.
+ fence_match = re.match(r"^ {0,3}(`{3,}|~{3,})", line)
+ if fence_match:
+ delimiter = fence_match[1]
+ if not fence:
+ fence = delimiter
+ elif (
+ delimiter[0] == fence[0]
+ and len(delimiter) >= len(fence)
+ and not line[fence_match.end() :].strip()
+ ):
+ fence = ""
+ lines.append(line)
+ if lines or bbox:
+ yield label, bbox, "".join(lines)
+
+
+def parse_lightonocr_markdown(
+ content: str,
+ original_page_size: Size,
+ page_no: int,
+ filename: str = "file",
+ page_image: PILImage.Image | None = None,
+ *,
+ normalize_heading_levels: bool = True,
+) -> DoclingDocument:
+ """Preserve Markdown/HTML content and assign each block bbox at most once.
+
+ Continuation labels retain their base class. Separate blocks are not merged
+ or associated by proximity. Plain transcription is recovered without boxes.
+ Heading depths normalize per page unless the pipeline defers normalization
+ until all pages are joined with ``normalize_heading_levels=False``.
+ """
+ markdown = create_markdown_parser()
+ # The parser above checks this optional dependency before its types are used.
+ from marko.block import Heading, SetextHeading
+
+ doc = DoclingDocument(
+ name=Path(filename).stem,
+ origin=DocumentOrigin(
+ filename=filename, mimetype="text/markdown", binary_hash=0
+ ),
+ )
+ dpi = (
+ max(1, round(72 * page_image.width / original_page_size.width))
+ if page_image is not None
+ else 72
+ )
+ doc.add_page(
+ page_no=page_no,
+ size=original_page_size,
+ image=ImageRef.from_pil(image=page_image, dpi=dpi) if page_image else None,
+ )
+ for label, bbox, body in _blocks(content):
+ if not body.strip() and label not in {
+ "image",
+ "chart",
+ "header_image",
+ "footer_image",
+ }:
+ continue
+ layout_label = _LABELS.get(label, "Text")
+ if label == "formula":
+ rendered = f''
+ elif label in {"image", "header_image", "footer_image"}:
+ rendered = f'
'
+ else:
+ parsed = markdown.parse(body)
+ rendered = markdown.render(parsed)
+ if label == "title" and any(
+ isinstance(node, (Heading, SetextHeading)) and node.level > 1
+ for node in parsed.children
+ ):
+ layout_label = "Section-Header"
+ attrs = (
+ f'data-bbox="{html.escape(bbox.replace(",", " "), quote=True)}"'
+ if bbox
+ else ""
+ )
+ if bbox:
+ attrs += f' data-label="{layout_label}"'
+ fragment = f"
{rendered}
"
+ picture_start = len(doc.pictures)
+ _add_html_fragment(doc, fragment, label=DocItemLabel.TEXT)
+ for picture in doc.pictures[picture_start:]:
+ if picture.meta is not None and picture.meta.description is not None:
+ picture.meta.description.created_by = "lightonocr"
+ if label in {"header_image", "footer_image"}:
+ picture.content_layer = ContentLayer.FURNITURE
+ if normalize_heading_levels:
+ normalize_section_header_levels(doc)
+ return doc
diff --git a/docling/utils/markdown_math.py b/docling/utils/markdown_math.py
new file mode 100644
index 0000000000..4241c664a2
--- /dev/null
+++ b/docling/utils/markdown_math.py
@@ -0,0 +1,104 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""Math-aware Markdown rendering for native VLM response adapters."""
+
+from __future__ import annotations
+
+import html
+import re
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import marko
+
+_MATH = re.compile(
+ r"(? marko.Markdown:
+ """Render LaTeX as literal math nodes and keep code and currency literal.
+
+ ``inline_only`` leaves the enclosing native block type in control instead
+ of interpreting its text as new Markdown lists, headings, or tables.
+ """
+ # Markdown is an optional dependency for slim installations.
+ try:
+ import marko
+ except ImportError as exc:
+ raise ImportError(
+ "Markdown parsing requires `pip install 'docling-slim[format-markdown]'`."
+ ) from exc
+ from marko.helpers import MarkoExtension
+
+ class Math(marko.inline.InlineElement):
+ children: str
+ pattern = _MATH
+ priority = 8
+ parse_group = 0
+
+ class CodeSpan(marko.inline.CodeSpan):
+ override = True
+ priority = 9
+
+ class LiteralCode(marko.inline.InlineElement):
+ children: str
+ pattern = re.compile(r"]*>[\s\S]*?", re.IGNORECASE)
+ priority = 10
+ parse_group = 0
+
+ class MathRenderer:
+ def render_math(self, element: Math) -> str:
+ match = _MATH.fullmatch(element.children)
+ assert match is not None
+ latex = next(group for group in match.groups() if group is not None)
+ display = match.group(1) is not None or match.group(4) is not None
+ attrs = ' display="block"' if display else ""
+ return f""
+
+ def render_literal_code(self, element: LiteralCode) -> str:
+ return element.children
+
+ class Paragraph(marko.block.Paragraph):
+ override = True
+
+ @classmethod
+ def break_paragraph(
+ cls, source: marko.source.Source, lazy: bool = False
+ ) -> bool:
+ return False
+
+ @staticmethod
+ def is_setext_heading(line: str) -> bool:
+ return False
+
+ class InlineParser(marko.Parser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.block_elements = {
+ name: element
+ for name, element in self.block_elements.items()
+ if name in {"Document", "Paragraph", "BlankLine"}
+ }
+
+ return marko.Markdown(
+ parser=InlineParser if inline_only else marko.Parser,
+ extensions=[
+ # GFM adds block elements after InlineParser filters them, and its
+ # strikethrough and autolinks change literal native block content.
+ *([] if inline_only else ["gfm"]),
+ MarkoExtension(
+ elements=[
+ Math,
+ CodeSpan,
+ LiteralCode,
+ *([Paragraph] if inline_only else []),
+ ],
+ renderer_mixins=[MathRenderer],
+ ),
+ ],
+ )
diff --git a/docs/usage/vision_models.md b/docs/usage/vision_models.md
index f021fb9fb6..d44a3cb505 100644
--- a/docs/usage/vision_models.md
+++ b/docs/usage/vision_models.md
@@ -121,6 +121,50 @@ converter = DocumentConverter(
doc = converter.convert(source="FILE").document
```
+### LightOnOCR-3
+
+LightOnOCR-3 has three presets: `lightonocr3_08b`, `lightonocr3_1b`, and
+`lightonocr3_4b`. They use the official
+[LightOnOCR-3 models](https://huggingface.co/lightonai/LightOnOCR-3-1B)
+and require `transformers>=5.5.4` for local inference. The existing `lightonocr`
+preset continues to use LightOnOCR-2-1B.
+
+For `docling-slim` installations, the parser also requires the Markdown extra:
+
+```bash
+pip install 'docling-slim[format-markdown]'
+```
+
+```bash
+docling contract.pdf --pipeline vlm --vlm-model lightonocr3_08b
+```
+
+```python
+from docling.datamodel.pipeline_options import VlmConvertOptions, VlmPipelineOptions
+
+pipeline_options = VlmPipelineOptions(
+ vlm_options=VlmConvertOptions.from_preset("lightonocr3_1b"),
+)
+```
+
+Pass these options to the `PdfFormatOption` in the example above. The presets
+use the `grounding` prompt. Docling parses the block labels and normalized
+bounding boxes, Markdown text, HTML tables, image descriptions, and chart tables.
+Section headings use the document-wide depth normalization described below.
+LaTeX math is preserved.
+Coordinates belong to blocks; derived text and table cells do not receive
+separate boxes. Continuation blocks keep their base labels without being merged.
+Lists keep their structure, without assigning the block box to individual items.
+Separate captions are not associated with pictures or tables by proximity.
+
+For plain transcription, set `pipeline_options.vlm_options.model_spec.prompt = ""`.
+The parser also accepts that Markdown output, without bounding boxes. Use only
+the empty prompt or `grounding`, as specified in the model cards.
+
+Local inference uses Transformers. No MLX export is configured for these presets.
+An OpenAI-compatible server can also serve the official model repositories;
+configure its URL and engine options as described below.
+
### Other models
Other models can be configured by directly providing the Hugging Face `repo_id`, the prompt and a few more options.
diff --git a/tests/test_backend_optional_dependencies.py b/tests/test_backend_optional_dependencies.py
index 626431bf25..ce49f65344 100644
--- a/tests/test_backend_optional_dependencies.py
+++ b/tests/test_backend_optional_dependencies.py
@@ -197,6 +197,26 @@ def test_backend_reports_missing_dependency_with_install_hint(
assert "actionable-error-raised" in result.stdout
+def test_lightonocr_parser_reports_missing_markdown_dependency() -> None:
+ result = _run_with_blocked_module(
+ "marko",
+ "from docling.utils.lightonocr_utils import parse_lightonocr_markdown\n"
+ "from docling_core.types.doc import Size\n"
+ "try:\n"
+ " parse_lightonocr_markdown(\n"
+ " '\\nRent: 1000',\n"
+ " Size(width=600, height=800), page_no=1,\n"
+ " )\n"
+ "except ImportError as exc:\n"
+ " assert \"pip install 'docling-slim[format-markdown]'\" in str(exc), str(exc)\n"
+ " print('actionable-error-raised')\n"
+ "else:\n"
+ " raise AssertionError('expected ImportError when marko is missing')\n",
+ )
+ assert result.returncode == 0, result.stderr
+ assert "actionable-error-raised" in result.stdout
+
+
def test_service_client_imports_without_pdf_pipeline_dependency() -> None:
# The service client reaches docling.utils.pdf_outline (via noop_backend ->
# datamodel.document, which needs only the _PdfOutlineItem model), so a
diff --git a/tests/test_lightonocr3_heading_levels.py b/tests/test_lightonocr3_heading_levels.py
new file mode 100644
index 0000000000..8154df16dd
--- /dev/null
+++ b/tests/test_lightonocr3_heading_levels.py
@@ -0,0 +1,68 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+from pathlib import PurePath
+from types import SimpleNamespace
+
+import pytest
+from docling_core.types.doc import SectionHeaderItem, Size
+
+from docling.datamodel.base_models import Page, PagePredictions, VlmPrediction
+from docling.datamodel.pipeline_options import VlmConvertOptions, VlmPipelineOptions
+from docling.pipeline.vlm_pipeline import VlmPipeline
+from docling.utils.lightonocr_utils import parse_lightonocr_markdown
+
+_SIZE = Size(width=600, height=800)
+
+
+@pytest.mark.parametrize("depth", [1, 2])
+def test_standalone_heading_levels_keep_source_depths_distinct(depth: int) -> None:
+ source = (
+ f" {'#' * depth} Parent\n"
+ f" {'#' * (depth + 1)} Child\n"
+ )
+ doc = parse_lightonocr_markdown(source, _SIZE, page_no=2)
+ assert [
+ item.level for item in doc.texts if isinstance(item, SectionHeaderItem)
+ ] == [
+ 1,
+ 2,
+ ]
+ assert doc.export_to_markdown() == "## Parent\n\n### Child"
+ assert all(item.prov[0].page_no == 2 for item in doc.texts)
+
+
+@pytest.mark.parametrize("has_depth_one", [False, True])
+def test_pipeline_normalizes_heading_levels_once_across_pages(
+ has_depth_one: bool,
+) -> None:
+ pipeline = VlmPipeline.__new__(VlmPipeline)
+ pipeline.pipeline_options = VlmPipelineOptions(
+ vlm_options=VlmConvertOptions.from_preset("lightonocr3_08b")
+ )
+ pipeline.force_backend_text = False
+ conv_res = SimpleNamespace(
+ input=SimpleNamespace(file=PurePath("test.pdf")), errors=[]
+ )
+ first = " # Chapter\n" if has_depth_one else ""
+ pages = []
+ for page_no, source in (
+ (1, first + " ## Section"),
+ (2, " ## Next section"),
+ ):
+ page = Page(
+ page_no=page_no,
+ size=_SIZE,
+ predictions=PagePredictions(vlm_response=VlmPrediction(text=source)),
+ )
+ pages.append((page_no, pipeline._finalize_page_document(conv_res, page)))
+ document = pipeline._join_page_documents(pages)
+ headings = [item for item in document.texts if isinstance(item, SectionHeaderItem)]
+ expected = [(1, 1), (1, 2), (2, 2)] if has_depth_one else [(1, 1), (2, 1)]
+ assert [(item.prov[0].page_no, item.level) for item in headings] == expected
+ assert document.export_to_markdown() == (
+ "## Chapter\n\n### Section\n\n### Next section"
+ if has_depth_one
+ else "## Section\n\n## Next section"
+ )
+ assert not conv_res.errors
diff --git a/tests/test_lightonocr3_vlm.py b/tests/test_lightonocr3_vlm.py
new file mode 100644
index 0000000000..8d4ace8d3b
--- /dev/null
+++ b/tests/test_lightonocr3_vlm.py
@@ -0,0 +1,313 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""LightOnOCR-3 preset and grounded response tests, without model downloads."""
+
+from pathlib import PurePath
+from types import SimpleNamespace
+
+import pytest
+from docling_core.types.doc import (
+ CodeItem,
+ ContentLayer,
+ DocItemLabel,
+ FormulaItem,
+ SectionHeaderItem,
+ Size,
+)
+from PIL import Image
+
+from docling.datamodel.base_models import Page, PagePredictions, VlmPrediction
+from docling.datamodel.pipeline_options import VlmConvertOptions, VlmPipelineOptions
+from docling.datamodel.pipeline_options_vlm_model import (
+ ResponseFormat,
+ TransformersModelType,
+ TransformersPromptStyle,
+)
+from docling.models.inference_engines.vlm.base import VlmEngineType
+from docling.pipeline.vlm_pipeline import VlmPipeline
+from docling.utils.lightonocr_utils import parse_lightonocr_markdown
+
+PAGE_SIZE = Size(width=600, height=800)
+
+
+@pytest.mark.parametrize("size, suffix", [("0.8B", "08b"), ("1B", "1b"), ("4B", "4b")])
+def test_preset_runtime_configuration(size: str, suffix: str):
+ options = VlmConvertOptions.from_preset(f"lightonocr3_{suffix}")
+ spec = options.model_spec
+ config = spec.get_engine_config(VlmEngineType.TRANSFORMERS)
+ runtime = spec.get_runtime_input_extra_config(VlmEngineType.TRANSFORMERS)
+ assert config.repo_id == f"lightonai/LightOnOCR-3-{size}"
+ assert config.min_engine_version == "5.5.4"
+ assert config.torch_dtype == "bfloat16"
+ assert (
+ config.extra_config["transformers_model_type"]
+ == TransformersModelType.AUTOMODEL_IMAGETEXTTOTEXT
+ )
+ assert runtime["transformers_prompt_style"] == TransformersPromptStyle.CHAT
+ assert runtime["skip_special_tokens"] is True
+ assert "extra_generation_config" not in runtime
+ assert spec.prompt == "grounding"
+ assert spec.response_format == ResponseFormat.LIGHTONOCR_MARKDOWN
+ assert options.max_size == 1540
+ for engine in (VlmEngineType.API, VlmEngineType.API_OPENAI):
+ assert spec.get_api_params(engine) == {
+ "model": config.repo_id,
+ "max_tokens": 4096,
+ }
+ assert not spec.has_explicit_engine_export(VlmEngineType.MLX)
+
+
+def test_grounded_text_and_continuation():
+ content = """
+# Contract
+
+
+First line
+and its continuation.
+
+A second paragraph.
+
+
+Continued block.
+"""
+ doc = parse_lightonocr_markdown(
+ content, PAGE_SIZE, page_no=3, filename="contract.pdf"
+ )
+ assert doc.name == "contract"
+ assert [t.text for t in doc.texts] == [
+ "Contract",
+ "First line and its continuation.",
+ "A second paragraph.",
+ "Continued block.",
+ ]
+ assert doc.texts[0].label == DocItemLabel.TITLE
+ assert doc.texts[0].prov[0].page_no == 3
+ bbox = doc.texts[0].prov[0].bbox
+ assert (bbox.l, bbox.t, bbox.r, bbox.b) == (60, 40, 540, 80)
+ assert doc.texts[1].prov
+ assert not doc.texts[2].prov
+ assert doc.texts[3].prov
+
+
+def test_inline_markers_from_model_prediction():
+ content = (
+ " # LEASE AGREEMENT\n\n"
+ " Tenant: Example AB Monthly rent: SEK 1000 "
+ "Start date: 2026-10-01"
+ )
+ doc = parse_lightonocr_markdown(content, PAGE_SIZE, page_no=1)
+ assert not doc.pictures
+ assert doc.texts[0].text == "LEASE AGREEMENT"
+ assert doc.texts[0].label == DocItemLabel.TITLE
+ assert doc.texts[0].prov[0].bbox.l == pytest.approx(30.6)
+ assert (
+ doc.texts[1].text
+ == "Tenant: Example AB Monthly rent: SEK 1000 Start date: 2026-10-01"
+ )
+ assert doc.texts[1].prov
+
+
+def test_tables_images_and_charts():
+ content = """
+
+
+
+A building with a sign saying "Office".
+
+
+
+
+
+Annual rent.
+"""
+ doc = parse_lightonocr_markdown(content, PAGE_SIZE, page_no=1)
+ assert len(doc.tables) == 2
+ assert doc.tables[0].data.grid[1][0].text == "1000"
+ assert len(doc.pictures) == 2
+ description = doc.pictures[0].meta.description
+ assert description.text == 'A building with a sign saying "Office".'
+ assert description.created_by == "lightonocr"
+ assert doc.tables[1].parent == doc.pictures[1].get_ref()
+ assert doc.pictures[1].prov
+ assert not doc.tables[1].prov
+ assert doc.texts[-1].label == DocItemLabel.CAPTION
+ assert not doc.pictures[1].captions
+
+
+def test_furniture_and_invalid_coordinates():
+ content = """
+Lease
+
+3
+
+Company logo.
+
+Retain this despite the reversed box.
+"""
+ doc = parse_lightonocr_markdown(content, PAGE_SIZE, page_no=1)
+ assert doc.texts[0].label == DocItemLabel.PAGE_HEADER
+ assert doc.texts[1].label == DocItemLabel.PAGE_FOOTER
+ assert all(t.content_layer == ContentLayer.FURNITURE for t in doc.texts[:2])
+ assert doc.pictures[0].content_layer == ContentLayer.FURNITURE
+ assert doc.texts[-1].text == "Retain this despite the reversed box."
+ assert not doc.texts[-1].prov
+
+
+def test_plain_transcription_and_code_markers():
+ content = """# Heading
+
+| Field | Value |
+| --- | --- |
+| Rent | 1000 |
+
+
+```markdown
+
+```
+"""
+ doc = parse_lightonocr_markdown(content, PAGE_SIZE, page_no=1)
+ assert doc.texts[0].text == "Heading"
+ assert not doc.texts[0].prov
+ assert len(doc.tables) == 1
+ assert doc.texts[-1].label == DocItemLabel.CODE
+ assert doc.texts[-1].text == ""
+ assert doc.texts[-1].prov
+
+
+def test_empty_page_preserves_image():
+ image = Image.new("RGB", (600, 800), "white")
+ doc = parse_lightonocr_markdown("", PAGE_SIZE, page_no=2, page_image=image)
+ assert doc.pages[2].image.pil_image.size == (600, 800)
+ assert not (doc.texts or doc.tables or doc.pictures)
+
+
+def test_lists_and_empty_image_blocks():
+ content = """
+1. First condition
+2. Second condition
+
+
+"""
+ doc = parse_lightonocr_markdown(content, PAGE_SIZE, page_no=1)
+ assert [t.text for t in doc.texts] == ["First condition", "Second condition"]
+ assert all(t.label == DocItemLabel.LIST_ITEM for t in doc.texts)
+ assert all(not t.prov for t in doc.texts)
+ assert len(doc.pictures) == 1
+ assert doc.pictures[0].prov
+
+
+def test_pipeline_dispatch_and_page_concatenation():
+ pipeline = VlmPipeline.__new__(VlmPipeline)
+ pipeline.pipeline_options = VlmPipelineOptions(
+ vlm_options=VlmConvertOptions.from_preset("lightonocr3_08b")
+ )
+ pipeline.force_backend_text = False
+ page = Page(
+ page_no=5,
+ size=PAGE_SIZE,
+ predictions=PagePredictions(
+ vlm_response=VlmPrediction(text="\nRent: 1000")
+ ),
+ )
+ result = SimpleNamespace(
+ input=SimpleNamespace(file=PurePath("contract.pdf")), errors=[]
+ )
+ page_document = pipeline._finalize_page_document(result, page)
+ document = pipeline._concatenate_page_documents([(5, page_document)])
+ assert sorted(document.pages) == [5]
+ assert document.texts[0].text == "Rent: 1000"
+ assert document.texts[0].prov[0].page_no == 5
+ bbox = document.texts[0].prov[0].bbox
+ assert (bbox.l, bbox.t, bbox.r, bbox.b) == (60, 160, 540, 240)
+ assert not result.errors
+
+
+@pytest.mark.parametrize(
+ "heading, level",
+ [("## 2.2 LightOnOCR-1", 2), ("### Subsection", 3), ("Section\n-------", 2)],
+)
+def test_grounded_section_heading_preserves_level(heading: str, level: int):
+ doc = parse_lightonocr_markdown(
+ f" {heading}", PAGE_SIZE, page_no=3
+ )
+ item = doc.texts[0]
+ assert isinstance(item, SectionHeaderItem)
+ assert item.level == level - 1
+ assert item.prov[0].page_no == 3
+ assert item.prov[0].bbox.t == 80
+ assert doc.export_to_markdown() == f"{'#' * level} {item.text}"
+
+
+@pytest.mark.parametrize(
+ "body, latex",
+ [
+ ("$$\nx^2 + y^2 = z^2\n$$", "x^2 + y^2 = z^2"),
+ (r"\[\left\{x : x > 0\right\}\]", r"\left\{x : x > 0\right\}"),
+ (r"\(x_1 * y_2 * z_3\)", r"x_1 * y_2 * z_3"),
+ (r"$x_1$", r"x_1"),
+ (r"\frac{a}{b}", r"\frac{a}{b}"),
+ (
+ "$$\n\\begin{aligned}\na &= b \\\\\nc &= d\n\\end{aligned}\n$$",
+ "\\begin{aligned}\na &= b \\\\\nc &= d\n\\end{aligned}",
+ ),
+ ],
+)
+def test_grounded_formula_preserves_latex(body: str, latex: str):
+ doc = parse_lightonocr_markdown(
+ f"\n{body}", PAGE_SIZE, page_no=2
+ )
+ assert len(doc.texts) == 1
+ formula = doc.texts[0]
+ assert isinstance(formula, FormulaItem)
+ assert formula.text == latex
+ assert formula.prov[0].page_no == 2
+ assert formula.prov[0].bbox.l == 60
+ assert doc.export_to_markdown() == f"$${latex}$$"
+
+
+@pytest.mark.parametrize("marker", ["", "\n"])
+def test_inline_math_preserves_latex_and_code(marker: str):
+ latex = r"\left\{x_1 * y_2 * z_3 < 2\right\}"
+ source = marker + f"**Value** ${latex}$ and `literal $x_1$`. Cost: \\$5."
+ doc = parse_lightonocr_markdown(source, PAGE_SIZE, page_no=1)
+ formulas = [item for item in doc.texts if isinstance(item, FormulaItem)]
+ assert [formula.text for formula in formulas] == [latex]
+ assert all(not formula.prov for formula in formulas)
+ assert [item.text for item in doc.texts if isinstance(item, CodeItem)] == [
+ "literal $x_1$"
+ ]
+ exported = doc.export_to_markdown()
+ assert f"${latex}$" in exported
+ assert "`literal $x_1$`" in exported
+ assert "**Value**" in exported
+ assert "$5" in exported
+
+
+@pytest.mark.parametrize("fence", ["```", "~~~~"])
+@pytest.mark.parametrize("code", ["print(1)", ""])
+def test_inline_code_fence_preserves_following_block(fence: str, code: str):
+ source = (
+ f" {fence}python\n{code}\n{fence}\n"
+ " After code.\n"
+ )
+ doc = parse_lightonocr_markdown(source, PAGE_SIZE, page_no=1)
+ assert [(item.label, item.text) for item in doc.texts] == [
+ (DocItemLabel.CODE, code),
+ (DocItemLabel.TEXT, "After code."),
+ ]
+ assert not doc.pictures
+ assert doc.texts[0].prov[0].bbox.b == 400
+ assert doc.texts[1].prov[0].bbox.t == 400
+ assert doc.export_to_markdown() == f"```\n{code}\n```\n\nAfter code."
+
+
+def test_formatted_section_heading_owns_its_coordinates_once():
+ doc = parse_lightonocr_markdown(
+ " ## **Rent** and terms", PAGE_SIZE, page_no=1
+ )
+ assert isinstance(doc.texts[0], SectionHeaderItem)
+ assert doc.texts[0].level == 1
+ assert doc.texts[0].prov
+ assert all(not item.prov for item in doc.texts[1:])
+ assert doc.export_to_markdown() == "## **Rent** and terms"
diff --git a/tests/test_markdown_math.py b/tests/test_markdown_math.py
new file mode 100644
index 0000000000..68a6b9668c
--- /dev/null
+++ b/tests/test_markdown_math.py
@@ -0,0 +1,40 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+import pytest
+
+from docling.utils.markdown_math import create_markdown_parser
+
+
+@pytest.mark.parametrize(
+ ("source", "expected"),
+ [
+ (
+ "Price $5-$10 and $x$; the answer is $5$ or $2+2$.",
+ "Price $5-$10 and ; the answer is or .",
+ ),
+ (
+ "about ~5 m and ~10 m, where $x$ is the length",
+ "about ~5 m and ~10 m, where is the length",
+ ),
+ (
+ "| A | B |\n| --- | --- |\n| $x$ | y |",
+ "| A | B |\n| --- | --- |\n| | y |",
+ ),
+ ("> [!NOTE]\n> Value $x$.", "> [!NOTE]\n> Value ."),
+ (
+ "See www.example.com and $x$.",
+ "See www.example.com and .",
+ ),
+ ],
+)
+def test_inline_math_preserves_native_text_syntax(source: str, expected: str) -> None:
+ assert create_markdown_parser(inline_only=True).convert(source) == (
+ f"{expected}
\n"
+ )
+
+
+def test_full_markdown_still_parses_tables_with_math() -> None:
+ rendered = create_markdown_parser().convert("| A | B |\n| --- | --- |\n| $x$ | y |")
+ assert "" in rendered
+ assert " | " in rendered
diff --git a/tests/test_vlm_presets_and_runtime_options.py b/tests/test_vlm_presets_and_runtime_options.py
index 0aa109bcdc..28cd7216d6 100644
--- a/tests/test_vlm_presets_and_runtime_options.py
+++ b/tests/test_vlm_presets_and_runtime_options.py
@@ -737,6 +737,7 @@ def test_response_format_consistency(self):
ResponseFormat.MARKDOWN,
ResponseFormat.DEEPSEEKOCR_MARKDOWN,
ResponseFormat.UNLIMITED_OCR_MARKDOWN,
+ ResponseFormat.LIGHTONOCR_MARKDOWN,
ResponseFormat.PLAINTEXT,
ResponseFormat.CHANDRA_HTML,
ResponseFormat.DOTS_JSON,