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
2 changes: 1 addition & 1 deletion docling/.agents/skills/docling/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,5 @@ uvx --from docling docling report.pdf --to md --output /tmp/

- Always report the conversion status and (for PDFs) the page count.
- If the user does not specify a format, ask whether they want **Markdown** (readable) or **JSON / DoclingDocument** (structured, lossless).
- For tables, prefer `export_to_markdown()` / `export_to_dataframe()` on the table item (Python) — see [references/python-sdk.md](references/python-sdk.md).
- For tables, prefer `export_to_markdown()` / `export_to_dataframe()` on the table item (Python) — see [references/python-sdk.md](references/python-sdk.md). For sparse tables in document Markdown, use `--markdown-table-format records` or `export_to_record_markdown(doc)`; neither changes default output.
- If a converted PDF comes back near-empty, repeated, or full of `�`, the source is likely scanned or complex layout — retry with OCR or `--pipeline vlm` (see [references/cli.md](references/cli.md)).
11 changes: 11 additions & 0 deletions docling/.agents/skills/docling/references/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ docling report.pdf --to md --to json --output /tmp/ # both at once
docling report.pdf --to dclx --output-file /tmp/named-result.dclx
```

For Markdown tables, opt in to labeled records rather than pipe tables:

```bash
docling report.pdf --to md --markdown-table-format records --output /tmp/
```

`--markdown-table-format default` is the default. It changes only Markdown
output. The same option works with `docling convert-remote`; it changes the
local export, not the remote service request. Record text labels actual cells
by row and column. It is not a Markdown pipe table.

Supported input formats include: `pdf`, `docx`/`doc`, `pptx`/`ppt`, `xlsx`/`xls`,
`html`, `md`, `asciidoc`, `csv`, `odt`/`ods`/`odp`, images, `audio`, and several
XML flavors. Restrict/force detection with `--from` (repeatable), e.g.
Expand Down
7 changes: 7 additions & 0 deletions docling/.agents/skills/docling/references/python-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ print(doc.export_to_markdown())
data = doc.export_to_dict() # structured / lossless
```

For Markdown tables as labeled source-cell records, use
`export_to_record_markdown(doc)` from `docling.utils.table_records`. It is an
opt-in export. The default `doc.export_to_markdown()` does not change. Use
`save_as_record_markdown(doc, "out.md", image_mode=ImageRefMode.REFERENCED)`
to save a file and its referenced images. For other Markdown settings, pass
`params=MarkdownParams(...)` to `export_to_record_markdown`.

`result.status` reports success/failure; `result.document` is always a
`DoclingDocument`.

Expand Down
18 changes: 16 additions & 2 deletions docling/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@
from docling.datamodel.pipeline_options_asr_model import InlineAsrOptions
from docling.datamodel.settings import DEFAULT_PAGE_RANGE, settings
from docling.utils.profiling import ProfilingItem
from docling.utils.table_records import (
MarkdownTableFormat,
_save_markdown_with_table_format,
)

