pdfdown 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.
- pdfdown/__init__.py +1 -0
- pdfdown/cleanup/__init__.py +36 -0
- pdfdown/cleanup/captions.py +126 -0
- pdfdown/cleanup/figures.py +62 -0
- pdfdown/cleanup/furniture.py +49 -0
- pdfdown/cleanup/notes.py +25 -0
- pdfdown/cleanup/order.py +119 -0
- pdfdown/cleanup/retext.py +34 -0
- pdfdown/cleanup/stitch.py +63 -0
- pdfdown/cli.py +128 -0
- pdfdown/document.py +136 -0
- pdfdown/edits.py +88 -0
- pdfdown/engine.py +102 -0
- pdfdown/equations.py +335 -0
- pdfdown/export.py +182 -0
- pdfdown/locks.py +12 -0
- pdfdown/pdftext.py +336 -0
- pdfdown/pipeline.py +46 -0
- pdfdown/quality.py +65 -0
- pdfdown/tui/__init__.py +0 -0
- pdfdown/tui/app.py +576 -0
- pdfdown/tui/screens.py +184 -0
- pdfdown-0.1.0.dist-info/METADATA +373 -0
- pdfdown-0.1.0.dist-info/RECORD +27 -0
- pdfdown-0.1.0.dist-info/WHEEL +4 -0
- pdfdown-0.1.0.dist-info/entry_points.txt +2 -0
- pdfdown-0.1.0.dist-info/licenses/LICENSE +21 -0
pdfdown/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The cleanup layer: small steps that turn Docling's blocks into a clean article.
|
|
2
|
+
|
|
3
|
+
Each step takes the Document and rewrites doc.blocks in place, adding a short
|
|
4
|
+
plain-language note to doc.notes about what it changed. Order matters:
|
|
5
|
+
text is rebuilt before captions are parsed, and paragraphs are stitched only
|
|
6
|
+
after floats and furniture have been sorted out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pdfdown.cleanup import captions, figures, furniture, notes, order, retext, stitch
|
|
12
|
+
from pdfdown.document import Document
|
|
13
|
+
from pdfdown.pdftext import PdfText
|
|
14
|
+
|
|
15
|
+
STEPS = [
|
|
16
|
+
("furniture", furniture.run),
|
|
17
|
+
("order", order.run),
|
|
18
|
+
("figure text", figures.drop_inner_text),
|
|
19
|
+
("retext", retext.run),
|
|
20
|
+
("captions", captions.run),
|
|
21
|
+
("icons", figures.drop_icons),
|
|
22
|
+
("stitch", stitch.run),
|
|
23
|
+
("notes", notes.run),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clean(doc: Document, pdf: PdfText | None = None) -> Document:
|
|
28
|
+
own = pdf is None
|
|
29
|
+
pdf = pdf or PdfText(doc.source)
|
|
30
|
+
try:
|
|
31
|
+
for _name, step in STEPS:
|
|
32
|
+
step(doc, pdf)
|
|
33
|
+
finally:
|
|
34
|
+
if own:
|
|
35
|
+
pdf.close()
|
|
36
|
+
return doc
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Captions: join split pieces and attach each caption to its figure or table.
|
|
2
|
+
|
|
3
|
+
In two-column journals a figure caption often runs across both columns and
|
|
4
|
+
Docling returns it in pieces ("Fig. 1 | ... a, ...", then "c, ..."), with
|
|
5
|
+
body text from the page in between. Pieces after a caption that starts with
|
|
6
|
+
"Fig./Figure/Table N" on the same page, and that do not start a new caption,
|
|
7
|
+
are merged into it. Each caption then goes to the nearest figure or table on
|
|
8
|
+
its page, and its number names the image file (figure-2.png, table-1.png).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from pdfdown.document import Block, Document
|
|
16
|
+
|
|
17
|
+
CAPTION_START = re.compile(r"^\s*(?:<[^>]+>)?\s*(Extended Data\s+)?(Fig(?:ure)?|Table|Supplementary Fig(?:ure)?)\.?\s*([A-Z]?\d+[a-z]?)\b", re.I)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def number_of(text: str) -> tuple[str, str] | None:
|
|
21
|
+
"""('figure', '2') for 'Fig. 2 | ...', ('table', '1') for 'Table 1.', ('figure', 'ED3') for 'Extended Data Fig. 3'."""
|
|
22
|
+
m = CAPTION_START.match(text)
|
|
23
|
+
if not m:
|
|
24
|
+
return None
|
|
25
|
+
kind = "table" if m.group(2).lower().startswith("table") else "figure"
|
|
26
|
+
prefix = "ED" if m.group(1) else ("S" if m.group(2).lower().startswith("supplementary") else "")
|
|
27
|
+
return kind, prefix + m.group(3)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
PROMOTE = re.compile(r"^\s*(?:Extended Data\s+)?(?:Supplementary\s+)?(?:Fig|Figure|Table)\.?\s*[A-Z]?\d+[a-z]?\s*[|.:]", re.I)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run(doc: Document, pdf=None) -> None:
|
|
34
|
+
blocks = doc.blocks
|
|
35
|
+
# 1. Promote "Fig. 2 |" and "Extended Data Fig. 3 |" text that Docling labelled as a paragraph
|
|
36
|
+
# or a heading. The separator after the number keeps "Fig. 2 shows ..." in the body.
|
|
37
|
+
for b in blocks:
|
|
38
|
+
if b.kind in ("paragraph", "heading") and PROMOTE.match(b.text):
|
|
39
|
+
b.kind = "caption"
|
|
40
|
+
# 2. Merge continuation pieces into the caption they belong to: unnumbered caption pieces on the
|
|
41
|
+
# page, and a paragraph right after an unfinished caption that carries on in lower case.
|
|
42
|
+
merged = 0
|
|
43
|
+
i = 0
|
|
44
|
+
while i < len(blocks):
|
|
45
|
+
b = blocks[i]
|
|
46
|
+
if b.kind == "caption" and number_of(b.text):
|
|
47
|
+
j = i + 1
|
|
48
|
+
while j < len(blocks):
|
|
49
|
+
nxt = blocks[j]
|
|
50
|
+
carries_on = (j == i + 1 and nxt.kind == "paragraph" and nxt.text[:1].islower()
|
|
51
|
+
and not re.search(r"[.!?:]\s*$", b.text))
|
|
52
|
+
if (nxt.kind == "caption" or carries_on) and nxt.page == b.page and not number_of(nxt.text):
|
|
53
|
+
b.text = b.text.rstrip() + " " + nxt.text.lstrip()
|
|
54
|
+
b.parts += nxt.parts
|
|
55
|
+
del blocks[j]
|
|
56
|
+
merged += 1
|
|
57
|
+
continue
|
|
58
|
+
if nxt.kind in ("caption",) or nxt.page != b.page:
|
|
59
|
+
break
|
|
60
|
+
j += 1
|
|
61
|
+
i += 1
|
|
62
|
+
# 3. Attach captions: first by Docling's own link between a float and its caption,
|
|
63
|
+
# then, for captions without one, to the nearest float of the matching kind on the page.
|
|
64
|
+
floats = [b for b in blocks if b.kind in ("figure", "table")]
|
|
65
|
+
attached = 0
|
|
66
|
+
by_ref = {b.extra.get("ref"): b for b in blocks if b.kind == "caption"}
|
|
67
|
+
for f in floats:
|
|
68
|
+
caps = [by_ref[r] for r in f.extra.get("caption_refs", []) if r in by_ref and not by_ref[r].extra.get("attached")]
|
|
69
|
+
if caps:
|
|
70
|
+
f.extra["caption"] = " ".join(c.text for c in caps)
|
|
71
|
+
info = number_of(caps[0].text)
|
|
72
|
+
if info:
|
|
73
|
+
f.extra["number"] = info[1]
|
|
74
|
+
for c in caps:
|
|
75
|
+
c.extra["attached"] = True
|
|
76
|
+
attached += 1
|
|
77
|
+
for cap in [b for b in blocks if b.kind == "caption"]:
|
|
78
|
+
info = number_of(cap.text)
|
|
79
|
+
want = info[0] if info else None
|
|
80
|
+
candidates = [f for f in floats if f.page == cap.page and "caption" not in f.extra and (want is None or f.kind == want)]
|
|
81
|
+
if not candidates:
|
|
82
|
+
continue
|
|
83
|
+
target = min(candidates, key=lambda f: _distance(f, cap))
|
|
84
|
+
target.extra["caption"] = cap.text
|
|
85
|
+
if info:
|
|
86
|
+
target.extra["number"] = info[1]
|
|
87
|
+
cap.extra["attached"] = True
|
|
88
|
+
attached += 1
|
|
89
|
+
attached_text = {_norm(f.extra["caption"])[:60] for f in floats if f.extra.get("caption")}
|
|
90
|
+
before = len(blocks)
|
|
91
|
+
doc.blocks = [b for b in blocks if not (b.kind == "caption" and b.extra.get("attached"))
|
|
92
|
+
and not (b.kind in ("paragraph", "caption") and _norm(b.text)[:60] in attached_text)]
|
|
93
|
+
used = sum(1 for b in blocks if b.kind == "caption" and b.extra.get("attached")) # one caption can serve several panels
|
|
94
|
+
duplicates = before - len(doc.blocks) - used
|
|
95
|
+
_number_rest(doc)
|
|
96
|
+
captioned = sum(1 for f in floats if f.extra.get("caption"))
|
|
97
|
+
if merged:
|
|
98
|
+
doc.notes.append(f"Joined {merged} captions that were split into pieces.")
|
|
99
|
+
if captioned:
|
|
100
|
+
doc.notes.append(f"Attached captions to {captioned} figures and tables.")
|
|
101
|
+
if duplicates:
|
|
102
|
+
doc.notes.append(f"Removed {duplicates} duplicate copies of captions.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _distance(f: Block, cap: Block) -> float:
|
|
106
|
+
"""Vertical gap between a float and a caption, preferring overlap in columns."""
|
|
107
|
+
gap = max(cap.box.t - f.box.b, f.box.t - cap.box.b, 0)
|
|
108
|
+
shared = f.box.overlap_x(cap.box) / max(min(f.box.width, cap.box.width), 1)
|
|
109
|
+
return gap + (0 if shared > 0.3 else 500)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _number_rest(doc: Document) -> None:
|
|
113
|
+
"""Give every figure and table without a caption number the next free one."""
|
|
114
|
+
for kind in ("figure", "table"):
|
|
115
|
+
used = {b.extra.get("number") for b in doc.blocks if b.kind == kind}
|
|
116
|
+
n = 0
|
|
117
|
+
for b in doc.blocks:
|
|
118
|
+
if b.kind == kind and not b.extra.get("number"):
|
|
119
|
+
n += 1
|
|
120
|
+
while f"u{n}" in used:
|
|
121
|
+
n += 1
|
|
122
|
+
b.extra["number"] = f"u{n}" # 'u' marks an unnumbered figure in file names
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _norm(text: str) -> str:
|
|
126
|
+
return re.sub(r"[^a-z0-9]+", "", re.sub(r"<[^>]+>", "", text.lower()))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Figures: drop text that belongs inside a figure (axis labels, legends, panel letters).
|
|
2
|
+
|
|
3
|
+
Docling sometimes reports the labels drawn inside a vector chart as separate
|
|
4
|
+
text blocks ("Time Time" under Nature's Fig. 1). Any short text block that
|
|
5
|
+
lies mostly inside a figure on the same page is part of the picture, which
|
|
6
|
+
pdfdown crops as an image anyway.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pdfdown.document import Box, Document
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _inside_share(inner: Box, outer: Box) -> float:
|
|
15
|
+
w = max(0.0, min(inner.r, outer.r) - max(inner.l, outer.l))
|
|
16
|
+
h = max(0.0, min(inner.b, outer.b) - max(inner.t, outer.t))
|
|
17
|
+
area = max(inner.width * inner.height, 1e-6)
|
|
18
|
+
return w * h / area
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def drop_inner_text(doc: Document, pdf=None) -> None:
|
|
22
|
+
figures = [b for b in doc.blocks if b.kind == "figure" and b.box]
|
|
23
|
+
kept, dropped = [], 0
|
|
24
|
+
for b in doc.blocks:
|
|
25
|
+
if b.kind in ("paragraph", "list_item", "heading") and b.box and len(b.text) < 200:
|
|
26
|
+
if any(f.page == b.page and _inside_share(b.box, f.box) > 0.6 for f in figures):
|
|
27
|
+
dropped += 1
|
|
28
|
+
continue
|
|
29
|
+
if _label_beside_figure(b, figures):
|
|
30
|
+
dropped += 1
|
|
31
|
+
continue
|
|
32
|
+
kept.append(b)
|
|
33
|
+
doc.blocks = kept
|
|
34
|
+
if dropped:
|
|
35
|
+
doc.notes.append(f"Dropped {dropped} text fragments drawn inside figures (axis labels and legends).")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _label_beside_figure(b, figures) -> bool:
|
|
39
|
+
"""A few words with no sentence punctuation, right at a figure's edge: an axis title or legend."""
|
|
40
|
+
words = b.text.split()
|
|
41
|
+
if b.kind != "paragraph" or len(words) > 4 or len(b.text) > 40 or b.text.rstrip().endswith((".", ":", ";")):
|
|
42
|
+
return False
|
|
43
|
+
for f in figures:
|
|
44
|
+
if f.page != b.page or b.box.overlap_x(f.box) < 0.5 * b.box.width:
|
|
45
|
+
continue
|
|
46
|
+
gap = max(b.box.t - f.box.b, f.box.t - b.box.b, 0)
|
|
47
|
+
if gap < 25:
|
|
48
|
+
return True
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ICON = 24.0 # points; a figure smaller than this both ways is a logo or badge
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def drop_icons(doc: Document, pdf=None) -> None:
|
|
56
|
+
"""Drop tiny images without a caption, such as a journal's "Check for updates" badge."""
|
|
57
|
+
keep = [b for b in doc.blocks if not (b.kind == "figure" and not b.extra.get("caption")
|
|
58
|
+
and b.box.width < ICON and b.box.height < ICON)]
|
|
59
|
+
dropped = len(doc.blocks) - len(keep)
|
|
60
|
+
doc.blocks = keep
|
|
61
|
+
if dropped:
|
|
62
|
+
doc.notes.append(f"Dropped {dropped} tiny image{'s' * (dropped != 1)} without a caption (logos and badges).")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Remove page furniture: running headers and footers, page numbers, journal lines.
|
|
2
|
+
|
|
3
|
+
Docling marks most of them, but not all: Nature's running header "Article"
|
|
4
|
+
comes through as a section heading on every left-hand page. A block is
|
|
5
|
+
treated as furniture when its text (with digits ignored) repeats on at least
|
|
6
|
+
a third of the pages, near the top or bottom edge.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
|
|
14
|
+
from pdfdown.document import Document
|
|
15
|
+
|
|
16
|
+
EDGE = 0.09 # top or bottom 9% of the page
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _key(text: str) -> str:
|
|
20
|
+
return re.sub(r"\d+", "#", text.strip().lower())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(doc: Document, pdf=None) -> None:
|
|
24
|
+
pages = max(len(doc.page_sizes), 1)
|
|
25
|
+
seen: dict[str, set[int]] = defaultdict(set)
|
|
26
|
+
for b in doc.blocks:
|
|
27
|
+
if b.box and b.text and len(b.text) < 120 and _near_edge(doc, b):
|
|
28
|
+
seen[_key(b.text)].add(b.page)
|
|
29
|
+
repeated = {k for k, ps in seen.items() if len(ps) >= max(2, pages / 3)}
|
|
30
|
+
words = {k for k in repeated if k.replace("#", "").strip() and len(k.split()) <= 3}
|
|
31
|
+
kept, dropped = [], 0
|
|
32
|
+
for b in doc.blocks:
|
|
33
|
+
if b.kind == "heading":
|
|
34
|
+
for key in words: # "Article Methods": a running header merged into a heading
|
|
35
|
+
if _key(b.text).startswith(key + " ") and len(b.text) > len(key) + 2:
|
|
36
|
+
b.text = b.text[len(key):].strip()
|
|
37
|
+
page_number = bool(re.fullmatch(r"\d{1,4}", b.text)) and b.box is not None and _near_edge(doc, b)
|
|
38
|
+
if b.kind == "furniture" or page_number or (b.text and _key(b.text) in repeated and _near_edge(doc, b)):
|
|
39
|
+
dropped += 1
|
|
40
|
+
continue
|
|
41
|
+
kept.append(b)
|
|
42
|
+
doc.blocks = kept
|
|
43
|
+
if dropped:
|
|
44
|
+
doc.notes.append(f"Removed {dropped} page headers, footers and page numbers.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _near_edge(doc: Document, b) -> bool:
|
|
48
|
+
height = doc.page_sizes.get(b.page, (0, 792))[1]
|
|
49
|
+
return b.box.t < EDGE * height or b.box.b > (1 - EDGE) * height
|
pdfdown/cleanup/notes.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Footnotes and affiliations: move them out of the reading flow to a Notes section.
|
|
2
|
+
|
|
3
|
+
On the first page of an article the author affiliations sit at the bottom of
|
|
4
|
+
the first column; left in place they interrupt the introduction. They are
|
|
5
|
+
moved to the end, before the references when there is a references heading.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from pdfdown.document import Block, Document
|
|
13
|
+
|
|
14
|
+
REFERENCES = re.compile(r"^\s*(references|bibliography|literature cited)\s*$", re.I)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(doc: Document, pdf=None) -> None:
|
|
18
|
+
notes = [b for b in doc.blocks if b.kind == "footnote"]
|
|
19
|
+
if not notes:
|
|
20
|
+
return
|
|
21
|
+
body = [b for b in doc.blocks if b.kind != "footnote"]
|
|
22
|
+
section = [Block(kind="heading", text="Notes", level=2, extra={"generated": True})] + notes
|
|
23
|
+
refs = next((i for i, b in enumerate(body) if b.kind == "heading" and REFERENCES.match(b.text)), None)
|
|
24
|
+
doc.blocks = body[:refs] + section + body[refs:] if refs is not None else body + section
|
|
25
|
+
doc.notes.append(f"Moved {len(notes)} footnotes and affiliations to a Notes section at the end.")
|
pdfdown/cleanup/order.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Reading order on two-column pages, and author lines mistaken for headings.
|
|
2
|
+
|
|
3
|
+
The layout step usually reads two columns correctly, but not always: on the
|
|
4
|
+
first page of the LLaMA paper it read the right column before the abstract in
|
|
5
|
+
the left one. This step puts every two-column page into the order a person
|
|
6
|
+
reads it. Blocks that cross the middle of the page (a title, an author list, a
|
|
7
|
+
wide figure) split the page into bands; within each band the left column is
|
|
8
|
+
read from top to bottom, then the right column. Pages with no block wholly in
|
|
9
|
+
each column are left as they are.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
from pdfdown.document import Block, Document
|
|
17
|
+
|
|
18
|
+
TEXT = {"title", "heading", "paragraph", "list_item", "caption", "footnote", "reference", "code", "equation"}
|
|
19
|
+
STRADDLE = 4.0 # points a block must reach past the middle of the page, on both sides, to count as full width
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(doc: Document, pdf=None) -> None:
|
|
23
|
+
pages: dict[int, list[int]] = {}
|
|
24
|
+
for i, b in enumerate(doc.blocks):
|
|
25
|
+
if b.kind != "furniture" and b.box is not None:
|
|
26
|
+
pages.setdefault(b.page, []).append(i)
|
|
27
|
+
changed = 0
|
|
28
|
+
for page, positions in pages.items():
|
|
29
|
+
width = doc.page_sizes.get(page, (612.0, 792.0))[0]
|
|
30
|
+
blocks = [doc.blocks[i] for i in positions]
|
|
31
|
+
ordered = _read_order(blocks, width / 2)
|
|
32
|
+
if ordered is not blocks and ordered != blocks:
|
|
33
|
+
for i, b in zip(positions, ordered):
|
|
34
|
+
doc.blocks[i] = b
|
|
35
|
+
changed += 1
|
|
36
|
+
_join_split_title(doc)
|
|
37
|
+
_authors_are_not_headings(doc)
|
|
38
|
+
if changed:
|
|
39
|
+
doc.notes.append(f"Put {changed} two-column page{'s' * (changed != 1)} into reading order (left column, then right).")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _side(b: Block, mid: float) -> str:
|
|
43
|
+
if b.box.l < mid - STRADDLE and b.box.r > mid + STRADDLE:
|
|
44
|
+
return "full"
|
|
45
|
+
return "left" if (b.box.l + b.box.r) / 2 < mid else "right"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _two_columns(blocks: list[Block], sides: dict[int, str]) -> bool:
|
|
49
|
+
"""At least two text blocks in each column, and columns of similar width (a narrow sidebar is not a column)."""
|
|
50
|
+
left = [b.box.width for b in blocks if b.kind in TEXT and sides[id(b)] == "left"]
|
|
51
|
+
right = [b.box.width for b in blocks if b.kind in TEXT and sides[id(b)] == "right"]
|
|
52
|
+
if len(left) < 2 or len(right) < 2:
|
|
53
|
+
return False
|
|
54
|
+
ratio = max(left) / max(max(right), 1.0)
|
|
55
|
+
return 0.7 <= ratio <= 1.4
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _right_before_left(blocks: list[Block], sides: dict[int, str]) -> bool:
|
|
59
|
+
"""Did the layout step read right-column text before left-column text that sits beside it?"""
|
|
60
|
+
for i, a in enumerate(blocks):
|
|
61
|
+
if sides[id(a)] != "right" or a.kind not in TEXT:
|
|
62
|
+
continue
|
|
63
|
+
for b in blocks[i + 1:]:
|
|
64
|
+
if sides[id(b)] == "left" and b.kind in TEXT and b.box.t < a.box.b: # b is not wholly below a
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_order(blocks: list[Block], mid: float) -> list[Block]:
|
|
70
|
+
sides = {id(b): _side(b, mid) for b in blocks}
|
|
71
|
+
if not _two_columns(blocks, sides) or not _right_before_left(blocks, sides):
|
|
72
|
+
return blocks # one column, a sidebar layout, or already in reading order: keep the layout step's order
|
|
73
|
+
full = sorted((b for b in blocks if sides[id(b)] == "full"), key=lambda b: b.box.t)
|
|
74
|
+
columns = [b for b in blocks if sides[id(b)] != "full"]
|
|
75
|
+
out: list[Block] = []
|
|
76
|
+
top = float("-inf")
|
|
77
|
+
for separator in [*full, None]:
|
|
78
|
+
bottom = separator.box.t if separator else float("inf")
|
|
79
|
+
band = [b for b in columns if top <= (b.box.t + b.box.b) / 2 < bottom]
|
|
80
|
+
out += sorted((b for b in band if sides[id(b)] == "left"), key=lambda b: b.box.t)
|
|
81
|
+
out += sorted((b for b in band if sides[id(b)] == "right"), key=lambda b: b.box.t)
|
|
82
|
+
if separator:
|
|
83
|
+
out.append(separator)
|
|
84
|
+
top = (separator.box.t + separator.box.b) / 2
|
|
85
|
+
missing = [b for b in blocks if b not in out] # never lose a block, whatever its position
|
|
86
|
+
return out + missing
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _join_split_title(doc: Document) -> None:
|
|
90
|
+
"""'Direct Preference Optimization:' and the line under it are one title, not a title and a heading."""
|
|
91
|
+
first = next((i for i, b in enumerate(doc.blocks) if b.page == 1 and b.kind in ("title", "heading")), None)
|
|
92
|
+
if first is None or first + 1 >= len(doc.blocks):
|
|
93
|
+
return
|
|
94
|
+
title, nxt = doc.blocks[first], doc.blocks[first + 1]
|
|
95
|
+
unfinished = re.search(r"[:\-–—,]\s*$|\b(of|and|for|the|in|on|with|to|a|an)\s*$", title.text, re.I)
|
|
96
|
+
close = nxt.box is not None and title.box is not None and 0 <= nxt.box.t - title.box.b < title.box.height
|
|
97
|
+
if nxt.page == 1 and nxt.kind == "heading" and unfinished and close:
|
|
98
|
+
title.text = f"{title.text.rstrip()} {nxt.text.strip()}"
|
|
99
|
+
title.box = title.box.union(nxt.box)
|
|
100
|
+
title.parts += nxt.parts
|
|
101
|
+
del doc.blocks[first + 1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_NAME_LIST = re.compile(r"(?:[A-Z][\w’'.-]+\s+){1,3}[A-Z][\w’'.-]+(?:<sup>[^<]*</sup>|[∗*†‡§¶\d,])*\s*,")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _authors_are_not_headings(doc: Document) -> None:
|
|
108
|
+
"""On the first page, a 'heading' that is a list of names before the first real section is the author line."""
|
|
109
|
+
seen_title = False
|
|
110
|
+
for b in doc.blocks:
|
|
111
|
+
if b.page != 1:
|
|
112
|
+
break
|
|
113
|
+
if b.kind in ("title", "heading") and not seen_title:
|
|
114
|
+
seen_title = True
|
|
115
|
+
continue
|
|
116
|
+
if b.kind == "heading" and re.match(r"^\s*(abstract|\d|[IVX]+\.|introduction)", b.text, re.I):
|
|
117
|
+
break
|
|
118
|
+
if b.kind == "heading" and len(_NAME_LIST.findall(b.text)) >= 2:
|
|
119
|
+
b.kind = "paragraph"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Rebuild block text from the PDF's characters (superscripts, subscripts, hyphenation).
|
|
2
|
+
|
|
3
|
+
Docling's text is kept when the PDF has no usable character layer for the
|
|
4
|
+
block (a scanned page) or when the rebuilt text is clearly incomplete.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from pdfdown.document import Document
|
|
12
|
+
from pdfdown.pdftext import PdfText, block_text, vocabulary
|
|
13
|
+
|
|
14
|
+
REBUILT = {"title", "heading", "paragraph", "list_item", "caption", "footnote", "reference"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _plain(text: str) -> str:
|
|
18
|
+
return re.sub(r"</?su[bp]>|\s+", "", text)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(doc: Document, pdf: PdfText) -> None:
|
|
22
|
+
vocab = vocabulary(pdf, sorted(doc.page_sizes))
|
|
23
|
+
rebuilt = 0
|
|
24
|
+
for b in doc.blocks:
|
|
25
|
+
if b.kind not in REBUILT or not b.parts:
|
|
26
|
+
continue
|
|
27
|
+
text, seen = block_text(pdf, b.parts, vocab)
|
|
28
|
+
old = re.sub(r"\s+", "", b.text)
|
|
29
|
+
# Accept the rebuilt text when it covers roughly the same characters as Docling's.
|
|
30
|
+
if seen and old and 0.85 <= len(_plain(text)) / max(len(old), 1) <= 1.2:
|
|
31
|
+
b.text = text
|
|
32
|
+
rebuilt += 1
|
|
33
|
+
if rebuilt:
|
|
34
|
+
doc.notes.append(f"Rebuilt {rebuilt} text blocks from the PDF's characters, restoring superscripts and subscripts and joining lines.")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Stitch paragraphs that a column break, page break or figure interrupted.
|
|
2
|
+
|
|
3
|
+
PDFs end a block wherever the column or page ends, so a sentence like
|
|
4
|
+
"... on the basis of" / "41,298,433 papers ..." arrives as two paragraphs,
|
|
5
|
+
sometimes with a figure, caption or footnote between them. A paragraph that
|
|
6
|
+
does not end a sentence is joined with the next body paragraph; anything that
|
|
7
|
+
sat between them (figures, tables, footnotes) moves to just after the joined
|
|
8
|
+
paragraph, so floats still appear next to the text that surrounds them.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from pdfdown.document import Document
|
|
16
|
+
|
|
17
|
+
END = re.compile(r"""[.!?:;](?:["'”’)\]]|</sup>|\s*<sup>[^<]*</sup>)*\s*$""")
|
|
18
|
+
FLOATING = {"figure", "table", "footnote", "caption"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def ends_sentence(text: str) -> bool:
|
|
22
|
+
return bool(END.search(text))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run(doc: Document, pdf=None) -> None:
|
|
26
|
+
out = []
|
|
27
|
+
joined = 0
|
|
28
|
+
i = 0
|
|
29
|
+
blocks = doc.blocks
|
|
30
|
+
while i < len(blocks):
|
|
31
|
+
b = blocks[i]
|
|
32
|
+
if b.kind != "paragraph":
|
|
33
|
+
out.append(b)
|
|
34
|
+
i += 1
|
|
35
|
+
continue
|
|
36
|
+
held = []
|
|
37
|
+
while not ends_sentence(b.text):
|
|
38
|
+
j = i + 1
|
|
39
|
+
between = []
|
|
40
|
+
while j < len(blocks) and blocks[j].kind in FLOATING:
|
|
41
|
+
between.append(blocks[j])
|
|
42
|
+
j += 1
|
|
43
|
+
if j < len(blocks) and blocks[j].kind == "paragraph" and _continues(blocks[j].text):
|
|
44
|
+
nxt = blocks[j]
|
|
45
|
+
b.text = b.text.rstrip() + " " + nxt.text.lstrip()
|
|
46
|
+
b.parts = b.parts + nxt.parts
|
|
47
|
+
held += between
|
|
48
|
+
i = j
|
|
49
|
+
joined += 1
|
|
50
|
+
else:
|
|
51
|
+
break
|
|
52
|
+
out.append(b)
|
|
53
|
+
out.extend(held)
|
|
54
|
+
i += 1
|
|
55
|
+
doc.blocks = out
|
|
56
|
+
if joined:
|
|
57
|
+
doc.notes.append(f"Joined {joined} paragraphs split across columns, pages or figures.")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _continues(text: str) -> bool:
|
|
61
|
+
"""A continuation starts in lowercase, with a digit, or with punctuation, never with a new heading-like line."""
|
|
62
|
+
stripped = re.sub(r"^<su[bp]>[^<]*</su[bp]>", "", text.lstrip())
|
|
63
|
+
return bool(stripped) and (stripped[0].islower() or stripped[0].isdigit() or stripped[0] in ",;:)]–-")
|
pdfdown/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Command line: `pdfdown` opens the TUI; `pdfdown --batch a.pdf b.pdf` converts without it.
|
|
2
|
+
|
|
3
|
+
pdfdown open the TUI and pick PDFs there
|
|
4
|
+
pdfdown paper.pdf more/*.pdf open the TUI with these PDFs queued
|
|
5
|
+
pdfdown --batch paper.pdf convert in the terminal, no TUI (also when output is piped)
|
|
6
|
+
pdfdown check out/paper/paper.md list problems left in a converted file
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pdfdown import __version__
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parser() -> argparse.ArgumentParser:
|
|
20
|
+
p = argparse.ArgumentParser(
|
|
21
|
+
prog="pdfdown",
|
|
22
|
+
description="Convert PDFs (research articles first) to clean Markdown, with figures, tables and equations.",
|
|
23
|
+
epilog="Example: pdfdown --batch paper.pdf -o notes/ writes notes/paper/paper.md and its images.",
|
|
24
|
+
)
|
|
25
|
+
p.add_argument("pdfs", nargs="*", type=Path, help="PDF files or folders of PDFs")
|
|
26
|
+
p.add_argument("-o", "--out", type=Path, default=Path("pdfdown-output"),
|
|
27
|
+
help="output folder; each PDF gets its own subfolder (default: ./pdfdown-output)")
|
|
28
|
+
p.add_argument("--batch", action="store_true", help="convert without the TUI")
|
|
29
|
+
p.add_argument("--no-latex", action="store_true", help="keep every equation as an image (skips the 97 MB equation model)")
|
|
30
|
+
p.add_argument("--ocr", action="store_true", help="run OCR, for scanned PDFs without a text layer")
|
|
31
|
+
p.add_argument("--no-tables", action="store_true", help="crop tables as images instead of rebuilding them")
|
|
32
|
+
p.add_argument("--device", default="auto", choices=["auto", "cpu", "mps", "cuda"], help="where the layout models run")
|
|
33
|
+
p.add_argument("--version", action="version", version=f"pdfdown {__version__}")
|
|
34
|
+
return p
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def expand(paths: list[Path]) -> list[Path]:
|
|
38
|
+
out: list[Path] = []
|
|
39
|
+
for path in paths:
|
|
40
|
+
path = path.expanduser()
|
|
41
|
+
if path.is_dir():
|
|
42
|
+
out += sorted(p for p in path.rglob("*") if p.suffix.lower() == ".pdf")
|
|
43
|
+
else:
|
|
44
|
+
out.append(path)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str] | None = None) -> int:
|
|
49
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
50
|
+
if argv[:1] == ["check"]:
|
|
51
|
+
return _check(argv[1:])
|
|
52
|
+
args = _parser().parse_args(argv)
|
|
53
|
+
from pdfdown.engine import EngineOptions
|
|
54
|
+
|
|
55
|
+
options = EngineOptions(tables=not args.no_tables, ocr=args.ocr, device=args.device)
|
|
56
|
+
pdfs = expand(args.pdfs)
|
|
57
|
+
missing = [p for p in pdfs if not p.is_file()]
|
|
58
|
+
if missing:
|
|
59
|
+
print(f"pdfdown: no such file: {missing[0]}", file=sys.stderr)
|
|
60
|
+
return 2
|
|
61
|
+
if args.batch or not sys.stdout.isatty():
|
|
62
|
+
if not pdfs:
|
|
63
|
+
print("pdfdown: give at least one PDF with --batch", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
return _batch(pdfs, args.out, not args.no_latex, options)
|
|
66
|
+
from pdfdown.tui.app import PdfdownApp
|
|
67
|
+
|
|
68
|
+
PdfdownApp(pdfs, args.out, latex=not args.no_latex, options=options).run()
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _batch(pdfs: list[Path], out: Path, latex: bool, options) -> int:
|
|
73
|
+
from pdfdown.pipeline import run
|
|
74
|
+
|
|
75
|
+
model = None
|
|
76
|
+
failed = 0
|
|
77
|
+
for n, pdf in enumerate(pdfs, start=1):
|
|
78
|
+
start = time.time()
|
|
79
|
+
print(f"[{n}/{len(pdfs)}] {pdf.name}", flush=True)
|
|
80
|
+
try:
|
|
81
|
+
if latex and model is None:
|
|
82
|
+
model = _lazy_model()
|
|
83
|
+
result = run(pdf, out, latex=latex, options=options, equation_model=model,
|
|
84
|
+
progress=lambda message: print(f" {message}", flush=True))
|
|
85
|
+
except Exception as error: # one bad PDF should not stop the batch
|
|
86
|
+
failed += 1
|
|
87
|
+
print(f" failed: {error}", file=sys.stderr)
|
|
88
|
+
continue
|
|
89
|
+
for note in result.doc.notes:
|
|
90
|
+
print(f" - {note}")
|
|
91
|
+
print(f" done in {time.time() - start:.0f} s: {result.markdown}")
|
|
92
|
+
return 1 if failed else 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _lazy_model():
|
|
96
|
+
"""Load pix2tex only when the first equation needs it."""
|
|
97
|
+
from pdfdown.equations import Pix2Tex
|
|
98
|
+
|
|
99
|
+
class Lazy:
|
|
100
|
+
name = "pix2tex"
|
|
101
|
+
_model = None
|
|
102
|
+
|
|
103
|
+
def __call__(self, image):
|
|
104
|
+
if self._model is None:
|
|
105
|
+
self._model = Pix2Tex()
|
|
106
|
+
return self._model(image)
|
|
107
|
+
|
|
108
|
+
return Lazy()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check(argv: list[str]) -> int:
|
|
112
|
+
from pdfdown.quality import scan
|
|
113
|
+
|
|
114
|
+
if not argv:
|
|
115
|
+
print("usage: pdfdown check FILE.md ...", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
bad = 0
|
|
118
|
+
for name in argv:
|
|
119
|
+
findings = scan(Path(name))
|
|
120
|
+
print(f"{name}: {'no problems found' if not findings else ''}")
|
|
121
|
+
for finding in findings:
|
|
122
|
+
print(f" - {finding}")
|
|
123
|
+
bad += bool(findings)
|
|
124
|
+
return 1 if bad else 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
sys.exit(main())
|