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.
Files changed (37) hide show
  1. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/ARCHITECTURE.md +43 -6
  2. docmd_cli-0.1.3/CONTRACT.md +110 -0
  3. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/PKG-INFO +62 -9
  4. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/README.md +61 -8
  5. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/__init__.py +2 -1
  6. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/converters/base.py +9 -0
  7. docmd_cli-0.1.3/docmd/converters/marker_converter.py +164 -0
  8. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/errors.py +24 -0
  9. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/image_handling.py +11 -1
  10. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/table_cleanup.py +13 -2
  11. docmd_cli-0.1.3/docs/licensing.md +50 -0
  12. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/pyproject.toml +1 -1
  13. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_converters.py +15 -0
  14. docmd_cli-0.1.3/tests/test_marker_converter_errors.py +49 -0
  15. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_postprocess.py +57 -0
  16. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_postprocess_more_integration.py +39 -19
  17. docmd_cli-0.1.1/docmd/converters/marker_converter.py +0 -89
  18. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/.github/workflows/ci.yml +0 -0
  19. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/.github/workflows/release.yml +0 -0
  20. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/.gitignore +0 -0
  21. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/LICENSE +0 -0
  22. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/cli.py +0 -0
  23. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/config.py +0 -0
  24. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/converters/__init__.py +0 -0
  25. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/converters/registry.py +0 -0
  26. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/__init__.py +0 -0
  27. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/docmd/postprocess/heading_normalize.py +0 -0
  28. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/generate_fixtures.py +0 -0
  29. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/generate_stress_fixtures.py +0 -0
  30. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/merged_cells.pdf +0 -0
  31. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/running_header.pdf +0 -0
  32. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/sample.docx +0 -0
  33. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/sample.pdf +0 -0
  34. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/stress.pdf +0 -0
  35. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/fixtures/with_image.pdf +0 -0
  36. {docmd_cli-0.1.1 → docmd_cli-0.1.3}/tests/test_cli.py +0 -0
  37. {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
- MIT for the `docmd` wrapper is fine and expected for an OSS trust play. Marker's code is
137
- Apache-2.0. Marker's *model weights* are under a modified Open RAIL-M license: free for
138
- research, personal use, and organizations under $5M funding/revenue; beyond that, a paid
139
- license from Datalab is required (see datalab.to/pricing). Re-check this before scaling
140
- the hosted API past that threshold.
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.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 *code* is
163
- Apache-2.0 and whose *model weights* are licensed under a modified Open RAIL-M license:
164
- free for research, personal use, and organizations under $5M in funding or revenue.
165
- Commercial use beyond that threshold requires a license from
166
- [Datalab](https://www.datalab.to/pricing). This applies to you if you deploy `docmd`
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 *code* is
136
- Apache-2.0 and whose *model weights* are licensed under a modified Open RAIL-M license:
137
- free for research, personal use, and organizations under $5M in funding or revenue.
138
- Commercial use beyond that threshold requires a license from
139
- [Datalab](https://www.datalab.to/pricing). This applies to you if you deploy `docmd`
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.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
- data_rows = [_split_cells(l) for l in lines[j:block_end]]
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(_split_cells(l) for l in lines[p:next_block_end])
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "docmd-cli"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Convert PDFs, DOCX, and PPTX to clean, structure-preserving Markdown."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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 = "![](img.png)"
99
123
  out = apply_image_handling(md, images={}, mode="alt-text")
100
124
  assert out == "![Image 1](img.png)"
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 = "![]()![](real.jpeg)"
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") == "![Image 1](real.jpeg)"
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). Raw Marker assigns them *different* levels -
20
- Section 1 comes out H3, Sections 2-4 come out H2, a genuine model
21
- inconsistency, not a deliberately constructed skip. Confirm that first,
22
- then confirm docmd normalizes them to be consistent."""
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
- assert "### **Section 1: Findings**" in raw.markdown
25
- assert "## **Section 2: Findings**" in raw.markdown
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
- for n in range(1, 5):
29
- assert f"## **Section {n}: Findings**" in result.markdown
30
- assert f"### **Section {n}: Findings**" not in result.markdown
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). Honest negative
47
- result: Marker already renders this as a clean, consistent 5-column
48
- table (blank cells for the spanned columns) with no ragged rows -
49
- nothing for table_cleanup.py to fix here. Pinned so this doesn't
50
- silently regress, and so nobody assumes this case needs a fix it
51
- doesn't need."""
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
- table_lines = [l for l in raw.markdown.splitlines() if l.strip().startswith("|")]
54
- # spanned quarter-label row, column-label row, separator, 2 data rows.
55
- assert len(table_lines) == 5
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