parse-anything 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.
- parse_anything/__init__.py +54 -0
- parse_anything/__main__.py +3 -0
- parse_anything/cli.py +104 -0
- parse_anything/engines/__init__.py +3 -0
- parse_anything/engines/base.py +34 -0
- parse_anything/engines/docling_engine.py +99 -0
- parse_anything/engines/lite_engine.py +67 -0
- parse_anything/engines/markitdown_engine.py +41 -0
- parse_anything/engines/mineru_engine.py +121 -0
- parse_anything/engines/ocr_engine.py +34 -0
- parse_anything/engines/unstructured_engine.py +81 -0
- parse_anything/factory.py +117 -0
- parse_anything/ocr/__init__.py +18 -0
- parse_anything/ocr/base.py +59 -0
- parse_anything/ocr/paddle_engine.py +66 -0
- parse_anything/ocr/tesseract_engine.py +47 -0
- parse_anything/result.py +60 -0
- parse_anything-0.1.0.dist-info/METADATA +124 -0
- parse_anything-0.1.0.dist-info/RECORD +23 -0
- parse_anything-0.1.0.dist-info/WHEEL +5 -0
- parse_anything-0.1.0.dist-info/entry_points.txt +2 -0
- parse_anything-0.1.0.dist-info/licenses/LICENSE +21 -0
- parse_anything-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""parse-anything: one interface over mineru, docling, unstructured, markitdown,
|
|
2
|
+
lite (PyMuPDF) and raw OCR (PaddleOCR / Tesseract).
|
|
3
|
+
|
|
4
|
+
from parse_anything import parse
|
|
5
|
+
|
|
6
|
+
result = parse("report.pdf", engine="mineru")
|
|
7
|
+
print(result.content) # markdown
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .factory import engine_names, get_engine
|
|
14
|
+
from .result import ParseResult
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
# Library convention: don't configure handlers here, just make sure "no
|
|
19
|
+
# handlers found" warnings don't show up for callers who haven't set up
|
|
20
|
+
# logging themselves. The CLI (cli.py) calls logging.basicConfig() instead.
|
|
21
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse(
|
|
26
|
+
path: str | Path,
|
|
27
|
+
engine: str = "auto",
|
|
28
|
+
ocr: str = "paddle",
|
|
29
|
+
**options,
|
|
30
|
+
) -> ParseResult:
|
|
31
|
+
"""Parses a document and returns its extracted content.
|
|
32
|
+
|
|
33
|
+
path: file to parse (pdf, docx, pptx, xlsx, html, images, ...)
|
|
34
|
+
engine: "auto" (default), "mineru"/"miner-u", "docling", "unstructured",
|
|
35
|
+
"markitdown", "lite"/"liteparse", or "ocr"
|
|
36
|
+
ocr: "paddle" (default, PP-OCRv5), "tesseract", or "none"
|
|
37
|
+
options: engine-specific knobs, e.g. strategy="hi_res" (unstructured),
|
|
38
|
+
force_ocr=True (mineru), dpi=300 (ocr), lang="ch" (ocr backends)
|
|
39
|
+
"""
|
|
40
|
+
file_path = Path(path)
|
|
41
|
+
if not file_path.exists():
|
|
42
|
+
raise FileNotFoundError(f"No such file: {file_path}")
|
|
43
|
+
|
|
44
|
+
logger.info("parsing %s (engine=%s, ocr=%s)", file_path.name, engine, ocr)
|
|
45
|
+
backend = get_engine(engine, path=file_path)
|
|
46
|
+
result = backend.parse(file_path, ocr=ocr, **options)
|
|
47
|
+
logger.info(
|
|
48
|
+
"finished %s: engine=%s ocr=%s %d chars in %.2fs",
|
|
49
|
+
file_path.name, result.engine, result.ocr, len(result.content), result.duration_s,
|
|
50
|
+
)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["parse", "ParseResult", "get_engine", "engine_names", "__version__"]
|
parse_anything/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import __version__, parse
|
|
6
|
+
from .factory import engine_names
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("parse_anything.cli")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
p = argparse.ArgumentParser(
|
|
13
|
+
prog="parse-anything",
|
|
14
|
+
description="Parse any document with your engine of choice and print the extracted content.",
|
|
15
|
+
)
|
|
16
|
+
p.add_argument("path", nargs="?", help="File to parse (pdf, docx, pptx, html, png, ...)")
|
|
17
|
+
p.add_argument(
|
|
18
|
+
"-e", "--engine", default="auto",
|
|
19
|
+
help="auto | mineru (miner-u) | docling | unstructured | markitdown | lite (liteparse) | ocr",
|
|
20
|
+
)
|
|
21
|
+
p.add_argument(
|
|
22
|
+
"--ocr", default="paddle", choices=["paddle", "tesseract", "none"],
|
|
23
|
+
help="OCR backend for scanned pages/images (default: paddle / PP-OCRv5)",
|
|
24
|
+
)
|
|
25
|
+
p.add_argument("-o", "--output", help="Write result here (.md/.txt for content, .json for full result)")
|
|
26
|
+
p.add_argument(
|
|
27
|
+
"-f", "--format", default="content", choices=["content", "json"],
|
|
28
|
+
help="stdout format: raw extracted content (default) or the full JSON result",
|
|
29
|
+
)
|
|
30
|
+
p.add_argument("--strategy", help="unstructured only: fast | hi_res | ocr_only | auto")
|
|
31
|
+
p.add_argument("--force-ocr", action="store_true", help="mineru only: OCR every page")
|
|
32
|
+
p.add_argument("--lang", help="OCR language (paddle: en/ch/..., tesseract: eng/deu/...)")
|
|
33
|
+
p.add_argument("--dpi", type=int, help="ocr engine only: PDF rasterization DPI (default 200)")
|
|
34
|
+
p.add_argument("--timeout", type=int, default=900, help="mineru timeout in seconds (default 900 / 15 min)")
|
|
35
|
+
p.add_argument("--list-engines", action="store_true", help="List engines and exit")
|
|
36
|
+
p.add_argument(
|
|
37
|
+
"-v", "--verbose", action="count", default=0,
|
|
38
|
+
help="raise log verbosity to debug (resolved engine names, per-page timing, ...); "
|
|
39
|
+
"progress logs (auto-selection, mineru output, OCR page progress) print by default",
|
|
40
|
+
)
|
|
41
|
+
p.add_argument("-q", "--quiet", action="store_true", help="suppress progress logs, print only errors")
|
|
42
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
43
|
+
return p
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _configure_logging(verbose: int, quiet: bool) -> None:
|
|
47
|
+
if quiet:
|
|
48
|
+
level = logging.ERROR
|
|
49
|
+
elif verbose >= 1:
|
|
50
|
+
level = logging.DEBUG
|
|
51
|
+
else:
|
|
52
|
+
level = logging.INFO
|
|
53
|
+
logging.basicConfig(
|
|
54
|
+
level=level,
|
|
55
|
+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
56
|
+
datefmt="%H:%M:%S",
|
|
57
|
+
stream=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
args = build_parser().parse_args(argv)
|
|
63
|
+
_configure_logging(args.verbose, args.quiet)
|
|
64
|
+
|
|
65
|
+
if args.list_engines:
|
|
66
|
+
print("\n".join(engine_names()))
|
|
67
|
+
return 0
|
|
68
|
+
if not args.path:
|
|
69
|
+
build_parser().print_help()
|
|
70
|
+
return 2
|
|
71
|
+
|
|
72
|
+
options = {}
|
|
73
|
+
if args.strategy:
|
|
74
|
+
options["strategy"] = args.strategy
|
|
75
|
+
if args.force_ocr:
|
|
76
|
+
options["force_ocr"] = True
|
|
77
|
+
if args.lang:
|
|
78
|
+
options["lang"] = args.lang
|
|
79
|
+
if args.dpi:
|
|
80
|
+
options["dpi"] = args.dpi
|
|
81
|
+
if args.timeout != 900:
|
|
82
|
+
options["timeout"] = args.timeout
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
result = parse(args.path, engine=args.engine, ocr=args.ocr, **options)
|
|
86
|
+
except (FileNotFoundError, ValueError, ImportError, RuntimeError) as exc:
|
|
87
|
+
logger.error(str(exc))
|
|
88
|
+
return 1
|
|
89
|
+
|
|
90
|
+
if args.output:
|
|
91
|
+
saved = result.save(args.output)
|
|
92
|
+
if not args.quiet:
|
|
93
|
+
print(
|
|
94
|
+
f"[{result.engine}] {result.path} -> {saved} "
|
|
95
|
+
f"({len(result.content)} chars, {result.duration_s:.1f}s)",
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
print(result.to_json() if args.format == "json" else result.content)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ..result import ParseResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ParserEngine(ABC):
|
|
8
|
+
"""Common interface every parsing backend implements.
|
|
9
|
+
|
|
10
|
+
Deliberately minimal — file path in, ParseResult out — so callers can swap
|
|
11
|
+
engines (mineru, docling, unstructured, markitdown, lite) without changing
|
|
12
|
+
any downstream code.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
|
|
17
|
+
#: pip extra that pulls in this engine's dependencies, used in error messages.
|
|
18
|
+
extra: str
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def parse(self, path: Path, ocr: str = "paddle", **options) -> ParseResult:
|
|
22
|
+
"""Extracts content from the file.
|
|
23
|
+
|
|
24
|
+
ocr: "paddle" (default), "tesseract", or "none". Engines that bundle
|
|
25
|
+
their own OCR pipeline (mineru) or don't support choosing (markitdown)
|
|
26
|
+
document how they interpret this.
|
|
27
|
+
"""
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
|
|
30
|
+
def _missing_dep(self, package: str) -> ImportError:
|
|
31
|
+
return ImportError(
|
|
32
|
+
f"Engine '{self.name}' needs '{package}'. "
|
|
33
|
+
f"Install it with: pip install parse-anything[{self.extra}]"
|
|
34
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ..result import ParseResult, Timer
|
|
5
|
+
from .base import ParserEngine
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DoclingEngine(ParserEngine):
|
|
11
|
+
"""Docling (IBM) — high-fidelity structure extraction (reading order,
|
|
12
|
+
tables, headings) for PDFs and office formats, exported as markdown.
|
|
13
|
+
|
|
14
|
+
OCR backends docling supports natively: easyocr (its default), tesseract,
|
|
15
|
+
rapidocr. It has no PaddleOCR plugin, so ocr="paddle" falls back to
|
|
16
|
+
docling's default with a note in metadata — pick engine="mineru" (whose
|
|
17
|
+
pipeline is built on PaddleOCR models) when paddle-quality OCR matters.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name = "docling"
|
|
21
|
+
extra = "docling"
|
|
22
|
+
|
|
23
|
+
def parse(self, path: Path, ocr: str = "paddle", **options) -> ParseResult:
|
|
24
|
+
try:
|
|
25
|
+
from docling.document_converter import DocumentConverter
|
|
26
|
+
except ImportError:
|
|
27
|
+
raise self._missing_dep("docling") from None
|
|
28
|
+
|
|
29
|
+
converter, ocr_used, note = self._build_converter(ocr)
|
|
30
|
+
if note:
|
|
31
|
+
logger.warning(note)
|
|
32
|
+
logger.info("running docling on %s (ocr=%s)", path.name, ocr_used)
|
|
33
|
+
|
|
34
|
+
with Timer() as t:
|
|
35
|
+
result = converter.convert(str(path))
|
|
36
|
+
content = result.document.export_to_markdown()
|
|
37
|
+
|
|
38
|
+
metadata = {"pages": len(getattr(result.document, "pages", []) or [])}
|
|
39
|
+
if note:
|
|
40
|
+
metadata["ocr_note"] = note
|
|
41
|
+
logger.info("docling finished %s: %d pages, %d chars", path.name, metadata["pages"], len(content))
|
|
42
|
+
return ParseResult(
|
|
43
|
+
path=str(path),
|
|
44
|
+
engine=self.name,
|
|
45
|
+
content=content,
|
|
46
|
+
format="markdown",
|
|
47
|
+
ocr=ocr_used,
|
|
48
|
+
metadata=metadata,
|
|
49
|
+
duration_s=t.elapsed,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _build_converter(self, ocr: str):
|
|
53
|
+
from docling.document_converter import DocumentConverter
|
|
54
|
+
|
|
55
|
+
if ocr == "tesseract":
|
|
56
|
+
try:
|
|
57
|
+
from docling.datamodel.base_models import InputFormat
|
|
58
|
+
from docling.datamodel.pipeline_options import (
|
|
59
|
+
PdfPipelineOptions,
|
|
60
|
+
TesseractOcrOptions,
|
|
61
|
+
)
|
|
62
|
+
from docling.document_converter import PdfFormatOption
|
|
63
|
+
|
|
64
|
+
opts = PdfPipelineOptions()
|
|
65
|
+
opts.do_ocr = True
|
|
66
|
+
opts.ocr_options = TesseractOcrOptions()
|
|
67
|
+
converter = DocumentConverter(
|
|
68
|
+
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)}
|
|
69
|
+
)
|
|
70
|
+
return converter, "tesseract", None
|
|
71
|
+
except ImportError:
|
|
72
|
+
return (
|
|
73
|
+
DocumentConverter(),
|
|
74
|
+
"easyocr (docling default)",
|
|
75
|
+
"tesseract requested but docling's tesseract option is unavailable",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if ocr == "none":
|
|
79
|
+
try:
|
|
80
|
+
from docling.datamodel.base_models import InputFormat
|
|
81
|
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
82
|
+
from docling.document_converter import PdfFormatOption
|
|
83
|
+
|
|
84
|
+
opts = PdfPipelineOptions()
|
|
85
|
+
opts.do_ocr = False
|
|
86
|
+
converter = DocumentConverter(
|
|
87
|
+
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)}
|
|
88
|
+
)
|
|
89
|
+
return converter, None, None
|
|
90
|
+
except ImportError:
|
|
91
|
+
return DocumentConverter(), None, None
|
|
92
|
+
|
|
93
|
+
note = (
|
|
94
|
+
"docling has no PaddleOCR backend; used its default (easyocr). "
|
|
95
|
+
"Use engine='mineru' for a PaddleOCR-based pipeline."
|
|
96
|
+
if ocr in ("paddle", "paddleocr")
|
|
97
|
+
else None
|
|
98
|
+
)
|
|
99
|
+
return DocumentConverter(), "easyocr (docling default)", note
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ..ocr import IMAGE_SUFFIXES, get_ocr
|
|
5
|
+
from ..result import ParseResult, Timer
|
|
6
|
+
from .base import ParserEngine
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LiteEngine(ParserEngine):
|
|
12
|
+
"""liteparse — the lightweight, fast path. PyMuPDF-based markdown for PDFs
|
|
13
|
+
(no ML models, near-instant), direct OCR for images, plain text extraction
|
|
14
|
+
for everything else PyMuPDF can open (epub, xps, txt).
|
|
15
|
+
|
|
16
|
+
Best when documents have a real text layer and speed matters more than
|
|
17
|
+
layout reconstruction.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name = "lite"
|
|
21
|
+
extra = "lite"
|
|
22
|
+
|
|
23
|
+
def parse(self, path: Path, ocr: str = "paddle", **options) -> ParseResult:
|
|
24
|
+
suffix = path.suffix.lower()
|
|
25
|
+
|
|
26
|
+
if suffix in IMAGE_SUFFIXES:
|
|
27
|
+
if ocr == "none":
|
|
28
|
+
raise ValueError(f"'{path.name}' is an image; lite needs ocr='paddle' or 'tesseract' for images.")
|
|
29
|
+
logger.info("lite: routing image %s to OCR backend '%s'", path.name, ocr)
|
|
30
|
+
backend = get_ocr(ocr, lang=options.get("lang"))
|
|
31
|
+
with Timer() as t:
|
|
32
|
+
text, meta = backend.parse_file(path)
|
|
33
|
+
return ParseResult(
|
|
34
|
+
path=str(path), engine=self.name, content=text, format="text",
|
|
35
|
+
ocr=backend.name, metadata=meta, duration_s=t.elapsed,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if suffix == ".pdf":
|
|
39
|
+
try:
|
|
40
|
+
import pymupdf4llm
|
|
41
|
+
except ImportError:
|
|
42
|
+
raise self._missing_dep("pymupdf4llm") from None
|
|
43
|
+
logger.info("lite: extracting text layer from %s via pymupdf4llm", path.name)
|
|
44
|
+
with Timer() as t:
|
|
45
|
+
content = pymupdf4llm.to_markdown(str(path))
|
|
46
|
+
logger.info("lite: finished %s, %d chars in %.2fs", path.name, len(content), t.elapsed)
|
|
47
|
+
return ParseResult(
|
|
48
|
+
path=str(path), engine=self.name, content=content, format="markdown",
|
|
49
|
+
ocr=None, metadata={}, duration_s=t.elapsed,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Anything else PyMuPDF understands (epub, xps, cbz, txt...)
|
|
53
|
+
try:
|
|
54
|
+
import pymupdf as fitz
|
|
55
|
+
except ImportError:
|
|
56
|
+
try:
|
|
57
|
+
import fitz
|
|
58
|
+
except ImportError:
|
|
59
|
+
raise self._missing_dep("pymupdf") from None
|
|
60
|
+
with Timer() as t:
|
|
61
|
+
with fitz.open(str(path)) as doc:
|
|
62
|
+
page_count = doc.page_count
|
|
63
|
+
content = "\n\n".join(page.get_text() for page in doc)
|
|
64
|
+
return ParseResult(
|
|
65
|
+
path=str(path), engine=self.name, content=content, format="text",
|
|
66
|
+
ocr=None, metadata={"pages": page_count}, duration_s=t.elapsed,
|
|
67
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ..result import ParseResult, Timer
|
|
5
|
+
from .base import ParserEngine
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MarkItDownEngine(ParserEngine):
|
|
11
|
+
"""MarkItDown (Microsoft) — fast conversion of office/web formats
|
|
12
|
+
(docx, pptx, xlsx, html, csv, json, zip, ...) straight to markdown.
|
|
13
|
+
|
|
14
|
+
It performs no OCR of its own, so the `ocr` parameter is ignored; route
|
|
15
|
+
scanned PDFs and images to mineru, docling, unstructured, or engine="ocr".
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name = "markitdown"
|
|
19
|
+
extra = "markitdown"
|
|
20
|
+
|
|
21
|
+
def parse(self, path: Path, ocr: str = "paddle", **options) -> ParseResult:
|
|
22
|
+
try:
|
|
23
|
+
from markitdown import MarkItDown
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise self._missing_dep("markitdown") from None
|
|
26
|
+
|
|
27
|
+
logger.info("running markitdown on %s", path.name)
|
|
28
|
+
with Timer() as t:
|
|
29
|
+
converter = MarkItDown(enable_plugins=options.get("enable_plugins", False))
|
|
30
|
+
result = converter.convert(str(path))
|
|
31
|
+
logger.info("markitdown finished %s: %d chars", path.name, len(result.text_content))
|
|
32
|
+
|
|
33
|
+
return ParseResult(
|
|
34
|
+
path=str(path),
|
|
35
|
+
engine=self.name,
|
|
36
|
+
content=result.text_content,
|
|
37
|
+
format="markdown",
|
|
38
|
+
ocr=None,
|
|
39
|
+
metadata={"title": getattr(result, "title", None)},
|
|
40
|
+
duration_s=t.elapsed,
|
|
41
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..result import ParseResult, Timer
|
|
10
|
+
from .base import ParserEngine
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MinerUEngine(ParserEngine):
|
|
16
|
+
"""MinerU — layout-aware PDF-to-markdown, strongest choice for scanned or
|
|
17
|
+
complex scientific PDFs (formulas, multi-column, tables).
|
|
18
|
+
|
|
19
|
+
Driven through the `mineru` CLI because its Python API churns between
|
|
20
|
+
releases while the CLI contract stays stable. MinerU ships its own OCR
|
|
21
|
+
pipeline built on PaddleOCR models, so the `ocr` parameter only controls
|
|
22
|
+
*whether* OCR runs, not which backend does it:
|
|
23
|
+
ocr="none" -> text-layer extraction only (mineru -m txt)
|
|
24
|
+
anything else -> auto-detect per page (mineru -m auto)
|
|
25
|
+
options force_ocr -> OCR every page (mineru -m ocr)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name = "mineru"
|
|
29
|
+
extra = "mineru"
|
|
30
|
+
|
|
31
|
+
def parse(self, path: Path, ocr: str = "paddle", **options) -> ParseResult:
|
|
32
|
+
exe = shutil.which("mineru")
|
|
33
|
+
if exe is None:
|
|
34
|
+
raise self._missing_dep("mineru (CLI not found on PATH)")
|
|
35
|
+
|
|
36
|
+
if options.get("force_ocr"):
|
|
37
|
+
method = "ocr"
|
|
38
|
+
elif ocr == "none":
|
|
39
|
+
method = "txt"
|
|
40
|
+
else:
|
|
41
|
+
method = "auto"
|
|
42
|
+
|
|
43
|
+
timeout = options.get("timeout", 900)
|
|
44
|
+
|
|
45
|
+
with Timer() as t, tempfile.TemporaryDirectory(prefix="parse_anything_mineru_") as tmp:
|
|
46
|
+
cmd = [exe, "-p", str(path), "-o", tmp, "-m", method]
|
|
47
|
+
if options.get("lang"):
|
|
48
|
+
cmd += ["--lang", options["lang"]]
|
|
49
|
+
logger.info("running mineru (method=%s, timeout=%ss): %s", method, timeout, " ".join(cmd))
|
|
50
|
+
|
|
51
|
+
self._run_with_live_logs(cmd, timeout=timeout, path=path)
|
|
52
|
+
|
|
53
|
+
# Output lands at <tmp>/<stem>/<method>/<stem>.md; rglob keeps us
|
|
54
|
+
# robust to layout changes across MinerU versions.
|
|
55
|
+
md_files = sorted(Path(tmp).rglob("*.md"), key=lambda p: p.stat().st_size)
|
|
56
|
+
if not md_files:
|
|
57
|
+
raise RuntimeError(f"mineru produced no markdown output for {path}")
|
|
58
|
+
content = md_files[-1].read_text(encoding="utf-8")
|
|
59
|
+
logger.info("mineru wrote %s (%d chars)", md_files[-1].name, len(content))
|
|
60
|
+
|
|
61
|
+
return ParseResult(
|
|
62
|
+
path=str(path),
|
|
63
|
+
engine=self.name,
|
|
64
|
+
content=content,
|
|
65
|
+
format="markdown",
|
|
66
|
+
ocr=None if method == "txt" else "mineru-builtin (PaddleOCR models)",
|
|
67
|
+
metadata={"method": method},
|
|
68
|
+
duration_s=t.elapsed,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _run_with_live_logs(cmd: list[str], timeout: int, path: Path) -> None:
|
|
73
|
+
"""Runs mineru streaming its stdout/stderr to the logger as it happens.
|
|
74
|
+
|
|
75
|
+
mineru can take minutes on large or scanned PDFs with no output at
|
|
76
|
+
all if captured all-at-once, which made a hung/slow run indistinguishable
|
|
77
|
+
from a silently-progressing one. Streaming line-by-line plus a periodic
|
|
78
|
+
"still running" heartbeat fixes that.
|
|
79
|
+
"""
|
|
80
|
+
proc = subprocess.Popen(
|
|
81
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
|
|
82
|
+
)
|
|
83
|
+
tail: list[str] = []
|
|
84
|
+
|
|
85
|
+
def _pump():
|
|
86
|
+
assert proc.stdout is not None
|
|
87
|
+
for line in proc.stdout:
|
|
88
|
+
line = line.rstrip()
|
|
89
|
+
if line:
|
|
90
|
+
tail.append(line)
|
|
91
|
+
del tail[:-40]
|
|
92
|
+
logger.info("[mineru] %s", line)
|
|
93
|
+
|
|
94
|
+
pump = threading.Thread(target=_pump, daemon=True)
|
|
95
|
+
pump.start()
|
|
96
|
+
|
|
97
|
+
start = time.monotonic()
|
|
98
|
+
last_heartbeat = start
|
|
99
|
+
while True:
|
|
100
|
+
try:
|
|
101
|
+
returncode = proc.wait(timeout=5)
|
|
102
|
+
break
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
elapsed = time.monotonic() - start
|
|
105
|
+
if elapsed > timeout:
|
|
106
|
+
proc.kill()
|
|
107
|
+
proc.wait()
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"mineru timed out after {timeout}s parsing {path.name}. "
|
|
110
|
+
"Pass a larger timeout=... (or --timeout on the CLI), "
|
|
111
|
+
"or try engine='docling'/'lite' instead."
|
|
112
|
+
) from None
|
|
113
|
+
if time.monotonic() - last_heartbeat > 30:
|
|
114
|
+
logger.info("[mineru] still running... %.0fs elapsed", elapsed)
|
|
115
|
+
last_heartbeat = time.monotonic()
|
|
116
|
+
|
|
117
|
+
pump.join(timeout=5)
|
|
118
|
+
if returncode != 0:
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
f"mineru failed (exit {returncode}):\n" + "\n".join(tail)
|
|
121
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from ..ocr import get_ocr
|
|
5
|
+
from ..result import ParseResult, Timer
|
|
6
|
+
from .base import ParserEngine
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OCROnlyEngine(ParserEngine):
|
|
12
|
+
"""Pure OCR: no layout analysis, just recognized text from images or
|
|
13
|
+
rasterized PDF pages — via PaddleOCR (default) or Tesseract."""
|
|
14
|
+
|
|
15
|
+
name = "ocr"
|
|
16
|
+
extra = "ocr"
|
|
17
|
+
|
|
18
|
+
def parse(self, path: Path, ocr: str = "paddle", **options) -> ParseResult:
|
|
19
|
+
backend_name = "paddle" if ocr in (None, "", "none") else ocr
|
|
20
|
+
dpi = options.get("dpi", 200)
|
|
21
|
+
logger.info("running OCR on %s with backend '%s' (dpi=%d)", path.name, backend_name, dpi)
|
|
22
|
+
backend = get_ocr(backend_name, lang=options.get("lang"))
|
|
23
|
+
with Timer() as t:
|
|
24
|
+
text, meta = backend.parse_file(path, dpi=dpi)
|
|
25
|
+
logger.info("OCR finished %s: %s pages, %d chars in %.2fs", path.name, meta.get("pages"), len(text), t.elapsed)
|
|
26
|
+
return ParseResult(
|
|
27
|
+
path=str(path),
|
|
28
|
+
engine=self.name,
|
|
29
|
+
content=text,
|
|
30
|
+
format="text",
|
|
31
|
+
ocr=backend.name,
|
|
32
|
+
metadata=meta,
|
|
33
|
+
duration_s=t.elapsed,
|
|
34
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..result import ParseResult, Timer
|
|
6
|
+
from .base import ParserEngine
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
# unstructured selects its OCR agent via this env var at partition time.
|
|
11
|
+
_OCR_AGENTS = {
|
|
12
|
+
"paddle": "unstructured.partition.utils.ocr_models.paddle_ocr.OCRAgentPaddle",
|
|
13
|
+
"tesseract": "unstructured.partition.utils.ocr_models.tesseract_ocr.OCRAgentTesseract",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UnstructuredEngine(ParserEngine):
|
|
18
|
+
"""unstructured.io — broadest format coverage (emails, html, office, pdf...),
|
|
19
|
+
returns typed elements which we render to markdown.
|
|
20
|
+
|
|
21
|
+
ocr="paddle" needs the `unstructured.paddleocr` package; ocr="tesseract"
|
|
22
|
+
uses unstructured's default tesseract agent.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "unstructured"
|
|
26
|
+
extra = "unstructured"
|
|
27
|
+
|
|
28
|
+
def parse(self, path: Path, ocr: str = "paddle", **options) -> ParseResult:
|
|
29
|
+
try:
|
|
30
|
+
from unstructured.partition.auto import partition
|
|
31
|
+
except ImportError:
|
|
32
|
+
raise self._missing_dep("unstructured") from None
|
|
33
|
+
|
|
34
|
+
strategy = options.get("strategy", "auto")
|
|
35
|
+
if ocr == "none":
|
|
36
|
+
strategy = options.get("strategy", "fast")
|
|
37
|
+
|
|
38
|
+
previous_agent = os.environ.get("OCR_AGENT")
|
|
39
|
+
if ocr in _OCR_AGENTS:
|
|
40
|
+
os.environ["OCR_AGENT"] = _OCR_AGENTS[ocr]
|
|
41
|
+
logger.info("running unstructured on %s (strategy=%s, ocr=%s)", path.name, strategy, ocr)
|
|
42
|
+
try:
|
|
43
|
+
with Timer() as t:
|
|
44
|
+
elements = partition(filename=str(path), strategy=strategy)
|
|
45
|
+
finally:
|
|
46
|
+
if ocr in _OCR_AGENTS:
|
|
47
|
+
if previous_agent is None:
|
|
48
|
+
os.environ.pop("OCR_AGENT", None)
|
|
49
|
+
else:
|
|
50
|
+
os.environ["OCR_AGENT"] = previous_agent
|
|
51
|
+
|
|
52
|
+
logger.info("unstructured finished %s: %d elements", path.name, len(elements))
|
|
53
|
+
content = self._to_markdown(elements)
|
|
54
|
+
return ParseResult(
|
|
55
|
+
path=str(path),
|
|
56
|
+
engine=self.name,
|
|
57
|
+
content=content,
|
|
58
|
+
format="markdown",
|
|
59
|
+
ocr=ocr if ocr in _OCR_AGENTS else None,
|
|
60
|
+
metadata={"strategy": strategy, "elements": len(elements)},
|
|
61
|
+
duration_s=t.elapsed,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _to_markdown(elements) -> str:
|
|
66
|
+
lines: list[str] = []
|
|
67
|
+
for el in elements:
|
|
68
|
+
category = getattr(el, "category", "")
|
|
69
|
+
text = str(el).strip()
|
|
70
|
+
if not text:
|
|
71
|
+
continue
|
|
72
|
+
if category == "Title":
|
|
73
|
+
lines.append(f"## {text}")
|
|
74
|
+
elif category == "ListItem":
|
|
75
|
+
lines.append(f"- {text}")
|
|
76
|
+
elif category == "Table":
|
|
77
|
+
html = getattr(getattr(el, "metadata", None), "text_as_html", None)
|
|
78
|
+
lines.append(html if html else text)
|
|
79
|
+
else:
|
|
80
|
+
lines.append(text)
|
|
81
|
+
return "\n\n".join(lines)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import logging
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .engines.base import ParserEngine
|
|
7
|
+
from .ocr.base import IMAGE_SUFFIXES
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Canonical engine name -> (module, class). Imported lazily so that having one
|
|
12
|
+
# backend installed is enough to use it — the others can stay uninstalled.
|
|
13
|
+
_ENGINES: dict[str, tuple[str, str]] = {
|
|
14
|
+
"mineru": ("parse_anything.engines.mineru_engine", "MinerUEngine"),
|
|
15
|
+
"docling": ("parse_anything.engines.docling_engine", "DoclingEngine"),
|
|
16
|
+
"unstructured": ("parse_anything.engines.unstructured_engine", "UnstructuredEngine"),
|
|
17
|
+
"markitdown": ("parse_anything.engines.markitdown_engine", "MarkItDownEngine"),
|
|
18
|
+
"lite": ("parse_anything.engines.lite_engine", "LiteEngine"),
|
|
19
|
+
"ocr": ("parse_anything.engines.ocr_engine", "OCROnlyEngine"),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_ALIASES = {
|
|
23
|
+
"miner-u": "mineru",
|
|
24
|
+
"miner_u": "mineru",
|
|
25
|
+
"magic-pdf": "mineru",
|
|
26
|
+
"unstructured.io": "unstructured",
|
|
27
|
+
"markitdown": "markitdown",
|
|
28
|
+
"mark-it-down": "markitdown",
|
|
29
|
+
"liteparse": "lite",
|
|
30
|
+
"lite-parse": "lite",
|
|
31
|
+
"pymupdf": "lite",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Python package whose presence means the engine is usable (for auto mode).
|
|
35
|
+
_AVAILABILITY_PROBE = {
|
|
36
|
+
"mineru": "mineru",
|
|
37
|
+
"docling": "docling",
|
|
38
|
+
"unstructured": "unstructured",
|
|
39
|
+
"markitdown": "markitdown",
|
|
40
|
+
"lite": "pymupdf",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
OFFICE_SUFFIXES = {".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls"}
|
|
44
|
+
MARKUP_SUFFIXES = {".html", ".htm", ".csv", ".json", ".xml", ".epub", ".md", ".txt", ".zip", ".msg", ".eml"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def engine_names() -> list[str]:
|
|
48
|
+
return list(_ENGINES)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_engine_name(name: str) -> str:
|
|
52
|
+
key = name.strip().lower()
|
|
53
|
+
key = _ALIASES.get(key, key)
|
|
54
|
+
if key not in _ENGINES and key != "auto":
|
|
55
|
+
supported = ", ".join(["auto", *_ENGINES])
|
|
56
|
+
raise ValueError(f"Unknown engine '{name}'. Supported: {supported}")
|
|
57
|
+
return key
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_engine(name: str, path: Path | None = None) -> ParserEngine:
|
|
61
|
+
key = resolve_engine_name(name)
|
|
62
|
+
if key == "auto":
|
|
63
|
+
if path is None:
|
|
64
|
+
raise ValueError("engine='auto' needs a file path to pick from")
|
|
65
|
+
key = _pick_engine_for(path)
|
|
66
|
+
else:
|
|
67
|
+
logger.debug("resolved engine '%s' -> '%s'", name, key)
|
|
68
|
+
|
|
69
|
+
module_name, class_name = _ENGINES[key]
|
|
70
|
+
import importlib
|
|
71
|
+
|
|
72
|
+
module = importlib.import_module(module_name)
|
|
73
|
+
logger.debug("loaded engine class %s from %s", class_name, module_name)
|
|
74
|
+
return getattr(module, class_name)()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_available(engine: str) -> bool:
|
|
78
|
+
if engine == "mineru":
|
|
79
|
+
# mineru is driven via its CLI; either the package or binary suffices.
|
|
80
|
+
return shutil.which("mineru") is not None or importlib.util.find_spec("mineru") is not None
|
|
81
|
+
probe = _AVAILABILITY_PROBE.get(engine)
|
|
82
|
+
return probe is not None and importlib.util.find_spec(probe) is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ocr_available() -> bool:
|
|
86
|
+
return (
|
|
87
|
+
importlib.util.find_spec("paddleocr") is not None
|
|
88
|
+
or importlib.util.find_spec("pytesseract") is not None
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _pick_engine_for(path: Path) -> str:
|
|
93
|
+
"""Best installed engine for the file type; preference orders reflect each
|
|
94
|
+
engine's strengths (mineru on PDFs, docling on office docs, etc.)."""
|
|
95
|
+
suffix = path.suffix.lower()
|
|
96
|
+
|
|
97
|
+
if suffix in IMAGE_SUFFIXES and _ocr_available():
|
|
98
|
+
return "ocr"
|
|
99
|
+
if suffix == ".pdf":
|
|
100
|
+
order = ["mineru", "docling", "unstructured", "lite"]
|
|
101
|
+
elif suffix in OFFICE_SUFFIXES:
|
|
102
|
+
order = ["docling", "markitdown", "unstructured"]
|
|
103
|
+
elif suffix in MARKUP_SUFFIXES:
|
|
104
|
+
order = ["markitdown", "unstructured", "lite"]
|
|
105
|
+
else:
|
|
106
|
+
order = ["unstructured", "markitdown", "docling", "lite"]
|
|
107
|
+
|
|
108
|
+
logger.debug("auto-selection order for '%s' (%s): %s", path.name, suffix, order)
|
|
109
|
+
for engine in order:
|
|
110
|
+
if _is_available(engine):
|
|
111
|
+
logger.info("auto-selected engine '%s' for %s", engine, path.name)
|
|
112
|
+
return engine
|
|
113
|
+
|
|
114
|
+
raise ImportError(
|
|
115
|
+
f"No parsing engine installed that can handle '{path.name}'. "
|
|
116
|
+
f"Tried: {', '.join(order)}. Install one, e.g.: pip install parse-anything[docling]"
|
|
117
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .base import IMAGE_SUFFIXES, OCREngine
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def get_ocr(name: str = "paddle", lang: str | None = None) -> OCREngine:
|
|
5
|
+
"""Returns the requested OCR backend: 'paddle' (default) or 'tesseract'."""
|
|
6
|
+
key = (name or "paddle").lower()
|
|
7
|
+
if key in ("paddle", "paddleocr", "pp-ocr", "ppocr"):
|
|
8
|
+
from .paddle_engine import PaddleOCREngine
|
|
9
|
+
|
|
10
|
+
return PaddleOCREngine(lang=lang or "en")
|
|
11
|
+
if key in ("tesseract", "pytesseract"):
|
|
12
|
+
from .tesseract_engine import TesseractEngine
|
|
13
|
+
|
|
14
|
+
return TesseractEngine(lang=lang or "eng")
|
|
15
|
+
raise ValueError(f"Unknown OCR backend '{name}'. Supported: paddle, tesseract")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["OCREngine", "IMAGE_SUFFIXES", "get_ocr"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp", ".gif"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OCREngine(ABC):
|
|
11
|
+
"""Text recognition backend: image (or scanned PDF) in, plain text out."""
|
|
12
|
+
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def image_to_text(self, image_path: Path) -> str:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def parse_file(self, path: Path, dpi: int = 200) -> tuple[str, dict]:
|
|
20
|
+
"""OCRs an image directly, or renders each PDF page to an image first.
|
|
21
|
+
|
|
22
|
+
Returns (text, metadata).
|
|
23
|
+
"""
|
|
24
|
+
if path.suffix.lower() == ".pdf":
|
|
25
|
+
pages = []
|
|
26
|
+
logger.info("[%s] rasterizing %s at %d dpi", self.name, path.name, dpi)
|
|
27
|
+
for i, image_path in enumerate(render_pdf_pages(path, dpi=dpi), start=1):
|
|
28
|
+
logger.info("[%s] OCR page %d of %s", self.name, i, path.name)
|
|
29
|
+
pages.append(self.image_to_text(image_path))
|
|
30
|
+
text = "\n\n".join(pages)
|
|
31
|
+
logger.info("[%s] finished %s: %d pages", self.name, path.name, len(pages))
|
|
32
|
+
return text, {"pages": len(pages), "dpi": dpi}
|
|
33
|
+
logger.info("[%s] OCR %s", self.name, path.name)
|
|
34
|
+
return self.image_to_text(path), {"pages": 1}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_pdf_pages(path: Path, dpi: int = 200):
|
|
38
|
+
"""Yields temporary PNG paths, one per PDF page, for OCR backends that want files."""
|
|
39
|
+
import tempfile
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import pymupdf # noqa: F401 (>= 1.24 exposes the new name)
|
|
43
|
+
import pymupdf as fitz
|
|
44
|
+
except ImportError:
|
|
45
|
+
try:
|
|
46
|
+
import fitz # older PyMuPDF releases
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
raise ImportError(
|
|
49
|
+
"OCR on PDFs needs PyMuPDF to rasterize pages. "
|
|
50
|
+
"Install it with: pip install parse-anything[ocr]"
|
|
51
|
+
) from exc
|
|
52
|
+
|
|
53
|
+
tmpdir = Path(tempfile.mkdtemp(prefix="parse_anything_ocr_"))
|
|
54
|
+
with fitz.open(path) as doc:
|
|
55
|
+
for i, page in enumerate(doc, start=1):
|
|
56
|
+
pix = page.get_pixmap(dpi=dpi)
|
|
57
|
+
out = tmpdir / f"page_{i:04d}.png"
|
|
58
|
+
pix.save(out)
|
|
59
|
+
yield out
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .base import OCREngine
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PaddleOCREngine(OCREngine):
|
|
10
|
+
"""PaddleOCR (PP-OCRv5) — the default OCR backend.
|
|
11
|
+
|
|
12
|
+
Chosen as default because PP-OCRv5 currently posts the strongest open-source
|
|
13
|
+
accuracy benchmarks across printed, handwritten, and multilingual text.
|
|
14
|
+
Works with both the PaddleOCR 3.x `predict` API and the legacy 2.x `ocr` API.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "paddle"
|
|
18
|
+
|
|
19
|
+
def __init__(self, lang: str = "en"):
|
|
20
|
+
try:
|
|
21
|
+
from paddleocr import PaddleOCR
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise ImportError(
|
|
24
|
+
"PaddleOCR is not installed. Install it with: pip install parse-anything[paddle]"
|
|
25
|
+
) from exc
|
|
26
|
+
|
|
27
|
+
logger.info("loading PaddleOCR (PP-OCRv5, lang=%s) — first run downloads model weights", lang)
|
|
28
|
+
try:
|
|
29
|
+
# 3.x: turn off document-rotation/unwarp preprocessing — we feed
|
|
30
|
+
# clean rasterized pages, and these extra models slow cold start.
|
|
31
|
+
# enable_mkldnn=False: PaddleX's default oneDNN + new-IR (PIR) CPU
|
|
32
|
+
# path can't convert some double-array attributes used by the
|
|
33
|
+
# PP-OCRv5 detection model, raising "ConvertPirAttribute2Runtime
|
|
34
|
+
# Attribute not support" — falling back to the plain Paddle
|
|
35
|
+
# executor avoids that unimplemented path.
|
|
36
|
+
self._ocr = PaddleOCR(
|
|
37
|
+
lang=lang,
|
|
38
|
+
use_doc_orientation_classify=False,
|
|
39
|
+
use_doc_unwarping=False,
|
|
40
|
+
use_textline_orientation=False,
|
|
41
|
+
enable_mkldnn=False,
|
|
42
|
+
)
|
|
43
|
+
except TypeError:
|
|
44
|
+
self._ocr = PaddleOCR(lang=lang)
|
|
45
|
+
logger.info("PaddleOCR ready")
|
|
46
|
+
|
|
47
|
+
def image_to_text(self, image_path: Path) -> str:
|
|
48
|
+
if hasattr(self._ocr, "predict"): # PaddleOCR >= 3.0
|
|
49
|
+
lines: list[str] = []
|
|
50
|
+
for res in self._ocr.predict(input=str(image_path)):
|
|
51
|
+
texts = None
|
|
52
|
+
try:
|
|
53
|
+
texts = res["rec_texts"]
|
|
54
|
+
except (TypeError, KeyError, IndexError):
|
|
55
|
+
texts = getattr(res, "rec_texts", None)
|
|
56
|
+
if texts:
|
|
57
|
+
lines.extend(texts)
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
# PaddleOCR 2.x: list of [box, (text, confidence)] per page
|
|
61
|
+
lines = []
|
|
62
|
+
for page in self._ocr.ocr(str(image_path)) or []:
|
|
63
|
+
for entry in page or []:
|
|
64
|
+
if entry and len(entry) > 1:
|
|
65
|
+
lines.append(entry[1][0])
|
|
66
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import shutil
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .base import OCREngine
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TesseractEngine(OCREngine):
|
|
11
|
+
"""Tesseract via pytesseract — ubiquitous, CPU-light fallback OCR.
|
|
12
|
+
|
|
13
|
+
Needs the tesseract binary on PATH (Windows: UB-Mannheim installer or
|
|
14
|
+
`choco install tesseract`; Linux: `apt install tesseract-ocr`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name = "tesseract"
|
|
18
|
+
|
|
19
|
+
def __init__(self, lang: str = "eng"):
|
|
20
|
+
try:
|
|
21
|
+
import pytesseract
|
|
22
|
+
from PIL import Image # noqa: F401
|
|
23
|
+
except ImportError as exc:
|
|
24
|
+
raise ImportError(
|
|
25
|
+
"pytesseract/Pillow are not installed. "
|
|
26
|
+
"Install them with: pip install parse-anything[tesseract]"
|
|
27
|
+
) from exc
|
|
28
|
+
|
|
29
|
+
self._pytesseract = pytesseract
|
|
30
|
+
self._lang = lang
|
|
31
|
+
|
|
32
|
+
if shutil.which("tesseract") is None and not getattr(
|
|
33
|
+
pytesseract.pytesseract, "tesseract_cmd", None
|
|
34
|
+
):
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
"The tesseract binary was not found on PATH. Install it "
|
|
37
|
+
"(Windows: https://github.com/UB-Mannheim/tesseract/wiki, "
|
|
38
|
+
"or `choco install tesseract`) or set "
|
|
39
|
+
"pytesseract.pytesseract.tesseract_cmd to its location."
|
|
40
|
+
)
|
|
41
|
+
logger.info("Tesseract OCR ready (lang=%s)", lang)
|
|
42
|
+
|
|
43
|
+
def image_to_text(self, image_path: Path) -> str:
|
|
44
|
+
from PIL import Image
|
|
45
|
+
|
|
46
|
+
with Image.open(image_path) as img:
|
|
47
|
+
return self._pytesseract.image_to_string(img, lang=self._lang).strip()
|
parse_anything/result.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ParseResult:
|
|
9
|
+
"""What every engine returns: extracted content plus enough metadata to trace it.
|
|
10
|
+
|
|
11
|
+
`content` is markdown whenever the engine can produce it (headings, tables),
|
|
12
|
+
plain text otherwise — `format` says which one you got.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
path: str
|
|
16
|
+
engine: str
|
|
17
|
+
content: str
|
|
18
|
+
format: str = "markdown" # "markdown" | "text"
|
|
19
|
+
ocr: str | None = None # OCR backend used, if any ("paddle", "tesseract")
|
|
20
|
+
metadata: dict = field(default_factory=dict)
|
|
21
|
+
duration_s: float = 0.0
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def text(self) -> str:
|
|
25
|
+
return self.content
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return {
|
|
29
|
+
"path": self.path,
|
|
30
|
+
"engine": self.engine,
|
|
31
|
+
"format": self.format,
|
|
32
|
+
"ocr": self.ocr,
|
|
33
|
+
"duration_s": round(self.duration_s, 3),
|
|
34
|
+
"metadata": self.metadata,
|
|
35
|
+
"content": self.content,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def to_json(self, indent: int = 2) -> str:
|
|
39
|
+
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
|
|
40
|
+
|
|
41
|
+
def save(self, out_path: str | Path) -> Path:
|
|
42
|
+
out = Path(out_path)
|
|
43
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
if out.suffix == ".json":
|
|
45
|
+
out.write_text(self.to_json(), encoding="utf-8")
|
|
46
|
+
else:
|
|
47
|
+
out.write_text(self.content, encoding="utf-8")
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Timer:
|
|
52
|
+
"""Tiny context manager so engines can fill ParseResult.duration_s uniformly."""
|
|
53
|
+
|
|
54
|
+
def __enter__(self):
|
|
55
|
+
self.start = time.perf_counter()
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __exit__(self, *exc):
|
|
59
|
+
self.elapsed = time.perf_counter() - self.start
|
|
60
|
+
return False
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: parse-anything
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One interface to parse any document: pick an engine (mineru, docling, unstructured, markitdown, lite) and get clean markdown out.
|
|
5
|
+
Author: Yugesh
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yugeshsr13/parse-anything
|
|
8
|
+
Project-URL: Repository, https://github.com/yugeshsr13/parse-anything
|
|
9
|
+
Project-URL: Issues, https://github.com/yugeshsr13/parse-anything/issues
|
|
10
|
+
Keywords: pdf,parsing,ocr,docling,mineru,unstructured,markitdown,paddleocr,tesseract
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: lite
|
|
25
|
+
Requires-Dist: pymupdf4llm>=0.0.17; extra == "lite"
|
|
26
|
+
Provides-Extra: docling
|
|
27
|
+
Requires-Dist: docling>=2.15; extra == "docling"
|
|
28
|
+
Provides-Extra: unstructured
|
|
29
|
+
Requires-Dist: unstructured[all-docs]>=0.16; extra == "unstructured"
|
|
30
|
+
Provides-Extra: markitdown
|
|
31
|
+
Requires-Dist: markitdown[all]>=0.1.1; extra == "markitdown"
|
|
32
|
+
Provides-Extra: mineru
|
|
33
|
+
Requires-Dist: mineru[core]>=2.0; extra == "mineru"
|
|
34
|
+
Provides-Extra: tesseract
|
|
35
|
+
Requires-Dist: pytesseract>=0.3.10; extra == "tesseract"
|
|
36
|
+
Requires-Dist: Pillow>=10.0; extra == "tesseract"
|
|
37
|
+
Requires-Dist: pymupdf>=1.24; extra == "tesseract"
|
|
38
|
+
Provides-Extra: paddle
|
|
39
|
+
Requires-Dist: paddleocr>=3.0; extra == "paddle"
|
|
40
|
+
Requires-Dist: paddlepaddle>=3.0; extra == "paddle"
|
|
41
|
+
Requires-Dist: pymupdf>=1.24; extra == "paddle"
|
|
42
|
+
Provides-Extra: ocr
|
|
43
|
+
Requires-Dist: parse-anything[paddle,tesseract]; extra == "ocr"
|
|
44
|
+
Provides-Extra: all
|
|
45
|
+
Requires-Dist: parse-anything[docling,lite,markitdown,mineru,ocr,unstructured]; extra == "all"
|
|
46
|
+
Provides-Extra: dev
|
|
47
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# parse-anything
|
|
51
|
+
|
|
52
|
+
One interface to parse any document. Pick an engine — **MinerU**, **Docling**, **unstructured**, **MarkItDown**, or the PyMuPDF **lite** path — pass a file path, get clean markdown/text back. OCR runs on **PaddleOCR (PP-OCRv5, default)** or **Tesseract**.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from parse_anything import parse
|
|
56
|
+
|
|
57
|
+
result = parse("report.pdf", engine="miner-u")
|
|
58
|
+
print(result.content) # markdown
|
|
59
|
+
print(result.duration_s) # seconds
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
parse-anything report.pdf --engine miner-u -o report.md
|
|
64
|
+
parse-anything scan.png --engine ocr --ocr paddle
|
|
65
|
+
parse-anything deck.pptx # auto-picks the best installed engine
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
Core has **zero dependencies**; each engine is an optional extra:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install -e .[all] # everything
|
|
74
|
+
pip install -e .[mineru] # MinerU (scanned/complex PDFs)
|
|
75
|
+
pip install -e .[docling] # Docling (structure fidelity)
|
|
76
|
+
pip install -e .[unstructured] # unstructured (broadest formats)
|
|
77
|
+
pip install -e .[markitdown] # MarkItDown (office/web -> md)
|
|
78
|
+
pip install -e .[lite] # PyMuPDF fast path
|
|
79
|
+
pip install -e .[paddle] # PaddleOCR (PP-OCRv5)
|
|
80
|
+
pip install -e .[tesseract] # pytesseract (+ install the tesseract binary)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Windows Tesseract binary: [UB-Mannheim build](https://github.com/UB-Mannheim/tesseract/wiki) or `choco install tesseract`.
|
|
84
|
+
|
|
85
|
+
## Engines
|
|
86
|
+
|
|
87
|
+
| `--engine` | Backed by | Reach for it when |
|
|
88
|
+
|------------|-----------|-------------------|
|
|
89
|
+
| `mineru` / `miner-u` | [MinerU](https://github.com/opendatalab/MinerU) | scanned or complex PDFs — formulas, tables, multi-column; OCR built on PaddleOCR models |
|
|
90
|
+
| `docling` | [Docling](https://github.com/docling-project/docling) | you care about reading order, tables, office formats |
|
|
91
|
+
| `unstructured` | [unstructured](https://github.com/Unstructured-IO/unstructured) | odd formats: email, html, mixed batches |
|
|
92
|
+
| `markitdown` | [MarkItDown](https://github.com/microsoft/markitdown) | fast docx/pptx/xlsx/html → markdown (no OCR) |
|
|
93
|
+
| `lite` / `liteparse` | [PyMuPDF4LLM](https://github.com/pymupdf/RAG) | speed on PDFs that already have a text layer |
|
|
94
|
+
| `ocr` | PaddleOCR / Tesseract | images or fully scanned docs, raw text out |
|
|
95
|
+
| `auto` (default) | — | picks the best installed engine for the file type |
|
|
96
|
+
|
|
97
|
+
## OCR
|
|
98
|
+
|
|
99
|
+
`--ocr paddle` (default) uses **PaddleOCR PP-OCRv5** — the current open-source benchmark leader. `--ocr tesseract` is the portable fallback. `--ocr none` skips OCR (text-layer extraction only).
|
|
100
|
+
|
|
101
|
+
## Logging
|
|
102
|
+
|
|
103
|
+
Progress prints to stderr by default (auto-selected engine, OCR page-by-page, live MinerU subprocess output with a heartbeat on long runs). `-v` for debug detail, `-q` to silence it. As a library, nothing prints unless you call `logging.basicConfig(...)` yourself.
|
|
104
|
+
|
|
105
|
+
See [SKILLS.md](SKILLS.md) for the full skill contract (inputs, outputs, failure modes).
|
|
106
|
+
|
|
107
|
+
## Layout
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
src/parse_anything/
|
|
111
|
+
├── __init__.py # parse(path, engine=..., ocr=...) public API
|
|
112
|
+
├── factory.py # engine registry, aliases, auto-selection
|
|
113
|
+
├── result.py # ParseResult
|
|
114
|
+
├── cli.py # parse-anything CLI
|
|
115
|
+
├── engines/ # one module per backend (lazy imports)
|
|
116
|
+
└── ocr/ # paddle + tesseract backends, PDF rasterization
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Tests
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install -e .[dev]
|
|
123
|
+
pytest
|
|
124
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
parse_anything/__init__.py,sha256=zEH3giD9uei5oQefVCHejGKok9qrFTofRg5KWV-AkL4,1918
|
|
2
|
+
parse_anything/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
parse_anything/cli.py,sha256=fKoPsHBjDtQa0EGVim6v6Mh4zVDmT3mN7XZexSgh0ZM,3836
|
|
4
|
+
parse_anything/factory.py,sha256=8b5cw8Z-L0XFsjMx1ylXaoYQndwL7znwqEZd4pYUdII,4114
|
|
5
|
+
parse_anything/result.py,sha256=Gm1HpSlocGiZm1KbFUj_eAzGhjd9gl1bhHir46j0UMw,1744
|
|
6
|
+
parse_anything/engines/__init__.py,sha256=ouLF6kn4xU515kBDJFgeEFCkbWz5PE8-X-OArnvMyNU,59
|
|
7
|
+
parse_anything/engines/base.py,sha256=X-cBvJej18wMz7ZzKv4ALMGXKuXIHj6nnQ4tE1PbpHM,1104
|
|
8
|
+
parse_anything/engines/docling_engine.py,sha256=nFOF9O_B9lSIveUbG_9p6m8vJbzMm9ihAlDhXzS2VNk,3768
|
|
9
|
+
parse_anything/engines/lite_engine.py,sha256=2mB15MKhJBlx_TM2Q5ygWirAvCMKwadkVDq6Wyirvq0,2641
|
|
10
|
+
parse_anything/engines/markitdown_engine.py,sha256=vAV5iziUXToKWoygLMvANCMS3QJ3vn4k3jnFjqyBYxE,1383
|
|
11
|
+
parse_anything/engines/mineru_engine.py,sha256=nlFjbX_coNFK7a6jpiqJwVEZ6Y01YU6CrxuOYiZ_lYk,4653
|
|
12
|
+
parse_anything/engines/ocr_engine.py,sha256=gE9Si_skvrbWp7n2Jr4wCo-7vNKtmRtpobP1YKxcsUs,1191
|
|
13
|
+
parse_anything/engines/unstructured_engine.py,sha256=IpZL7qJY05nL_fUE7oU1gSqJzzCfNERZLYSQXZ-y3Zs,2892
|
|
14
|
+
parse_anything/ocr/__init__.py,sha256=_XTcDas7bp5xJbjwBz_WKi51uDXyHLpmbck1sgiLJDo,685
|
|
15
|
+
parse_anything/ocr/base.py,sha256=OWlNEZ0d8i3TRAoKvtKYY9P65HZDysF9w3eZ-7as1Ys,2165
|
|
16
|
+
parse_anything/ocr/paddle_engine.py,sha256=2PcaCtmNA5pTlJ4rDihozWPvHIT945YUdl3FW_DvJ9Y,2592
|
|
17
|
+
parse_anything/ocr/tesseract_engine.py,sha256=a4DBsLPw1lT-PvmdJYFnb22uY8_tr9283VCgwbFOKEg,1559
|
|
18
|
+
parse_anything-0.1.0.dist-info/licenses/LICENSE,sha256=brhk1hp9uq9FHo4jpQv6JY5FzWamZ9rp3O9QgfU_L1w,1063
|
|
19
|
+
parse_anything-0.1.0.dist-info/METADATA,sha256=tz1vNgO4FOupo9PIhBoxeaaRrYseU-hoI3_626AWiis,5732
|
|
20
|
+
parse_anything-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
21
|
+
parse_anything-0.1.0.dist-info/entry_points.txt,sha256=5Kwj9SMu3dr-H7plRU6wSPzJI1Ek3yV261Oy-lwkcJg,59
|
|
22
|
+
parse_anything-0.1.0.dist-info/top_level.txt,sha256=C9cS-cX-LF54R-82U7UFSQWGGu1-DIUVPktRlMIoOpQ,15
|
|
23
|
+
parse_anything-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yugesh
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
parse_anything
|