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 +14 -0
- pdf2wiki/batch.py +138 -0
- pdf2wiki/cli.py +144 -0
- pdf2wiki/config.py +105 -0
- pdf2wiki/convert/__init__.py +18 -0
- pdf2wiki/convert/merge.py +661 -0
- pdf2wiki/executor.py +104 -0
- pdf2wiki/phase5/__init__.py +51 -0
- pdf2wiki/phase5/caption_unbleed.py +63 -0
- pdf2wiki/phase5/chapter_split.py +107 -0
- pdf2wiki/phase5/code_unescape.py +45 -0
- pdf2wiki/phase5/dash_normalize.py +41 -0
- pdf2wiki/phase5/lang_retag.py +129 -0
- pdf2wiki/phase5/mermaid_repair.py +84 -0
- pdf2wiki/qa/__init__.py +0 -0
- pdf2wiki/qa/review.py +31 -0
- pdf2wiki/qa/sample.py +32 -0
- pdf2wiki/render.py +33 -0
- pdf2wiki/scan.py +66 -0
- pdf2wiki-0.1.0.dist-info/METADATA +84 -0
- pdf2wiki-0.1.0.dist-info/RECORD +24 -0
- pdf2wiki-0.1.0.dist-info/WHEEL +4 -0
- pdf2wiki-0.1.0.dist-info/entry_points.txt +2 -0
- pdf2wiki-0.1.0.dist-info/licenses/LICENSE +661 -0
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"]
|