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 +83 -0
- docmd/cli.py +68 -0
- docmd/config.py +51 -0
- docmd/converters/__init__.py +0 -0
- docmd/converters/base.py +36 -0
- docmd/converters/marker_converter.py +89 -0
- docmd/converters/registry.py +43 -0
- docmd/errors.py +60 -0
- docmd/postprocess/__init__.py +0 -0
- docmd/postprocess/heading_normalize.py +76 -0
- docmd/postprocess/image_handling.py +54 -0
- docmd/postprocess/table_cleanup.py +129 -0
- docmd_cli-0.1.0.dist-info/METADATA +151 -0
- docmd_cli-0.1.0.dist-info/RECORD +17 -0
- docmd_cli-0.1.0.dist-info/WHEEL +4 -0
- docmd_cli-0.1.0.dist-info/entry_points.txt +2 -0
- docmd_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
docmd/converters/base.py
ADDED
|
@@ -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 ``
|
|
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""
|
|
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,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.
|