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 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()