D

dockling

Локальная конвертация PDF в структурированный Markdown (Docling), граф документа (слой A) и MCP-сервер для агентов. Лицензия MIT.

dockling

Русский | English

Локальная обработка PDF для агентов и RAG: конвертация в структурированный Markdown (на Docling) и граф документа, который агент читает через MCP — по узлам, а не «прочитай весь Markdown целиком».

Всё считается локально: ни LLM, ни API-ключей, ни облака. Граф собирается из уже готового DoclingDocument как обычный JSON, поэтому его можно строить и там, где Docling не установлен.

Компонент Что делает
pdf2md.py PDF → Markdown: текст, рисунки, таблицы, подписи; артефакты рядом (artifacts/, tables/, captions.md, suspicious.md)
check_run.py проверка готового прогона: файлы, ссылки, покрытие страниц, таблицы
docgraph.py граф документа из кэша DoclingDocument: узлы, рёбра, проекции, GraphML, режим чтения для агента
mcp_docgraph.py MCP-сервер над графом: corpus, overview, search, node, tree

Требования

  • Python 3.10+ (проверено на 3.10–3.13), Windows / Linux / macOS;
  • для конвертации — Docling 2.130.0 (см. requirements.txt), ~2 ГБ с моделями; при первом запуске скачиваются HF-модели (~500 МБ), каталог задаёт HF_HOME;
  • для графа, MCP-сервера и тестов сторонние зависимости не нужны — только стандартная библиотека (networkx — лишь для экспорта в GraphML).

Установка

python -m venv .venv
.venv\Scripts\activate           # Linux/macOS: source .venv/bin/activate
pip install -r requirements.txt  # только для конвертации

Без установки, через uv:

uv run --with docling python pdf2md.py <input.pdf> <out_dir>

Быстрый старт

# 1. PDF → Markdown (+ кэш DoclingDocument, картинки, таблицы)
python pdf2md.py <input.pdf> docling_output

# 2. граф документа из кэша — Docling уже не нужен
python docgraph.py "docling_output/<имя>/<имя>.json" --view skeleton

# 3. спросить граф как агент: структура, поиск, узел
python docgraph.py "docling_output/<имя>/<имя>.json" --tree
python docgraph.py "docling_output/<имя>/<имя>.json" --search "verification"
python docgraph.py "docling_output/<имя>/<имя>.json" --show texts/12

В примерах ниже встречается req_dev.pdf — локальный эталонный документ; в репозиторий он не входит (статья под авторским правом). Подставьте свой PDF.

Ключи:

  • --no-cache — игнорировать JSON-кэш и переконвертировать PDF заново;
  • подписи рисунков по умолчанию привязываются к изображению прямо в Markdown — <figure><img …><figcaption>…</figcaption></figure> — с относительными ссылками artifacts/…, а не только попадают в captions.md. Плоский абзац подписи при этом убирается, чтобы она не дублировалась;
  • --no-captions-inline — вернуть прежний плоский вывод (![Image](…) и подпись отдельным абзацем) — для сравнения с прежним поведением;
  • --captions-inline — устаревший флаг, оставлен для совместимости: привязка подписей теперь поведение по умолчанию;
  • --fast — быстрый режим (в 3–5 раз): отключает разбор таблиц (do_table_structure) и генерацию изображений, поэтому рисунков в artifacts/ и CSV/HTML-таблиц не будет — подходит для быстрой проверки текста;
  • --no-ocr — отключить распознавание текста (OCR). Быстрее на PDF с текстовым слоем; цена — текст внутри растровых страниц и рисунков остаётся нераспознанным. Замер на INCOSE Guide, 10 страниц: 165 с против 205 с с OCR; структура таблиц и число замечаний те же, потому что таблицы этих страниц брались из текстового слоя — растровые таблицы без OCR всё равно теряются;
  • --pages N — конвертировать только первые N страниц (для пробных прогонов на больших PDF). Требует необязательный пакет PyPDF2: без него печатается предупреждение в stderr и конвертируется весь документ;
  • --raw-tables — выгружать таблицы ровно так, как их разметил Docling, без нормализации по геометрии ячеек (см. «Таблицы» ниже): нужно только для сравнения с прежним поведением;
  • --no-toc-repair — не восстанавливать оглавление из текстового слоя PDF (см. «Оглавление» в разделе «Таблицы»). Флаг удобен, чтобы увидеть, что делает Docling сам, и не зависеть от текстового слоя на сканах без него.

