pdf2wiki 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.
pdf2wiki/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """pdf2wiki — convert technical books (native-text PDFs) into clean, chapter-split,
2
+ LLM-ready Markdown, built on a dual-pass MinerU pipeline.
3
+
4
+ Pipeline stages:
5
+ 1. convert — MinerU pipeline pass (embedded text layer, byte-perfect code) merged with a
6
+ MinerU hybrid/VLM pass (correct table grids, Mermaid diagram transcription).
7
+ 2. phase5 — post-processing chain: caption unbleed -> code-fence language retag ->
8
+ dash normalize -> mermaid repair -> code unescape -> chapter split with
9
+ YAML frontmatter.
10
+ 3. qa — reproducible page sampling + per-page review artifacts for manual back-checks.
11
+ 4. batch — manifest-driven multi-book runs, resumable, with optional SSH remote execution.
12
+ """
13
+
14
+ __version__ = "0.1.0"
pdf2wiki/batch.py ADDED
@@ -0,0 +1,138 @@
1
+ """Manifest-driven batch conversion: convert -> (fetch) -> phase5 -> optional vault placement.
2
+
3
+ The book list is a user-supplied TOML file — never code:
4
+
5
+ # books.toml
6
+ [[book]]
7
+ pdf = "Some_Technical_Book.pdf" # path (local mode) or filename under remote books_dir
8
+ slug = "some-technical-book" # output name, used everywhere downstream
9
+ domain = "distributed-systems" # optional: subfolder under the output/vault root
10
+
11
+ Resumable: a JSON status manifest records per-book status next to the book list; a book whose
12
+ status is exactly "done" is skipped on re-run. One book's failure is logged and does NOT abort
13
+ the batch. Sequential only — a single GPU cannot run concurrent VLM passes.
14
+
15
+ A STOP file next to the status manifest halts cleanly between books (it is consumed on halt).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import shutil
22
+ import time
23
+ import tomllib
24
+
25
+ from .executor import ExecutionError, LocalExecutor, SSHExecutor
26
+ from .phase5 import run_chain
27
+
28
+
29
+ def load_books(books_toml: str) -> list[dict]:
30
+ with open(books_toml, "rb") as f:
31
+ data = tomllib.load(f)
32
+ books = data.get("book", [])
33
+ for b in books:
34
+ if "pdf" not in b or "slug" not in b:
35
+ raise ValueError(f"each [[book]] needs `pdf` and `slug`: {b}")
36
+ return books
37
+
38
+
39
+ def run_batch(books_toml: str, cfg, stage_dir: str, remote: str | None = None,
40
+ max_books: int | None = None, only: str | None = None,
41
+ vault: str | None = None) -> dict:
42
+ """Run the batch. Returns the final status manifest."""
43
+ stage = os.path.expanduser(stage_dir)
44
+ os.makedirs(stage, exist_ok=True)
45
+ manifest_path = os.path.join(stage, "manifest.json")
46
+ stop_file = os.path.join(stage, "STOP")
47
+
48
+ manifest: dict = {}
49
+ if os.path.exists(manifest_path):
50
+ try:
51
+ with open(manifest_path) as f:
52
+ manifest = json.load(f)
53
+ except json.JSONDecodeError as e:
54
+ raise SystemExit(
55
+ f"status manifest {manifest_path} is corrupt ({e}). "
56
+ f"Repair or delete it (deleting restarts every book) and re-run."
57
+ )
58
+
59
+ def save():
60
+ # atomic: a kill mid-dump must never truncate the manifest (it's the resume backbone)
61
+ tmp = manifest_path + ".tmp"
62
+ with open(tmp, "w") as f:
63
+ json.dump(manifest, f, indent=1)
64
+ os.replace(tmp, manifest_path)
65
+
66
+ if remote:
67
+ ex = SSHExecutor(remote, cfg.remote.books_dir, cfg.remote.workdir,
68
+ cfg.remote.connect_timeout, cfg.remote.convert_timeout)
69
+ else:
70
+ ex = LocalExecutor()
71
+ ex.check() # fail fast before touching any book
72
+
73
+ books = load_books(books_toml)
74
+ attempted = 0
75
+ for b in books:
76
+ slug, domain = b["slug"], b.get("domain", "")
77
+ if only is not None and slug != only:
78
+ continue
79
+ if max_books is not None and attempted >= max_books:
80
+ print(f"--max-books {max_books} reached — stopping cleanly (done books skip on re-run).")
81
+ break
82
+ if os.path.exists(stop_file):
83
+ print(f"STOP file present ({stop_file}) — halting cleanly between books.")
84
+ os.remove(stop_file)
85
+ break
86
+ if manifest.get(slug, {}).get("status") == "done":
87
+ print(f"[skip] {slug} already done")
88
+ continue
89
+ attempted += 1
90
+ print(f"=== {slug} ({domain or 'no domain'}) ===", flush=True)
91
+ t0 = time.time()
92
+
93
+ timeout = cfg.remote.convert_timeout if remote else cfg.convert.timeout
94
+ ok, log = ex.convert(b["pdf"], slug, cfg.convert.out_root, timeout)
95
+ if not ok:
96
+ print(f" CONVERT FAILED: {slug} — log tail:\n{log[-2000:]}")
97
+ manifest[slug] = {"status": "convert_failed", "domain": domain}
98
+ save()
99
+ continue
100
+
101
+ work = os.path.join(stage, slug)
102
+ if not ex.fetch(slug, cfg.convert.out_root, work):
103
+ manifest[slug] = {"status": "fetch_failed", "domain": domain}
104
+ save()
105
+ continue
106
+ if isinstance(ex, LocalExecutor):
107
+ work = ex.artifacts_dir(slug, cfg.convert.out_root)
108
+
109
+ md = os.path.join(work, f"{slug}.md")
110
+ try:
111
+ run_chain(md, slug, out_dir=os.path.join(work, "chapters"),
112
+ source_name=os.path.basename(b["pdf"]), apply=True)
113
+ except Exception as e:
114
+ print(f" PHASE5 FAILED: {slug}: {e}")
115
+ manifest[slug] = {"status": "phase5_failed", "domain": domain}
116
+ save()
117
+ continue
118
+ # chapters need the shared images/ dir next to them for relative refs to resolve
119
+ img_src = os.path.join(work, "images")
120
+ img_dst = os.path.join(work, "chapters", "images")
121
+ if os.path.isdir(img_src):
122
+ os.makedirs(img_dst, exist_ok=True)
123
+ for f in os.listdir(img_src):
124
+ shutil.copy(os.path.join(img_src, f), os.path.join(img_dst, f))
125
+
126
+ entry = {"status": "done", "domain": domain, "minutes": round((time.time() - t0) / 60, 1)}
127
+ if vault:
128
+ dest = os.path.join(os.path.expanduser(vault), domain, slug) if domain \
129
+ else os.path.join(os.path.expanduser(vault), slug)
130
+ shutil.copytree(os.path.join(work, "chapters"), dest, dirs_exist_ok=True)
131
+ entry["vault_path"] = dest
132
+ print(f" DONE {slug} -> {dest} ({entry['minutes']} min)")
133
+ else:
134
+ print(f" DONE {slug} -> {os.path.join(work, 'chapters')} ({entry['minutes']} min)")
135
+ manifest[slug] = entry
136
+ save()
137
+ print("BATCH COMPLETE")
138
+ return manifest
pdf2wiki/cli.py ADDED
@@ -0,0 +1,144 @@
1
+ """pdf2wiki command-line interface.
2
+
3
+ Convention: every mutating command is DRY-RUN by default and requires --apply to write
4
+ (exceptions: convert and qa, whose whole purpose is producing new artifacts in their own
5
+ output directories — they never modify existing files in place).
6
+ """
7
+ import argparse
8
+ import json
9
+ import sys
10
+
11
+ from .config import load_config
12
+
13
+
14
+ def _cmd_convert(a, cfg):
15
+ from .executor import LocalExecutor, SSHExecutor
16
+ if a.remote or cfg.remote.host:
17
+ host = a.remote or cfg.remote.host
18
+ ex = SSHExecutor(host, cfg.remote.books_dir, cfg.remote.workdir,
19
+ cfg.remote.connect_timeout, cfg.remote.convert_timeout)
20
+ ex.check()
21
+ ok, log = ex.convert(a.pdf, a.name, a.out or cfg.convert.out_root)
22
+ print(log) # remote log was captured on the remote host — show it
23
+ else:
24
+ ex = LocalExecutor()
25
+ ok, log = ex.convert(a.pdf, a.name, a.out or cfg.convert.out_root,
26
+ cfg.convert.timeout)
27
+ # local progress already streamed live by convert_book — don't print the log twice
28
+ return 0 if ok else 1
29
+
30
+
31
+ def _cmd_phase5(a, cfg):
32
+ from .phase5 import run_chain
33
+ report = run_chain(a.md, a.book, out_dir=a.out, source_name=a.source_name, apply=a.apply)
34
+ print(f"caption_unbleed: {report['caption_unbleed']['unwrapped']} unwrapped")
35
+ lr = report["lang_retag"]
36
+ print(f"lang_retag: {lr['changes']} changes {lr['stats']}")
37
+ print(f"dash_normalize: {report['dash_normalize']['fixes']} fixes")
38
+ mr = report["mermaid_repair"]
39
+ print(f"mermaid_repair: {mr['blocks_changed']} blocks, "
40
+ f"parse-breaker score {mr['score_before']} -> {mr['score_after']}")
41
+ cs = report["chapter_split"]
42
+ print(f"chapter_split: {cs['boundaries']} boundaries")
43
+ for i, t in enumerate(cs["titles"], 1):
44
+ print(f" {i:2d}. {t}")
45
+ if a.apply:
46
+ print(f"\nAPPLIED — wrote {len(cs['files'])} chapter files")
47
+ else:
48
+ print(f"\n(dry-run — would write {len(cs['files'])} chapter files; pass --apply)")
49
+ return 0
50
+
51
+
52
+ def _cmd_qa_sample(a, cfg):
53
+ from .qa.sample import sample_pages
54
+ r = sample_pages(a.pdf, a.name, a.qa_root or cfg.qa.root,
55
+ n=a.pages or cfg.qa.pages, seed=a.seed if a.seed is not None else cfg.qa.seed,
56
+ dpi=a.dpi or cfg.qa.dpi)
57
+ print(f"{a.name}: sampled {len(r['pages'])} pages (seed {r['seed']}) "
58
+ f"from range {r['range'][0]}-{r['range'][1]} of {r['page_count']}")
59
+ print("pages:", r["pages"])
60
+ print(f"sample pdf: {r['sample_pdf']} ; PNGs in {r['qa_dir']}/pages/")
61
+ return 0
62
+
63
+
64
+ def _cmd_qa_review(a, cfg):
65
+ from .qa.review import build_review
66
+ r = build_review(a.qa_dir, a.name, blocks_path=a.blocks)
67
+ print(f"wrote {r['review']} ; pages with content: {r['pages_with_content']}/{r['sampled']}")
68
+ return 0
69
+
70
+
71
+ def _cmd_scan(a, cfg):
72
+ from .scan import scan_dir
73
+ print(json.dumps(scan_dir(a.directory), indent=1))
74
+ return 0
75
+
76
+
77
+ def _cmd_batch(a, cfg):
78
+ from .batch import run_batch
79
+ run_batch(a.books, cfg, a.stage, remote=a.remote or (cfg.remote.host or None),
80
+ max_books=a.max_books, only=a.only, vault=a.vault or (cfg.output.vault or None))
81
+ return 0
82
+
83
+
84
+ def main(argv=None):
85
+ ap = argparse.ArgumentParser(prog="pdf2wiki",
86
+ description="Convert technical books (native-text PDFs) into "
87
+ "clean, chapter-split, LLM-ready Markdown.")
88
+ ap.add_argument("--config", default=None, help="explicit config TOML path")
89
+ sub = ap.add_subparsers(dest="cmd", required=True)
90
+
91
+ p = sub.add_parser("convert", help="convert one PDF (dual-pass MinerU merge)")
92
+ p.add_argument("pdf")
93
+ p.add_argument("--name", required=True, help="output slug")
94
+ p.add_argument("--out", default=None, help="output root (default from config)")
95
+ p.add_argument("--remote", default=None, help="ssh host to run the conversion on")
96
+ p.set_defaults(fn=_cmd_convert)
97
+
98
+ p = sub.add_parser("phase5", help="post-process a converted .md (dry-run by default)")
99
+ p.add_argument("md")
100
+ p.add_argument("--book", required=True, help="book slug for frontmatter")
101
+ p.add_argument("--out", default=None, help="chapter output dir (default: <md dir>/chapters)")
102
+ p.add_argument("--source-name", default=None,
103
+ help="original PDF filename for frontmatter `source:` "
104
+ "(avoids leaking a staging path)")
105
+ p.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
106
+ p.set_defaults(fn=_cmd_phase5)
107
+
108
+ pqa = sub.add_parser("qa", help="conversion QA tools")
109
+ qsub = pqa.add_subparsers(dest="qa_cmd", required=True)
110
+ p = qsub.add_parser("sample", help="sample N random pages -> sample PDF + PNGs")
111
+ p.add_argument("pdf")
112
+ p.add_argument("name")
113
+ p.add_argument("-n", "--pages", type=int, default=None)
114
+ p.add_argument("--seed", type=int, default=None)
115
+ p.add_argument("--dpi", type=int, default=None)
116
+ p.add_argument("--qa-root", default=None)
117
+ p.set_defaults(fn=_cmd_qa_sample)
118
+ p = qsub.add_parser("review", help="build per-page review.txt from blocks.json")
119
+ p.add_argument("qa_dir")
120
+ p.add_argument("name")
121
+ p.add_argument("--blocks", default=None, help="explicit blocks.json path")
122
+ p.set_defaults(fn=_cmd_qa_review)
123
+
124
+ p = sub.add_parser("scan", help="scan a directory of PDFs -> title/year guesses (JSON)")
125
+ p.add_argument("directory")
126
+ p.set_defaults(fn=_cmd_scan)
127
+
128
+ p = sub.add_parser("batch", help="manifest-driven multi-book run (resumable)")
129
+ p.add_argument("books", help="books TOML file ([[book]] entries with pdf/slug/domain)")
130
+ p.add_argument("--stage", default="~/pdf2wiki/stage", help="staging + status-manifest dir")
131
+ p.add_argument("--remote", default=None, help="ssh host to convert on")
132
+ p.add_argument("--max-books", type=int, default=None,
133
+ help="stop after this many books attempted this run")
134
+ p.add_argument("--only", default=None, help="run only this slug")
135
+ p.add_argument("--vault", default=None, help="final placement root (e.g. an Obsidian vault)")
136
+ p.set_defaults(fn=_cmd_batch)
137
+
138
+ a = ap.parse_args(argv)
139
+ cfg = load_config(a.config)
140
+ return a.fn(a, cfg)
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())
pdf2wiki/config.py ADDED
@@ -0,0 +1,105 @@
1
+ """Configuration for pdf2wiki.
2
+
3
+ Resolution order (first hit wins, per key):
4
+ 1. CLI flags
5
+ 2. ./pdf2wiki.toml (project-local)
6
+ 3. ~/.config/pdf2wiki/config.toml (user)
7
+ 4. built-in defaults below
8
+
9
+ All values that were once hardcoded for a specific machine live here instead.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ import tomllib
16
+ from dataclasses import dataclass, field, fields
17
+
18
+
19
+ @dataclass
20
+ class MineruConfig:
21
+ binary: str = "" # empty -> discover `mineru` on PATH
22
+ model_source: str = "huggingface" # MINERU_MODEL_SOURCE
23
+ effort: str = "high" # hybrid-engine effort for the VLM pass
24
+
25
+ def resolve_binary(self) -> str:
26
+ if self.binary:
27
+ return os.path.expanduser(self.binary)
28
+ found = shutil.which("mineru")
29
+ if not found:
30
+ raise FileNotFoundError(
31
+ "mineru CLI not found on PATH. Install MinerU (https://github.com/opendatalab/MinerU) "
32
+ "or set [mineru].binary in pdf2wiki.toml"
33
+ )
34
+ return found
35
+
36
+
37
+ @dataclass
38
+ class ConvertConfig:
39
+ out_root: str = "~/pdf2wiki/out"
40
+ workdir: str = "~/.pdf2wiki/run" # clean cwd for MinerU subprocesses (see README: stdlib-shadow gotcha)
41
+ timeout: int = 7200 # seconds per MinerU pass (local conversion)
42
+ gap: int = 3 # merge nearby rich pages into one hybrid run if gap <= this
43
+ seg: int = 40 # pipeline segment size (pages) for chunked passes
44
+ maxrun: int = 25 # cap on a single hybrid run length (pages)
45
+ tiny_px2: int = 2500 # ignore images smaller than this (px^2) when profiling pages
46
+
47
+
48
+ @dataclass
49
+ class QaConfig:
50
+ root: str = "~/pdf2wiki/qa"
51
+ dpi: int = 140
52
+ seed: int = 42
53
+ pages: int = 20
54
+
55
+
56
+ @dataclass
57
+ class RemoteConfig:
58
+ host: str = "" # ssh destination (alias or user@host); empty -> local execution
59
+ books_dir: str = "" # directory on the remote host holding the PDFs
60
+ workdir: str = "~/pdf2wiki-remote" # remote working directory (converter output lives under it)
61
+ connect_timeout: int = 8
62
+ convert_timeout: int = 7200
63
+
64
+
65
+ @dataclass
66
+ class OutputConfig:
67
+ vault: str = "" # optional: final placement root (e.g. an Obsidian vault)
68
+
69
+
70
+ @dataclass
71
+ class Config:
72
+ mineru: MineruConfig = field(default_factory=MineruConfig)
73
+ convert: ConvertConfig = field(default_factory=ConvertConfig)
74
+ qa: QaConfig = field(default_factory=QaConfig)
75
+ remote: RemoteConfig = field(default_factory=RemoteConfig)
76
+ output: OutputConfig = field(default_factory=OutputConfig)
77
+
78
+
79
+ def _apply_section(obj, data: dict):
80
+ valid = {f.name for f in fields(obj)}
81
+ for k, v in data.items():
82
+ if k in valid:
83
+ setattr(obj, k, v)
84
+
85
+
86
+ def load_config(explicit_path: str | None = None) -> Config:
87
+ """Load config, merging user config then project-local config over defaults."""
88
+ cfg = Config()
89
+ candidates = []
90
+ if explicit_path:
91
+ candidates = [explicit_path]
92
+ else:
93
+ candidates = [
94
+ os.path.expanduser("~/.config/pdf2wiki/config.toml"),
95
+ "pdf2wiki.toml",
96
+ ]
97
+ for path in candidates:
98
+ if not os.path.exists(path):
99
+ continue
100
+ with open(path, "rb") as f:
101
+ data = tomllib.load(f)
102
+ for section_name in ("mineru", "convert", "qa", "remote", "output"):
103
+ if section_name in data:
104
+ _apply_section(getattr(cfg, section_name), data[section_name])
105
+ return cfg
@@ -0,0 +1,18 @@
1
+ """Dual-pass converter (MinerU pipeline + hybrid merge).
2
+
3
+ Base-driven merge: the pipeline `-m txt` pass gives a byte-perfect skeleton from the PDF's
4
+ embedded text layer; the hybrid/VLM pass is grafted in for table grids, Mermaid diagram
5
+ transcriptions, LaTeX equations and chart data, matched by page + bbox-IoU. Code blocks are
6
+ token-verified between the passes and flagged on divergence. A coverage gate hard-stops if any
7
+ text-bearing page produced no blocks (zero-fail scrape); all MinerU passes are cached with
8
+ `.done` sentinels for safe resume.
9
+
10
+ Output layout (consumed by phase5, qa.review, batch):
11
+ <out_root>/<slug>/<slug>.md merged markdown
12
+ <out_root>/<slug>/images/ extracted figure images (relative refs from the md)
13
+ <out_root>/<slug>/blocks.json per-block records with abs_page (for QA review)
14
+ <out_root>/<slug>/*.log per-pass MinerU logs (never suppressed)
15
+ """
16
+ from .merge import convert_book
17
+
18
+ __all__ = ["convert_book"]