From 259b44f963b12efaebefb1fd58c6036a675b634f Mon Sep 17 00:00:00 2001 From: sabith Date: Sun, 11 Oct 2026 22:58:18 -0700 Subject: [PATCH] feat: opt in to record-oriented Markdown exports --- docling/.agents/skills/docling/SKILL.md | 2 +- .../.agents/skills/docling/references/cli.md | 11 +++ .../skills/docling/references/python-sdk.md | 7 ++ docling/cli/main.py | 18 ++++- docling/cli/remote.py | 9 +++ docling/utils/table_records.py | 66 ++++++++++++++++- docs/concepts/serialization.md | 30 ++++++-- docs/reference/cli.md | 2 + tests/test_cli.py | 71 +++++++++++++++++++ tests/test_cli_remote.py | 52 ++++++++++++++ tests/test_table_records.py | 44 +++++++++++- 11 files changed, 301 insertions(+), 11 deletions(-) diff --git a/docling/.agents/skills/docling/SKILL.md b/docling/.agents/skills/docling/SKILL.md index f6bdfa26ae..22105e3b59 100644 --- a/docling/.agents/skills/docling/SKILL.md +++ b/docling/.agents/skills/docling/SKILL.md @@ -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)). diff --git a/docling/.agents/skills/docling/references/cli.md b/docling/.agents/skills/docling/references/cli.md index 056290f344..81658f1baf 100644 --- a/docling/.agents/skills/docling/references/cli.md +++ b/docling/.agents/skills/docling/references/cli.md @@ -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. diff --git a/docling/.agents/skills/docling/references/python-sdk.md b/docling/.agents/skills/docling/references/python-sdk.md index 54cc35d6b2..47b8dd614f 100644 --- a/docling/.agents/skills/docling/references/python-sdk.md +++ b/docling/.agents/skills/docling/references/python-sdk.md @@ -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`. diff --git a/docling/cli/main.py b/docling/cli/main.py index 8c5f2daab5..68614e6c5a 100644 --- a/docling/cli/main.py +++ b/docling/cli/main.py @@ -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 @@ -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 @@ -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 = ( @@ -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", @@ -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, diff --git a/docling/cli/remote.py b/docling/cli/remote.py index aef469221e..63c74e69e2 100644 --- a/docling/cli/remote.py +++ b/docling/cli/remote.py @@ -37,6 +37,7 @@ StatusWatcherKind, ) from docling.utils.ocr_language import OcrLanguageResolver +from docling.utils.table_records import MarkdownTableFormat _log = logging.getLogger(__name__) @@ -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", @@ -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, diff --git a/docling/utils/table_records.py b/docling/utils/table_records.py index e44a263746..ad745d529b 100644 --- a/docling/utils/table_records.py +++ b/docling/utils/table_records.py @@ -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 ( @@ -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, @@ -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. diff --git a/docs/concepts/serialization.md b/docs/concepts/serialization.md index 3a817782ca..862e3f69b0 100644 --- a/docs/concepts/serialization.md +++ b/docs/concepts/serialization.md @@ -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 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 93790351a1..31aec986fc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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. | @@ -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. | diff --git a/tests/test_cli.py b/tests/test_cli.py index 19f9b04643..8a6e25783e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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", + "
Record image
", + ) + 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") diff --git a/tests/test_cli_remote.py b/tests/test_cli_remote.py index 10d8f813f6..95dd524830 100644 --- a/tests/test_cli_remote.py +++ b/tests/test_cli_remote.py @@ -9,6 +9,7 @@ from pathlib import Path, PurePath import pytest +from docling_core.types.doc import DoclingDocument, TableCell, TableData from typer.testing import CliRunner from docling.cli.main import app @@ -137,6 +138,57 @@ def test_remote_converts_local_file(tmp_path, _patch_client): assert client.captured_sources == [source] +def test_remote_record_markdown_is_local_only(tmp_path, monkeypatch, _patch_client): + source = tmp_path / "report.pdf" + source.write_bytes(b"%PDF-1.4") + output = tmp_path / "out" + doc = DoclingDocument(name="remote") + doc.add_table( + data=TableData( + num_rows=1, + num_cols=2, + table_cells=[ + TableCell( + text="remote value", + start_row_offset_idx=0, + end_row_offset_idx=1, + start_col_offset_idx=1, + end_col_offset_idx=2, + ) + ], + ) + ) + original_init = _FakeResult.__init__ + + def _with_record_document(self, name): + original_init(self, name) + self.document = doc + + monkeypatch.setattr(_FakeResult, "__init__", _with_record_document) + + result = runner.invoke( + app, + [ + "convert-remote", + str(source), + "--service-url", + "https://docling.example.com", + "--markdown-table-format", + "records", + "--image-export-mode", + "placeholder", + "--output", + str(output), + ], + ) + + assert result.exit_code == 0, result.output + assert 'Column 2: "remote value"' in (output / "report.md").read_text() + opts = _FakeClient.instances[-1].captured_options + assert opts.image_export_mode.value == "placeholder" + assert "markdown_table_format" not in type(opts).model_fields + + def test_remote_url_source_stays_string(tmp_path, _patch_client): output = tmp_path / "out" result = runner.invoke( diff --git a/tests/test_table_records.py b/tests/test_table_records.py index a045782894..5e1cea34f0 100644 --- a/tests/test_table_records.py +++ b/tests/test_table_records.py @@ -3,6 +3,7 @@ import json from io import BytesIO +from pathlib import Path from unittest.mock import patch from docling_core.transforms.serializer.markdown import ( @@ -15,16 +16,23 @@ CoordOrigin, DocItemLabel, DoclingDocument, + ImageRef, + ImageRefMode, ProvenanceItem, RefItem, RichTableCell, TableCell, TableData, ) +from PIL import Image from docling.datamodel.base_models import DocumentStream, InputFormat from docling.document_converter import DocumentConverter -from docling.utils.table_records import RecordTableSerializer +from docling.utils.table_records import ( + RecordTableSerializer, + export_to_record_markdown, + save_as_record_markdown, +) def _cell(row: int, column: int, text: str) -> TableCell: @@ -117,6 +125,40 @@ def test_csv_conversion_keeps_document_cells_and_changes_only_selected_export() assert doc.export_to_markdown().startswith("| Name") +def test_record_export_helpers_keep_image_modes_and_default_output( + tmp_path: Path, +) -> None: + doc = DoclingDocument(name="with image") + doc.add_table( + data=TableData(num_rows=1, num_cols=2, table_cells=[_cell(0, 1, "value")]) + ) + doc.add_picture( + image=ImageRef.from_pil(Image.new("RGB", (1, 1), color="red"), dpi=72) + ) + standard = doc.export_to_markdown() + + assert export_to_record_markdown(doc) == _render(doc) + assert "Record 1" in export_to_record_markdown( + doc, params=MarkdownParams(image_mode=ImageRefMode.PLACEHOLDER) + ) + assert doc.export_to_markdown() == standard + + for mode in ImageRefMode: + target = tmp_path / mode.value / "output.md" + target.parent.mkdir() + save_as_record_markdown(doc, target, image_mode=mode) + actual = target.read_text(encoding="utf-8") + assert 'Column 2: "value"' in actual + if mode == ImageRefMode.EMBEDDED: + assert "data:image/png;base64" in actual + elif mode == ImageRefMode.REFERENCED: + assert "output_artifacts/image_" in actual + assert list((target.parent / "output_artifacts").glob("*.png")) + else: + assert "" in actual + assert doc.export_to_markdown() == standard + + def test_spans_and_header_flags_are_attached_to_origin_cell() -> None: doc = DoclingDocument(name="spans") doc.add_table(