Кэш имеет приоритет над --fast/--pages/--no-ocr: если <name>.json уже есть, документ берётся из него, поэтому для пробного прогона добавляйте --no-cache.

Диагностика

Ход работы идёт в stdout, а шум — в stderr, поэтому они не перемешиваются:

  • прогресс по таблицам — tqdm в stderr;
  • во время конвертации каждые 10 секунд в stderr пишется RAM процесса ([RAM ××MB], [RAM +×MB], при превышении 4 ГБ — [RAM ××MB ⚠ >4GB]), по завершении — пик RAM и совет использовать --fast.

Структура результата

Каждый документ получает свою папку <out_dir>/<name>/, поэтому рисунки и таблицы разных PDF не перемешиваются:

Файл/каталог (в <out_dir>/<name>/) Назначение
<name>.md основной Markdown: текст, рисунки (<figure> с относительными ссылками artifacts/…), markdown-таблицы
<name>.json кэш DoclingDocument — повторный запуск без переконвертации
artifacts/ изображения рисунков (PNG)
tables/ таблицы: table_NN.csv + table_NN.html
captions.md подписи к рисункам и таблицам
suspicious.md отчёт о подозрительных местах для ручной сверки
<name>.graph.json граф документа (слой A, docgraph.py) — все элементы
<name>.graph.skeleton.json проекция графа: структура и медиа (--view skeleton)
<name>.graph.blocks.json проекция графа: разделы со склеенным текстом (--view blocks)

Пример: pdf2md.py req_dev.pdf docling_output → docling_output/req_dev/req_dev.md.

Таблицы

Таблицы выгружаются не «как получилось у Docling», а нормализованными по геометрии ячеек (table_cells в кэше):

  • объединённые ячейки (col_span/row_span > 1) больше не размножаются по всем колонкам — текст попадает в первую из них, остальные остаются пустыми. Без этого шапка оглавления превращалась в три одинаковые колонки;
  • точечные выноски оглавления (1.1 PURPOSE AND SCOPE ....... 7) вырезаются, номера страниц остаются отдельной колонкой;
  • если геометрии в кэше нет или нормализация упала, таблица выгружается прежним путём (export_to_dataframe) — прогон не ломается, причина попадает в stderr;
  • одна и та же нормализованная таблица идёт во все три вида: tables/table_NN.csv, tables/table_NN.html и markdown-блок ## Таблица N в основном файле; именно её видят эвристики (suspicious.md, check_run.py), поэтому ложных «подозрительных годов» из оглавления больше нет.

Флаг --raw-tables возвращает выгрузку без нормализации — для сравнения.

Оглавление

Оглавление Docling разбирает как обычную таблицу, и на INCOSE Guide его строки расползаются: TableFormer объединяет по 2–3 визуальные строки в одну ячейку, номера страниц уезжают в отдельные строки. Такие ячейки починить нельзя — поэтому оглавление собирается заново из текстового слоя PDF (pdfium, он и так идёт с Docling), а Docling-таблица выбрасывается:

  • таблица считается оглавлением, если минимум в 15 % ячеек есть точечная выноска (is_toc_cells);
  • страницы берутся из prov таблицы, символы страницы собираются в строки по базовой линии, а текст строки запрашивается у pdfium по bbox всей строки — так выравненные пробелы и слова с разрядкой (PURPOSE, а не P URPOSE) остаются целыми;
  • из строки достаются номер пункта (1.2, B.2), название и номер страницы; строки без выноски (колонтитулы, «Table of Contents») отбрасываются;
  • результат — таблица | Номер | Название | Страница | в tables/, в разделе «Таблицы» и на месте оглавления в теле Markdown (заменяются только блоки-таблицы с выносками, ровно столько, сколько восстановлено).

На INCOSE Guide это 140 записей: 50 + 55 + 35 на страницах 3–5. Если текстового слоя нет (скан) или pypdfium2 не установлен, остаётся прежняя выгрузка Docling, а причина попадает в stderr.

Кэш

Повторный запуск берёт документ из <name>.json и выполняется быстро. Кэш привязан к имени входного файла: после смены PDF или настроек конвертации используйте --no-cache, иначе результат возьмётся из старого кэша. Перед записью результатов очищаются только управляемые каталоги artifacts/ и tables/; прочие файлы в папке документа не трогаются.

