docmd-cli 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,83 @@
1
+ """docmd: convert PDF/DOCX/PPTX to clean, structure-preserving Markdown.
2
+
3
+ from docmd import convert
4
+ markdown = convert("report.pdf")
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from docmd.config import ConvertConfig
14
+ from docmd.converters.base import ConversionResult
15
+ from docmd.converters.registry import get_converter
16
+ from docmd.postprocess.heading_normalize import normalize_headings
17
+ from docmd.postprocess.image_handling import apply_image_handling
18
+ from docmd.postprocess.table_cleanup import clean_tables
19
+
20
+ __all__ = ["convert", "convert_document", "ConvertConfig", "ConversionResult"]
21
+ __version__ = "0.1.0"
22
+
23
+
24
+ def convert_document(
25
+ source: str | Path | bytes,
26
+ *,
27
+ filename: str | None = None,
28
+ config: ConvertConfig | None = None,
29
+ ) -> ConversionResult:
30
+ """Convert `source` and return the full result (markdown + page count +
31
+ metadata), after docmd's post-processing pass has run.
32
+
33
+ `source` is either a path to a file, or raw bytes - in which case
34
+ `filename` is required so docmd knows the format (its extension is used
35
+ to pick a converter; the content itself is what gets converted).
36
+ """
37
+ config = config or ConvertConfig()
38
+
39
+ if isinstance(source, (bytes, bytearray)):
40
+ if not filename:
41
+ raise ValueError("filename is required when passing bytes")
42
+ suffix = Path(filename).suffix
43
+ fd, tmp_path = tempfile.mkstemp(suffix=suffix)
44
+ try:
45
+ with os.fdopen(fd, "wb") as tmp:
46
+ tmp.write(source)
47
+ result = _convert_path(tmp_path, config)
48
+ finally:
49
+ os.unlink(tmp_path)
50
+ else:
51
+ result = _convert_path(str(source), config)
52
+
53
+ return result
54
+
55
+
56
+ def convert(
57
+ source: str | Path | bytes,
58
+ *,
59
+ filename: str | None = None,
60
+ config: ConvertConfig | None = None,
61
+ ) -> str:
62
+ """Convert `source` (a file path, or bytes + filename) and return the
63
+ resulting Markdown as a string."""
64
+ return convert_document(source, filename=filename, config=config).markdown
65
+
66
+
67
+ def _convert_path(filepath: str, config: ConvertConfig) -> ConversionResult:
68
+ converter = get_converter(filepath)
69
+ result = converter.convert(filepath, config)
70
+
71
+ markdown = result.markdown
72
+ if config.clean_tables:
73
+ markdown = clean_tables(markdown)
74
+ if config.normalize_headings:
75
+ markdown = normalize_headings(markdown)
76
+ markdown = apply_image_handling(markdown, result.images, config.image_mode)
77
+
78
+ return ConversionResult(
79
+ markdown=markdown,
80
+ page_count=result.page_count,
81
+ images=result.images,
82
+ metadata=result.metadata,
83
+ )
docmd/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ """`docmd convert <file> [-o output.md]`"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from docmd import convert_document
11
+ from docmd.config import ConvertConfig
12
+ from docmd.errors import DocmdError
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(package_name="docmd")
17
+ def main() -> None:
18
+ """docmd: convert PDF/DOCX/PPTX to clean, structure-preserving Markdown."""
19
+
20
+
21
+ @main.command()
22
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
23
+ @click.option(
24
+ "-o",
25
+ "--output",
26
+ "output",
27
+ type=click.Path(dir_okay=False, path_type=Path),
28
+ default=None,
29
+ help="Write Markdown to this file instead of stdout.",
30
+ )
31
+ @click.option(
32
+ "--force-ocr",
33
+ is_flag=True,
34
+ default=False,
35
+ help="Force OCR even on pages that already have a text layer.",
36
+ )
37
+ @click.option(
38
+ "--use-llm",
39
+ is_flag=True,
40
+ default=False,
41
+ help="Use an LLM pass for higher-fidelity table/form extraction (needs a provider API key set for Marker; has a real marginal cost).",
42
+ )
43
+ @click.option(
44
+ "--image-mode",
45
+ type=click.Choice(["placeholder", "alt-text", "skip"]),
46
+ default="placeholder",
47
+ show_default=True,
48
+ help="How to represent images in the output.",
49
+ )
50
+ def convert(file: Path, output: Path | None, force_ocr: bool, use_llm: bool, image_mode: str) -> None:
51
+ """Convert FILE to Markdown."""
52
+ config = ConvertConfig(force_ocr=force_ocr, use_llm=use_llm, image_mode=image_mode)
53
+
54
+ try:
55
+ result = convert_document(str(file), config=config)
56
+ except DocmdError as exc:
57
+ click.echo(f"error: {exc}", err=True)
58
+ sys.exit(1)
59
+
60
+ if output is not None:
61
+ output.write_text(result.markdown, encoding="utf-8")
62
+ click.echo(f"wrote {output} ({result.page_count} page(s))", err=True)
63
+ else:
64
+ click.echo(result.markdown)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
docmd/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """Conversion configuration for docmd.
2
+
3
+ Kept small and explicit. This is *not* the hosted API's config (rate limits,
4
+ billing thresholds, etc.) - see ARCHITECTURE.md, that lives in `api/` in a
5
+ later stage. This is just what controls a single conversion.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ _VALID_IMAGE_MODES = {"placeholder", "alt-text", "skip"}
13
+
14
+
15
+ @dataclass
16
+ class ConvertConfig:
17
+ """Options for a single conversion.
18
+
19
+ Attributes:
20
+ use_llm: Ask Marker to use an LLM pass for higher-fidelity extraction
21
+ of tricky tables/forms. Off by default: it requires an API key
22
+ for an LLM provider and has a real marginal cost, so it should be
23
+ an explicit opt-in rather than a surprise dependency.
24
+ force_ocr: Force OCR even on PDFs that already have a text layer.
25
+ Useful for PDFs with a broken/garbled embedded text layer.
26
+ normalize_headings: Run docmd's own heading-normalization
27
+ post-processing pass (see postprocess/heading_normalize.py).
28
+ clean_tables: Run docmd's own table-cleanup post-processing pass
29
+ (see postprocess/table_cleanup.py).
30
+ image_mode: How to represent images in the output Markdown.
31
+ - "placeholder" (default): RAG-friendly. No binary image data is
32
+ written; each image becomes a short, consistent placeholder
33
+ line so downstream chunking/embedding code has something
34
+ predictable to key off (or skip) rather than a dangling link.
35
+ - "alt-text": images are saved next to the output file and kept
36
+ as real Markdown image links with non-empty alt text.
37
+ - "skip": images are dropped entirely, no placeholder either.
38
+ """
39
+
40
+ use_llm: bool = False
41
+ force_ocr: bool = False
42
+ normalize_headings: bool = True
43
+ clean_tables: bool = True
44
+ image_mode: str = "placeholder"
45
+
46
+ def __post_init__(self) -> None:
47
+ if self.image_mode not in _VALID_IMAGE_MODES:
48
+ raise ValueError(
49
+ f"image_mode must be one of {sorted(_VALID_IMAGE_MODES)}, "
50
+ f"got {self.image_mode!r}"
51
+ )
File without changes
@@ -0,0 +1,36 @@
1
+ """Converter interface.
2
+
3
+ Every converter (currently just Marker; the registry exists so a second
4
+ engine could be added later without touching callers) implements this
5
+ protocol: take a filepath, return a ConversionResult.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Protocol
13
+
14
+ from docmd.config import ConvertConfig
15
+
16
+
17
+ @dataclass
18
+ class ConversionResult:
19
+ """The raw result of running a document through a conversion engine,
20
+ before docmd's own post-processing pass runs on top of it.
21
+ """
22
+
23
+ markdown: str
24
+ page_count: int
25
+ images: dict[str, Any] = field(default_factory=dict)
26
+ """Maps an image filename referenced in `markdown` (e.g.
27
+ '_page_0_Figure_1.jpeg') to a PIL.Image.Image instance."""
28
+ metadata: dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ class Converter(Protocol):
32
+ """A document -> Markdown conversion engine."""
33
+
34
+ def convert(self, filepath: str | Path, config: ConvertConfig) -> ConversionResult:
35
+ """Convert the file at `filepath` into a ConversionResult."""
36
+ ...
@@ -0,0 +1,89 @@
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
+ )
@@ -0,0 +1,43 @@
1
+ """Maps a file extension to the converter (and required extras) that handle it.
2
+
3
+ Kept as a registry rather than hardcoding checks in the CLI/library entrypoint
4
+ so a second conversion engine could be added later without touching callers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ from pathlib import Path
11
+
12
+ from docmd.converters.base import Converter
13
+ from docmd.converters.marker_converter import MarkerConverter
14
+ from docmd.errors import MissingExtraError, UnsupportedFormatError
15
+
16
+ # suffix -> module that must be importable for that format to work.
17
+ # None means "supported by the base install, no extra needed".
18
+ _SUPPORTED_SUFFIXES: dict[str, str | None] = {
19
+ ".pdf": None,
20
+ ".docx": "mammoth",
21
+ ".pptx": "pptx",
22
+ }
23
+
24
+ _marker_converter = MarkerConverter()
25
+
26
+
27
+ def get_converter(filepath: str | Path) -> Converter:
28
+ """Return the Converter that should handle `filepath`.
29
+
30
+ Raises:
31
+ UnsupportedFormatError: the extension isn't one docmd knows about.
32
+ MissingExtraError: the extension needs `pip install docmd[full]`.
33
+ """
34
+ suffix = Path(filepath).suffix.lower()
35
+
36
+ if suffix not in _SUPPORTED_SUFFIXES:
37
+ raise UnsupportedFormatError(suffix)
38
+
39
+ required_module = _SUPPORTED_SUFFIXES[suffix]
40
+ if required_module and importlib.util.find_spec(required_module) is None:
41
+ raise MissingExtraError(suffix)
42
+
43
+ return _marker_converter
docmd/errors.py ADDED
@@ -0,0 +1,60 @@
1
+ """Error taxonomy for docmd.
2
+
3
+ Kept flat and explicit (see ARCHITECTURE.md "Error handling") so callers -
4
+ CLI, library, and eventually the hosted API - can catch a small, well-known
5
+ set of exceptions instead of guessing at what a bare Exception means.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class DocmdError(Exception):
12
+ """Base class for all docmd errors."""
13
+
14
+
15
+ class UnsupportedFormatError(DocmdError):
16
+ """Raised when the input file extension isn't handled by any converter."""
17
+
18
+ def __init__(self, suffix: str) -> None:
19
+ self.suffix = suffix
20
+ super().__init__(
21
+ f"Unsupported file format: '{suffix}'. "
22
+ "docmd currently supports: .pdf, .docx, .pptx "
23
+ "(.docx/.pptx require `pip install docmd[full]`)."
24
+ )
25
+
26
+
27
+ class MissingExtraError(DocmdError):
28
+ """Raised when a format needs the `full` extra and it isn't installed."""
29
+
30
+ def __init__(self, suffix: str) -> None:
31
+ self.suffix = suffix
32
+ super().__init__(
33
+ f"'{suffix}' files require the optional dependencies for "
34
+ "DOCX/PPTX support. Install them with: pip install 'docmd[full]'"
35
+ )
36
+
37
+
38
+ class EncryptedDocumentError(DocmdError):
39
+ """Raised when the input is a password-protected / encrypted document."""
40
+
41
+ def __init__(self) -> None:
42
+ super().__init__(
43
+ "This document is encrypted or password-protected. "
44
+ "Remove the password before converting."
45
+ )
46
+
47
+
48
+ class FileTooLargeError(DocmdError):
49
+ """Raised when a file exceeds a caller-supplied hard size/page limit."""
50
+
51
+ def __init__(self, limit_description: str) -> None:
52
+ super().__init__(f"File too large: exceeds {limit_description}.")
53
+
54
+
55
+ class ConversionError(DocmdError):
56
+ """Raised when the underlying conversion engine fails."""
57
+
58
+ def __init__(self, message: str, *, cause: Exception | None = None) -> None:
59
+ super().__init__(message)
60
+ self.__cause__ = cause
File without changes
@@ -0,0 +1,76 @@
1
+ """Heading normalization: docmd's fix for Marker's most common structural
2
+ quirks in raw output - headings that skip levels (H1 -> H3), the same
3
+ section header repeated verbatim right after itself (Marker sometimes turns
4
+ a running page header into a heading on every page), and stray '#' markers
5
+ with no text. None of this is Marker's fault exactly - it's an artifact of
6
+ reconstructing structure from visual layout - but it's exactly the kind of
7
+ thing that makes raw conversion output bad for RAG chunking, where heading
8
+ level is often used to decide chunk boundaries.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ _ATX_RE = re.compile(r"^(#{1,6})\s*(.*?)\s*$")
16
+ _FENCE_RE = re.compile(r"^\s*(```|~~~)")
17
+
18
+
19
+ def normalize_headings(markdown: str) -> str:
20
+ """Return `markdown` with heading levels and duplicates cleaned up.
21
+
22
+ Rules applied, line by line (skipping the contents of fenced code blocks,
23
+ since a '#' there is a comment/directive, not a heading):
24
+ 1. A heading with no text after the '#'s is dropped.
25
+ 2. A heading level may increase by at most 1 relative to the deepest
26
+ heading seen so far (H1 -> H3 becomes H1 -> H2), preventing
27
+ orphaned levels. Decreasing back to any shallower level is always
28
+ allowed.
29
+ 3. A heading whose text exactly repeats the immediately preceding
30
+ heading's text (ignoring blank lines between them) is dropped,
31
+ regardless of level - this is Marker turning a repeated running
32
+ page header into a heading on every page.
33
+ 4. Spacing after '#' is normalized to exactly one space.
34
+ """
35
+ lines = markdown.splitlines()
36
+ out: list[str] = []
37
+ last_level = 0
38
+ last_heading_text: str | None = None
39
+ in_fence = False
40
+
41
+ for line in lines:
42
+ if _FENCE_RE.match(line):
43
+ in_fence = not in_fence
44
+ out.append(line)
45
+ continue
46
+ if in_fence:
47
+ out.append(line)
48
+ continue
49
+
50
+ match = _ATX_RE.match(line)
51
+ if not match:
52
+ out.append(line)
53
+ if line.strip():
54
+ # Any real content resets "last heading" adjacency, so a
55
+ # heading appearing after body text is never treated as a
56
+ # duplicate of one seen much earlier.
57
+ last_heading_text = None
58
+ continue
59
+
60
+ level = len(match.group(1))
61
+ text = match.group(2).strip(" \t#")
62
+
63
+ if not text:
64
+ continue # drop orphaned '#' with no content
65
+
66
+ if text == last_heading_text:
67
+ continue # drop immediate duplicate heading
68
+
69
+ if level > last_level + 1:
70
+ level = last_level + 1
71
+
72
+ last_level = level
73
+ last_heading_text = text
74
+ out.append("#" * level + " " + text)
75
+
76
+ return "\n".join(out) + ("\n" if markdown.endswith("\n") else "")
@@ -0,0 +1,54 @@
1
+ """Image handling: gives RAG pipelines a single, predictable convention for
2
+ what to do with images instead of Marker's raw behavior (an `![](file.jpeg)`
3
+ reference plus a separate dict of image bytes that most callers have no
4
+ plumbing for at all).
5
+
6
+ Three modes (see docmd.config.ConvertConfig.image_mode):
7
+ - "placeholder": no binary data anywhere. Each image reference becomes a
8
+ short, consistent text placeholder - safe to embed/chunk, nothing to
9
+ resolve.
10
+ - "alt-text": images are written to disk next to the output and kept as
11
+ real Markdown image links with guaranteed non-empty alt text.
12
+ - "skip": the image reference is removed entirely, no placeholder either.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ _IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
22
+
23
+
24
+ def apply_image_handling(
25
+ markdown: str,
26
+ images: dict[str, Any],
27
+ mode: str,
28
+ output_dir: str | Path | None = None,
29
+ ) -> str:
30
+ counter = 0
31
+
32
+ def _replace(match: re.Match[str]) -> str:
33
+ nonlocal counter
34
+ alt_text, ref = match.group(1), match.group(2)
35
+ filename = Path(ref).name
36
+
37
+ if mode == "skip":
38
+ return ""
39
+
40
+ if mode == "placeholder":
41
+ label = alt_text.strip() or "image"
42
+ return f"*[{label} omitted]*"
43
+
44
+ # mode == "alt-text"
45
+ counter += 1
46
+ image = images.get(filename)
47
+ if image is not None and output_dir is not None:
48
+ out_path = Path(output_dir) / filename
49
+ out_path.parent.mkdir(parents=True, exist_ok=True)
50
+ image.save(out_path)
51
+ final_alt = alt_text.strip() or f"Image {counter}"
52
+ return f"![{final_alt}]({ref})"
53
+
54
+ return _IMAGE_RE.sub(_replace, markdown)
@@ -0,0 +1,129 @@
1
+ """Table cleanup: fixes the two most common ways Marker's tables come out
2
+ mangled for complex layouts:
3
+
4
+ 1. Ragged / malformed structure - a missing or malformed separator row,
5
+ or data rows with more or fewer cells than the header.
6
+ 2. A single logical table split into two because it spans a page break -
7
+ Marker (reasonably) treats each page independently, so a table that
8
+ continues onto the next page comes out as two separate tables, each
9
+ with its own repeated header row.
10
+
11
+ Operates on already-rendered Markdown text rather than Marker's internal
12
+ block tree, so it works standalone on any Markdown, not just docmd's own
13
+ output - and keeps this module decoupled from Marker's internals.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ _FENCE_RE = re.compile(r"^\s*(```|~~~)")
21
+ _SEP_CELL_RE = re.compile(r"^:?-+:?$")
22
+ _PAGE_SEPARATOR = "-" * 48
23
+
24
+
25
+ def _is_row(line: str) -> bool:
26
+ stripped = line.strip()
27
+ return bool(stripped) and "|" in stripped
28
+
29
+
30
+ def _split_cells(line: str) -> list[str]:
31
+ stripped = line.strip()
32
+ if stripped.startswith("|"):
33
+ stripped = stripped[1:]
34
+ if stripped.endswith("|"):
35
+ stripped = stripped[:-1]
36
+ return [cell.strip() for cell in stripped.split("|")]
37
+
38
+
39
+ def _is_separator_row(line: str) -> bool:
40
+ cells = _split_cells(line)
41
+ return bool(cells) and all(_SEP_CELL_RE.match(c) for c in cells)
42
+
43
+
44
+ def _render_row(cells: list[str]) -> str:
45
+ return "| " + " | ".join(cells) + " |"
46
+
47
+
48
+ def _normalize_block(header: list[str], data_rows: list[list[str]]) -> list[str]:
49
+ """Pad/truncate every row to the header's column count and re-render
50
+ with a clean separator row."""
51
+ col_count = len(header)
52
+ out = [_render_row(header), _render_row(["---"] * col_count)]
53
+ for row in data_rows:
54
+ if len(row) < col_count:
55
+ row = row + [""] * (col_count - len(row))
56
+ elif len(row) > col_count:
57
+ row = row[:col_count]
58
+ out.append(_render_row(row))
59
+ return out
60
+
61
+
62
+ def _headers_match(a: list[str], b: list[str]) -> bool:
63
+ norm = lambda cells: [c.strip().lower() for c in cells]
64
+ return norm(a) == norm(b)
65
+
66
+
67
+ def clean_tables(markdown: str) -> str:
68
+ """Return `markdown` with pipe-table structure fixed up, including
69
+ re-joining tables that were split across a page break."""
70
+ lines = markdown.splitlines()
71
+ out: list[str] = []
72
+ i = 0
73
+ in_fence = False
74
+
75
+ while i < len(lines):
76
+ line = lines[i]
77
+
78
+ if _FENCE_RE.match(line):
79
+ in_fence = not in_fence
80
+ out.append(line)
81
+ i += 1
82
+ continue
83
+ if in_fence:
84
+ out.append(line)
85
+ i += 1
86
+ continue
87
+
88
+ if not (_is_row(line) and i + 1 < len(lines) and _is_row(lines[i + 1])):
89
+ out.append(line)
90
+ i += 1
91
+ continue
92
+
93
+ # Found the start of a table block: header + at least one more row.
94
+ header = _split_cells(line)
95
+ j = i + 1
96
+ if _is_separator_row(lines[j]):
97
+ j += 1
98
+ block_end = j
99
+ while block_end < len(lines) and _is_row(lines[block_end]):
100
+ block_end += 1
101
+ data_rows = [_split_cells(l) for l in lines[j:block_end]]
102
+
103
+ # Look ahead past blank lines / Marker's page separator for a
104
+ # continuation: another table block whose header repeats this one.
105
+ k = block_end
106
+ while True:
107
+ probe = k
108
+ while probe < len(lines) and (
109
+ not lines[probe].strip() or lines[probe].strip() == _PAGE_SEPARATOR
110
+ ):
111
+ probe += 1
112
+ if not (probe < len(lines) and _is_row(lines[probe]) and probe + 1 < len(lines) and _is_row(lines[probe + 1])):
113
+ break
114
+ next_header = _split_cells(lines[probe])
115
+ if not _headers_match(header, next_header):
116
+ break
117
+ p = probe + 1
118
+ if _is_separator_row(lines[p]):
119
+ p += 1
120
+ next_block_end = p
121
+ while next_block_end < len(lines) and _is_row(lines[next_block_end]):
122
+ next_block_end += 1
123
+ data_rows.extend(_split_cells(l) for l in lines[p:next_block_end])
124
+ k = next_block_end
125
+
126
+ out.extend(_normalize_block(header, data_rows))
127
+ i = k
128
+
129
+ return "\n".join(out) + ("\n" if markdown.endswith("\n") else "")
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.5
2
+ Name: docmd-cli
3
+ Version: 0.1.0
4
+ Summary: Convert PDFs, DOCX, and PPTX to clean, structure-preserving Markdown.
5
+ Project-URL: Homepage, https://github.com/taherzribi/docmd
6
+ Project-URL: Issues, https://github.com/taherzribi/docmd/issues
7
+ Author: taherzribi
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: document-conversion,docx,markdown,pdf,pptx,rag
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Requires-Python: <4,>=3.10
18
+ Requires-Dist: click<9,>=8.2.0
19
+ Requires-Dist: marker-pdf==2.0.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
22
+ Requires-Dist: python-docx>=1.1.0; extra == 'dev'
23
+ Requires-Dist: reportlab>=4.0.0; extra == 'dev'
24
+ Provides-Extra: full
25
+ Requires-Dist: marker-pdf[full]==2.0.0; extra == 'full'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # docmd
29
+
30
+ **Convert PDFs, DOCX, and PPTX to clean, structure-preserving Markdown — from the CLI or a Python library.**
31
+
32
+ Built for feeding documents into LLM and RAG pipelines, where clean Markdown beats raw text extraction.
33
+
34
+ *(Published on PyPI as `docmd-cli` since `docmd` was already taken by an unrelated
35
+ package — the import (`import docmd`) and CLI command (`docmd convert ...`) are
36
+ unaffected.)*
37
+
38
+ ```bash
39
+ pip install docmd-cli
40
+ docmd convert report.pdf
41
+ ```
42
+
43
+ ```python
44
+ from docmd import convert
45
+
46
+ markdown = convert("report.pdf")
47
+ print(markdown)
48
+ ```
49
+
50
+ ## Why docmd
51
+
52
+ Great open-source document-to-Markdown converters already exist (docmd is built on
53
+ [Marker](https://github.com/datalab-to/marker)). The problem isn't quality — it's that
54
+ running them yourself means a Python environment, several GB of RAM, ideally a GPU, and
55
+ a non-trivial setup process before you convert your first file.
56
+
57
+ `docmd` wraps that engine with sane defaults and adds real post-processing on top
58
+ (table cleanup, heading normalization) so the output is closer to what a RAG pipeline
59
+ actually wants, not just raw model output.
60
+
61
+ A hosted API (`POST` a file, get Markdown back, no local setup) is planned — see
62
+ [ARCHITECTURE.md](ARCHITECTURE.md). It is not live yet; this repo is the open-source
63
+ core, usable standalone today.
64
+
65
+ ## What it handles
66
+
67
+ | Input | Output |
68
+ |---|---|
69
+ | PDF (text-based) | Markdown with preserved headings, lists, tables |
70
+ | PDF (scanned) | Markdown via OCR — bundled by Marker, free |
71
+ | DOCX | Markdown with formatting preserved (`pip install docmd-cli[full]`) |
72
+ | PPTX | Markdown, one section per slide (`pip install docmd-cli[full]`) |
73
+
74
+ ## Quickstart
75
+
76
+ **CLI**
77
+ ```bash
78
+ pip install docmd-cli
79
+ docmd convert my-file.pdf -o output.md
80
+ ```
81
+
82
+ **Python**
83
+ ```python
84
+ from docmd import convert
85
+
86
+ # From a file path
87
+ markdown = convert("my-file.pdf")
88
+
89
+ # From bytes
90
+ with open("my-file.pdf", "rb") as f:
91
+ markdown = convert(f.read(), filename="my-file.pdf")
92
+ ```
93
+
94
+ ## Installing DOCX/PPTX support
95
+
96
+ The base install (`pip install docmd-cli`) covers PDF only and stays lean. DOCX and
97
+ PPTX need Marker's own additional dependencies:
98
+
99
+ ```bash
100
+ pip install "docmd-cli[full]"
101
+ ```
102
+
103
+ DOCX/PPTX conversion also needs [weasyprint](https://doc.courtbouillon.org/weasyprint/stable/first_steps.html#installation)'s
104
+ native Pango/GObject/Cairo libraries, which `pip` cannot install for you:
105
+
106
+ ```bash
107
+ # macOS
108
+ brew install pango
109
+
110
+ # Debian/Ubuntu
111
+ sudo apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info
112
+ ```
113
+
114
+ PDF conversion (the base install) does not need this.
115
+
116
+ ## How it works
117
+
118
+ `docmd` wraps [Marker](https://github.com/datalab-to/marker) with sane defaults and a
119
+ clean output format, then runs its own post-processing pass
120
+ (`docmd/postprocess/`) to fix table structure and normalize heading levels — see
121
+ [ARCHITECTURE.md](ARCHITECTURE.md) for why this is the actual differentiation, not
122
+ just a thin wrapper.
123
+
124
+ ## License
125
+
126
+ The `docmd` wrapper code is MIT — see [LICENSE](LICENSE).
127
+
128
+ `docmd` depends on [Marker](https://github.com/datalab-to/marker), whose *code* is
129
+ Apache-2.0 and whose *model weights* are licensed under a modified Open RAIL-M license:
130
+ free for research, personal use, and organizations under $5M in funding or revenue.
131
+ Commercial use beyond that threshold requires a license from
132
+ [Datalab](https://www.datalab.to/pricing). This applies to you if you deploy `docmd`
133
+ commercially at scale — check Marker's current license terms directly before doing so.
134
+
135
+ ## Roadmap
136
+
137
+ - [x] PDF, DOCX, PPTX → Markdown
138
+ - [x] Table cleanup / heading normalization post-processing
139
+ - [ ] Hosted API
140
+ - [ ] OCR quality tuning for scanned documents
141
+ - [ ] Batch conversion endpoint
142
+ - [ ] HTML output option
143
+
144
+ ## Contributing
145
+
146
+ Issues and PRs welcome. If you're hitting a conversion quality issue, please include a
147
+ sample file (or a minimal reproduction) — it makes fixes much faster.
148
+
149
+ ---
150
+
151
+ *If docmd saves you the trouble of setting up your own PDF-parsing pipeline, consider starring the repo — it's how other people find it.*
@@ -0,0 +1,17 @@
1
+ docmd/__init__.py,sha256=TZrcYqGJGN0U5ah1pxkxm4k2gM5p4ES-yk7jXq0oGgA,2667
2
+ docmd/cli.py,sha256=UGi_LwInWEbjxRitWgBNZyDsH1Cho4vAH2MxGZj_meI,1889
3
+ docmd/config.py,sha256=0pwnoaLYkjcUFLSJnoWisXkiCQDmEIDcTKlGg3W9Rj0,2183
4
+ docmd/errors.py,sha256=td3wxA2zVz550Ug-a8sp_VjSz9wZM_rCkiY6BZ4Xj5M,1954
5
+ docmd/converters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ docmd/converters/base.py,sha256=JE0-r59-WpAjWEBgXgeDUO6fQl3CzD-nrRpstl4cgxk,1113
7
+ docmd/converters/marker_converter.py,sha256=8BgrHce1THHD-0WuvjJMagDaxisjxXbDKbU-KG_A9xg,3126
8
+ docmd/converters/registry.py,sha256=T1jGdnkjRMwusH7LJQ4ilgPeS7_EvlMvJksmzXBEra4,1397
9
+ docmd/postprocess/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ docmd/postprocess/heading_normalize.py,sha256=Wlx5vm6pgv95QloqtndTaiVSqJEIJ1HOUe79WnM3vbI,2843
11
+ docmd/postprocess/image_handling.py,sha256=VMI_QCApXjGVrED74HKPQza9M1iOEQwrMn2oatQD0yg,1766
12
+ docmd/postprocess/table_cleanup.py,sha256=3WcV7zPhMIhkqe6VXJbJYvDWLyet6oZOOjPW-SH-tIk,4355
13
+ docmd_cli-0.1.0.dist-info/METADATA,sha256=NasQeeBhGM0PZBcRH4TGXkExNVWPwUJNQppYIkPhEaY,5098
14
+ docmd_cli-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
15
+ docmd_cli-0.1.0.dist-info/entry_points.txt,sha256=f555Z7tM7TIhR9s4k-JRR1JoN45V0Kfom7WYvVkWcQ4,41
16
+ docmd_cli-0.1.0.dist-info/licenses/LICENSE,sha256=yj7qe5Hn99la3ZFCwiZ9iL-gPqfdU2h39dbJsRtj0zU,1067
17
+ docmd_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docmd = docmd.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 taherzribi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.