Metadata-Version: 2.5
Name: bloat2md
Version: 0.1.0
Summary: Turn bloated documents into markdown an LLM can read
Project-URL: Repository, https://github.com/wprhvso/bloat2md
Project-URL: Issues, https://github.com/wprhvso/bloat2md/issues
Author-email: wprhvso <wprhvso@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: converter,documents,docx,llm,markdown,pdf
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Office/Business
Classifier: Topic :: Text Processing :: Markup :: Markdown
Classifier: Typing :: Typed
Requires-Python: >=3.14
Requires-Dist: aiohttp>=3.12
Requires-Dist: structlog>=25.4
Requires-Dist: yaol>=0.3.0
Provides-Extra: server
Requires-Dist: charset-normalizer>=3.5.1; extra == 'server'
Requires-Dist: defusedxml>=0.7.1; extra == 'server'
Requires-Dist: fastapi>=0.116; extra == 'server'
Requires-Dist: lxml>=6.1; extra == 'server'
Requires-Dist: mammoth>=1.12; extra == 'server'
Requires-Dist: markdownify>=1.2; extra == 'server'
Requires-Dist: odfdo>=3.24; extra == 'server'
Requires-Dist: pillow>=12.3; extra == 'server'
Requires-Dist: pydantic-settings>=2.7; extra == 'server'
Requires-Dist: pydantic>=2.11; extra == 'server'
Requires-Dist: pypdfium2>=5.13; extra == 'server'
Requires-Dist: python-calamine>=0.8.2; extra == 'server'
Requires-Dist: python-pptx>=1.0.2; extra == 'server'
Requires-Dist: striprtf>=0.0.33; extra == 'server'
Requires-Dist: uvicorn>=0.35; extra == 'server'
Description-Content-Type: text/markdown

# bloat2md

Превращает документ в markdown, который не стыдно положить в промпт. PDF, docx,
xlsx, pptx, OpenDocument, rtf, html, epub, csv, json, yaml, xml, ipynb, картинки
и zip — на выходе один markdown, а страницы, на которых текста нет, приезжают
пережатыми jpeg.

Пакет — это две половины. `bloat2md` без экстры ставит только SDK: класс,
который умеет сходить в сервис по HTTP. `bloat2md[server]` добавляет парсеры и
сам сервис.

## Установка

```
pip install bloat2md
pip install 'bloat2md[server]'
```

## SDK

```python
from bloat2md import Client

client = Client("http://bloat2md:8084", timeout=45.0)

rendered = await client.convert("report.pdf", raw)
if rendered is not None:
    print(rendered.markdown)
```

`convert` не бросает: сервис лежит, документ не распознан, ответ невнятный —
везде `None` и запись в лог. Вызывающему остаётся решить, что делать с
документом, который прочитать не вышло, и это обычно «пропустить».

`Rendered` — это `kind`, `markdown`, `images` (список пар mime/bytes), `pages`,
`truncated` и `dropped`. Последнее — счётчики того, что выкинули по дороге:
`hidden_nodes`, `hidden_spans`, `hidden_sheets`, `unsafe_names`, `bidi`,
`zero_width` и прочее.

## Сервис

```
bloat2md
```

`POST /convert` принимает файл как есть, `application/octet-stream`, имя — в
заголовке `X-File-Name`. Имя нужно только там, где по содержимому не отличить
csv от txt, а yaml от чего угодно.

| Код | Когда |
| --- | --- |
| 200 | markdown, картинки, счётчики |
| 413 | файл больше `BLOAT2MD_INPUT_LIMIT_MB` |
| 415 | это не документ |
| 422 | документ, но прочитать не вышло |
| 504 | воркер не уложился в `BLOAT2MD_TIMEOUT` |

`GET /healthz` отвечает `{"status": "ok"}` и не попадает в трейсы.

## Песочница

