pdf2notes 1.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sandeep Singh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdf2notes
3
+ Version: 1.0.0
4
+ Summary: Turn a PDF page range into AI-generated study notes (Markdown) for Obsidian/Notion
5
+ Author: Sandeep Singh
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/SandeepSinghSethi/pdf2notes
8
+ Keywords: pdf,notes,obsidian,notion,study,ai,llm
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: pdfplumber>=0.11.0
19
+ Requires-Dist: openai>=1.0.0
20
+ Dynamic: license-file
21
+
22
+ # pdf2notes
23
+
24
+ Turn a page range of a PDF book into descriptive, review-ready study notes
25
+ (Markdown) — ready to drop into **Obsidian** or import into **Notion**.
26
+
27
+ Works with any **OpenAI-compatible** chat completion API: NVIDIA NIM
28
+ (`integrate.api.nvidia.com`), OpenAI, Groq, Together AI, OpenRouter, or a
29
+ local server (Ollama, vLLM, LM Studio). You bring your own API key.
30
+
31
+ ## Setup
32
+
33
+ ```bash
34
+ pip install -r requirements.txt
35
+ ```
36
+
37
+ ## Get an API key (NVIDIA NIM example — free tier available)
38
+
39
+ 1. Go to https://build.nvidia.com
40
+ 2. Sign in, pick a model (e.g. `meta/llama-3.1-70b-instruct`), click "Get API Key"
41
+ 3. Export it:
42
+ ```bash
43
+ export NVIDIA_API_KEY="nvapi-xxxxxxxxxxxxxxxx"
44
+ ```
45
+
46
+ Any other OpenAI-compatible provider works the same way — just pass
47
+ `--base-url` and `--api-key-env` for that provider.
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ python pdf2notes.py --pdf book.pdf --pages 120-180 --api-key-env NVIDIA_API_KEY
53
+ ```
54
+
55
+ This writes `book_notes_120-180.md` in the current folder. Requests run
56
+ concurrently and are throttled to a requests-per-minute cap, so a 100-page
57
+ range finishes in a couple of minutes instead of an hour.
58
+
59
+ If the run is interrupted, hits a persistent error, or you just Ctrl-C it,
60
+ **rerun the exact same command** — chunks already saved in the output file
61
+ are detected and skipped, so you only pay for/wait on what's missing.
62
+
63
+ ### Common options
64
+
65
+ | Flag | Meaning | Default |
66
+ |---|---|---|
67
+ | `--pdf` | Path to the source PDF | required |
68
+ | `--pages` | Page range, e.g. `120-180` (1-indexed, inclusive) | required |
69
+ | `--output` | Output `.md` path | `<pdf-name>_notes_<range>.md` |
70
+ | `--style` | `descriptive`, `cornell`, `qa`, or `outline` | `descriptive` |
71
+ | `--chunk-chars` | Max source characters sent per API call | `6000` |
72
+ | `--concurrency` | Max simultaneous API requests | `5` |
73
+ | `--rpm` | Max API requests per minute (across all workers) | `40` |
74
+ | `--model` | Model name as your provider expects it | `nvidia/llama-3.3-nemotron-super-49b-v1.5` |
75
+ | `--base-url` | OpenAI-compatible API base URL | NVIDIA NIM endpoint |
76
+ | `--api-key-env` | Env var holding your key | `NVIDIA_API_KEY` |
77
+
78
+ **Tune `--rpm` to your actual plan.** NVIDIA's free tier, OpenAI's free/low
79
+ tiers, etc. all cap requests per minute — check your provider's dashboard
80
+ and set `--rpm` a bit under that. Setting it too high just means the tool
81
+ eats a 429 and backs off; setting `--concurrency` too high does the same
82
+ without helping speed once you're rpm-bound.
83
+
84
+ **Model IDs get retired.** NVIDIA (and other providers) periodically pull
85
+ old model IDs from the catalog — you'll get an HTTP 404/410 if that
86
+ happens. The tool detects this as non-retryable and fails immediately with
87
+ the error instead of burning your rate-limit budget retrying a request
88
+ that will never succeed. Check https://build.nvidia.com/models for current
89
+ IDs and pass the right one via `--model`.
90
+
91
+ ### Examples
92
+
93
+ Using OpenAI instead of NVIDIA:
94
+ ```bash
95
+ export OPENAI_API_KEY="sk-..."
96
+ python pdf2notes.py --pdf book.pdf --pages 1-50 \
97
+ --base-url https://api.openai.com/v1 \
98
+ --model gpt-4o-mini \
99
+ --api-key-env OPENAI_API_KEY
100
+ ```
101
+
102
+ Q&A flashcard-style notes instead of descriptive prose:
103
+ ```bash
104
+ python pdf2notes.py --pdf book.pdf --pages 200-260 --style qa
105
+ ```
106
+
107
+ ## Notes on how it works
108
+
109
+ 1. Extracts text from the given page range with `pdfplumber`.
110
+ 2. Groups pages into chunks (default ~6000 chars each) so each API call
111
+ covers a coherent span without blowing past context limits.
112
+ 3. Sends each chunk to the model with a prompt tuned to produce **learnable
113
+ notes**, not a shortened summary — definitions, examples, and important
114
+ details are preserved and explained.
115
+ 4. Stitches everything into one Markdown file with YAML frontmatter
116
+ (title, source, page range, style, timestamp) so it's ready to file
117
+ straight into Obsidian, or import into Notion via *Import → Markdown*.
118
+
119
+ ## Limitations
120
+
121
+ - **Scanned/image-only PDFs** won't extract text — OCR the PDF first (e.g.
122
+ with `pytesseract` + `pdf2image`), then run this tool on the OCR'd version.
123
+ - Very dense chunking settings (`--chunk-chars` too high) can exceed your
124
+ model's context window — 6000–8000 is a safe default for most models.
125
+ - Quality depends on the model you point it at — bigger instruction-tuned
126
+ models produce noticeably better notes than small ones.
127
+ - **Memory**: page text is extracted one page at a time and each page's
128
+ internal pdfplumber cache (fonts, layout objects) is flushed immediately,
129
+ so memory stays roughly proportional to a chunk's text, not the whole
130
+ page range. For genuinely huge ranges (500+ pages in one run), consider
131
+ splitting into a couple of `--pages` calls instead of one giant one.
132
+ - **A crash/segfault message *after* "Done."**: if you see a SIGSEGV or
133
+ similar right as the process exits but *after* your notes file is
134
+ already complete and readable, it's happening during Python interpreter
135
+ teardown, not during note generation — a known quirk of some PDF C
136
+ extensions (e.g. pypdfium2, used internally by pdfplumber) not tearing
137
+ down cleanly at exit. It doesn't affect your output file. If you want to
138
+ suppress the noise, run with `python pdf2notes.py ... ; true` in a
139
+ script, or ignore the exit code.
@@ -0,0 +1,118 @@
1
+ # pdf2notes
2
+
3
+ Turn a page range of a PDF book into descriptive, review-ready study notes
4
+ (Markdown) — ready to drop into **Obsidian** or import into **Notion**.
5
+
6
+ Works with any **OpenAI-compatible** chat completion API: NVIDIA NIM
7
+ (`integrate.api.nvidia.com`), OpenAI, Groq, Together AI, OpenRouter, or a
8
+ local server (Ollama, vLLM, LM Studio). You bring your own API key.
9
+
10
+ ## Setup
11
+
12
+ ```bash
13
+ pip install -r requirements.txt
14
+ ```
15
+
16
+ ## Get an API key (NVIDIA NIM example — free tier available)
17
+
18
+ 1. Go to https://build.nvidia.com
19
+ 2. Sign in, pick a model (e.g. `meta/llama-3.1-70b-instruct`), click "Get API Key"
20
+ 3. Export it:
21
+ ```bash
22
+ export NVIDIA_API_KEY="nvapi-xxxxxxxxxxxxxxxx"
23
+ ```
24
+
25
+ Any other OpenAI-compatible provider works the same way — just pass
26
+ `--base-url` and `--api-key-env` for that provider.
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ python pdf2notes.py --pdf book.pdf --pages 120-180 --api-key-env NVIDIA_API_KEY
32
+ ```
33
+
34
+ This writes `book_notes_120-180.md` in the current folder. Requests run
35
+ concurrently and are throttled to a requests-per-minute cap, so a 100-page
36
+ range finishes in a couple of minutes instead of an hour.
37
+
38
+ If the run is interrupted, hits a persistent error, or you just Ctrl-C it,
39
+ **rerun the exact same command** — chunks already saved in the output file
40
+ are detected and skipped, so you only pay for/wait on what's missing.
41
+
42
+ ### Common options
43
+
44
+ | Flag | Meaning | Default |
45
+ |---|---|---|
46
+ | `--pdf` | Path to the source PDF | required |
47
+ | `--pages` | Page range, e.g. `120-180` (1-indexed, inclusive) | required |
48
+ | `--output` | Output `.md` path | `<pdf-name>_notes_<range>.md` |
49
+ | `--style` | `descriptive`, `cornell`, `qa`, or `outline` | `descriptive` |
50
+ | `--chunk-chars` | Max source characters sent per API call | `6000` |
51
+ | `--concurrency` | Max simultaneous API requests | `5` |
52
+ | `--rpm` | Max API requests per minute (across all workers) | `40` |
53
+ | `--model` | Model name as your provider expects it | `nvidia/llama-3.3-nemotron-super-49b-v1.5` |
54
+ | `--base-url` | OpenAI-compatible API base URL | NVIDIA NIM endpoint |
55
+ | `--api-key-env` | Env var holding your key | `NVIDIA_API_KEY` |
56
+
57
+ **Tune `--rpm` to your actual plan.** NVIDIA's free tier, OpenAI's free/low
58
+ tiers, etc. all cap requests per minute — check your provider's dashboard
59
+ and set `--rpm` a bit under that. Setting it too high just means the tool
60
+ eats a 429 and backs off; setting `--concurrency` too high does the same
61
+ without helping speed once you're rpm-bound.
62
+
63
+ **Model IDs get retired.** NVIDIA (and other providers) periodically pull
64
+ old model IDs from the catalog — you'll get an HTTP 404/410 if that
65
+ happens. The tool detects this as non-retryable and fails immediately with
66
+ the error instead of burning your rate-limit budget retrying a request
67
+ that will never succeed. Check https://build.nvidia.com/models for current
68
+ IDs and pass the right one via `--model`.
69
+
70
+ ### Examples
71
+
72
+ Using OpenAI instead of NVIDIA:
73
+ ```bash
74
+ export OPENAI_API_KEY="sk-..."
75
+ python pdf2notes.py --pdf book.pdf --pages 1-50 \
76
+ --base-url https://api.openai.com/v1 \
77
+ --model gpt-4o-mini \
78
+ --api-key-env OPENAI_API_KEY
79
+ ```
80
+
81
+ Q&A flashcard-style notes instead of descriptive prose:
82
+ ```bash
83
+ python pdf2notes.py --pdf book.pdf --pages 200-260 --style qa
84
+ ```
85
+
86
+ ## Notes on how it works
87
+
88
+ 1. Extracts text from the given page range with `pdfplumber`.
89
+ 2. Groups pages into chunks (default ~6000 chars each) so each API call
90
+ covers a coherent span without blowing past context limits.
91
+ 3. Sends each chunk to the model with a prompt tuned to produce **learnable
92
+ notes**, not a shortened summary — definitions, examples, and important
93
+ details are preserved and explained.
94
+ 4. Stitches everything into one Markdown file with YAML frontmatter
95
+ (title, source, page range, style, timestamp) so it's ready to file
96
+ straight into Obsidian, or import into Notion via *Import → Markdown*.
97
+
98
+ ## Limitations
99
+
100
+ - **Scanned/image-only PDFs** won't extract text — OCR the PDF first (e.g.
101
+ with `pytesseract` + `pdf2image`), then run this tool on the OCR'd version.
102
+ - Very dense chunking settings (`--chunk-chars` too high) can exceed your
103
+ model's context window — 6000–8000 is a safe default for most models.
104
+ - Quality depends on the model you point it at — bigger instruction-tuned
105
+ models produce noticeably better notes than small ones.
106
+ - **Memory**: page text is extracted one page at a time and each page's
107
+ internal pdfplumber cache (fonts, layout objects) is flushed immediately,
108
+ so memory stays roughly proportional to a chunk's text, not the whole
109
+ page range. For genuinely huge ranges (500+ pages in one run), consider
110
+ splitting into a couple of `--pages` calls instead of one giant one.
111
+ - **A crash/segfault message *after* "Done."**: if you see a SIGSEGV or
112
+ similar right as the process exits but *after* your notes file is
113
+ already complete and readable, it's happening during Python interpreter
114
+ teardown, not during note generation — a known quirk of some PDF C
115
+ extensions (e.g. pypdfium2, used internally by pdfplumber) not tearing
116
+ down cleanly at exit. It doesn't affect your output file. If you want to
117
+ suppress the noise, run with `python pdf2notes.py ... ; true` in a
118
+ script, or ignore the exit code.
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pdf2notes"
7
+ version = "1.0.0"
8
+ description = "Turn a PDF page range into AI-generated study notes (Markdown) for Obsidian/Notion"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Sandeep Singh" }]
13
+ keywords = ["pdf", "notes", "obsidian", "notion", "study", "ai", "llm"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Education",
20
+ "Topic :: Text Processing :: Markup :: Markdown",
21
+ ]
22
+ dependencies = [
23
+ "pdfplumber>=0.11.0",
24
+ "openai>=1.0.0",
25
+ ]
26
+
27
+ [project.scripts]
28
+ pdf2notes = "pdf2notes.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/SandeepSinghSethi/pdf2notes"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """pdf2notes — turn a PDF page range into AI-generated study notes."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ pdf2notes — Turn a page range of a PDF book into descriptive, review-ready
4
+ study notes (Markdown), ready to drop into Obsidian or Notion.
5
+
6
+ Works with ANY OpenAI-compatible chat completion API: NVIDIA NIM
7
+ (integrate.api.nvidia.com), OpenAI, Groq, Together AI, OpenRouter, a local
8
+ vLLM/Ollama server, etc. You bring your own API key and base URL.
9
+
10
+ Requests run concurrently (bounded by --concurrency) and are throttled to
11
+ stay under --rpm (requests/minute), so large page ranges finish in minutes
12
+ instead of hours without tripping your provider's rate limit.
13
+
14
+ USAGE
15
+ -----
16
+ python pdf2notes.py --pdf book.pdf --pages 120-180 \
17
+ --api-key-env NVIDIA_API_KEY \
18
+ --base-url https://integrate.api.nvidia.com/v1 \
19
+ --model nvidia/llama-3.3-nemotron-super-49b-v1.5 \
20
+ --style descriptive \
21
+ --concurrency 5 --rpm 40 \
22
+ --output notes.md
23
+
24
+ Set the API key as an environment variable first, e.g.:
25
+ export NVIDIA_API_KEY="nvapi-xxxxxxxx"
26
+
27
+ NOTE: NVIDIA (and other providers) periodically retire model IDs. If you
28
+ get an HTTP 404/410 error, check the current catalog at
29
+ https://build.nvidia.com/models and pass the correct --model.
30
+
31
+ If a run is interrupted or fails partway, just rerun the exact same
32
+ command — chunks already written to the output file are skipped.
33
+
34
+ Run `python pdf2notes.py --help` for all options.
35
+ """
36
+
37
+ import argparse
38
+ import asyncio
39
+ import os
40
+ import re
41
+ import sys
42
+ import time
43
+ from collections import deque
44
+ from datetime import datetime
45
+
46
+ import pdfplumber
47
+ from openai import (
48
+ AsyncOpenAI,
49
+ APIStatusError,
50
+ APIConnectionError,
51
+ RateLimitError,
52
+ InternalServerError,
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Prompt templates per note style
58
+ # ---------------------------------------------------------------------------
59
+
60
+ STYLE_PROMPTS = {
61
+ "descriptive": (
62
+ "You are an expert study-notes writer. Turn the given book excerpt into "
63
+ "detailed, descriptive notes a learner can review later WITHOUT rereading "
64
+ "the original text. Explain concepts in your own words, don't just "
65
+ "shorten sentences. Include definitions, examples, cause-effect "
66
+ "relationships, and any numbers, names, or terms that matter. "
67
+ "Use Markdown headers (##, ###) for structure and bullet points for "
68
+ "detail. Where useful, add a one-line 'Why it matters' or 'Key "
69
+ "takeaway' callout. Do not skip content just because it seems minor — "
70
+ "the goal is a faithful, learnable rewrite, not a compressed summary."
71
+ ),
72
+ "cornell": (
73
+ "You are an expert study-notes writer using the Cornell Notes method. "
74
+ "For the given book excerpt, produce Markdown with three parts: "
75
+ "1) '## Cues' — a bullet list of key terms/questions, "
76
+ "2) '## Notes' — detailed explanatory notes matching each cue, "
77
+ "3) '## Summary' — a short paragraph summarizing the excerpt. "
78
+ "Be thorough in the Notes section — this is what the learner studies from."
79
+ ),
80
+ "qa": (
81
+ "You are an expert study-notes writer. Convert the given book excerpt "
82
+ "into a thorough set of Markdown Q&A flashcard-style notes: "
83
+ "'### Q: <question>' followed by a detailed '**A:** <answer>'. Cover "
84
+ "every important concept, definition, and detail in the excerpt — "
85
+ "write enough questions to fully capture the material, not just the "
86
+ "obvious ones."
87
+ ),
88
+ "outline": (
89
+ "You are an expert study-notes writer. Convert the given book excerpt "
90
+ "into a deeply nested Markdown outline (using #, ##, ###, and nested "
91
+ "bullet points) that mirrors the structure of the content and captures "
92
+ "every important detail, definition, and example in the source."
93
+ ),
94
+ }
95
+
96
+ SYSTEM_SUFFIX = (
97
+ "\n\nFormatting rules:\n"
98
+ "- Output ONLY Markdown, no preamble like 'Here are the notes'.\n"
99
+ "- Start directly with a level-2 heading (##) that names the topic of "
100
+ "this excerpt.\n"
101
+ "- Reference page numbers in parentheses, e.g. (p. 42), when introducing "
102
+ "a major concept, so the notes stay traceable to the source.\n"
103
+ "- Never say things like 'the author discusses' — just present the "
104
+ "content directly, as notes.\n"
105
+ )
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # PDF extraction (memory-conscious: flush pdfplumber's per-page cache
110
+ # immediately after pulling text, instead of letting fonts/layout objects
111
+ # for the whole range pile up in memory)
112
+ # ---------------------------------------------------------------------------
113
+
114
+ def extract_pages(pdf_path, start_page, end_page):
115
+ """Returns list of (page_number, text) for the given 1-indexed inclusive range."""
116
+ pages = []
117
+ with pdfplumber.open(pdf_path) as pdf:
118
+ total = len(pdf.pages)
119
+ if start_page < 1 or end_page > total or start_page > end_page:
120
+ raise ValueError(
121
+ f"Invalid page range {start_page}-{end_page}. "
122
+ f"This PDF has {total} pages."
123
+ )
124
+ for i in range(start_page - 1, end_page):
125
+ page = pdf.pages[i]
126
+ text = page.extract_text() or ""
127
+ pages.append((i + 1, text))
128
+ # Release this page's cached fonts/chars/layout objects now —
129
+ # otherwise pdfplumber keeps them alive for the whole range.
130
+ page.flush_cache()
131
+ return pages
132
+
133
+
134
+ def chunk_pages(pages, max_chars=6000):
135
+ """
136
+ Groups consecutive pages into chunks under max_chars, so each API call
137
+ covers a coherent span of pages. Returns list of (first_pg, last_pg, text).
138
+ """
139
+ chunks = []
140
+ cur_text = []
141
+ cur_start = None
142
+ cur_len = 0
143
+ last_pg_seen = None
144
+
145
+ for pg_num, text in pages:
146
+ last_pg_seen = pg_num
147
+ text = text.strip()
148
+ if not text:
149
+ continue
150
+ if cur_start is None:
151
+ cur_start = pg_num
152
+ addition = f"\n\n[--- page {pg_num} ---]\n{text}"
153
+ if cur_len + len(addition) > max_chars and cur_text:
154
+ chunks.append((cur_start, pg_num - 1, "".join(cur_text)))
155
+ cur_text = [addition]
156
+ cur_start = pg_num
157
+ cur_len = len(addition)
158
+ else:
159
+ cur_text.append(addition)
160
+ cur_len += len(addition)
161
+
162
+ if cur_text:
163
+ chunks.append((cur_start, last_pg_seen, "".join(cur_text)))
164
+
165
+ return chunks
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Resume support: figure out which chunks are already in the output file
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def load_completed_ranges(output_path):
173
+ if not os.path.exists(output_path):
174
+ return set()
175
+ with open(output_path, "r", encoding="utf-8") as f:
176
+ content = f.read()
177
+ return {
178
+ (int(a), int(b))
179
+ for a, b in re.findall(r"<!-- pages (\d+)-(\d+) -->", content)
180
+ }
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Rate limiter (sliding window, requests/minute) — shared across all
185
+ # concurrent workers so total request rate stays under your provider's limit
186
+ # ---------------------------------------------------------------------------
187
+
188
+ class RateLimiter:
189
+ def __init__(self, rpm):
190
+ self.rpm = max(rpm, 1)
191
+ self.lock = asyncio.Lock()
192
+ self.timestamps = deque()
193
+
194
+ async def acquire(self):
195
+ async with self.lock:
196
+ while True:
197
+ now = time.monotonic()
198
+ while self.timestamps and now - self.timestamps[0] > 60:
199
+ self.timestamps.popleft()
200
+ if len(self.timestamps) < self.rpm:
201
+ self.timestamps.append(now)
202
+ return
203
+ wait = 60 - (now - self.timestamps[0]) + 0.05
204
+ await asyncio.sleep(max(wait, 0.05))
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # AI call (async, with retry only for transient errors)
209
+ # ---------------------------------------------------------------------------
210
+
211
+ NON_RETRYABLE_HINT = (
212
+ "This usually means the --model name is wrong or has been retired, or "
213
+ "the API key / --base-url is invalid. Check your provider's current "
214
+ "model list (e.g. https://build.nvidia.com/models) and retry with the "
215
+ "correct --model."
216
+ )
217
+
218
+
219
+ async def generate_notes_for_chunk(client, limiter, semaphore, model, style,
220
+ chunk_text, first_pg, last_pg, stop_event,
221
+ max_retries=4):
222
+ system_prompt = STYLE_PROMPTS[style] + SYSTEM_SUFFIX
223
+ user_prompt = f"Book excerpt covering pages {first_pg}-{last_pg}:\n\n{chunk_text}"
224
+
225
+ async with semaphore:
226
+ delay = 2
227
+ for attempt in range(1, max_retries + 1):
228
+ if stop_event.is_set():
229
+ raise RuntimeError("cancelled: a fatal error occurred elsewhere")
230
+
231
+ await limiter.acquire()
232
+ try:
233
+ resp = await client.chat.completions.create(
234
+ model=model,
235
+ messages=[
236
+ {"role": "system", "content": system_prompt},
237
+ {"role": "user", "content": user_prompt},
238
+ ],
239
+ temperature=0.3,
240
+ )
241
+ return resp.choices[0].message.content.strip()
242
+
243
+ except (RateLimitError, InternalServerError, APIConnectionError) as e:
244
+ if attempt == max_retries:
245
+ raise RuntimeError(
246
+ f"gave up after {max_retries} attempts (pages "
247
+ f"{first_pg}-{last_pg}): {e}"
248
+ ) from e
249
+ print(f" [pages {first_pg}-{last_pg}] transient error "
250
+ f"({type(e).__name__}), retrying in {delay}s...", file=sys.stderr)
251
+ await asyncio.sleep(delay)
252
+ delay *= 2
253
+
254
+ except APIStatusError as e:
255
+ # Non-retryable: bad model, auth, deprecated model (404/410), etc.
256
+ # Retrying this would just waste your rate-limit budget.
257
+ raise RuntimeError(
258
+ f"HTTP {e.status_code} on pages {first_pg}-{last_pg}: "
259
+ f"{e.message}. {NON_RETRYABLE_HINT}"
260
+ ) from e
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Async pipeline
265
+ # ---------------------------------------------------------------------------
266
+
267
+ async def run_pipeline(args, chunks, output_path, frontmatter, api_key):
268
+ completed = load_completed_ranges(output_path)
269
+
270
+ if not os.path.exists(output_path):
271
+ with open(output_path, "w", encoding="utf-8") as f:
272
+ f.write(frontmatter)
273
+
274
+ pending = [
275
+ (i, f, l, t) for i, (f, l, t) in enumerate(chunks) if (f, l) not in completed
276
+ ]
277
+
278
+ already_done = len(chunks) - len(pending)
279
+ if already_done:
280
+ print(f"{already_done} chunk(s) already in {output_path} — skipping those.")
281
+ if not pending:
282
+ print("Nothing left to generate.")
283
+ return 0, None
284
+
285
+ print(f"Generating {len(pending)} chunk(s) "
286
+ f"(concurrency={args.concurrency}, rate limit={args.rpm}/min)...")
287
+
288
+ client = AsyncOpenAI(api_key=api_key, base_url=args.base_url)
289
+ limiter = RateLimiter(args.rpm)
290
+ semaphore = asyncio.Semaphore(args.concurrency)
291
+ stop_event = asyncio.Event()
292
+
293
+ results = {}
294
+ errors = {}
295
+
296
+ async def worker(idx, first_pg, last_pg, text):
297
+ if stop_event.is_set():
298
+ return
299
+ try:
300
+ notes = await generate_notes_for_chunk(
301
+ client, limiter, semaphore, args.model, args.style,
302
+ text, first_pg, last_pg, stop_event,
303
+ )
304
+ results[idx] = (first_pg, last_pg, notes)
305
+ print(f" done: pages {first_pg}-{last_pg} "
306
+ f"({len(results)}/{len(pending)})")
307
+ except RuntimeError as e:
308
+ errors[idx] = (first_pg, last_pg, e)
309
+ stop_event.set()
310
+
311
+ try:
312
+ tasks = [asyncio.create_task(worker(i, f, l, t)) for i, f, l, t in pending]
313
+ await asyncio.gather(*tasks)
314
+ finally:
315
+ await client.close()
316
+
317
+ # Flush a *contiguous* prefix of newly-completed chunks in page order.
318
+ # Anything after the first gap (still running/failed) is left for a
319
+ # rerun — resume picks it up via the <!-- pages a-b --> markers.
320
+ written = 0
321
+ with open(output_path, "a", encoding="utf-8") as fh:
322
+ for i, (f, l, _t) in enumerate(chunks):
323
+ if (f, l) in completed:
324
+ continue
325
+ if i not in results:
326
+ break
327
+ first_pg, last_pg, notes = results[i]
328
+ fh.write(f"\n\n<!-- pages {first_pg}-{last_pg} -->\n{notes}\n")
329
+ written += 1
330
+
331
+ fatal = None
332
+ if errors:
333
+ first_idx = min(errors)
334
+ fatal = errors[first_idx]
335
+
336
+ return written, fatal
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Main
341
+ # ---------------------------------------------------------------------------
342
+
343
+ def parse_page_range(s):
344
+ m = re.match(r"^\s*(\d+)\s*-\s*(\d+)\s*$", s)
345
+ if not m:
346
+ raise argparse.ArgumentTypeError("Pages must look like START-END, e.g. 120-180")
347
+ return int(m.group(1)), int(m.group(2))
348
+
349
+
350
+ def main():
351
+ parser = argparse.ArgumentParser(
352
+ prog="pdf2notes",
353
+ description="Convert a PDF page range into AI-generated study notes."
354
+ )
355
+ parser.add_argument("--pdf", required=True, help="Path to the source PDF")
356
+ parser.add_argument("--pages", required=True, type=parse_page_range,
357
+ help="Page range, e.g. 120-180 (inclusive, 1-indexed)")
358
+ parser.add_argument("--output", default=None,
359
+ help="Output .md file (default: <pdf-name>_notes_<range>.md)")
360
+ parser.add_argument("--style", choices=list(STYLE_PROMPTS.keys()),
361
+ default="descriptive", help="Note style (default: descriptive)")
362
+ parser.add_argument("--chunk-chars", type=int, default=6000,
363
+ help="Max characters of source text per API call (default 6000)")
364
+ parser.add_argument("--concurrency", type=int, default=30,
365
+ help="Max simultaneous API requests (default 5)")
366
+ parser.add_argument("--rpm", type=int, default=40,
367
+ help="Max API requests per minute, across all workers (default 40) "
368
+ "— set this to match your provider's actual rate limit")
369
+ parser.add_argument("--model", default="nvidia/llama-3.3-nemotron-super-49b-v1.5",
370
+ help="Model name as your provider expects it. Providers retire "
371
+ "model IDs periodically — check your provider's current "
372
+ "catalog if this default 404s/410s.")
373
+ parser.add_argument("--base-url", default="https://integrate.api.nvidia.com/v1",
374
+ help="OpenAI-compatible API base URL "
375
+ "(default: NVIDIA NIM). Use https://api.openai.com/v1 "
376
+ "for OpenAI, http://localhost:11434/v1 for local, etc.")
377
+ parser.add_argument("--api-key", default=None,
378
+ help="API key (avoid this on shared machines — prefer --api-key-env)")
379
+ parser.add_argument("--api-key-env", default="NVIDIA_API_KEY",
380
+ help="Env var name holding the API key (default: NVIDIA_API_KEY)")
381
+ args = parser.parse_args()
382
+
383
+ api_key = args.api_key or os.environ.get(args.api_key_env)
384
+ if not api_key:
385
+ print(
386
+ f"No API key found. Set it with:\n"
387
+ f" export {args.api_key_env}=\"your-key-here\"\n"
388
+ f"or pass --api-key directly.",
389
+ file=sys.stderr,
390
+ )
391
+ sys.exit(1)
392
+
393
+ start_page, end_page = args.pages
394
+ output_path = args.output or (
395
+ f"{os.path.splitext(os.path.basename(args.pdf))[0]}"
396
+ f"_notes_{start_page}-{end_page}.md"
397
+ )
398
+
399
+ print(f"Extracting pages {start_page}-{end_page} from {args.pdf} ...")
400
+ pages = extract_pages(args.pdf, start_page, end_page)
401
+
402
+ non_empty = sum(1 for _, t in pages if t.strip())
403
+ if non_empty == 0:
404
+ print(
405
+ "No extractable text found in this range — the PDF might be "
406
+ "scanned/image-based. This tool needs OCR'd text first "
407
+ "(e.g. run it through an OCR tool, then retry).",
408
+ file=sys.stderr,
409
+ )
410
+ sys.exit(1)
411
+
412
+ chunks = chunk_pages(pages, max_chars=args.chunk_chars)
413
+ del pages # done with raw page text; chunks hold what we still need
414
+ print(f"Split into {len(chunks)} chunk(s) for note generation.")
415
+
416
+ book_title = os.path.splitext(os.path.basename(args.pdf))[0].replace("_", " ")
417
+ frontmatter = (
418
+ "---\n"
419
+ f"title: \"{book_title} — Notes (pp. {start_page}-{end_page})\"\n"
420
+ f"source: \"{os.path.basename(args.pdf)}\"\n"
421
+ f"pages: \"{start_page}-{end_page}\"\n"
422
+ f"style: {args.style}\n"
423
+ f"generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
424
+ "tags: [book-notes]\n"
425
+ "---\n\n"
426
+ )
427
+
428
+ written, fatal = asyncio.run(
429
+ run_pipeline(args, chunks, output_path, frontmatter, api_key)
430
+ )
431
+
432
+ if fatal:
433
+ first_pg, last_pg, err = fatal
434
+ print(f"\nStopped due to an error on pages {first_pg}-{last_pg}:\n {err}",
435
+ file=sys.stderr)
436
+ if written:
437
+ print(f"{written} new chunk(s) were still saved to {output_path}.",
438
+ file=sys.stderr)
439
+ print("Fix the issue above, then rerun the exact same command — "
440
+ "completed chunks are skipped automatically.", file=sys.stderr)
441
+ sys.exit(1)
442
+
443
+ print(f"\nDone. Notes saved to: {output_path}")
444
+ print("Drop this file straight into your Obsidian vault, or import into Notion "
445
+ "(Import > Markdown).")
446
+
447
+
448
+ if __name__ == "__main__":
449
+ main()
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdf2notes
3
+ Version: 1.0.0
4
+ Summary: Turn a PDF page range into AI-generated study notes (Markdown) for Obsidian/Notion
5
+ Author: Sandeep Singh
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/SandeepSinghSethi/pdf2notes
8
+ Keywords: pdf,notes,obsidian,notion,study,ai,llm
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: pdfplumber>=0.11.0
19
+ Requires-Dist: openai>=1.0.0
20
+ Dynamic: license-file
21
+
22
+ # pdf2notes
23
+
24
+ Turn a page range of a PDF book into descriptive, review-ready study notes
25
+ (Markdown) — ready to drop into **Obsidian** or import into **Notion**.
26
+
27
+ Works with any **OpenAI-compatible** chat completion API: NVIDIA NIM
28
+ (`integrate.api.nvidia.com`), OpenAI, Groq, Together AI, OpenRouter, or a
29
+ local server (Ollama, vLLM, LM Studio). You bring your own API key.
30
+
31
+ ## Setup
32
+
33
+ ```bash
34
+ pip install -r requirements.txt
35
+ ```
36
+
37
+ ## Get an API key (NVIDIA NIM example — free tier available)
38
+
39
+ 1. Go to https://build.nvidia.com
40
+ 2. Sign in, pick a model (e.g. `meta/llama-3.1-70b-instruct`), click "Get API Key"
41
+ 3. Export it:
42
+ ```bash
43
+ export NVIDIA_API_KEY="nvapi-xxxxxxxxxxxxxxxx"
44
+ ```
45
+
46
+ Any other OpenAI-compatible provider works the same way — just pass
47
+ `--base-url` and `--api-key-env` for that provider.
48
+
49
+ ## Usage
50
+
51
+ ```bash
52
+ python pdf2notes.py --pdf book.pdf --pages 120-180 --api-key-env NVIDIA_API_KEY
53
+ ```
54
+
55
+ This writes `book_notes_120-180.md` in the current folder. Requests run
56
+ concurrently and are throttled to a requests-per-minute cap, so a 100-page
57
+ range finishes in a couple of minutes instead of an hour.
58
+
59
+ If the run is interrupted, hits a persistent error, or you just Ctrl-C it,
60
+ **rerun the exact same command** — chunks already saved in the output file
61
+ are detected and skipped, so you only pay for/wait on what's missing.
62
+
63
+ ### Common options
64
+
65
+ | Flag | Meaning | Default |
66
+ |---|---|---|
67
+ | `--pdf` | Path to the source PDF | required |
68
+ | `--pages` | Page range, e.g. `120-180` (1-indexed, inclusive) | required |
69
+ | `--output` | Output `.md` path | `<pdf-name>_notes_<range>.md` |
70
+ | `--style` | `descriptive`, `cornell`, `qa`, or `outline` | `descriptive` |
71
+ | `--chunk-chars` | Max source characters sent per API call | `6000` |
72
+ | `--concurrency` | Max simultaneous API requests | `5` |
73
+ | `--rpm` | Max API requests per minute (across all workers) | `40` |
74
+ | `--model` | Model name as your provider expects it | `nvidia/llama-3.3-nemotron-super-49b-v1.5` |
75
+ | `--base-url` | OpenAI-compatible API base URL | NVIDIA NIM endpoint |
76
+ | `--api-key-env` | Env var holding your key | `NVIDIA_API_KEY` |
77
+
78
+ **Tune `--rpm` to your actual plan.** NVIDIA's free tier, OpenAI's free/low
79
+ tiers, etc. all cap requests per minute — check your provider's dashboard
80
+ and set `--rpm` a bit under that. Setting it too high just means the tool
81
+ eats a 429 and backs off; setting `--concurrency` too high does the same
82
+ without helping speed once you're rpm-bound.
83
+
84
+ **Model IDs get retired.** NVIDIA (and other providers) periodically pull
85
+ old model IDs from the catalog — you'll get an HTTP 404/410 if that
86
+ happens. The tool detects this as non-retryable and fails immediately with
87
+ the error instead of burning your rate-limit budget retrying a request
88
+ that will never succeed. Check https://build.nvidia.com/models for current
89
+ IDs and pass the right one via `--model`.
90
+
91
+ ### Examples
92
+
93
+ Using OpenAI instead of NVIDIA:
94
+ ```bash
95
+ export OPENAI_API_KEY="sk-..."
96
+ python pdf2notes.py --pdf book.pdf --pages 1-50 \
97
+ --base-url https://api.openai.com/v1 \
98
+ --model gpt-4o-mini \
99
+ --api-key-env OPENAI_API_KEY
100
+ ```
101
+
102
+ Q&A flashcard-style notes instead of descriptive prose:
103
+ ```bash
104
+ python pdf2notes.py --pdf book.pdf --pages 200-260 --style qa
105
+ ```
106
+
107
+ ## Notes on how it works
108
+
109
+ 1. Extracts text from the given page range with `pdfplumber`.
110
+ 2. Groups pages into chunks (default ~6000 chars each) so each API call
111
+ covers a coherent span without blowing past context limits.
112
+ 3. Sends each chunk to the model with a prompt tuned to produce **learnable
113
+ notes**, not a shortened summary — definitions, examples, and important
114
+ details are preserved and explained.
115
+ 4. Stitches everything into one Markdown file with YAML frontmatter
116
+ (title, source, page range, style, timestamp) so it's ready to file
117
+ straight into Obsidian, or import into Notion via *Import → Markdown*.
118
+
119
+ ## Limitations
120
+
121
+ - **Scanned/image-only PDFs** won't extract text — OCR the PDF first (e.g.
122
+ with `pytesseract` + `pdf2image`), then run this tool on the OCR'd version.
123
+ - Very dense chunking settings (`--chunk-chars` too high) can exceed your
124
+ model's context window — 6000–8000 is a safe default for most models.
125
+ - Quality depends on the model you point it at — bigger instruction-tuned
126
+ models produce noticeably better notes than small ones.
127
+ - **Memory**: page text is extracted one page at a time and each page's
128
+ internal pdfplumber cache (fonts, layout objects) is flushed immediately,
129
+ so memory stays roughly proportional to a chunk's text, not the whole
130
+ page range. For genuinely huge ranges (500+ pages in one run), consider
131
+ splitting into a couple of `--pages` calls instead of one giant one.
132
+ - **A crash/segfault message *after* "Done."**: if you see a SIGSEGV or
133
+ similar right as the process exits but *after* your notes file is
134
+ already complete and readable, it's happening during Python interpreter
135
+ teardown, not during note generation — a known quirk of some PDF C
136
+ extensions (e.g. pypdfium2, used internally by pdfplumber) not tearing
137
+ down cleanly at exit. It doesn't affect your output file. If you want to
138
+ suppress the noise, run with `python pdf2notes.py ... ; true` in a
139
+ script, or ignore the exit code.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ src/pdf2notes/__init__.py
6
+ src/pdf2notes/cli.py
7
+ src/pdf2notes.egg-info/PKG-INFO
8
+ src/pdf2notes.egg-info/SOURCES.txt
9
+ src/pdf2notes.egg-info/dependency_links.txt
10
+ src/pdf2notes.egg-info/entry_points.txt
11
+ src/pdf2notes.egg-info/requires.txt
12
+ src/pdf2notes.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdf2notes = pdf2notes.cli:main
@@ -0,0 +1,2 @@
1
+ pdfplumber>=0.11.0
2
+ openai>=1.0.0
@@ -0,0 +1 @@
1
+ pdf2notes