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 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
@@ -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.")
@@ -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())