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 |