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.
Files changed (36) hide show
  1. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/ARCHITECTURE.md +15 -5
  2. docmd_cli-0.1.3/CONTRACT.md +110 -0
  3. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/PKG-INFO +32 -8
  4. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/README.md +31 -7
  5. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/__init__.py +2 -1
  6. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/base.py +9 -0
  7. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/marker_converter.py +51 -1
  8. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/postprocess/table_cleanup.py +13 -2
  9. docmd_cli-0.1.3/docs/licensing.md +50 -0
  10. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/pyproject.toml +1 -1
  11. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_converters.py +15 -0
  12. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_postprocess.py +20 -0
  13. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_postprocess_more_integration.py +16 -10
  14. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/.github/workflows/ci.yml +0 -0
  15. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/.github/workflows/release.yml +0 -0
  16. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/.gitignore +0 -0
  17. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/LICENSE +0 -0
  18. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/cli.py +0 -0
  19. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/config.py +0 -0
  20. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/__init__.py +0 -0
  21. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/converters/registry.py +0 -0
  22. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/errors.py +0 -0
  23. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/postprocess/__init__.py +0 -0
  24. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/postprocess/heading_normalize.py +0 -0
  25. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/docmd/postprocess/image_handling.py +0 -0
  26. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/generate_fixtures.py +0 -0
  27. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/generate_stress_fixtures.py +0 -0
  28. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/merged_cells.pdf +0 -0
  29. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/running_header.pdf +0 -0
  30. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/sample.docx +0 -0
  31. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/sample.pdf +0 -0
  32. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/stress.pdf +0 -0
  33. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/fixtures/with_image.pdf +0 -0
  34. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_cli.py +0 -0
  35. {docmd_cli-0.1.2 → docmd_cli-0.1.3}/tests/test_marker_converter_errors.py +0 -0
  36. {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
- MIT for the `docmd` wrapper is fine and expected for an OSS trust play. Marker's code is
164
- Apache-2.0. Marker's *model weights* are under a modified Open RAIL-M license: free for
165
- research, personal use, and organizations under $5M funding/revenue; beyond that, a paid
166
- license from Datalab is required (see datalab.to/pricing). Re-check this before scaling
167
- 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.
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.2
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 *code* is
192
- Apache-2.0 and whose *model weights* are licensed under a modified Open RAIL-M license:
193
- free for research, personal use, and organizations under $5M in funding or revenue.
194
- Commercial use beyond that threshold requires a license from
195
- [Datalab](https://www.datalab.to/pricing). This applies to you if you deploy `docmd`
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 *code* is
165
- Apache-2.0 and whose *model weights* are licensed under a modified Open RAIL-M license:
166
- free for research, personal use, and organizations under $5M in funding or revenue.
167
- Commercial use beyond that threshold requires a license from
168
- [Datalab](https://www.datalab.to/pricing). This applies to you if you deploy `docmd`
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.2"
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
- page_count = len(metadata.get("page_stats", [])) or 1
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
- 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.2"
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 "
@@ -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") == "![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
+ ]
@@ -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). Honest negative
61
- result: Marker already renders this as a clean, consistent 5-column
62
- table (blank cells for the spanned columns) with no ragged rows -
63
- nothing for table_cleanup.py to fix here. Pinned so this doesn't
64
- silently regress, and so nobody assumes this case needs a fix it
65
- 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."""
66
70
  raw = MarkerConverter().convert(str(FIXTURES / "merged_cells.pdf"), ConvertConfig())
67
- table_lines = [l for l in raw.markdown.splitlines() if l.strip().startswith("|")]
68
- # spanned quarter-label row, column-label row, separator, 2 data rows.
69
- assert len(table_lines) == 5
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