# The local model stack (scipy, torch, …) is absent on lightweight installs
# (docling-slim[service-client] / docling-client). Guard these imports so the
Expand Down Expand Up @@ -529,6 +533,7 @@ def export_documents(
chunk_tokenizer: str = "sentence-transformers/all-MiniLM-L6-v2",
debug_vlm_native_output: bool = False,
output_file: Path | None = None,
markdown_table_format: MarkdownTableFormat = MarkdownTableFormat.DEFAULT,
):
success_count = 0
failure_count = 0
Expand Down Expand Up @@ -629,8 +634,11 @@ def export_documents(
if export_md:
fname = output_file or output_dir / f"{doc_filename}.md"
_log.info(f"writing Markdown output to {fname}")
conv_res.document.save_as_markdown(
filename=fname, image_mode=image_export_mode
_save_markdown_with_table_format(
conv_res.document,
fname,
image_mode=image_export_mode,
table_format=markdown_table_format,
)
if _is_empty_output(fname):
error_message = (
Expand Down Expand Up @@ -809,6 +817,11 @@ def convert( # noqa: C901
to_formats: list[OutputFormat] = typer.Option(
None, "--to", help="Specify output formats. Defaults to Markdown."
),
markdown_table_format: MarkdownTableFormat = typer.Option(
MarkdownTableFormat.DEFAULT,
"--markdown-table-format",
help="Table output in Markdown: default pipe tables or labeled cell records.",
),
chunker_type: ChunkerType = typer.Option(
ChunkerType.HYBRID,
"--chunks-type",
Expand Down Expand Up @@ -1857,6 +1870,7 @@ def _resolve_pdf_backend() -> tuple[type[PdfDocumentBackend], PdfBackendOptions]
print_timings=profiling,
export_timings=save_profiling,
image_export_mode=image_export_mode,
markdown_table_format=markdown_table_format,
chunker_type=chunker_type,
chunk_max_tokens=chunk_max_tokens,
chunk_tokenizer=chunk_tokenizer,
Expand Down
9 changes: 9 additions & 0 deletions docling/cli/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
StatusWatcherKind,
)
from docling.utils.ocr_language import OcrLanguageResolver
from docling.utils.table_records import MarkdownTableFormat

_log = logging.getLogger(__name__)

Expand Down Expand Up @@ -173,6 +174,13 @@ def convert_remote(
help="Output formats to produce and write locally. Defaults to Markdown.",
),
] = None,
markdown_table_format: Annotated[
MarkdownTableFormat,
typer.Option(
"--markdown-table-format",
help="Table output in local Markdown: default pipe tables or labeled cell records.",
),
] = MarkdownTableFormat.DEFAULT,
chunker_type: ChunkerType = typer.Option(
ChunkerType.HYBRID,
"--chunks-type",
Expand Down Expand Up @@ -390,6 +398,7 @@ def convert_remote(
print_timings=False,
export_timings=False,
image_export_mode=resolved_image_mode,
markdown_table_format=markdown_table_format,
chunker_type=chunker_type,
chunk_max_tokens=chunk_max_tokens,
chunk_tokenizer=chunk_tokenizer,
Expand Down
66 changes: 65 additions & 1 deletion docling/utils/table_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""Render table cells as labeled records without building a padded grid."""

import json
from enum import Enum
from pathlib import Path
from typing import Any

from docling_core.transforms.serializer.base import (
Expand All @@ -11,11 +13,15 @@
SerializationResult,
)
from docling_core.transforms.serializer.common import create_ser_result
from docling_core.transforms.serializer.markdown import MarkdownParams
from docling_core.transforms.serializer.markdown import (
MarkdownDocSerializer,
MarkdownParams,
)
from docling_core.types.doc import (
CaptionPlacement,
CoordOrigin,
DoclingDocument,
ImageRefMode,
NodeItem,
ProvenanceItem,
RichTableCell,
Expand All @@ -25,6 +31,64 @@
)


class MarkdownTableFormat(str, Enum):
DEFAULT = "default"
RECORDS = "records"


def export_to_record_markdown(
doc: DoclingDocument, *, params: MarkdownParams | None = None
) -> str:
"""Export Markdown with source cells labeled by row and column."""
return (
MarkdownDocSerializer(
doc=doc,
table_serializer=RecordTableSerializer(),
params=params if params is not None else MarkdownParams(),
)
.serialize()
.text
)


def _save_markdown_with_table_format(
doc: DoclingDocument,
filename: str | Path,
*,
image_mode: ImageRefMode,
table_format: MarkdownTableFormat,
) -> None:
"""Save Markdown using the selected table format."""
if table_format == MarkdownTableFormat.RECORDS:
save_as_record_markdown(doc, filename, image_mode=image_mode)
else:
doc.save_as_markdown(filename=filename, image_mode=image_mode)


def save_as_record_markdown(
doc: DoclingDocument,
filename: str | Path,
*,
image_mode: ImageRefMode = ImageRefMode.PLACEHOLDER,
) -> None:
"""Save record-oriented Markdown with Docling's usual image export modes."""
path = Path(filename)
# Match docling-core's save_as_markdown image preparation, including the
# sibling artifacts directory and image references for referenced mode.
artifacts_dir, reference_path = doc._get_output_paths(path)
if image_mode == ImageRefMode.REFERENCED:
artifacts_dir.mkdir(parents=True, exist_ok=True)
prepared = doc._make_copy_with_refmode(
artifacts_dir, image_mode, page_no=None, reference_path=reference_path
)
path.write_text(
export_to_record_markdown(
prepared, params=MarkdownParams(image_mode=image_mode)
),
encoding="utf-8",
)


class RecordTableSerializer(BaseTableSerializer):
"""Write one entry per source cell, including its position and span.

Expand Down
30 changes: 24 additions & 6 deletions docs/concepts/serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,34 @@ For wide, sparse tables, you can opt in to a record-oriented table serializer.
It writes each source cell once, with its one-based row and column position.
It does not build the padded `TableData.grid` during table rendering.

From Python, select the record format for one export:

```python
from docling_core.transforms.serializer.markdown import MarkdownDocSerializer
from docling.utils.table_records import RecordTableSerializer
from docling.document_converter import DocumentConverter
from docling.utils.table_records import export_to_record_markdown

doc = DocumentConverter().convert("input.pdf").document
text = export_to_record_markdown(doc)
```

text = MarkdownDocSerializer(
doc=doc,
table_serializer=RecordTableSerializer(),
).serialize().text
Use `save_as_record_markdown(doc, "output.md", image_mode=ImageRefMode.REFERENCED)`
if you need a file and referenced images. For other Markdown settings, pass
`params=MarkdownParams(...)` to `export_to_record_markdown`. You can also
select `RecordTableSerializer()` directly on a `MarkdownDocSerializer` instance.
The `DoclingDocument` Markdown methods belong to `docling-core` and do not
accept a table serializer.

From the CLI, use `--markdown-table-format records` on `docling convert` (or
`docling convert-remote` when writing the returned document locally):

```bash
docling convert input.csv --from csv --to md --markdown-table-format records --output ./out
```

The default is `--markdown-table-format default`. The option changes only
Markdown output. Other formats remain unchanged. `convert-remote` applies the
setting in the local exporter; it does not send the setting to Docling Serve.

This is text within a Markdown export, **not** a Markdown pipe table. Missing
positions are omitted, but explicit empty cells are present. Header flags and
merged ranges are written beside their source cells. `RichTableCell` content is
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ docling convert [OPTIONS] source
| --- | --- | --- | --- |
| `--from` | `text` (repeatable) | | Input formats to accept. Use 'odf' for odt, ods, and odp. Defaults to all supported formats. |
| `--to` | `md`, `json`, `yaml`, `html`, `html_split_page`, `text`, `doctags`, `vtt`, `doclang`, `dclx`, `chunks`, `latex` (repeatable) | | Specify output formats. Defaults to Markdown. |
| `--markdown-table-format` | `default`, `records` | `default` | Table output in Markdown: default pipe tables or labeled cell records. |
| `--chunks-type` | `hybrid`, `hierarchical` | `hybrid` | Chunker type for '--to chunks'. |
| `--chunks-max-tokens` | `integer` | | Max tokens per chunk. Defaults to the tokenizer's own limit. |
| `--chunks-tokenizer` | `text` | `sentence-transformers/all-MiniLM-L6-v2` | HuggingFace tokenizer model name/path. Used only with --chunks-type hybrid. |
Expand Down Expand Up @@ -131,6 +132,7 @@ docling convert-remote [OPTIONS] source
| `--api-key` | `text` | | API key for the service (optional; falls back to DOCLING_SERVICE_API_KEY or a .env file; omit if unauthenticated). |
| `--from` | `docx`, `doc`, `pptx`, `ppt`, `html`, `image`, `pdf`, `asciidoc`, `md`, `csv`, `xlsx`, `xls`, `odt`, `ods`, `odp`, `xml_uspto`, `xml_jats`, `xml_xbrl`, `xml_doclang`, `dclx`, `mets_gbs`, `json_docling`, `audio`, `video`, `vtt`, `latex`, `email`, `epub`, `boxnote`, `iwork_pages`, `ebcdic` (repeatable) | | Input formats to accept; filters directories and is sent as the server allow-list. Defaults to all supported formats. |
| `--to` | `md`, `json`, `yaml`, `html`, `html_split_page`, `text`, `doctags`, `vtt`, `doclang`, `dclx`, `chunks`, `latex` (repeatable) | | Output formats to produce and write locally. Defaults to Markdown. |
| `--markdown-table-format` | `default`, `records` | `default` | Table output in local Markdown: default pipe tables or labeled cell records. |
| `--chunks-type` | `hybrid`, `hierarchical` | `hybrid` | Chunker type for '--to chunks'. |
| `--chunks-max-tokens` | `integer` | | Max tokens per chunk. Defaults to the tokenizer's own limit. |
| `--chunks-tokenizer` | `text` | `sentence-transformers/all-MiniLM-L6-v2` | HuggingFace tokenizer model name/path. Used only with --chunks-type hybrid. |
Expand Down
71 changes: 71 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,77 @@ def test_cli_convert(tmp_path):
assert converted.exists()


@pytest.mark.parametrize("table_format", ["default", "records"])
def test_cli_markdown_table_format_is_opt_in(tmp_path, table_format):
source = tmp_path / "input.csv"
source.write_text("Name,Status\nAda,ready\nLin,\n", encoding="utf-8")
output = tmp_path / "out"

result = runner.invoke(
app,
[
str(source),
"--from",
"csv",
"--to",
"md",
"--to",
"json",
"--markdown-table-format",
table_format,
"--output",
str(output),
],
)

assert result.exit_code == 0, result.output
markdown = (output / "input.md").read_text(encoding="utf-8")
if table_format == "records":
assert 'Column 2: ""' in markdown
assert "Record 3" in markdown
assert "| Name" not in markdown
else:
assert "| Name" in markdown
assert "Record 3" not in markdown
assert (output / "input.json").exists()


@pytest.mark.parametrize("image_mode", ["embedded", "referenced"])
def test_cli_record_tables_keep_image_export_mode(tmp_path, image_mode):
source = _write_html_image_case(
tmp_path / "source",
"index.html",
"<table><tr><td>Record image</td></tr></table>",
)
output = tmp_path / "out"
result = runner.invoke(
app,
[
str(source),
"--from",
"html",
"--to",
"md",
"--markdown-table-format",
"records",
"--image-export-mode",
image_mode,
"--html-image-fetch",
"local",
"--output",
str(output),
],
)
assert result.exit_code == 0, result.output
markdown = (output / "index.md").read_text(encoding="utf-8")
assert 'Column 1: "Record image"' in markdown
if image_mode == "embedded":
_assert_markdown_embeds_png(output / "index.md")
else:
assert "index_artifacts/image_" in markdown
assert list((output / "index_artifacts").glob("*.png"))


def test_cli_exports_doclang(tmp_path):
source = tmp_path / "input.md"
source.write_text("# DocLang CLI\n\nHello from Markdown.", encoding="utf-8")
Expand Down
Loading
Loading