Коды возврата

  • 0 — успех;
  • 2 — неверные аргументы или вход (файл не найден / не PDF);
  • 1 — ошибка конвертации или экспорта части артефактов (детали в stderr и в итоговом выводе).

Тесты

python -m unittest discover -s tests -v

Тесты покрывают чистые функции (без загрузки моделей) и не требуют Docling. В tests/test_check_run.py — тесты инструмента проверки ниже, в tests/test_docgraph.py — тесты сборки графа документа.

Проверка готового прогона

check_run.py читает уже сконвертированную папку и говорит, всё ли на месте. Docling не нужен: только json, csv и re, поэтому инструментом можно проверить и чужой прогон. Что смотрит:

  • покрытие страниц провенансом (страницы, на которые не ссылается ни один элемент), элементы по типам;
  • таблицы: размеры, пустые ячейки, наличие подписей; рисунки — то же плюс наличие изображения в кэше;
  • ссылки в Markdown (относительные, file:///, процентное кодирование; и markdown-ссылки ![Image](…), и теги <img src="…">) — рабочие, внешние и битые;
  • файлы в artifacts/ и tables/, разбор suspicious.md по категориям;
  • повторный прогон эвристик (find_suspicious) по CSV-таблицам — видно, как изменение правил влияет на уже готовый прогон.
python check_run.py "docling_output/<имя>"
python check_run.py "docling_output/<имя>" --json

Коды возврата: 0 — расхождений нет; 1 — есть (битые ссылки, страницы без элементов, таблицы без CSV и т. п.); 2 — папка не найдена.

Граф документа (слой A)

docgraph.py читает готовый кэш DoclingDocument (<name>.json) и собирает из него детерминированный граф документа. Ни LLM, ни Docling, ни новых зависимостей: кэш разбирается как обычный JSON (для GraphML нужен networkx, но он необязателен).

python docgraph.py "docling_output/<имя>/<имя>.json"
python docgraph.py "docling_output/<имя>/<имя>.json" --view skeleton
python docgraph.py "docling_output/<имя>/<имя>.json" --view blocks --graphml

Узлы: document, page, group, section (из section_header), text, table, picture, caption. Рёбра: contains (вложенность по parent), next (порядок чтения), on_page (страница из prov), captions (таблица или рисунок → подпись).

Что важно в узлах:

  • content_layer — body или furniture (колонтитулы и подвалы);
  • in_reading_order — false, если элемент не попал в body.children. Такие элементы остаются в графе: у INCOSE Guide это 611 элементов (602 текстовых, 6 подписей, 2 заголовка, 1 таблица) плюс отдельно 108 служебных узлов страниц — ~11 % символов текста документа; у req_dev — 53 (52 текста и 1 колонтитул) плюс 14 страниц. Терять их нельзя (они и есть самая частая причина «пропавшего» текста);
  • walk — позиция в полном порядке обхода, next идёт строго по ней;
  • у таблиц и рисунков — относительные пути к готовым артефактам (tables/table_NN.csv, artifacts/image_NNNNNN_*.png), если они есть: кэш хранит рисунки как data:-URI, и в граф этот блоб не попадает;
  • level — уровень раздела, text — полный текст элемента.

Виды (--view):

Вид Что получается Файл
full (по умолчанию) канонический граф: все элементы <name>.graph.json
skeleton структура и медиа: документ, страницы, группы, разделы, таблицы, рисунки, подписи; next строится заново по порядку чтения <name>.graph.skeleton.json
blocks разделы со склеенным текстом абзацев (block_text, block_chars); текст до первого заголовка — блок #/preamble <name>.graph.blocks.json

Проекции считаются из полного графа в памяти, кэш повторно не читается. Формат — node-link JSON (nodes + links, тип ребра в links[].type), годный для networkx.node_link_graph. Флаг --graphml дополнительно пишет GraphML.

Прогоны: INCOSE Guide — 2351 узел и 6787 рёбер в full, 554/1396 в skeleton, 395/792 в blocks (340 разделов, из них 308 с непустым текстом; максимум 21 961 симв.); req_dev — 221/624, 48/105, 27/52 (17 разделов).

Коды возврата: 0 — успех; 2 — неверные аргументы или вход (файл не найден, не JSON, не кэш DoclingDocument); 1 — ошибка записи файла.

Просмотр графа

Любой из флагов --tree, --search, --show включает режим просмотра: файлы не пишутся, срез графа печатается в stdout (с --json — машиночитаемо, для скриптов и агентов).

# иерархия документа по рёбрам contains, в порядке чтения
python docgraph.py "docling_output/<имя>/<имя>.json" --tree
python docgraph.py "docling_output/<имя>/<имя>.json" --tree --depth 1

# поиск по тексту: подстрока (регистр не важен) либо --regex
python docgraph.py "docling_output/<имя>/<имя>.json" --search "verification"
python docgraph.py "docling_output/<имя>/<имя>.json" --search "R[0-9]{3}" --regex --limit 0

# один узел: по id, по self_ref или по уникальному хвосту
python docgraph.py "docling_output/<имя>/<имя>.json" --show "texts/123"
python docgraph.py "docling_output/<имя>/<имя>.json" --search "requirement" --json --limit 50
  • --tree идёт по иерархии contains и печатает строки вида - [text] Заголовок — стр. 5, 120 симв.; элементы не из body.children — с пометкой «вне порядка чтения», колонтитулы — «колонтитул». --depth N ограничивает глубину (0, по умолчанию, — без ограничения);
  • --search ищет по title, text и block_text (в выдачу идёт первое совпавшее поле), в порядке чтения, и показывает фрагмент вокруг совпадения (--snippet N, по умолчанию 160 символов). --limit N ограничивает число показанных совпадений (0 — все), общее число печатается всегда. По INCOSE Guide --search "requirement" находит 877 совпадений примерно за 0,4 с;
  • --show печатает краткую строку узла и все его поля в JSON (с --json — только JSON узла);
  • --json для --tree даёт вложенный JSON (children), для --search — matches плюс count/returned.

Коды возврата в режиме просмотра: 0 — есть что показать; 1 — поиск ничего не нашёл; 2 — неверные аргументы (конфликт режимов, пустой шаблон, плохое регулярное выражение, отрицательные --depth/--limit/--snippet) или узел не найден.

MCP-сервер (граф для агента)

mcp_docgraph.py отдаёт граф документа по MCP (stdio, JSON-RPC, только стандартная библиотека): агент сам решает, когда посмотреть структуру, найти фрагмент или поднять узел по id, вместо чтения всего Markdown целиком.

# запуск вручную (сервер читает построчный JSON-RPC со stdin)
python mcp_docgraph.py --root docling_output

Папки с документами задаются позиционно или флагом --root (можно несколько раз); без аргументов и без переменной DOCGRAPH_ROOTS берётся текущая папка проекта, рекурсивно до глубины --scan-depth (по умолчанию 3). Находятся оба вида файлов: кэш <имя>.json (граф собирается на лету) и готовый <имя>.graph*.json. По умолчанию предпочитается кэш Docling, с флагом --prefer-graph — готовый граф (он весит в разы меньше: на этом корпусе 49,9 МБ против 153,8 МБ). --max-text N ограничивает длину текста в ответах (0 — без обрезки).

Разобранные документы сервер держит в ограниченном кэше: --max-cached-docs N (сколько документов, по умолчанию 3) и --max-cached-mb N (потолок суммарного веса, по умолчанию 64); вес каждого документа — размер его исходного json, вытесняется самая давняя запись. На корпусе из семи документов без потолка сервер забирал ~125 МБ и рос дальше, с потолком — 70–85 МБ; в паре с --prefer-graph corpus отвечает за 0,8 с вместо 4,5 с. Инструмент corpus показывает число узлов и страниц, поэтому разбирает весь корпус целиком — именно на нём разница между кэшем и готовым графом заметна сильнее всего. Сервер ничего не пишет на диск, диагностика идёт в stderr, ответы — в stdout.

Инструментов пять, намеренно мало:

Инструмент Что делает
corpus какие документы видны: имя, путь, число узлов/страниц, размер
overview паспорт документа: версия, счётчики, типы узлов, структура (depth)
search поиск по заголовкам и текстам: id, тип, страница, фрагмент; без doc — по всему корпусу
node узел целиком: текст, родитель, соседи, bbox, путь к tables/*.csv и artifacts/*.png
tree иерархия от документа или от узла (id, depth); служебные узлы страниц не показываются

Каждый ответ несёт готовую ссылку на источник с реквизитами места, а не только id и страницу:

  • corpus и overview — строка ссылка: [документ](file:///…/имя.md) (overview ещё и файл: — исходный PDF/DOCX);
  • search — у каждого совпадения ссылка: [документ, разд. «глава», абз. N, стр. P — id](file:///…/имя.md) (и при поиске по одному документу, и по всему корпусу);
  • node — строка источник: … сразу после метаданных и строка раздел: «…» — id, стр. P;
  • tree — строка источник: … первой строкой.

Подпись документа в ссылке — короткая: скобочные уточнения ((2019, INCOSE Publications Office)), дублирующийся префикс (INCOSE - INCOSE Guide …) и хвост источника (… - libgen.li) убираются, длинное имя обрезается до 60 знаков, а id идёт без имени документа (— /texts/966) — полное имя всё равно видно в самой ссылке file:///….

Реквизиты — это «глава и параграф»: раздел берётся по порядку чтения (последний заголовок перед узлом; колонтитулы и повторяющиеся служебные заголовки отбрасываются), номер — абз. N для текста и подписи, табл. N / рис. N для таблиц и рисунков (родной номер из Docling, иначе порядковый номер в разделе), стр. P — страница узла.

Ссылка ведёт на <имя>.md рядом с кэшем (для .graph*.json — на <имя>.md без суффикса графа) и открывается из ответа в редакторе. Инструкция сервера (initialize.instructions) требует от агента переносить эту строку в ответ человеку и заканчивать такой ответ списком источников; если search ничего не нашёл — значит этого нет в корпусе, и подставлять источник нельзя.

Подключение к MCP-клиенту (пример — секция mcpServers в конфиге клиента):

"docgraph": {
  "command": "/абсолютный/путь/к/dockling/.venv/bin/python",
  "args": [
    "/абсолютный/путь/к/dockling/mcp_docgraph.py",
    "--root",
    "/абсолютный/путь/к/docling_output"
  ],
  "exposure": "direct",
  "description": "dockling: structure, text search and provenance over converted documents."
}

Путь к интерпретатору указывайте абсолютный (в Windows — ...\python): MCP-клиент запускает сервер вне активированного окружения. Папки документов можно не перечислять — без --root сервер обходит текущую папку проекта.

Сервер входит в поставку пакета: если он установлен (pip install . или pip install -e .), доступна команда docgraph-mcp — тогда вместо пары абсолютных путей в конфиге клиента достаточно "command": "docgraph-mcp" с теми же аргументами. В PyPI пакет пока не опубликован (после публикации запуск станет таким: uvx --from dockling docgraph-mcp).

Ограничения OCR

Распознавание таблиц основано на моделях Docling (TableFormer + OCR) и не является достоверным источником: годы, имена и значения в таблицах могут быть искажены. Перед использованием сверяйте таблицы с исходным PDF; подозрительные места автоматически перечисляются в suspicious.md, но значения, искажённые без нарушения формата (например, «1988» вместо «1985»), детектор не ловит — их нужно проверять визуально.

Поиск искажённых годов идёт только в тех столбцах, где есть хотя бы одно значение с годом 19xx/20xx: иначе номера страниц и пунктов дают ложные срабатывания на каждом документе с оглавлением. Обратная сторона — столбец, в котором искажены все годы до единого, распознан не будет.

Структура репозитория

pdf2md.py              конвертер PDF → Markdown (CLI)
check_run.py           проверка готового прогона
docgraph.py            граф документа из кэша DoclingDocument (CLI + режим чтения)
mcp_docgraph.py        MCP-сервер над графом (stdio)
.gitlab-ci.yml         CI на GitLab (hub.mos.ru): тесты на Linux
tests/                 юнит-тесты (unittest, без сторонних зависимостей)
plans/                 планы развития
docling_output/<имя>/  результат прогона: .md, .json (кэш), artifacts/, tables/, captions.md

Разработка

python -m unittest discover -s tests -v   # 373 проверки, ~0.9 с, без зависимостей

Тесты проверяют чистые функции: Docling, networkx и pypdfium2 импортируются лениво, поэтому окружение для тестов не нужно. CI гоняет их на Linux (.gitlab-ci.yml, job unittest на python:3.12-slim); workflow GitHub Actions (.github/workflows/ci.yml) оставлен для Windows/Linux и Python 3.10 / 3.12 / 3.13 — на случай зеркала на GitHub.

Правила участия — CONTRIBUTING.md, история изменений — CHANGELOG.md, устройство проекта и правила для агентов — AGENTS.md.

Лицензия

MIT — см. LICENSE (то же выражение указано в метаданных pyproject.toml). Лицензия распространяется на код репозитория; материалы документов, попавших в docling_output/, подчиняются лицензиям самих документов.