docmd-cli 0.1.2__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.2 → docmd_cli-0.1.3}/ARCHITECTURE.md +15 -5
- docmd_cli-0.1.3/CONTRACT.md +110 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/PKG-INFO +32 -8
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/README.md +31 -7
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/__init__.py +2 -1
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/base.py +9 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/marker_converter.py +51 -1
- {docmd_cli-0.1.2 → 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.2 → docmd_cli-0.1.3}/pyproject.toml +1 -1
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_converters.py +15 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_postprocess.py +20 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_postprocess_more_integration.py +16 -10
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/.github/workflows/ci.yml +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/.github/workflows/release.yml +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/.gitignore +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/LICENSE +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/cli.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/config.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/__init__.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/registry.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/errors.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/postprocess/__init__.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/postprocess/heading_normalize.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/postprocess/image_handling.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/generate_fixtures.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/generate_stress_fixtures.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/merged_cells.pdf +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/running_header.pdf +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/sample.docx +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/sample.pdf +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/stress.pdf +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/with_image.pdf +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_cli.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_marker_converter_errors.py +0 -0
- {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_postprocess_integration.py +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
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
|
|
|
@@ -160,11 +166,15 @@ Treat this folder as the actual differentiator. A thin wrapper is a weekend proj
|
|
|
160
166
|
|
|
161
167
|
## License clarity
|
|
162
168
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
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.
|
|
168
178
|
|
|
169
179
|
## What "done" looks like for Stage 1 (this repo, today)
|
|
170
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
|
|
@@ -145,6 +145,13 @@ Without it, converting a scanned PDF or one with equations raises a
|
|
|
145
145
|
First real OCR run also downloads the model's GGUF weights from Hugging Face
|
|
146
146
|
(a few GB), separate from the PyTorch weights Marker already downloaded.
|
|
147
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
|
+
|
|
148
155
|
## Image handling
|
|
149
156
|
|
|
150
157
|
Images default to a text placeholder (`*[... omitted]*`) - no binary data, nothing to
|
|
@@ -176,24 +183,41 @@ docmd convert report.pdf -o output/report.md --image-mode alt-text
|
|
|
176
183
|
|
|
177
184
|
`image_mode="skip"` drops images entirely - no placeholder, no files.
|
|
178
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
|
+
|
|
179
203
|
## How it works
|
|
180
204
|
|
|
181
205
|
`docmd` wraps [Marker](https://github.com/datalab-to/marker) with sane defaults and a
|
|
182
206
|
clean output format, then runs its own post-processing pass
|
|
183
207
|
(`docmd/postprocess/`) to fix table structure and normalize heading levels — see
|
|
184
208
|
[ARCHITECTURE.md](ARCHITECTURE.md) for why this is the actual differentiation, not
|
|
185
|
-
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.
|
|
186
211
|
|
|
187
212
|
## License
|
|
188
213
|
|
|
189
214
|
The `docmd` wrapper code is MIT — see [LICENSE](LICENSE).
|
|
190
215
|
|
|
191
|
-
`docmd` depends on [Marker](https://github.com/datalab-to/marker), whose
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
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.
|
|
197
221
|
|
|
198
222
|
## Roadmap
|
|
199
223
|
|
|
@@ -118,6 +118,13 @@ Without it, converting a scanned PDF or one with equations raises a
|
|
|
118
118
|
First real OCR run also downloads the model's GGUF weights from Hugging Face
|
|
119
119
|
(a few GB), separate from the PyTorch weights Marker already downloaded.
|
|
120
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
|
+
|
|
121
128
|
## Image handling
|
|
122
129
|
|
|
123
130
|
Images default to a text placeholder (`*[... omitted]*`) - no binary data, nothing to
|
|
@@ -149,24 +156,41 @@ docmd convert report.pdf -o output/report.md --image-mode alt-text
|
|
|
149
156
|
|
|
150
157
|
`image_mode="skip"` drops images entirely - no placeholder, no files.
|
|
151
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
|
+
|
|
152
176
|
## How it works
|
|
153
177
|
|
|
154
178
|
`docmd` wraps [Marker](https://github.com/datalab-to/marker) with sane defaults and a
|
|
155
179
|
clean output format, then runs its own post-processing pass
|
|
156
180
|
(`docmd/postprocess/`) to fix table structure and normalize heading levels — see
|
|
157
181
|
[ARCHITECTURE.md](ARCHITECTURE.md) for why this is the actual differentiation, not
|
|
158
|
-
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.
|
|
159
184
|
|
|
160
185
|
## License
|
|
161
186
|
|
|
162
187
|
The `docmd` wrapper code is MIT — see [LICENSE](LICENSE).
|
|
163
188
|
|
|
164
|
-
`docmd` depends on [Marker](https://github.com/datalab-to/marker), whose
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
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.
|
|
170
194
|
|
|
171
195
|
## Roadmap
|
|
172
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):
|
|
@@ -8,6 +8,8 @@ based on the file's actual content, via `provider_from_filepath`.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import importlib.metadata
|
|
12
|
+
import time
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from typing import Any
|
|
13
15
|
|
|
@@ -42,6 +44,34 @@ def _get_model_dict() -> dict[str, Any]:
|
|
|
42
44
|
return _model_dict
|
|
43
45
|
|
|
44
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
|
+
|
|
45
75
|
def _build_config_dict(config: ConvertConfig):
|
|
46
76
|
from marker.config.parser import ConfigParser
|
|
47
77
|
|
|
@@ -82,6 +112,7 @@ class MarkerConverter:
|
|
|
82
112
|
config_parser = _build_config_dict(config)
|
|
83
113
|
config_dict = config_parser.generate_config_dict()
|
|
84
114
|
|
|
115
|
+
start = time.monotonic()
|
|
85
116
|
try:
|
|
86
117
|
converter = PdfConverter(
|
|
87
118
|
config=config_dict,
|
|
@@ -102,13 +133,32 @@ class MarkerConverter:
|
|
|
102
133
|
f"Marker failed to convert '{filepath}': {exc}", cause=exc
|
|
103
134
|
) from exc
|
|
104
135
|
|
|
136
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
105
137
|
markdown, _, images = text_from_rendered(rendered)
|
|
106
138
|
metadata = dict(getattr(rendered, "metadata", {}) or {})
|
|
107
|
-
|
|
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
|
+
}
|
|
108
157
|
|
|
109
158
|
return ConversionResult(
|
|
110
159
|
markdown=markdown,
|
|
111
160
|
page_count=page_count,
|
|
112
161
|
images=images,
|
|
113
162
|
metadata=metadata,
|
|
163
|
+
provenance=provenance,
|
|
114
164
|
)
|
|
@@ -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 "
|
|
@@ -135,3 +135,23 @@ def test_image_handling_drops_fully_empty_image_reference():
|
|
|
135
135
|
assert apply_image_handling(md, images={}, mode="placeholder") == "*[image omitted]*"
|
|
136
136
|
assert apply_image_handling(md, images={}, mode="skip") == ""
|
|
137
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
|
+
]
|
|
@@ -57,19 +57,25 @@ def test_running_header_does_not_leak_into_output():
|
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def test_merged_header_cells_survive_cleanly():
|
|
60
|
-
"""merged_cells.pdf has a spanned header cell (colspan).
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
nothing for table_cleanup.py to fix
|
|
64
|
-
|
|
65
|
-
|
|
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."""
|
|
66
70
|
raw = MarkerConverter().convert(str(FIXTURES / "merged_cells.pdf"), ConvertConfig())
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
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}")
|
|
71
74
|
|
|
72
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}"
|
|
73
79
|
assert "West" in result.markdown and "$1.2M" in result.markdown
|
|
74
80
|
|
|
75
81
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|