agentpdf 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.
- agentpdf/__init__.py +13 -0
- agentpdf/chars.py +265 -0
- agentpdf/cli.py +98 -0
- agentpdf/convert.py +205 -0
- agentpdf/document.py +178 -0
- agentpdf/furniture.py +98 -0
- agentpdf/headings.py +163 -0
- agentpdf/model.py +103 -0
- agentpdf/paragraphs.py +132 -0
- agentpdf/regions.py +203 -0
- agentpdf/search.py +226 -0
- agentpdf/sections.py +119 -0
- agentpdf/util.py +27 -0
- agentpdf/writers.py +289 -0
- agentpdf-0.1.0.dist-info/METADATA +129 -0
- agentpdf-0.1.0.dist-info/RECORD +19 -0
- agentpdf-0.1.0.dist-info/WHEEL +4 -0
- agentpdf-0.1.0.dist-info/entry_points.txt +3 -0
- agentpdf-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpdf/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Convert large PDF documents into small, searchable files for AI coding agents."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("agentpdf")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from .convert import Options, convert
|
|
11
|
+
from .search import Hit, build_index, search
|
|
12
|
+
|
|
13
|
+
__all__ = ["Hit", "Options", "__version__", "build_index", "convert", "search"]
|
agentpdf/chars.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Characters from PDFium, grouped into lines, words and layout text.
|
|
2
|
+
|
|
3
|
+
PDFium follows the content stream order (usually the intended reading order,
|
|
4
|
+
including multi-column layouts), reconstructs word spacing and decodes glyphs
|
|
5
|
+
reliably.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ctypes
|
|
9
|
+
import itertools
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
import pypdfium2 as pdfium
|
|
15
|
+
import pypdfium2.raw as pdfium_c
|
|
16
|
+
|
|
17
|
+
from .model import BBox, Line, bbox_union
|
|
18
|
+
|
|
19
|
+
# Characters PDFium uses to mark a hyphen at a line break.
|
|
20
|
+
_SOFT_HYPHENS = "\ufffe\xad\u0002"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class Char:
|
|
25
|
+
text: str
|
|
26
|
+
index: int # position in PDFium's text stream
|
|
27
|
+
bbox: BBox | None # None for whitespace and line breaks
|
|
28
|
+
size: float = 0.0
|
|
29
|
+
bold: bool = False
|
|
30
|
+
upright: bool = True
|
|
31
|
+
soft_hyphen: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Word = list[Char]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _display_transform(page: pdfium.PdfPage) -> Callable[[float, float, float, float], BBox]:
|
|
38
|
+
"""Map PDF-space boxes (left, bottom, right, top) to displayed page
|
|
39
|
+
coordinates (top-left origin, after /Rotate)."""
|
|
40
|
+
mx0, my0, mx1, my1 = page.get_mediabox()
|
|
41
|
+
width, height = mx1 - mx0, my1 - my0
|
|
42
|
+
rotation = page.get_rotation() % 360
|
|
43
|
+
|
|
44
|
+
def point(x: float, y: float) -> tuple[float, float]:
|
|
45
|
+
x, y = x - mx0, y - my0
|
|
46
|
+
if rotation == 90:
|
|
47
|
+
return y, x
|
|
48
|
+
if rotation == 180:
|
|
49
|
+
return width - x, y
|
|
50
|
+
if rotation == 270:
|
|
51
|
+
return height - y, width - x
|
|
52
|
+
return x, height - y
|
|
53
|
+
|
|
54
|
+
def transform(left: float, bottom: float, right: float, top: float) -> BBox:
|
|
55
|
+
ax, ay = point(left, bottom)
|
|
56
|
+
bx, by = point(right, top)
|
|
57
|
+
return (min(ax, bx), min(ay, by), max(ax, bx), max(ay, by))
|
|
58
|
+
|
|
59
|
+
return transform
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_chars(page: pdfium.PdfPage) -> list[Char]:
|
|
63
|
+
"""Read all characters of a page in stream order."""
|
|
64
|
+
textpage = page.get_textpage()
|
|
65
|
+
try:
|
|
66
|
+
return _read_chars(page, textpage)
|
|
67
|
+
finally:
|
|
68
|
+
textpage.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _read_chars(page: pdfium.PdfPage, textpage: pdfium.PdfTextPage) -> list[Char]:
|
|
72
|
+
to_display = _display_transform(page)
|
|
73
|
+
rotation = math.radians(page.get_rotation())
|
|
74
|
+
raw = textpage.raw
|
|
75
|
+
font_buf = ctypes.create_string_buffer(256)
|
|
76
|
+
font_flags = ctypes.c_int()
|
|
77
|
+
matrix = pdfium_c.FS_MATRIX()
|
|
78
|
+
chars: list[Char] = []
|
|
79
|
+
last_style = None
|
|
80
|
+
font = ""
|
|
81
|
+
# Characters are read by index (not by slicing get_text_range(), whose
|
|
82
|
+
# string offsets can drift from character indices).
|
|
83
|
+
for i in range(textpage.count_chars()):
|
|
84
|
+
code = pdfium_c.FPDFText_GetUnicode(raw, i)
|
|
85
|
+
ch = chr(code) if 0 < code < 0x110000 and not 0xD800 <= code < 0xE000 else "�"
|
|
86
|
+
if ch == "\r":
|
|
87
|
+
continue
|
|
88
|
+
if ch in _SOFT_HYPHENS:
|
|
89
|
+
chars.append(Char("-", i, None, soft_hyphen=True))
|
|
90
|
+
continue
|
|
91
|
+
if ch.isspace() or pdfium_c.FPDFText_IsGenerated(raw, i) == 1:
|
|
92
|
+
chars.append(Char("\n" if ch == "\n" else " ", i, None))
|
|
93
|
+
continue
|
|
94
|
+
# The nominal font size is often 1 with the actual size applied via
|
|
95
|
+
# the text matrix; the effective size accounts for both.
|
|
96
|
+
pdfium_c.FPDFText_GetMatrix(raw, i, ctypes.byref(matrix))
|
|
97
|
+
size = round(pdfium_c.FPDFText_GetFontSize(raw, i) * (math.hypot(matrix.c, matrix.d) or 1.0), 2)
|
|
98
|
+
weight = pdfium_c.FPDFText_GetFontWeight(raw, i)
|
|
99
|
+
if (size, weight) != last_style:
|
|
100
|
+
pdfium_c.FPDFText_GetFontInfo(raw, i, font_buf, len(font_buf), ctypes.byref(font_flags))
|
|
101
|
+
font = font_buf.value.decode("utf-8", "replace")
|
|
102
|
+
last_style = (size, weight)
|
|
103
|
+
# Upright as displayed, i.e. taking the page's /Rotate into account.
|
|
104
|
+
angle = pdfium_c.FPDFText_GetCharAngle(raw, i)
|
|
105
|
+
displayed = (angle + rotation) % (2 * math.pi)
|
|
106
|
+
chars.append(
|
|
107
|
+
Char(
|
|
108
|
+
text=ch,
|
|
109
|
+
index=i,
|
|
110
|
+
bbox=to_display(*textpage.get_charbox(i, loose=True)),
|
|
111
|
+
size=size,
|
|
112
|
+
bold=weight >= 600 or "bold" in font.lower(),
|
|
113
|
+
upright=angle < 0 or min(displayed, 2 * math.pi - displayed) < 0.05,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return chars
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def lines_from_chars(chars: list[Char]) -> list[Line]:
|
|
120
|
+
"""Group characters into lines at PDFium's line breaks, and where the
|
|
121
|
+
geometry shows a break PDFium did not report (e.g. on rotated pages)."""
|
|
122
|
+
lines: list[Line] = []
|
|
123
|
+
current: list[Char] = []
|
|
124
|
+
|
|
125
|
+
def flush() -> None:
|
|
126
|
+
visible = [c for c in current if c.bbox is not None]
|
|
127
|
+
text = " ".join("".join(c.text for c in current).split())
|
|
128
|
+
if visible and text:
|
|
129
|
+
lines.append(
|
|
130
|
+
Line(
|
|
131
|
+
text=text,
|
|
132
|
+
bbox=_union(visible),
|
|
133
|
+
size=max(c.size for c in visible),
|
|
134
|
+
bold=sum(c.bold for c in visible) > len(visible) / 2,
|
|
135
|
+
order=visible[0].index,
|
|
136
|
+
soft_hyphen=current[-1].soft_hyphen,
|
|
137
|
+
tabbed=any(
|
|
138
|
+
b.bbox[0] - a.bbox[2] > 1.5 * max(a.size, b.size)
|
|
139
|
+
for a, b in itertools.pairwise(visible)
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
current.clear()
|
|
144
|
+
|
|
145
|
+
prev: Char | None = None
|
|
146
|
+
for c in chars:
|
|
147
|
+
if c.text == "\n":
|
|
148
|
+
flush()
|
|
149
|
+
prev = None
|
|
150
|
+
continue
|
|
151
|
+
if c.bbox is not None:
|
|
152
|
+
if prev is not None and current and _starts_new_line(prev, c):
|
|
153
|
+
flush()
|
|
154
|
+
prev = c
|
|
155
|
+
current.append(c)
|
|
156
|
+
if c.soft_hyphen:
|
|
157
|
+
flush()
|
|
158
|
+
flush()
|
|
159
|
+
return lines
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _starts_new_line(prev: Char, c: Char) -> bool:
|
|
163
|
+
if prev.upright != c.upright:
|
|
164
|
+
return True
|
|
165
|
+
if not c.upright:
|
|
166
|
+
return False
|
|
167
|
+
no_vertical_overlap = c.bbox[1] >= prev.bbox[3] or c.bbox[3] <= prev.bbox[1]
|
|
168
|
+
moved_back = c.bbox[0] < prev.bbox[0] - max(prev.size, c.size)
|
|
169
|
+
return no_vertical_overlap or moved_back
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def words(chars: list[Char]) -> list[Word]:
|
|
173
|
+
"""Split visible characters into words at whitespace."""
|
|
174
|
+
return [
|
|
175
|
+
list(group) for visible, group in itertools.groupby(chars, lambda c: c.bbox is not None) if visible
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def word_bbox(word: Word) -> BBox:
|
|
180
|
+
return _union(word)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _union(chars: list[Char]) -> BBox:
|
|
184
|
+
bbox = chars[0].bbox
|
|
185
|
+
for c in chars[1:]:
|
|
186
|
+
bbox = bbox_union(bbox, c.bbox)
|
|
187
|
+
return bbox
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def layout_text(chars: list[Char], bbox: BBox) -> str:
|
|
191
|
+
"""Render characters as plain text that preserves horizontal positions.
|
|
192
|
+
|
|
193
|
+
Upright characters are placed on a character grid; rows are formed from
|
|
194
|
+
vertically overlapping characters so sub- and superscripts stay on their
|
|
195
|
+
base line. Rotated text (axis labels etc.) is listed separately.
|
|
196
|
+
"""
|
|
197
|
+
upright = [c for c in chars if c.bbox is not None and c.upright]
|
|
198
|
+
rotated = [c for c in chars if c.bbox is not None and not c.upright]
|
|
199
|
+
out = _grid_rows(upright, bbox) if upright else []
|
|
200
|
+
if rotated:
|
|
201
|
+
out += ["", "[rotated text] " + " | ".join(_runs(rotated))]
|
|
202
|
+
indent = min((len(s) - len(s.lstrip()) for s in out if s.strip()), default=0)
|
|
203
|
+
return "\n".join(s[indent:] for s in out).strip("\n")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _grid_rows(chars: list[Char], bbox: BBox) -> list[str]:
|
|
207
|
+
sizes = sorted(c.size for c in chars)
|
|
208
|
+
body = sizes[len(sizes) // 2] or 8.0
|
|
209
|
+
unit = body * 0.5 # approximate width of one monospace column
|
|
210
|
+
|
|
211
|
+
# Tallest glyphs define the rows; smaller ones (subscripts) join a row
|
|
212
|
+
# they overlap substantially.
|
|
213
|
+
rows: list[tuple[float, float, list[Char]]] = []
|
|
214
|
+
for c in sorted(chars, key=lambda c: -c.size):
|
|
215
|
+
top, bottom = c.bbox[1], c.bbox[3]
|
|
216
|
+
for row_top, row_bottom, members in rows:
|
|
217
|
+
overlap = min(bottom, row_bottom) - max(top, row_top)
|
|
218
|
+
if overlap > 0.4 * min(bottom - top, row_bottom - row_top):
|
|
219
|
+
members.append(c)
|
|
220
|
+
break
|
|
221
|
+
else:
|
|
222
|
+
rows.append((top, bottom, [c]))
|
|
223
|
+
rows.sort(key=lambda r: r[0])
|
|
224
|
+
|
|
225
|
+
out: list[str] = []
|
|
226
|
+
prev_bottom = None
|
|
227
|
+
for top, bottom, members in rows:
|
|
228
|
+
if prev_bottom is not None and top - prev_bottom > body * 1.2:
|
|
229
|
+
out.append("")
|
|
230
|
+
prev_bottom = bottom
|
|
231
|
+
members.sort(key=lambda c: c.bbox[0])
|
|
232
|
+
line: list[str] = []
|
|
233
|
+
prev: Char | None = None
|
|
234
|
+
for c in members:
|
|
235
|
+
if prev is not None and c.index == prev.index + 1 and c.bbox[0] - prev.bbox[2] < c.size * 0.2:
|
|
236
|
+
# Continuation of a word: glyph widths vary, so append
|
|
237
|
+
# directly instead of snapping to the grid.
|
|
238
|
+
line.append(c.text)
|
|
239
|
+
else:
|
|
240
|
+
col = max(round((c.bbox[0] - bbox[0]) / unit), len(line) + (1 if line else 0))
|
|
241
|
+
line.extend(" " * (col - len(line)))
|
|
242
|
+
line.append(c.text)
|
|
243
|
+
prev = c
|
|
244
|
+
out.append("".join(line).rstrip())
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _runs(chars: list[Char]) -> list[str]:
|
|
249
|
+
"""Join characters that are consecutive in the stream into labels."""
|
|
250
|
+
labels: list[str] = []
|
|
251
|
+
current: list[str] = []
|
|
252
|
+
last_index = 0
|
|
253
|
+
for c in sorted(chars, key=lambda c: c.index):
|
|
254
|
+
if current:
|
|
255
|
+
step = c.index - last_index
|
|
256
|
+
if step > 2:
|
|
257
|
+
labels.append("".join(current).strip())
|
|
258
|
+
current = []
|
|
259
|
+
elif step == 2: # separated by a space
|
|
260
|
+
current.append(" ")
|
|
261
|
+
current.append(c.text)
|
|
262
|
+
last_index = c.index
|
|
263
|
+
if current:
|
|
264
|
+
labels.append("".join(current).strip())
|
|
265
|
+
return labels
|
agentpdf/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .convert import Options, convert
|
|
10
|
+
from .search import INDEX_FILE, build_index, format_hits, search
|
|
11
|
+
from .writers import write_agents_md
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main(argv: list[str] | None = None) -> int:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="agentpdf",
|
|
17
|
+
description="Convert PDF documents into small, searchable files for AI coding agents.",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("--version", action="version", version=f"agentpdf {__version__}")
|
|
20
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
conv = sub.add_parser("convert", help="convert one or more PDF files")
|
|
23
|
+
conv.add_argument("pdf", nargs="+", help="PDF files to convert")
|
|
24
|
+
conv.add_argument("-o", "--output", required=True, help="output directory (one subdirectory per PDF)")
|
|
25
|
+
conv.add_argument("--dpi", type=int, default=150, help="resolution of rendered images (default: 150)")
|
|
26
|
+
conv.add_argument(
|
|
27
|
+
"--max-tokens",
|
|
28
|
+
type=int,
|
|
29
|
+
default=6000,
|
|
30
|
+
help="approximate size limit of a section file in tokens; larger sections are split at "
|
|
31
|
+
"subheadings, or into parts (default: 6000)",
|
|
32
|
+
)
|
|
33
|
+
conv.add_argument("--page-images", action="store_true", help="also render every full page as PNG")
|
|
34
|
+
conv.add_argument("--split-pdf", action="store_true", help="also write one PDF per section")
|
|
35
|
+
conv.add_argument("--no-index", action="store_true", help="do not build the search index")
|
|
36
|
+
conv.add_argument(
|
|
37
|
+
"--force", action="store_true", help="write into an existing non-empty output directory"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
find = sub.add_parser("search", help="search converted documents")
|
|
41
|
+
find.add_argument("query", help='search terms; "quoted phrase", prefix*')
|
|
42
|
+
find.add_argument(
|
|
43
|
+
"-d", "--dir", default=".", help="output directory of `convert` (default: current directory)"
|
|
44
|
+
)
|
|
45
|
+
find.add_argument("-n", "--limit", type=int, default=10, help="maximum number of results (default: 10)")
|
|
46
|
+
find.add_argument("--doc", help="restrict to one document (its directory name)")
|
|
47
|
+
find.add_argument("--any", action="store_true", help="match any term instead of all terms")
|
|
48
|
+
|
|
49
|
+
args = parser.parse_args(argv)
|
|
50
|
+
if args.command == "search":
|
|
51
|
+
return _search(args)
|
|
52
|
+
return _convert(args)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _convert(args: argparse.Namespace) -> int:
|
|
56
|
+
options = Options(
|
|
57
|
+
dpi=args.dpi,
|
|
58
|
+
max_tokens=args.max_tokens,
|
|
59
|
+
page_images=args.page_images,
|
|
60
|
+
split_pdf=args.split_pdf,
|
|
61
|
+
index=False, # built once below
|
|
62
|
+
force=args.force,
|
|
63
|
+
)
|
|
64
|
+
status = 0
|
|
65
|
+
converted = 0
|
|
66
|
+
for path in args.pdf:
|
|
67
|
+
start = time.monotonic()
|
|
68
|
+
try:
|
|
69
|
+
out_dir = convert(path, args.output, options)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
print(f"error: {path}: {exc}", file=sys.stderr)
|
|
72
|
+
status = 1
|
|
73
|
+
continue
|
|
74
|
+
converted += 1
|
|
75
|
+
print(f"{path} -> {out_dir} ({time.monotonic() - start:.1f}s)")
|
|
76
|
+
if converted and not args.no_index:
|
|
77
|
+
build_index(Path(args.output))
|
|
78
|
+
write_agents_md(Path(args.output), has_search=(Path(args.output) / INDEX_FILE).is_file())
|
|
79
|
+
return status
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _search(args: argparse.Namespace) -> int:
|
|
83
|
+
try:
|
|
84
|
+
hits, relaxed = search(Path(args.dir), args.query, args.limit, args.doc, args.any)
|
|
85
|
+
except FileNotFoundError as exc:
|
|
86
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
if not hits:
|
|
89
|
+
print("no matches", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
if relaxed:
|
|
92
|
+
print("(no entry contains all terms; showing entries matching any term)")
|
|
93
|
+
print(format_hits(hits))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
agentpdf/convert.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Convert a PDF into a directory of Markdown sections, images and indexes."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import pypdf
|
|
11
|
+
|
|
12
|
+
from .document import Document
|
|
13
|
+
from .furniture import drop_printed_toc, remove_furniture
|
|
14
|
+
from .headings import detect_headings, mark_outline_headings, repair_outline_levels
|
|
15
|
+
from .paragraphs import Block, page_blocks, resolve_hyphens
|
|
16
|
+
from .search import INDEX_FILE, build_index
|
|
17
|
+
from .sections import Section, build_sections
|
|
18
|
+
from .util import page_range, slugify
|
|
19
|
+
from .writers import (
|
|
20
|
+
MANIFEST,
|
|
21
|
+
MARKER,
|
|
22
|
+
is_agentpdf_agents_md,
|
|
23
|
+
is_agentpdf_dir,
|
|
24
|
+
manifest,
|
|
25
|
+
pages_markdown,
|
|
26
|
+
section_markdown,
|
|
27
|
+
write_agents_md,
|
|
28
|
+
write_index,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Options:
|
|
34
|
+
dpi: int = 150 # resolution of rendered images
|
|
35
|
+
max_tokens: int = 6000 # approximate size limit of a section file
|
|
36
|
+
page_images: bool = False # also render every full page
|
|
37
|
+
split_pdf: bool = False # also write one PDF per section
|
|
38
|
+
index: bool = True # rebuild the search index after converting
|
|
39
|
+
force: bool = False # allow existing non-empty output root
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prepare_out_root(out_root: Path, force: bool) -> None:
|
|
43
|
+
if out_root.is_symlink() or (out_root.exists() and not out_root.is_dir()):
|
|
44
|
+
raise FileExistsError(f"{out_root} exists and is not a directory")
|
|
45
|
+
if out_root.exists():
|
|
46
|
+
has_marker = (out_root / MARKER).is_file() and not (out_root / MARKER).is_symlink()
|
|
47
|
+
if not has_marker and any(out_root.iterdir()) and not force:
|
|
48
|
+
raise FileExistsError(
|
|
49
|
+
f"{out_root} already exists and was not created by agentpdf (use --force to use it anyway)"
|
|
50
|
+
)
|
|
51
|
+
agents_path = out_root / "AGENTS.md"
|
|
52
|
+
if (agents_path.exists() or agents_path.is_symlink()) and not is_agentpdf_agents_md(agents_path):
|
|
53
|
+
raise FileExistsError(f"{agents_path} exists and was not created by agentpdf")
|
|
54
|
+
index_path = out_root / INDEX_FILE
|
|
55
|
+
if index_path.is_symlink() or index_path.is_dir() or (not has_marker and index_path.exists()):
|
|
56
|
+
raise FileExistsError(f"{index_path} exists and was not created by agentpdf")
|
|
57
|
+
else:
|
|
58
|
+
out_root.mkdir(parents=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _swap_dir(work_dir: Path, final_dir: Path) -> None:
|
|
62
|
+
if not final_dir.exists():
|
|
63
|
+
work_dir.rename(final_dir)
|
|
64
|
+
return
|
|
65
|
+
backup = tempfile.mkdtemp(prefix=f".{final_dir.name}.", suffix=".backup", dir=final_dir.parent)
|
|
66
|
+
backup_dir = Path(backup)
|
|
67
|
+
backup_dir.rmdir()
|
|
68
|
+
final_dir.rename(backup_dir)
|
|
69
|
+
try:
|
|
70
|
+
work_dir.rename(final_dir)
|
|
71
|
+
except BaseException:
|
|
72
|
+
backup_dir.rename(final_dir)
|
|
73
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
74
|
+
raise
|
|
75
|
+
shutil.rmtree(backup_dir, ignore_errors=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _manifest_sha256(doc_dir: Path) -> str | None:
|
|
79
|
+
try:
|
|
80
|
+
info = json.loads((doc_dir / MANIFEST).read_text(encoding="utf-8"))
|
|
81
|
+
return info["source"]["sha256"]
|
|
82
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _target_dir(out_root: Path, slug: str, sha256: str) -> Path:
|
|
87
|
+
n = 1
|
|
88
|
+
while True:
|
|
89
|
+
name = slug if n == 1 else f"{slug}-{n}"
|
|
90
|
+
candidate = out_root / name
|
|
91
|
+
if not candidate.exists() and not candidate.is_symlink():
|
|
92
|
+
return candidate
|
|
93
|
+
if not is_agentpdf_dir(candidate):
|
|
94
|
+
raise FileExistsError(f"{candidate} exists and was not created by agentpdf")
|
|
95
|
+
if _manifest_sha256(candidate) == sha256:
|
|
96
|
+
return candidate
|
|
97
|
+
n += 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def convert(pdf_path: str | Path, out_root: str | Path, options: Options | None = None) -> Path:
|
|
101
|
+
"""Convert one PDF into ``out_root/<name>/`` and update the shared
|
|
102
|
+
AGENTS.md and search index in ``out_root``. Returns the document's
|
|
103
|
+
output directory.
|
|
104
|
+
|
|
105
|
+
The output is built in a temporary directory and swapped in when
|
|
106
|
+
complete, so an interrupted conversion leaves the previous result intact.
|
|
107
|
+
"""
|
|
108
|
+
options = options or Options()
|
|
109
|
+
source, out_root = Path(pdf_path), Path(out_root)
|
|
110
|
+
sha256 = _sha256(source)
|
|
111
|
+
_prepare_out_root(out_root, options.force)
|
|
112
|
+
final_dir = _target_dir(out_root, slugify(source.stem), sha256)
|
|
113
|
+
|
|
114
|
+
work_dir = Path(tempfile.mkdtemp(prefix=f".{final_dir.name}.", suffix=".partial", dir=out_root))
|
|
115
|
+
try:
|
|
116
|
+
_convert(source, sha256, work_dir, options)
|
|
117
|
+
except BaseException:
|
|
118
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
119
|
+
raise
|
|
120
|
+
_swap_dir(work_dir, final_dir)
|
|
121
|
+
marker = out_root / MARKER
|
|
122
|
+
if not marker.exists():
|
|
123
|
+
marker.write_text("agentpdf\n", encoding="utf-8")
|
|
124
|
+
|
|
125
|
+
if options.index:
|
|
126
|
+
build_index(out_root)
|
|
127
|
+
write_agents_md(out_root, has_search=(out_root / INDEX_FILE).is_file())
|
|
128
|
+
return final_dir
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _convert(source: Path, sha256: str, out_dir: Path, options: Options) -> None:
|
|
132
|
+
(out_dir / "sections").mkdir()
|
|
133
|
+
(out_dir / "images").mkdir()
|
|
134
|
+
|
|
135
|
+
with Document(str(source)) as doc:
|
|
136
|
+
pages = list(doc.pages())
|
|
137
|
+
outline = doc.outline()
|
|
138
|
+
remove_furniture(pages)
|
|
139
|
+
toc_pages = drop_printed_toc(pages, outline)
|
|
140
|
+
|
|
141
|
+
# Tables and figures become images; pages without text are rendered whole.
|
|
142
|
+
for page in pages:
|
|
143
|
+
for region in page.regions:
|
|
144
|
+
region.image = f"images/p{page.number:04d}-{region.id}.png"
|
|
145
|
+
doc.render(page.number - 1, str(out_dir / region.image), options.dpi, region.bbox)
|
|
146
|
+
if options.page_images or not page.has_text_layer:
|
|
147
|
+
page.image = f"images/p{page.number:04d}.png"
|
|
148
|
+
doc.render(page.number - 1, str(out_dir / page.image), options.dpi)
|
|
149
|
+
|
|
150
|
+
blocks = [b for page in pages for b in page_blocks(page)]
|
|
151
|
+
blocks += [
|
|
152
|
+
Block(p.number, -1, f"*(This page has no text layer. See [page image](../{p.image}).)*")
|
|
153
|
+
for p in pages
|
|
154
|
+
if not p.has_text_layer
|
|
155
|
+
]
|
|
156
|
+
if toc_pages:
|
|
157
|
+
pages_text = page_range((toc_pages[0], toc_pages[-1]))
|
|
158
|
+
note = f"*(Printed table of contents omitted ({pages_text}); see [INDEX.md](../INDEX.md).)*"
|
|
159
|
+
blocks.append(Block(toc_pages[0], -1, note))
|
|
160
|
+
blocks.sort(key=lambda b: (b.page, b.order))
|
|
161
|
+
resolve_hyphens(blocks)
|
|
162
|
+
|
|
163
|
+
if outline:
|
|
164
|
+
blocks = mark_outline_headings(blocks, repair_outline_levels(outline))
|
|
165
|
+
outline_source = "bookmarks"
|
|
166
|
+
else:
|
|
167
|
+
outline_source = "typography" if detect_headings(blocks) else "none"
|
|
168
|
+
sections = build_sections(blocks, options.max_tokens * 4)
|
|
169
|
+
|
|
170
|
+
for section in sections:
|
|
171
|
+
if section.has_file:
|
|
172
|
+
(out_dir / "sections" / section.filename).write_text(
|
|
173
|
+
section_markdown(section), encoding="utf-8"
|
|
174
|
+
)
|
|
175
|
+
if options.split_pdf:
|
|
176
|
+
_split_pdf(source, sections, out_dir / "pdf")
|
|
177
|
+
|
|
178
|
+
info = manifest(source, sha256, doc.metadata, pages, sections, outline_source)
|
|
179
|
+
(out_dir / MANIFEST).write_text(
|
|
180
|
+
json.dumps(info, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
|
181
|
+
)
|
|
182
|
+
write_index(out_dir, info, sections)
|
|
183
|
+
(out_dir / "PAGES.md").write_text(pages_markdown(pages, sections), encoding="utf-8")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _split_pdf(source: Path, sections: list[Section], out_dir: Path) -> None:
|
|
187
|
+
out_dir.mkdir()
|
|
188
|
+
reader = pypdf.PdfReader(source)
|
|
189
|
+
for section in sections:
|
|
190
|
+
if not section.has_file or section.pages is None:
|
|
191
|
+
continue
|
|
192
|
+
first, last = section.pages
|
|
193
|
+
writer = pypdf.PdfWriter()
|
|
194
|
+
for number in range(first, last + 1):
|
|
195
|
+
writer.add_page(reader.pages[number - 1])
|
|
196
|
+
with open(out_dir / section.filename.replace(".md", ".pdf"), "wb") as f:
|
|
197
|
+
writer.write(f)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _sha256(path: Path) -> str:
|
|
201
|
+
h = hashlib.sha256()
|
|
202
|
+
with open(path, "rb") as f:
|
|
203
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
204
|
+
h.update(chunk)
|
|
205
|
+
return h.hexdigest()
|