File size: 6,707 Bytes
7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 8e93c60 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 7f153d6 5840d20 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 | """Stage 2 — parse a document with MinerU, cached by content.
Why the cache is keyed to document CONTENT rather than to a run id: parsing is
by far the most expensive stage (19 s/page on CPU). A per-run cache would
re-parse everything each time the normaliser changes and the pipeline is run
again, for nothing. Keyed by content + settings + version, the same document is
parsed once — however many times the normaliser is revised.
Cache key = sha256(file bytes) + settings fingerprint + MinerU version. The
version is in there so figures from an older MinerU never quietly mix with
figures from a newer one — which matters, because those figures are used to
justify spend.
"""
from __future__ import annotations
import hashlib
import json
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from .config import PipelineConfig
@dataclass
class ParseResult:
doc_id: str
source: Path
cache_dir: Path # folder holding MinerU's output untouched
content_list: Path # *_content_list.json
middle_json: Path | None
pages: int
seconds: float
from_cache: bool
backend_recorded: str | None # read from _middle.json, NOT from the config
mineru_version: str | None
def mineru_version() -> str:
try:
from mineru.version import __version__ # type: ignore
return str(__version__)
except Exception:
try:
from importlib.metadata import version
return version("mineru")
except Exception:
return "unknown"
def file_hash(path: Path, length: int = 16) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
return h.hexdigest()[:length]
def cache_key(source: Path, cfg: PipelineConfig) -> str:
return f"{file_hash(source)}-{cfg.fingerprint()}-{mineru_version()}"
def _find_output(root: Path) -> tuple[Path | None, Path | None]:
"""Locate content_list & middle json anywhere under root.
Deliberately a glob rather than a fixed path: MinerU's subfolder layout
differs per backend ('auto' for pipeline, something else for vlm). Globbing
means this module needs no change when the backend does.
"""
content = next(iter(sorted(root.rglob("*_content_list.json"))), None)
middle = next(iter(sorted(root.rglob("*_middle.json"))), None)
return content, middle
def _read_middle(middle: Path | None) -> tuple[str | None, str | None]:
"""The backend & version that ACTUALLY ran, taken from MinerU's own output."""
if not middle or not middle.exists():
return None, None
try:
d = json.loads(middle.read_text(encoding="utf-8"))
return d.get("_backend"), d.get("_version_name")
except Exception:
return None, None
def parse_document(source: Path, cfg: PipelineConfig) -> ParseResult:
"""Parse one document. A cache hit is not re-run."""
doc_id = source.stem
target = cfg.cache_dir / "parse" / cache_key(source, cfg)
marker = target / ".done"
if marker.exists():
content, middle = _find_output(target)
if content:
meta = json.loads(marker.read_text(encoding="utf-8"))
backend_recorded, version = _read_middle(middle)
return ParseResult(
doc_id=doc_id, source=source, cache_dir=target,
content_list=content, middle_json=middle,
pages=meta.get("pages", 0), seconds=meta.get("seconds", 0.0),
from_cache=True,
backend_recorded=backend_recorded, mineru_version=version,
)
shutil.rmtree(target, ignore_errors=True) # cache corrupt, redo it
from mineru.cli.common import do_parse, read_fn
# MinerU dispatches on prefixes: "pipeline", then `startswith("vlm-")` and
# `startswith("hybrid-")`. The bare names "vlm"/"hybrid" match NONE of those
# branches — MinerU simply does nothing, without error, and the failure only
# surfaces here as "content_list not found", which points at the wrong place
# entirely. The short names stay in the config because they are part of the
# cache key and read better; the translation happens here, right before
# MinerU is touched.
mineru_backend = {
"pipeline": "pipeline",
"vlm": "vlm-engine",
"hybrid": "hybrid-engine",
}[cfg.backend]
# The playground does this before parsing; aliases like "latin"/"en" are
# resolved to a canonical code. Matched here so results are identical.
lang = cfg.canonical_lang()
# with_suffix() REPLACES the last suffix, and the folder name ends in the
# MinerU version ("...-3.4.4") — so ".4" would be thrown away and two
# different versions would collide on one temp directory.
staging = target.with_name(target.name + ".partial")
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir(parents=True, exist_ok=True)
pdf_bytes = read_fn(source)
started = time.perf_counter()
do_parse(
output_dir=str(staging),
pdf_file_names=[doc_id],
pdf_bytes_list=[pdf_bytes],
p_lang_list=[lang],
backend=mineru_backend,
formula_enable=cfg.formula_enable,
table_enable=cfg.table_enable,
f_draw_layout_bbox=cfg.write_debug_pdf,
f_draw_span_bbox=cfg.write_debug_pdf,
start_page_id=cfg.start_page,
end_page_id=cfg.end_page,
**({"effort": cfg.effort} if cfg.backend == "hybrid" else {}),
)
seconds = time.perf_counter() - started
content, middle = _find_output(staging)
if content is None:
raise RuntimeError(
f"MinerU finished but no *_content_list.json was found in {staging}"
)
pages = _count_pages(source, cfg)
staging.rename(target)
content = target / content.relative_to(staging)
middle = target / middle.relative_to(staging) if middle else None
marker.write_text(
json.dumps({"pages": pages, "seconds": seconds, "doc_id": doc_id}),
encoding="utf-8",
)
backend_recorded, version = _read_middle(middle)
return ParseResult(
doc_id=doc_id, source=source, cache_dir=target,
content_list=content, middle_json=middle,
pages=pages, seconds=seconds, from_cache=False,
backend_recorded=backend_recorded, mineru_version=version,
)
def _count_pages(source: Path, cfg: PipelineConfig) -> int:
if cfg.end_page is not None:
return cfg.end_page - cfg.start_page + 1
try:
from pypdf import PdfReader
return len(PdfReader(str(source)).pages)
except Exception:
return 0
|