Документ — чужой ввод, и парсеры у него на пути написаны не нами. Поэтому
конвертация уходит в дочерний процесс: своя сессия, свой `TMPDIR`, урезанное
окружение и `RLIMIT_AS`, `RLIMIT_CPU`, `RLIMIT_FSIZE`, `RLIMIT_CORE`. Воркер
общается с сервисом одним JSON через stdout, а по таймауту его группа процессов
получает `SIGKILL`.

Из этого следует, что `MemoryError` в парсере стоит одного запроса, а не пода.

## Инъекции

Текст, которого человек в документе не увидит, до промпта не доходит: скрытые
css-ом узлы html, невидимый и микроскопический текст в pdf, спрятанные листы
xlsx. Bidi-управляющие символы, теги Unicode, нулевой ширины и длинные цепочки
joiner'ов вырезаются, а сколько чего вырезали — видно в `dropped`.

Zip не распаковывается, а описывается: имена и размеры. Архив с ratio выше
`BLOAT2MD_MAX_ARCHIVE_RATIO` отвергается целиком.

## Настройки

Переменные окружения с префиксом `BLOAT2MD_`, читается и `.env`.

| Переменная | По умолчанию | Что делает |
| --- | --- | --- |
| `HOST`, `PORT` | `127.0.0.1`, `8084` | что слушать |
| `ENV` | `prod` | окружение для трейсов |
| `TIMEOUT` | `30` | секунд на документ |
| `MEMORY_LIMIT_MB` | `512` | `RLIMIT_AS` воркера |
| `CPU_SECONDS` | `20` | `RLIMIT_CPU` воркера |
| `OUTPUT_LIMIT_MB` | `64` | `RLIMIT_FSIZE` воркера |
| `INPUT_LIMIT_MB` | `20` | больше — 413 |
| `MAX_PAGES` | `50` | страниц pdf читаем |
| `MAX_RENDER_PAGES` | `10` | страниц отдаём картинкой |
| `RENDER_EDGE` | `1536` | длинная сторона картинки |
| `RENDER_QUALITY` | `80` | jpeg |
| `MAX_MARKDOWN_CHARS` | `200000` | дальше режем с серединой |
| `MAX_IMAGE_PIXELS` | `50000000` | защита от decompression bomb |
| `MAX_ARCHIVE_MEMBERS` | `2000` | членов в архиве |
| `MAX_ARCHIVE_RATIO` | `100` | во сколько раз член вправе распухнуть |
| `MAX_ARCHIVE_BYTES` | `104857600` | суммарный размер после распаковки |
| `LIBREOFFICE` | `true` | пускать ли soffice |

## LibreOffice

Doc, xls, ppt и всё, на чём споткнулись питонячьи парсеры, уходят в
`soffice --convert-to pdf`, а дальше идут как pdf. Если бинаря нет, формат
просто становится неподдерживаемым — падать по этому поводу незачем.

Образ `ghcr.io/wprhvso/bloat2md` собирается с `libreoffice-*-nogui` внутри.

## CI

Раннеры здесь `ubuntu-latest`, а не `pool`, как в остальных репозиториях.
Флот self-hosted раннеров разворачивается по списку реп в `runners.toml`, а тот
лежит зашифрованным в кластере — новый репозиторий в него сам собой не попадёт,
и job'ы висели бы в очереди вечно. Репозиторий публичный, GitHub считает его
минуты бесплатными, так что цена нулевая. Появится строка в `runners.toml` —
можно вернуть `pool` и убрать шаги установки uv и earthly.

`cd` собирает и публикует только тогда, когда версии в `pyproject.toml` ещё нет
тега: PyPI по trusted publishing, образ в ghcr, следом тег `vX.Y.Z`.

Пин образа по digest в `deploy/bloat2md/deployment.yaml` CD ставит сам: сразу
после пуша в ghcr он спрашивает у реестра digest тега и коммитит его в `main`.
Руками digest не резолвится и в манифесте не выдумывается никогда.

## Лицензия

MIT
