docmd-cli 0.1.1__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/ARCHITECTURE.md +43 -6
- docmd_cli-0.1.3/CONTRACT.md +110 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/PKG-INFO +62 -9
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/README.md +61 -8
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/__init__.py +2 -1
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/converters/base.py +9 -0
- docmd_cli-0.1.3/docmd/converters/marker_converter.py +164 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/errors.py +24 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/image_handling.py +11 -1
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/table_cleanup.py +13 -2
- docmd_cli-0.1.3/docs/licensing.md +50 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/pyproject.toml +1 -1
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_converters.py +15 -0
- docmd_cli-0.1.3/tests/test_marker_converter_errors.py +49 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_postprocess.py +57 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_postprocess_more_integration.py +39 -19
- docmd_cli-0.1.1/docmd/converters/marker_converter.py +0 -89
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/.github/workflows/ci.yml +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/.github/workflows/release.yml +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/.gitignore +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/LICENSE +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/cli.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/config.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/converters/__init__.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/converters/registry.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/__init__.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/heading_normalize.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/generate_fixtures.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/generate_stress_fixtures.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/merged_cells.pdf +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/running_header.pdf +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/sample.docx +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/sample.pdf +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/stress.pdf +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/with_image.pdf +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_cli.py +0 -0
- {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_postprocess_integration.py +0 -0
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
# Architecture: Doc-to-Markdown API
|
|
2
2
|
|
|
3
|
+
See [CONTRACT.md](CONTRACT.md) for what docmd actually guarantees about its output,
|
|
4
|
+
independent of Marker or any future backend - the formalized version of a testing
|
|
5
|
+
philosophy this project arrived at the hard way, via two real CI failures caused by
|
|
6
|
+
tests that pinned Marker's exact raw output instead of docmd's own guarantees
|
|
7
|
+
(commits `066fd1b` and part of `c7225c5`).
|
|
8
|
+
|
|
3
9
|
## The pitch (keep this pinned above your desk)
|
|
4
10
|
We are not competing on conversion quality. We are selling **"hit an endpoint, get clean Markdown back"** — no Python env, no 8GB+ RAM, no GPU, no dependency hell. The open-source core proves the engine works and builds trust. The hosted API sells convenience.
|
|
5
11
|
|
|
12
|
+
## Real dependency gap found by testing (2026-09-18)
|
|
13
|
+
The pitch above says "no dependency hell." That held for text-layer PDFs, DOCX, and
|
|
14
|
+
PPTX when actually tested against real downloaded documents (an arXiv paper, an IMF
|
|
15
|
+
report). It did **not** hold for OCR: Marker's current recognition model (used for
|
|
16
|
+
both scanned-PDF OCR and equation recognition) is a VLM served through either `vllm`
|
|
17
|
+
or llama.cpp's `llama-server` binary - no plain-transformers/CPU fallback exists in
|
|
18
|
+
this version. Neither ships via pip, so `pip install docmd-cli[full]` alone cannot
|
|
19
|
+
actually OCR a scanned document or handle a PDF with equations, despite the README
|
|
20
|
+
previously implying "OCR - bundled by Marker, free" meant it worked out of the box.
|
|
21
|
+
|
|
22
|
+
Confirmed via a real test: converting a genuinely scanned PDF (downloaded from
|
|
23
|
+
archive.org) raised a missing-binary error on this dev machine (no Homebrew). Fixed
|
|
24
|
+
two ways: (1) a real fix - download a prebuilt `llama-server` binary directly from
|
|
25
|
+
llama.cpp's GitHub releases (no package manager needed) and set `LLAMA_CPP_BINARY`,
|
|
26
|
+
which then produced correct real OCR output; (2) a docmd fix - a dedicated
|
|
27
|
+
`MissingSystemDependencyError` with install instructions instead of a raw stack trace
|
|
28
|
+
when the binary is missing (see `docmd/errors.py`).
|
|
29
|
+
|
|
30
|
+
**Implications to carry into Stage 2/3 (`api/`)**: the hosted API's Docker image
|
|
31
|
+
does not currently install `llama-server` either - meaning a customer uploading a
|
|
32
|
+
scanned PDF or an equation-heavy document to the live API would hit this same error
|
|
33
|
+
today. This needs a fix in `docmd-api`'s Dockerfile before OCR can be honestly
|
|
34
|
+
advertised as working end-to-end there. Not yet fixed as of this note - flagged here
|
|
35
|
+
so it isn't lost.
|
|
36
|
+
|
|
6
37
|
## Positioning risk (checked 2026-09-18)
|
|
7
38
|
Marker's *code* is Apache-2.0 (no restriction). Marker's *model weights* use a modified
|
|
8
39
|
Open RAIL-M license: free for research, personal use, and organizations under $5M in
|
|
@@ -128,16 +159,22 @@ Treat this folder as the actual differentiator. A thin wrapper is a weekend proj
|
|
|
128
159
|
|
|
129
160
|
- `pip install docmd-cli` -> core PDF conversion (Marker bundles OCR here already; there is
|
|
130
161
|
no separate lean/no-OCR base install, since Marker's own base dependencies include
|
|
131
|
-
`surya-ocr`)
|
|
162
|
+
`surya-ocr`) - but actually *running* OCR (or equation recognition) on top of that
|
|
163
|
+
also needs the `llama-server` native binary, which no `pip install` variant can
|
|
164
|
+
provide - see "Real dependency gap found by testing" above
|
|
132
165
|
- `pip install docmd-cli[full]` -> adds DOCX/PPTX/EPUB/XLSX support via Marker's `full` extra
|
|
133
166
|
|
|
134
167
|
## License clarity
|
|
135
168
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
the hosted API
|
|
169
|
+
Full detail moved to [docs/licensing.md](docs/licensing.md) so it's maintained in one
|
|
170
|
+
place instead of drifting between README and this file. Short version: MIT for the
|
|
171
|
+
`docmd` wrapper is fine and expected; Marker's code (Apache-2.0) and model weights
|
|
172
|
+
(modified Open RAIL-M, $5M funding/revenue threshold) are licensed separately, and the
|
|
173
|
+
weights license is the one that actually constrains a commercial hosted API at scale.
|
|
174
|
+
Re-check `docs/licensing.md` before scaling `api/` past that threshold, and get written
|
|
175
|
+
clarification from Datalab on hosted-API competitiveness before a commercial launch -
|
|
176
|
+
see "Positioning risk" above for why Datalab's own competing hosted API makes this
|
|
177
|
+
worth resolving explicitly, not assuming.
|
|
141
178
|
|
|
142
179
|
## What "done" looks like for Stage 1 (this repo, today)
|
|
143
180
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# The docmd contract
|
|
2
|
+
|
|
3
|
+
This is what docmd guarantees about its output, independent of which extraction
|
|
4
|
+
backend is running underneath (currently [Marker](https://github.com/datalab-to/marker),
|
|
5
|
+
pinned to `marker-pdf==2.0.0`) or which platform it runs on. Everything here is
|
|
6
|
+
enforced by a test that fails if the guarantee breaks - the file is named next to
|
|
7
|
+
each one.
|
|
8
|
+
|
|
9
|
+
The reason this document exists: a test suite that asserts *the backend's exact raw
|
|
10
|
+
output* is testing the backend, not docmd, and breaks the moment the backend changes
|
|
11
|
+
- confirmed the hard way twice in this project's history (`066fd1b`, `c7225c5`), where
|
|
12
|
+
a test pinned an exact heading level Marker happened to produce on one platform, and
|
|
13
|
+
CI failed on a different platform running the identical, pinned backend version. Both
|
|
14
|
+
fixes replaced "assert the backend did X" with "assert docmd guarantees Y" - that
|
|
15
|
+
distinction is the entire point of this document.
|
|
16
|
+
|
|
17
|
+
## What's guaranteed
|
|
18
|
+
|
|
19
|
+
**Heading hierarchy** (`docmd/postprocess/heading_normalize.py`,
|
|
20
|
+
`tests/test_postprocess.py`, `tests/test_postprocess_more_integration.py`)
|
|
21
|
+
- No heading level skips more than one deeper than the deepest level seen so far.
|
|
22
|
+
`H1` followed directly by `H4` becomes `H1` followed by `H2`; a later, legitimately
|
|
23
|
+
deep heading elsewhere in the same document is left alone once the hierarchy has
|
|
24
|
+
caught up. Confirmed against six independent real documents (an IMF report, four
|
|
25
|
+
arXiv papers/patents, one hand-built fixture) - this is the single most common
|
|
26
|
+
real-world defect found in this project's testing.
|
|
27
|
+
- No orphaned `#` with empty text.
|
|
28
|
+
- No heading whose text exactly repeats the immediately preceding heading (a Marker
|
|
29
|
+
artifact from running page headers).
|
|
30
|
+
- A document's first heading is always promoted to H1 if nothing shallower precedes it.
|
|
31
|
+
|
|
32
|
+
**Table structure** (`docmd/postprocess/table_cleanup.py`)
|
|
33
|
+
- Every row in a rendered table has the same column count as its header, padded or
|
|
34
|
+
truncated as needed.
|
|
35
|
+
- A missing or malformed separator row is synthesized.
|
|
36
|
+
- A stray row that is itself separator-shaped (all dashes) is dropped rather than
|
|
37
|
+
rendered as literal data - found via a real two-column paper where a numbered
|
|
38
|
+
equation came back as a one-row table with two separator rows.
|
|
39
|
+
- A table split into two blocks by a page break, with a repeated identical header, is
|
|
40
|
+
merged into one continuous table.
|
|
41
|
+
- **Not guaranteed**: correct table content when the backend's own reading order is
|
|
42
|
+
wrong. docmd normalizes structure; it doesn't re-derive reading order the backend
|
|
43
|
+
got wrong (confirmed real gap: a patent's front-page bibliographic table).
|
|
44
|
+
|
|
45
|
+
**Image references** (`docmd/postprocess/image_handling.py`)
|
|
46
|
+
- No malformed Markdown image syntax (`![]()` with an empty or partial reference)
|
|
47
|
+
ever appears in output, in any mode.
|
|
48
|
+
- Three modes, each with a specific, tested behavior: `placeholder` (default, no
|
|
49
|
+
binary data referenced at all), `alt-text` (real links, non-empty alt text, and the
|
|
50
|
+
image file is actually written to `output_dir` if given), `skip` (removed entirely).
|
|
51
|
+
|
|
52
|
+
**Errors** (`docmd/errors.py`)
|
|
53
|
+
- A small, fixed set of `DocmdError` subclasses for known failure categories:
|
|
54
|
+
`UnsupportedFormatError`, `MissingExtraError`, `MissingSystemDependencyError`,
|
|
55
|
+
`EncryptedDocumentError`, `ConversionError`. Each carries an actionable message,
|
|
56
|
+
not a raw backend stack trace, for the failure modes docmd knows about.
|
|
57
|
+
|
|
58
|
+
**Backend variation**
|
|
59
|
+
- Platform or backend-version differences in raw extraction output never change
|
|
60
|
+
docmd's own guarantees above. They may change the *content* extracted (a backend
|
|
61
|
+
bug is still a backend bug), but never the *structural* invariants this document
|
|
62
|
+
lists.
|
|
63
|
+
|
|
64
|
+
**Provenance** (`docmd/converters/base.py:ConversionResult.provenance`)
|
|
65
|
+
- Every `convert_document()` call returns `docmd_version`, `backend`,
|
|
66
|
+
`backend_version`, `ocr_used`, and `conversion_duration_ms` - same shape regardless
|
|
67
|
+
of which backend ran. This records *what ran*, not that the *output* is
|
|
68
|
+
reproducible - see OCR determinism below, where it isn't.
|
|
69
|
+
|
|
70
|
+
## What's explicitly not guaranteed (yet)
|
|
71
|
+
|
|
72
|
+
Found by real-world testing, not fixed:
|
|
73
|
+
|
|
74
|
+
- **Form/checkbox structure.** Adjacent checkbox option labels (e.g. a tax form's
|
|
75
|
+
filing-status options) are not separated or structured - they extract as one
|
|
76
|
+
run-on phrase, identical to raw backend output.
|
|
77
|
+
- **RTL bidirectional punctuation.** Citation brackets and similar LTR punctuation
|
|
78
|
+
embedded in right-to-left script can render reversed (`]1[` instead of `[1]`).
|
|
79
|
+
- **Multi-column reading order in dense bibliographic/legal layouts.** Proven to
|
|
80
|
+
work correctly on a two-column academic paper; proven to fail on a patent's
|
|
81
|
+
front-page citation block. No known rule yet for which case a given document falls
|
|
82
|
+
into.
|
|
83
|
+
- **OCR reproducibility.** Running the identical file through the identical code path
|
|
84
|
+
twice, in the same process, produced different text - confirmed directly, not
|
|
85
|
+
inferred (28,916 vs 27,973 characters on a real degraded scan; individual word
|
|
86
|
+
choices and even a poem's line-break structure differed between the two runs).
|
|
87
|
+
Root cause investigated and is architectural, not a misconfiguration: OCR/equation
|
|
88
|
+
recognition already requests greedy decoding (`temperature=0.0`, confirmed in
|
|
89
|
+
`surya/inference/backends/openai_client.py`), but `llama-server` runs with
|
|
90
|
+
`--parallel 8` by default (`surya/inference/backends/llamacpp.py`), and
|
|
91
|
+
floating-point matrix multiplication under concurrent batched inference is not
|
|
92
|
+
strictly order-independent - a well-documented property of essentially every
|
|
93
|
+
production LLM-serving stack (vLLM, llama.cpp, TensorRT-LLM), not specific to this
|
|
94
|
+
one. A small numerical difference from batch composition can flip an argmax choice
|
|
95
|
+
at a near-tied token, and the autoregressive generation diverges from there.
|
|
96
|
+
`SURYA_INFERENCE_PARALLEL=1` (env var, not currently exposed through docmd's own
|
|
97
|
+
config) would remove the batching-composition variable and likely reduce this a
|
|
98
|
+
lot, at a real throughput cost - untested, and even then, multi-threaded CPU matrix
|
|
99
|
+
math without an explicit `--threads 1` could still leave some residual variance.
|
|
100
|
+
Does not affect the plain `pdftext` text-extraction path (no VLM inference
|
|
101
|
+
involved) - only pages that actually go through OCR or equation recognition.
|
|
102
|
+
|
|
103
|
+
## Writing a new test against this contract
|
|
104
|
+
|
|
105
|
+
Ask: does this assertion describe something docmd promises, or something the
|
|
106
|
+
backend happened to do on one machine? If the latter, either don't assert it, or -
|
|
107
|
+
if it's worth pinning as a canary for upstream behavior changing silently - say so
|
|
108
|
+
explicitly in the test's docstring and keep it separate from the real assertion, the
|
|
109
|
+
way `test_marker_really_does_split_a_page_spanning_table` and
|
|
110
|
+
`test_running_header_does_not_leak_into_output` do.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: docmd-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Convert PDFs, DOCX, and PPTX to clean, structure-preserving Markdown.
|
|
5
5
|
Project-URL: Homepage, https://github.com/taherzribi/docmd
|
|
6
6
|
Project-URL: Issues, https://github.com/taherzribi/docmd/issues
|
|
@@ -67,7 +67,7 @@ core, usable standalone today.
|
|
|
67
67
|
| Input | Output |
|
|
68
68
|
|---|---|
|
|
69
69
|
| PDF (text-based) | Markdown with preserved headings, lists, tables |
|
|
70
|
-
| PDF (scanned) | Markdown via OCR — bundled by Marker, free |
|
|
70
|
+
| PDF (scanned) | Markdown via OCR — bundled by Marker, free, but needs [one extra native binary](#ocr-and-equations-need-one-native-binary) |
|
|
71
71
|
| DOCX | Markdown with formatting preserved (`pip install docmd-cli[full]`) |
|
|
72
72
|
| PPTX | Markdown, one section per slide (`pip install docmd-cli[full]`) |
|
|
73
73
|
|
|
@@ -116,6 +116,42 @@ sudo apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 libf
|
|
|
116
116
|
|
|
117
117
|
PDF conversion (the base install) does not need this.
|
|
118
118
|
|
|
119
|
+
## OCR and equations need one native binary
|
|
120
|
+
|
|
121
|
+
Found by testing docmd against a real scanned document, not documented anywhere
|
|
122
|
+
upstream at the time of writing: Marker's current OCR and equation-recognition model
|
|
123
|
+
is a vision-language model served through either `vllm` (GPU/Linux-oriented) or
|
|
124
|
+
[llama.cpp](https://github.com/ggml-org/llama.cpp)'s `llama-server` binary - there is
|
|
125
|
+
no plain-CPU/transformers fallback. `pip install docmd-cli` cannot provide either one,
|
|
126
|
+
since neither ships as a normal Python wheel.
|
|
127
|
+
|
|
128
|
+
This only matters for **scanned PDFs** (no embedded text layer) and PDFs with
|
|
129
|
+
**equations** - a normal text-layer PDF never touches this code path, and everything
|
|
130
|
+
else in this README works with just `pip install`.
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# macOS / Linux with Homebrew
|
|
134
|
+
brew install llama.cpp
|
|
135
|
+
|
|
136
|
+
# No Homebrew: download a prebuilt binary directly, no package manager needed
|
|
137
|
+
# (pick the archive matching your OS/arch from the releases page)
|
|
138
|
+
curl -LO https://github.com/ggml-org/llama.cpp/releases/latest/download/llama-<version>-bin-macos-arm64.tar.gz
|
|
139
|
+
tar xzf llama-<version>-bin-macos-arm64.tar.gz
|
|
140
|
+
export LLAMA_CPP_BINARY=$PWD/llama-<version>/llama-server
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Without it, converting a scanned PDF or one with equations raises a
|
|
144
|
+
`MissingSystemDependencyError` with these same instructions - not a raw stack trace.
|
|
145
|
+
First real OCR run also downloads the model's GGUF weights from Hugging Face
|
|
146
|
+
(a few GB), separate from the PyTorch weights Marker already downloaded.
|
|
147
|
+
|
|
148
|
+
**OCR output is not guaranteed reproducible run to run** - confirmed directly:
|
|
149
|
+
the same scanned file through the same code path twice produced different text.
|
|
150
|
+
This is architectural (concurrent batched inference under the hood), not a docmd
|
|
151
|
+
bug and not fixable by a config flag alone - see [CONTRACT.md](CONTRACT.md) for the
|
|
152
|
+
full investigation. Doesn't affect plain text-layer PDFs, only pages that actually
|
|
153
|
+
go through OCR or equation recognition.
|
|
154
|
+
|
|
119
155
|
## Image handling
|
|
120
156
|
|
|
121
157
|
Images default to a text placeholder (`*[... omitted]*`) - no binary data, nothing to
|
|
@@ -147,24 +183,41 @@ docmd convert report.pdf -o output/report.md --image-mode alt-text
|
|
|
147
183
|
|
|
148
184
|
`image_mode="skip"` drops images entirely - no placeholder, no files.
|
|
149
185
|
|
|
186
|
+
## Provenance
|
|
187
|
+
|
|
188
|
+
Every conversion carries tracking info in `result.provenance` - useful for "this
|
|
189
|
+
converted differently yesterday" debugging:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from docmd import convert_document
|
|
193
|
+
|
|
194
|
+
result = convert_document("report.pdf")
|
|
195
|
+
print(result.provenance)
|
|
196
|
+
# {'docmd_version': '0.1.3', 'backend': 'marker', 'backend_version': '2.0.0',
|
|
197
|
+
# 'ocr_used': False, 'conversion_duration_ms': 489}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Same shape regardless of which backend actually ran - `convert()` (the plain
|
|
201
|
+
string-returning function) doesn't expose this; use `convert_document()` for it.
|
|
202
|
+
|
|
150
203
|
## How it works
|
|
151
204
|
|
|
152
205
|
`docmd` wraps [Marker](https://github.com/datalab-to/marker) with sane defaults and a
|
|
153
206
|
clean output format, then runs its own post-processing pass
|
|
154
207
|
(`docmd/postprocess/`) to fix table structure and normalize heading levels — see
|
|
155
208
|
[ARCHITECTURE.md](ARCHITECTURE.md) for why this is the actual differentiation, not
|
|
156
|
-
just a thin wrapper.
|
|
209
|
+
just a thin wrapper, and [CONTRACT.md](CONTRACT.md) for exactly what's guaranteed
|
|
210
|
+
about the output (and what isn't, yet) — independent of Marker or any future backend.
|
|
157
211
|
|
|
158
212
|
## License
|
|
159
213
|
|
|
160
214
|
The `docmd` wrapper code is MIT — see [LICENSE](LICENSE).
|
|
161
215
|
|
|
162
|
-
`docmd` depends on [Marker](https://github.com/datalab-to/marker), whose
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
commercially at scale — check Marker's current license terms directly before doing so.
|
|
216
|
+
`docmd` depends on [Marker](https://github.com/datalab-to/marker), whose code and
|
|
217
|
+
model weights carry *separate* licenses — the weights license has a revenue/funding
|
|
218
|
+
threshold that matters if you deploy commercially at scale. See
|
|
219
|
+
[docs/licensing.md](docs/licensing.md) for the precise terms; don't rely on this
|
|
220
|
+
sentence alone.
|
|
168
221
|
|
|
169
222
|
## Roadmap
|
|
170
223
|
|
|
@@ -40,7 +40,7 @@ core, usable standalone today.
|
|
|
40
40
|
| Input | Output |
|
|
41
41
|
|---|---|
|
|
42
42
|
| PDF (text-based) | Markdown with preserved headings, lists, tables |
|
|
43
|
-
| PDF (scanned) | Markdown via OCR — bundled by Marker, free |
|
|
43
|
+
| PDF (scanned) | Markdown via OCR — bundled by Marker, free, but needs [one extra native binary](#ocr-and-equations-need-one-native-binary) |
|
|
44
44
|
| DOCX | Markdown with formatting preserved (`pip install docmd-cli[full]`) |
|
|
45
45
|
| PPTX | Markdown, one section per slide (`pip install docmd-cli[full]`) |
|
|
46
46
|
|
|
@@ -89,6 +89,42 @@ sudo apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 libf
|
|
|
89
89
|
|
|
90
90
|
PDF conversion (the base install) does not need this.
|
|
91
91
|
|
|
92
|
+
## OCR and equations need one native binary
|
|
93
|
+
|
|
94
|
+
Found by testing docmd against a real scanned document, not documented anywhere
|
|
95
|
+
upstream at the time of writing: Marker's current OCR and equation-recognition model
|
|
96
|
+
is a vision-language model served through either `vllm` (GPU/Linux-oriented) or
|
|
97
|
+
[llama.cpp](https://github.com/ggml-org/llama.cpp)'s `llama-server` binary - there is
|
|
98
|
+
no plain-CPU/transformers fallback. `pip install docmd-cli` cannot provide either one,
|
|
99
|
+
since neither ships as a normal Python wheel.
|
|
100
|
+
|
|
101
|
+
This only matters for **scanned PDFs** (no embedded text layer) and PDFs with
|
|
102
|
+
**equations** - a normal text-layer PDF never touches this code path, and everything
|
|
103
|
+
else in this README works with just `pip install`.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# macOS / Linux with Homebrew
|
|
107
|
+
brew install llama.cpp
|
|
108
|
+
|
|
109
|
+
# No Homebrew: download a prebuilt binary directly, no package manager needed
|
|
110
|
+
# (pick the archive matching your OS/arch from the releases page)
|
|
111
|
+
curl -LO https://github.com/ggml-org/llama.cpp/releases/latest/download/llama-<version>-bin-macos-arm64.tar.gz
|
|
112
|
+
tar xzf llama-<version>-bin-macos-arm64.tar.gz
|
|
113
|
+
export LLAMA_CPP_BINARY=$PWD/llama-<version>/llama-server
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Without it, converting a scanned PDF or one with equations raises a
|
|
117
|
+
`MissingSystemDependencyError` with these same instructions - not a raw stack trace.
|
|
118
|
+
First real OCR run also downloads the model's GGUF weights from Hugging Face
|
|
119
|
+
(a few GB), separate from the PyTorch weights Marker already downloaded.
|
|
120
|
+
|
|
121
|
+
**OCR output is not guaranteed reproducible run to run** - confirmed directly:
|
|
122
|
+
the same scanned file through the same code path twice produced different text.
|
|
123
|
+
This is architectural (concurrent batched inference under the hood), not a docmd
|
|
124
|
+
bug and not fixable by a config flag alone - see [CONTRACT.md](CONTRACT.md) for the
|
|
125
|
+
full investigation. Doesn't affect plain text-layer PDFs, only pages that actually
|
|
126
|
+
go through OCR or equation recognition.
|
|
127
|
+
|
|
92
128
|
## Image handling
|
|
93
129
|
|
|
94
130
|
Images default to a text placeholder (`*[... omitted]*`) - no binary data, nothing to
|
|
@@ -120,24 +156,41 @@ docmd convert report.pdf -o output/report.md --image-mode alt-text
|
|
|
120
156
|
|
|
121
157
|
`image_mode="skip"` drops images entirely - no placeholder, no files.
|
|
122
158
|
|
|
159
|
+
## Provenance
|
|
160
|
+
|
|
161
|
+
Every conversion carries tracking info in `result.provenance` - useful for "this
|
|
162
|
+
converted differently yesterday" debugging:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from docmd import convert_document
|
|
166
|
+
|
|
167
|
+
result = convert_document("report.pdf")
|
|
168
|
+
print(result.provenance)
|
|
169
|
+
# {'docmd_version': '0.1.3', 'backend': 'marker', 'backend_version': '2.0.0',
|
|
170
|
+
# 'ocr_used': False, 'conversion_duration_ms': 489}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Same shape regardless of which backend actually ran - `convert()` (the plain
|
|
174
|
+
string-returning function) doesn't expose this; use `convert_document()` for it.
|
|
175
|
+
|
|
123
176
|
## How it works
|
|
124
177
|
|
|
125
178
|
`docmd` wraps [Marker](https://github.com/datalab-to/marker) with sane defaults and a
|
|
126
179
|
clean output format, then runs its own post-processing pass
|
|
127
180
|
(`docmd/postprocess/`) to fix table structure and normalize heading levels — see
|
|
128
181
|
[ARCHITECTURE.md](ARCHITECTURE.md) for why this is the actual differentiation, not
|
|
129
|
-
just a thin wrapper.
|
|
182
|
+
just a thin wrapper, and [CONTRACT.md](CONTRACT.md) for exactly what's guaranteed
|
|
183
|
+
about the output (and what isn't, yet) — independent of Marker or any future backend.
|
|
130
184
|
|
|
131
185
|
## License
|
|
132
186
|
|
|
133
187
|
The `docmd` wrapper code is MIT — see [LICENSE](LICENSE).
|
|
134
188
|
|
|
135
|
-
`docmd` depends on [Marker](https://github.com/datalab-to/marker), whose
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
commercially at scale — check Marker's current license terms directly before doing so.
|
|
189
|
+
`docmd` depends on [Marker](https://github.com/datalab-to/marker), whose code and
|
|
190
|
+
model weights carry *separate* licenses — the weights license has a revenue/funding
|
|
191
|
+
threshold that matters if you deploy commercially at scale. See
|
|
192
|
+
[docs/licensing.md](docs/licensing.md) for the precise terms; don't rely on this
|
|
193
|
+
sentence alone.
|
|
141
194
|
|
|
142
195
|
## Roadmap
|
|
143
196
|
|
|
@@ -18,7 +18,7 @@ from docmd.postprocess.image_handling import apply_image_handling
|
|
|
18
18
|
from docmd.postprocess.table_cleanup import clean_tables
|
|
19
19
|
|
|
20
20
|
__all__ = ["convert", "convert_document", "ConvertConfig", "ConversionResult"]
|
|
21
|
-
__version__ = "0.1.
|
|
21
|
+
__version__ = "0.1.3"
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def convert_document(
|
|
@@ -92,4 +92,5 @@ def _convert_path(
|
|
|
92
92
|
page_count=result.page_count,
|
|
93
93
|
images=result.images,
|
|
94
94
|
metadata=result.metadata,
|
|
95
|
+
provenance=result.provenance,
|
|
95
96
|
)
|
|
@@ -26,6 +26,15 @@ class ConversionResult:
|
|
|
26
26
|
"""Maps an image filename referenced in `markdown` (e.g.
|
|
27
27
|
'_page_0_Figure_1.jpeg') to a PIL.Image.Image instance."""
|
|
28
28
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
29
|
+
"""Whatever the backend itself returned - opaque, backend-specific, not
|
|
30
|
+
a contract. For Marker this is its own metadata dict (page_stats,
|
|
31
|
+
table_of_contents, ...). A different backend would put different keys
|
|
32
|
+
here; don't build stable behavior on top of this dict's shape."""
|
|
33
|
+
provenance: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
"""docmd's own tracking info, same shape regardless of which backend
|
|
35
|
+
ran: docmd_version, backend, backend_version, ocr_used,
|
|
36
|
+
conversion_duration_ms. For debugging "this converted differently
|
|
37
|
+
yesterday" - see ARCHITECTURE.md."""
|
|
29
38
|
|
|
30
39
|
|
|
31
40
|
class Converter(Protocol):
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Wraps `marker-pdf` (https://github.com/datalab-to/marker) as a docmd Converter.
|
|
2
|
+
|
|
3
|
+
Uses Marker's Python API directly (not its CLI/server) so we control input and
|
|
4
|
+
output cleanly. `PdfConverter` is Marker's general-purpose converter despite
|
|
5
|
+
the name: it dispatches to the right internal provider (PDF, DOCX, PPTX, ...)
|
|
6
|
+
based on the file's actual content, via `provider_from_filepath`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from docmd.config import ConvertConfig
|
|
17
|
+
from docmd.converters.base import ConversionResult
|
|
18
|
+
from docmd.errors import (
|
|
19
|
+
ConversionError,
|
|
20
|
+
EncryptedDocumentError,
|
|
21
|
+
MissingExtraError,
|
|
22
|
+
MissingSystemDependencyError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Marker loads its models (a few hundred MB to ~1GB of weights, downloaded
|
|
26
|
+
# from Hugging Face on first use) lazily and caches them at module scope, so
|
|
27
|
+
# repeated conversions within one process don't reload them.
|
|
28
|
+
_model_dict: dict[str, Any] | None = None
|
|
29
|
+
|
|
30
|
+
_PASSWORD_HINTS = ("password", "encrypt")
|
|
31
|
+
# Raised as surya.inference.backends.spawn.SpawnError when OCR/equation
|
|
32
|
+
# recognition needs the llama-server binary and it isn't on PATH - matched
|
|
33
|
+
# by message/type name rather than importing surya's internal exception
|
|
34
|
+
# class, so this doesn't break if that module path moves.
|
|
35
|
+
_MISSING_BINARY_HINTS = ("llama-server", "spawnerror")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_model_dict() -> dict[str, Any]:
|
|
39
|
+
global _model_dict
|
|
40
|
+
if _model_dict is None:
|
|
41
|
+
from marker.models import create_model_dict
|
|
42
|
+
|
|
43
|
+
_model_dict = create_model_dict()
|
|
44
|
+
return _model_dict
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_docmd_version_cache: str | None = None
|
|
48
|
+
_marker_version_cache: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _docmd_version() -> str:
|
|
52
|
+
# Read from installed package metadata rather than `from docmd import
|
|
53
|
+
# __version__` - that would import docmd/__init__.py, which imports
|
|
54
|
+
# this module transitively (via the converter registry), a circular
|
|
55
|
+
# import.
|
|
56
|
+
global _docmd_version_cache
|
|
57
|
+
if _docmd_version_cache is None:
|
|
58
|
+
try:
|
|
59
|
+
_docmd_version_cache = importlib.metadata.version("docmd-cli")
|
|
60
|
+
except importlib.metadata.PackageNotFoundError:
|
|
61
|
+
_docmd_version_cache = "unknown"
|
|
62
|
+
return _docmd_version_cache
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _marker_version() -> str:
|
|
66
|
+
global _marker_version_cache
|
|
67
|
+
if _marker_version_cache is None:
|
|
68
|
+
try:
|
|
69
|
+
_marker_version_cache = importlib.metadata.version("marker-pdf")
|
|
70
|
+
except importlib.metadata.PackageNotFoundError:
|
|
71
|
+
_marker_version_cache = "unknown"
|
|
72
|
+
return _marker_version_cache
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_config_dict(config: ConvertConfig):
|
|
76
|
+
from marker.config.parser import ConfigParser
|
|
77
|
+
|
|
78
|
+
options: dict[str, Any] = {
|
|
79
|
+
"output_format": "markdown",
|
|
80
|
+
"force_ocr": config.force_ocr,
|
|
81
|
+
"use_llm": config.use_llm,
|
|
82
|
+
# Marker's own default (4) spins up a multiprocessing.ProcessPoolExecutor
|
|
83
|
+
# for page-text extraction on any sufficiently multi-page PDF. On
|
|
84
|
+
# spawn-based platforms (macOS, Windows) that crashes with
|
|
85
|
+
# "An attempt has been made to start a new process before the
|
|
86
|
+
# current process has finished its bootstrapping phase" whenever the
|
|
87
|
+
# caller isn't wrapped in `if __name__ == "__main__":` - an easy trap
|
|
88
|
+
# for a library used from a plain script, a notebook, or a web
|
|
89
|
+
# server's request handler. Marker's own bundled server.py sets this
|
|
90
|
+
# to 1 for exactly this reason; docmd does the same as a library
|
|
91
|
+
# default, trading a bit of extraction parallelism for not crashing
|
|
92
|
+
# on arbitrary callers.
|
|
93
|
+
"pdftext_workers": 1,
|
|
94
|
+
}
|
|
95
|
+
if config.image_mode == "skip":
|
|
96
|
+
options["disable_image_extraction"] = True
|
|
97
|
+
|
|
98
|
+
return ConfigParser(options)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class MarkerConverter:
|
|
102
|
+
"""Converter implementation backed by Marker."""
|
|
103
|
+
|
|
104
|
+
def convert(self, filepath: str | Path, config: ConvertConfig) -> ConversionResult:
|
|
105
|
+
filepath = str(filepath)
|
|
106
|
+
try:
|
|
107
|
+
from marker.converters.pdf import PdfConverter
|
|
108
|
+
from marker.output import text_from_rendered
|
|
109
|
+
except ImportError as exc:
|
|
110
|
+
raise MissingExtraError(Path(filepath).suffix) from exc
|
|
111
|
+
|
|
112
|
+
config_parser = _build_config_dict(config)
|
|
113
|
+
config_dict = config_parser.generate_config_dict()
|
|
114
|
+
|
|
115
|
+
start = time.monotonic()
|
|
116
|
+
try:
|
|
117
|
+
converter = PdfConverter(
|
|
118
|
+
config=config_dict,
|
|
119
|
+
artifact_dict=_get_model_dict(),
|
|
120
|
+
processor_list=config_parser.get_processors(),
|
|
121
|
+
renderer=config_parser.get_renderer(),
|
|
122
|
+
llm_service=config_parser.get_llm_service(),
|
|
123
|
+
)
|
|
124
|
+
rendered = converter(filepath)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
message = str(exc).lower()
|
|
127
|
+
exc_type_name = type(exc).__name__.lower()
|
|
128
|
+
if any(hint in message for hint in _PASSWORD_HINTS):
|
|
129
|
+
raise EncryptedDocumentError() from exc
|
|
130
|
+
if any(hint in message or hint in exc_type_name for hint in _MISSING_BINARY_HINTS):
|
|
131
|
+
raise MissingSystemDependencyError(str(exc)) from exc
|
|
132
|
+
raise ConversionError(
|
|
133
|
+
f"Marker failed to convert '{filepath}': {exc}", cause=exc
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
137
|
+
markdown, _, images = text_from_rendered(rendered)
|
|
138
|
+
metadata = dict(getattr(rendered, "metadata", {}) or {})
|
|
139
|
+
page_stats = metadata.get("page_stats", [])
|
|
140
|
+
page_count = len(page_stats) or 1
|
|
141
|
+
|
|
142
|
+
# A page whose text_extraction_method isn't "pdftext" went through
|
|
143
|
+
# OCR/vision-based recognition rather than reading an embedded text
|
|
144
|
+
# layer - independent of force_ocr, since Marker also falls back to
|
|
145
|
+
# this per-page for a PDF with no usable text layer at all.
|
|
146
|
+
ocr_used = config.force_ocr or any(
|
|
147
|
+
page.get("text_extraction_method") != "pdftext" for page in page_stats
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
provenance = {
|
|
151
|
+
"docmd_version": _docmd_version(),
|
|
152
|
+
"backend": "marker",
|
|
153
|
+
"backend_version": _marker_version(),
|
|
154
|
+
"ocr_used": ocr_used,
|
|
155
|
+
"conversion_duration_ms": duration_ms,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return ConversionResult(
|
|
159
|
+
markdown=markdown,
|
|
160
|
+
page_count=page_count,
|
|
161
|
+
images=images,
|
|
162
|
+
metadata=metadata,
|
|
163
|
+
provenance=provenance,
|
|
164
|
+
)
|
|
@@ -35,6 +35,30 @@ class MissingExtraError(DocmdError):
|
|
|
35
35
|
)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
class MissingSystemDependencyError(DocmdError):
|
|
39
|
+
"""Raised when Marker's OCR/equation recognition needs a native binary
|
|
40
|
+
that isn't installed - not a pip package, so `pip install docmd[full]`
|
|
41
|
+
can't provide it. See ARCHITECTURE.md "OCR and equation recognition need
|
|
42
|
+
a native binary" for why this exists."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, detail: str) -> None:
|
|
45
|
+
super().__init__(
|
|
46
|
+
"OCR/equation recognition needs the `llama-server` binary from "
|
|
47
|
+
"llama.cpp, which pip cannot install. This only matters for "
|
|
48
|
+
"scanned PDFs or documents with equations - a normal text-layer "
|
|
49
|
+
"PDF doesn't hit this path.\n\n"
|
|
50
|
+
"Install it with:\n"
|
|
51
|
+
" macOS: brew install llama.cpp\n"
|
|
52
|
+
" Linux: brew install llama.cpp, or download a release from\n"
|
|
53
|
+
" https://github.com/ggml-org/llama.cpp/releases\n"
|
|
54
|
+
"No Homebrew? Download the right archive from that releases "
|
|
55
|
+
"page directly (llama-<version>-bin-macos-arm64.tar.gz etc.), "
|
|
56
|
+
"then either put `llama-server` on your PATH or set "
|
|
57
|
+
"LLAMA_CPP_BINARY to its full path.\n\n"
|
|
58
|
+
f"Underlying error: {detail}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
38
62
|
class EncryptedDocumentError(DocmdError):
|
|
39
63
|
"""Raised when the input is a password-protected / encrypted document."""
|
|
40
64
|
|
|
@@ -18,7 +18,7 @@ import re
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any
|
|
20
20
|
|
|
21
|
-
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]
|
|
21
|
+
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]*)\)")
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def apply_image_handling(
|
|
@@ -32,6 +32,16 @@ def apply_image_handling(
|
|
|
32
32
|
def _replace(match: re.Match[str]) -> str:
|
|
33
33
|
nonlocal counter
|
|
34
34
|
alt_text, ref = match.group(1), match.group(2)
|
|
35
|
+
|
|
36
|
+
if not ref.strip():
|
|
37
|
+
# `![]()` with no reference at all - real Marker output on some
|
|
38
|
+
# scanned pages (an OCR'd region with no recoverable image data).
|
|
39
|
+
# No mode should keep this: a placeholder/skip has nothing to
|
|
40
|
+
# describe, and alt-text mode would otherwise emit a link to
|
|
41
|
+
# nowhere (an empty href renders as a broken image for no
|
|
42
|
+
# legitimate reason, since there was never a file to begin with).
|
|
43
|
+
return ""
|
|
44
|
+
|
|
35
45
|
filename = Path(ref).name
|
|
36
46
|
|
|
37
47
|
if mode == "skip":
|
|
@@ -98,7 +98,14 @@ def clean_tables(markdown: str) -> str:
|
|
|
98
98
|
block_end = j
|
|
99
99
|
while block_end < len(lines) and _is_row(lines[block_end]):
|
|
100
100
|
block_end += 1
|
|
101
|
-
|
|
101
|
+
# A stray extra separator row (found via a real two-column paper: a
|
|
102
|
+
# single-row "table" - actually a numbered equation - with two
|
|
103
|
+
# separator rows back to back and no real data) would otherwise get
|
|
104
|
+
# treated as a literal data row of dashes. Drop any row that's
|
|
105
|
+
# itself separator-shaped rather than rendering it as content.
|
|
106
|
+
data_rows = [
|
|
107
|
+
_split_cells(l) for l in lines[j:block_end] if not _is_separator_row(l)
|
|
108
|
+
]
|
|
102
109
|
|
|
103
110
|
# Look ahead past blank lines / Marker's page separator for a
|
|
104
111
|
# continuation: another table block whose header repeats this one.
|
|
@@ -120,7 +127,11 @@ def clean_tables(markdown: str) -> str:
|
|
|
120
127
|
next_block_end = p
|
|
121
128
|
while next_block_end < len(lines) and _is_row(lines[next_block_end]):
|
|
122
129
|
next_block_end += 1
|
|
123
|
-
data_rows.extend(
|
|
130
|
+
data_rows.extend(
|
|
131
|
+
_split_cells(l)
|
|
132
|
+
for l in lines[p:next_block_end]
|
|
133
|
+
if not _is_separator_row(l)
|
|
134
|
+
)
|
|
124
135
|
k = next_block_end
|
|
125
136
|
|
|
126
137
|
out.extend(_normalize_block(header, data_rows))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Licensing
|
|
2
|
+
|
|
3
|
+
Three separate things, three separate licenses. Conflating them is the exact
|
|
4
|
+
overclaim this document exists to prevent.
|
|
5
|
+
|
|
6
|
+
## docmd itself
|
|
7
|
+
|
|
8
|
+
MIT - see [LICENSE](../LICENSE). No restrictions on commercial use, modification, or
|
|
9
|
+
redistribution of docmd's own wrapper/post-processing code.
|
|
10
|
+
|
|
11
|
+
## Marker's source code
|
|
12
|
+
|
|
13
|
+
docmd depends on [Marker](https://github.com/datalab-to/marker), pinned to
|
|
14
|
+
`marker-pdf==2.0.0`. Marker's code is Apache-2.0. No restriction relevant here.
|
|
15
|
+
|
|
16
|
+
## Marker's model weights
|
|
17
|
+
|
|
18
|
+
This is the one that actually constrains you, and it's separate from the code
|
|
19
|
+
license above - a common point of confusion, and the reason this file exists rather
|
|
20
|
+
than a single blanket "Marker is Apache-2.0" statement.
|
|
21
|
+
|
|
22
|
+
Marker's model weights (the actual OCR/layout/recognition models, downloaded
|
|
23
|
+
separately from the code) are licensed under a **modified Open RAIL-M license**:
|
|
24
|
+
|
|
25
|
+
- Free for research, personal use, and organizations under **$5M in funding or
|
|
26
|
+
revenue**.
|
|
27
|
+
- Beyond that threshold, a commercial license from [Datalab](https://www.datalab.to/pricing)
|
|
28
|
+
(Marker's maintainer) is required.
|
|
29
|
+
|
|
30
|
+
**This applies to you if** you deploy docmd commercially at a scale that crosses that
|
|
31
|
+
threshold - not at small/bootstrapped scale, but worth planning around before it
|
|
32
|
+
matters. Check Marker's current license terms directly before relying on this
|
|
33
|
+
summary; license terms can change between versions, and the terms above reflect
|
|
34
|
+
`marker-pdf==2.0.0` specifically, checked 2026-09-18.
|
|
35
|
+
|
|
36
|
+
**If you're building a hosted/SaaS product on top of docmd**: Datalab runs its own
|
|
37
|
+
hosted document-conversion API. Before launching a commercial hosted API built on
|
|
38
|
+
Marker, get written clarification from Datalab on whether your product would be
|
|
39
|
+
considered competitive with theirs under their weights license - don't assume the
|
|
40
|
+
$5M threshold is the only relevant term. This is a business/legal step, not
|
|
41
|
+
something resolvable by reading the license text alone.
|
|
42
|
+
|
|
43
|
+
## Other dependencies
|
|
44
|
+
|
|
45
|
+
docmd's `[full]` extra and the OCR/equation path pull in several other open-source
|
|
46
|
+
projects (`weasyprint`, `python-docx`, `python-pptx`, `llama.cpp`, and Marker's own
|
|
47
|
+
transitive dependencies). None of these are known to impose restrictions beyond their
|
|
48
|
+
standard OSS licenses (BSD/MIT/Apache-family) as of this writing, but this file
|
|
49
|
+
doesn't audit them individually - check `pyproject.toml` for the current pinned
|
|
50
|
+
versions if you need to.
|
|
@@ -32,6 +32,21 @@ def test_convert_pdf_produces_structured_markdown():
|
|
|
32
32
|
assert "|" in result.markdown # a table survived
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def test_convert_document_includes_provenance():
|
|
36
|
+
"""docmd's own tracking info (docmd_version, backend, backend_version,
|
|
37
|
+
ocr_used, conversion_duration_ms), same shape regardless of which
|
|
38
|
+
backend ran - see docmd/converters/base.py:ConversionResult.provenance.
|
|
39
|
+
For debugging "this converted differently yesterday"."""
|
|
40
|
+
result = convert_document(str(FIXTURES / "sample.pdf"))
|
|
41
|
+
prov = result.provenance
|
|
42
|
+
assert prov["backend"] == "marker"
|
|
43
|
+
assert prov["docmd_version"] != "unknown"
|
|
44
|
+
assert prov["backend_version"] != "unknown"
|
|
45
|
+
assert prov["ocr_used"] is False # sample.pdf has a real text layer
|
|
46
|
+
assert isinstance(prov["conversion_duration_ms"], int)
|
|
47
|
+
assert prov["conversion_duration_ms"] > 0
|
|
48
|
+
|
|
49
|
+
|
|
35
50
|
@pytest.mark.skipif(
|
|
36
51
|
not _HAS_WEASYPRINT_DEPS,
|
|
37
52
|
reason="weasyprint's native deps (Pango/GObject/Cairo) aren't installed "
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Unit tests for MarkerConverter's exception classification - mocked so
|
|
2
|
+
these run fast and don't depend on whether llama-server happens to be
|
|
3
|
+
installed on the machine running the tests. See
|
|
4
|
+
docmd/converters/marker_converter.py for the real (slow, environment-
|
|
5
|
+
dependent) version of this failure, found via real OCR testing on a
|
|
6
|
+
genuinely scanned PDF from archive.org.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import docmd.converters.marker_converter as marker_converter_module
|
|
12
|
+
from docmd.config import ConvertConfig
|
|
13
|
+
from docmd.converters.marker_converter import MarkerConverter
|
|
14
|
+
from docmd.errors import MissingSystemDependencyError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _FakeSpawnError(Exception):
|
|
18
|
+
"""Stands in for surya.inference.backends.spawn.SpawnError without
|
|
19
|
+
importing surya's internal exception class directly."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _FakePdfConverterMissingBinary:
|
|
23
|
+
def __init__(self, **_kwargs) -> None:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def __call__(self, _filepath: str):
|
|
27
|
+
raise _FakeSpawnError(
|
|
28
|
+
"llama-server binary not found. Install with:\n"
|
|
29
|
+
" macOS: brew install llama.cpp"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_missing_llama_server_binary_raises_clear_error(monkeypatch, tmp_path):
|
|
34
|
+
monkeypatch.setattr(marker_converter_module, "_get_model_dict", lambda: {})
|
|
35
|
+
monkeypatch.setattr(
|
|
36
|
+
"marker.converters.pdf.PdfConverter", _FakePdfConverterMissingBinary
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
dummy_pdf = tmp_path / "dummy.pdf"
|
|
40
|
+
dummy_pdf.write_bytes(b"%PDF-1.4\n%%EOF")
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
MarkerConverter().convert(str(dummy_pdf), ConvertConfig())
|
|
44
|
+
raise AssertionError("expected MissingSystemDependencyError")
|
|
45
|
+
except MissingSystemDependencyError as exc:
|
|
46
|
+
message = str(exc)
|
|
47
|
+
assert "llama.cpp" in message
|
|
48
|
+
assert "brew install llama.cpp" in message
|
|
49
|
+
assert "LLAMA_CPP_BINARY" in message
|
|
@@ -9,6 +9,30 @@ def test_heading_normalize_fixes_level_skip():
|
|
|
9
9
|
assert out == "# Title\n## Subsection\n"
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
def test_heading_normalize_fixes_level_skip_then_leaves_recovered_depth_alone():
|
|
13
|
+
"""The exact pattern found in a real 27-page IMF report while stress-
|
|
14
|
+
testing against downloaded (not synthetic) documents: an H1 jumps
|
|
15
|
+
straight to H4 (skipping H2/H3), gets clamped to H2 - but a *later*
|
|
16
|
+
heading that's also originally H4 is left alone, because an H3 heading
|
|
17
|
+
in between legitimately re-establishes the depth. Never reproduced
|
|
18
|
+
synthetically (see skip_headings.pdf in git history, removed after
|
|
19
|
+
Marker's own layout model wouldn't cooperate) - this encodes the real
|
|
20
|
+
structure without redistributing the copyrighted source PDF."""
|
|
21
|
+
md = (
|
|
22
|
+
"# Summary of the Economy Classification\n"
|
|
23
|
+
"#### General Features and Composition of Groups\n"
|
|
24
|
+
"### Advanced Economies\n"
|
|
25
|
+
"#### Emerging Market and Developing Economies\n"
|
|
26
|
+
)
|
|
27
|
+
out = normalize_headings(md)
|
|
28
|
+
assert out == (
|
|
29
|
+
"# Summary of the Economy Classification\n"
|
|
30
|
+
"## General Features and Composition of Groups\n"
|
|
31
|
+
"### Advanced Economies\n"
|
|
32
|
+
"#### Emerging Market and Developing Economies\n"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
12
36
|
def test_heading_normalize_drops_empty_heading():
|
|
13
37
|
md = "# Title\n##\nBody text.\n"
|
|
14
38
|
out = normalize_headings(md)
|
|
@@ -98,3 +122,36 @@ def test_image_handling_alt_text_mode_fills_empty_alt():
|
|
|
98
122
|
md = ""
|
|
99
123
|
out = apply_image_handling(md, images={}, mode="alt-text")
|
|
100
124
|
assert out == ""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_image_handling_drops_fully_empty_image_reference():
|
|
128
|
+
"""Real Marker output on a scanned page: `![]()` with no href at all,
|
|
129
|
+
directly adjacent to a real image reference with no separator between
|
|
130
|
+
them - found while OCR-testing a genuinely scanned PDF. The old regex
|
|
131
|
+
required at least one character inside the parens, so `![]()` never
|
|
132
|
+
matched and leaked through every mode unprocessed."""
|
|
133
|
+
md = "![]()"
|
|
134
|
+
|
|
135
|
+
assert apply_image_handling(md, images={}, mode="placeholder") == "*[image omitted]*"
|
|
136
|
+
assert apply_image_handling(md, images={}, mode="skip") == ""
|
|
137
|
+
assert apply_image_handling(md, images={}, mode="alt-text") == ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_clean_tables_drops_stray_duplicate_separator_row():
|
|
141
|
+
"""Real Marker output found stress-testing a real two-column paper
|
|
142
|
+
("Attention Is All You Need"): a single-row "table" - actually a
|
|
143
|
+
numbered equation rendered as a 1-cell table - came out with two
|
|
144
|
+
separator rows back to back and no real data row. The old code treated
|
|
145
|
+
the second separator as a literal data row and printed a row of dashes
|
|
146
|
+
into the output."""
|
|
147
|
+
md = (
|
|
148
|
+
"| Attention(Q, K, V) = softmax(...)V | (1) |\n"
|
|
149
|
+
"|----------------------------------------|-----|\n"
|
|
150
|
+
"|----------------------------------------|-----|\n"
|
|
151
|
+
)
|
|
152
|
+
out = clean_tables(md)
|
|
153
|
+
lines = [l for l in out.splitlines() if l.strip()]
|
|
154
|
+
assert lines == [
|
|
155
|
+
"| Attention(Q, K, V) = softmax(...)V | (1) |",
|
|
156
|
+
"| --- | --- |",
|
|
157
|
+
]
|
|
@@ -16,18 +16,32 @@ FIXTURES = Path(__file__).parent / "fixtures"
|
|
|
16
16
|
|
|
17
17
|
def test_inconsistent_heading_levels_get_normalized_to_match():
|
|
18
18
|
"""running_header.pdf has 4 structurally identical section headings
|
|
19
|
-
(same paragraph style).
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
(same paragraph style). On the machine this was first written on (macOS,
|
|
20
|
+
MPS), raw Marker assigned them *different* levels - Section 1 came back
|
|
21
|
+
H3, Sections 2-4 came back H2 - a genuine model inconsistency, not a
|
|
22
|
+
deliberately constructed skip.
|
|
23
|
+
|
|
24
|
+
That specific raw output isn't portable, though: CI (Linux, CPU) ran the
|
|
25
|
+
exact same PDF through the exact same pinned Marker version and got all
|
|
26
|
+
four sections back as H2 from the start - a real platform-dependent
|
|
27
|
+
difference in the underlying ML model's inference, not something our
|
|
28
|
+
code controls. Hard-pinning the raw "bug" broke CI twice for that
|
|
29
|
+
reason. So this only *asserts* the thing docmd actually guarantees -
|
|
30
|
+
consistent heading levels across all four sections, however Marker
|
|
31
|
+
happened to render them - and reports (without failing) whether this
|
|
32
|
+
particular run's raw output shows the original inconsistency."""
|
|
23
33
|
raw = MarkerConverter().convert(str(FIXTURES / "running_header.pdf"), ConvertConfig())
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
if "### **Section 1: Findings**" in raw.markdown:
|
|
35
|
+
print("raw Marker output reproduced the H3-vs-H2 inconsistency on this run")
|
|
36
|
+
else:
|
|
37
|
+
print("raw Marker output did not reproduce the inconsistency on this run/platform")
|
|
26
38
|
|
|
27
39
|
result = convert_document(str(FIXTURES / "running_header.pdf"))
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
levels = {
|
|
41
|
+
n: "###" if f"### **Section {n}: Findings**" in result.markdown else "##"
|
|
42
|
+
for n in range(1, 5)
|
|
43
|
+
}
|
|
44
|
+
assert len(set(levels.values())) == 1, f"inconsistent levels in docmd output: {levels}"
|
|
31
45
|
|
|
32
46
|
|
|
33
47
|
def test_running_header_does_not_leak_into_output():
|
|
@@ -43,19 +57,25 @@ def test_running_header_does_not_leak_into_output():
|
|
|
43
57
|
|
|
44
58
|
|
|
45
59
|
def test_merged_header_cells_survive_cleanly():
|
|
46
|
-
"""merged_cells.pdf has a spanned header cell (colspan).
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
nothing for table_cleanup.py to fix
|
|
50
|
-
|
|
51
|
-
|
|
60
|
+
"""merged_cells.pdf has a spanned header cell (colspan). On the machine
|
|
61
|
+
this was first written on, raw Marker already renders it as a clean,
|
|
62
|
+
consistent 5-column table with no ragged rows - an honest negative
|
|
63
|
+
result, nothing for table_cleanup.py to fix.
|
|
64
|
+
|
|
65
|
+
Same lesson as test_inconsistent_heading_levels_get_normalized_to_match:
|
|
66
|
+
the exact raw structure is backend inference output, not something
|
|
67
|
+
docmd controls, so it's reported (via print) rather than asserted. What
|
|
68
|
+
docmd actually guarantees - a well-formed, consistent table in the
|
|
69
|
+
processed output, with the real data intact - is what's asserted."""
|
|
52
70
|
raw = MarkerConverter().convert(str(FIXTURES / "merged_cells.pdf"), ConvertConfig())
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert all(line.count("|") == 6 for line in table_lines) # 5 columns, consistent
|
|
71
|
+
raw_table_lines = [l for l in raw.markdown.splitlines() if l.strip().startswith("|")]
|
|
72
|
+
raw_col_counts = {line.count("|") for line in raw_table_lines}
|
|
73
|
+
print(f"raw table: {len(raw_table_lines)} rows, column counts seen: {raw_col_counts}")
|
|
57
74
|
|
|
58
75
|
result = convert_document(str(FIXTURES / "merged_cells.pdf"))
|
|
76
|
+
proc_table_lines = [l for l in result.markdown.splitlines() if l.strip().startswith("|")]
|
|
77
|
+
proc_col_counts = {line.count("|") for line in proc_table_lines}
|
|
78
|
+
assert len(proc_col_counts) == 1, f"docmd output has inconsistent column counts: {proc_col_counts}"
|
|
59
79
|
assert "West" in result.markdown and "$1.2M" in result.markdown
|
|
60
80
|
|
|
61
81
|
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"""Wraps `marker-pdf` (https://github.com/datalab-to/marker) as a docmd Converter.
|
|
2
|
-
|
|
3
|
-
Uses Marker's Python API directly (not its CLI/server) so we control input and
|
|
4
|
-
output cleanly. `PdfConverter` is Marker's general-purpose converter despite
|
|
5
|
-
the name: it dispatches to the right internal provider (PDF, DOCX, PPTX, ...)
|
|
6
|
-
based on the file's actual content, via `provider_from_filepath`.
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
from typing import Any
|
|
13
|
-
|
|
14
|
-
from docmd.config import ConvertConfig
|
|
15
|
-
from docmd.converters.base import ConversionResult
|
|
16
|
-
from docmd.errors import ConversionError, EncryptedDocumentError, MissingExtraError
|
|
17
|
-
|
|
18
|
-
# Marker loads its models (a few hundred MB to ~1GB of weights, downloaded
|
|
19
|
-
# from Hugging Face on first use) lazily and caches them at module scope, so
|
|
20
|
-
# repeated conversions within one process don't reload them.
|
|
21
|
-
_model_dict: dict[str, Any] | None = None
|
|
22
|
-
|
|
23
|
-
_PASSWORD_HINTS = ("password", "encrypt")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _get_model_dict() -> dict[str, Any]:
|
|
27
|
-
global _model_dict
|
|
28
|
-
if _model_dict is None:
|
|
29
|
-
from marker.models import create_model_dict
|
|
30
|
-
|
|
31
|
-
_model_dict = create_model_dict()
|
|
32
|
-
return _model_dict
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _build_config_dict(config: ConvertConfig):
|
|
36
|
-
from marker.config.parser import ConfigParser
|
|
37
|
-
|
|
38
|
-
options: dict[str, Any] = {
|
|
39
|
-
"output_format": "markdown",
|
|
40
|
-
"force_ocr": config.force_ocr,
|
|
41
|
-
"use_llm": config.use_llm,
|
|
42
|
-
}
|
|
43
|
-
if config.image_mode == "skip":
|
|
44
|
-
options["disable_image_extraction"] = True
|
|
45
|
-
|
|
46
|
-
return ConfigParser(options)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class MarkerConverter:
|
|
50
|
-
"""Converter implementation backed by Marker."""
|
|
51
|
-
|
|
52
|
-
def convert(self, filepath: str | Path, config: ConvertConfig) -> ConversionResult:
|
|
53
|
-
filepath = str(filepath)
|
|
54
|
-
try:
|
|
55
|
-
from marker.converters.pdf import PdfConverter
|
|
56
|
-
from marker.output import text_from_rendered
|
|
57
|
-
except ImportError as exc:
|
|
58
|
-
raise MissingExtraError(Path(filepath).suffix) from exc
|
|
59
|
-
|
|
60
|
-
config_parser = _build_config_dict(config)
|
|
61
|
-
config_dict = config_parser.generate_config_dict()
|
|
62
|
-
|
|
63
|
-
try:
|
|
64
|
-
converter = PdfConverter(
|
|
65
|
-
config=config_dict,
|
|
66
|
-
artifact_dict=_get_model_dict(),
|
|
67
|
-
processor_list=config_parser.get_processors(),
|
|
68
|
-
renderer=config_parser.get_renderer(),
|
|
69
|
-
llm_service=config_parser.get_llm_service(),
|
|
70
|
-
)
|
|
71
|
-
rendered = converter(filepath)
|
|
72
|
-
except Exception as exc:
|
|
73
|
-
message = str(exc).lower()
|
|
74
|
-
if any(hint in message for hint in _PASSWORD_HINTS):
|
|
75
|
-
raise EncryptedDocumentError() from exc
|
|
76
|
-
raise ConversionError(
|
|
77
|
-
f"Marker failed to convert '{filepath}': {exc}", cause=exc
|
|
78
|
-
) from exc
|
|
79
|
-
|
|
80
|
-
markdown, _, images = text_from_rendered(rendered)
|
|
81
|
-
metadata = dict(getattr(rendered, "metadata", {}) or {})
|
|
82
|
-
page_count = len(metadata.get("page_stats", [])) or 1
|
|
83
|
-
|
|
84
|
-
return ConversionResult(
|
|
85
|
-
markdown=markdown,
|
|
86
|
-
page_count=page_count,
|
|
87
|
-
images=images,
|
|
88
|
-
metadata=metadata,
|
|
89
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|