The Need: The Search Works, But the Highlight Is on the Wrong Line
I scanned a printed training workbook, one photo per page, 184 pages plus a cover. I wanted one PDF where Ctrl+F finds a phrase. I also wanted the answers from my sermon-and-book library to cite the right page.
My first version used one model for everything. The text was good. But when I searched, the yellow highlight sat a line or two away from the words. That is a small thing until you use the book as a source. If the highlight cannot be trusted, you stop trusting the page number too.
I wrote that to Claude in the same words I would use with a person.
💬 Prompt that worked “When I search, the lines don’t match up. I think that makes it a weaker source for the graph RAG.”
The Proof: Same Book, Highlight on the Right Line
The result is one PDF. Each page is the original photo, with an invisible text layer on top. The cover is the first page, with the white margins cropped away.
Numbers from the first book (184 pages):
- 3,757 lines of text in total.
- 43 lines (about 1%) had no good box from the second model, so I used the first model’s rough box.
- Cost: 613 won on the Gemini balance (5,187 → 4,574 won). That is about 3.3 won a page.
I told Claude the result was good. For the library, I asked “What is revelation?” and the page that defines it came back as source number 1. As far as I can tell, the first sentence of the answer matches that page. I did not test many questions.
The Story: Two Models, Each Good at One Thing
I did not start with two models. I started with one, because that is the simple way.
- Gemini (
gemini-2.5-flash) reads the page well. Spaces are right. Bible references are right. But the box it gives for each line uses a rough 0–1000 grid, so a highlight can drift. - PaddleOCR (Korean PP-OCRv5) finds the lines very well. The boxes are tight. But as text it is poor: it drops spaces and reads some characters wrong. I tried it alone in an earlier test, and the errors would hurt search.
So the idea was simple: take the words from one, take the positions from the other. The text matters most for search and for the library. The position only matters for the highlight.
This is not a new idea. I just did not think of it until the highlight was wrong in front of me.
The How: Pair the Lines, Then Hide the Text Under the Photo
The whole flow is one script, now a Claude Code skill I call scan2pdf. These are the steps.
1. Gemini reads each page. I shrink the photo to 1700 px wide, turn it to grayscale, and ask for one JSON item per line: the text, and a box from 0 to 1000. I set thinkingBudget to 0, because reading does not need long thinking. Results are cached in one JSON file per page. If the job stops, I run the same command again and it continues.
2. PaddleOCR finds the lines. It runs on a GPU box I borrow (CPU mode only, about 20 seconds a page). I upload the small 1700 px copies, not the originals, run it, copy the results back, and delete the folder on the box.
3. Pair the lines.
- If both models found the same number of lines, pair them in order.
- If not, compare the text with
diffliband look at the next 5 boxes. Accept a match at 0.6 or higher. - If nothing matches, keep Gemini’s rough box.
4. Write invisible text with PyMuPDF. Each line is placed in its box, and the width is stretched to fit the box. render_mode=3 makes it invisible.
# simplified from my script
font = fitz.Font(fontfile=r"C:\Windows\Fonts\malgun.ttf")
tw = fitz.TextWriter(page.rect)
tw.append((x0, y1), text, font=font, fontsize=size)
sx = (x1 - x0) / font.text_length(text, fontsize=size)
tw.write_text(page, render_mode=3,
morph=(fitz.Point(x0, y1), fitz.Matrix(sx, 1)))
The morph part took one error to learn. It wants a fitz.Point first, not a plain tuple.
Cost, in full.
| Item | Amount |
|---|---|
| Book 1, 184 pages | 613 won |
| Book 2, 200 pages | about 1,178 won (4,574 → 3,396), or about 5.9 won a page |
| GPU box | $0 (borrowed) |
| Claude | my existing subscription, nothing extra |
Book 2 cost almost twice as much per page. That number includes a 3-page test and one failed first run, and I think the pages simply have more text. I did not check the token logs. I now expect 3 to 6 won a page, and I wrote that range into the skill. I kept the costs in won because that is what my balance shows.
Book 2 pairing: 4,240 lines paired, 85 lines (about 2%) used Gemini’s box. On 171 pages both models found the same number of lines, and on 29 pages they did not.
🗂 Claude.md Rule A script that starts a background job on another machine must not wait for the
sshthat started it. Start it withPopen, all stdin/stdout/stderr closed, and then check on it by polling.
I learned that rule the hard way. My first run on book 2 started PaddleOCR fine, but my script waited 60 seconds for the ssh command to return, hit a timeout, and died. The job on the box kept running with nobody to collect it. I killed it, fixed the launch, and ran again. The Gemini results were cached, so only one missing page was read again.
What I Did Not Check
- On the 29 pages where the line counts differ, I have not looked at the highlights one by one.
- Inside a line, each word’s position is stretched to fit the line. The highlight can be a little off for a single word.
- Gemini sometimes changes an old spelling or verb ending into the modern form. For search this is usually fine. For quoting, check the printed page.
- I have not compared this against a paid scanning app, so I cannot say which is cheaper or better. I do not know that app’s price.
- I tested the library with only a few questions. I have not measured how often the right page comes back.
- For a book you cannot lend to a GPU box, run the PaddleOCR step on your own computer, or accept Gemini’s rough boxes with
--no-paddle.
Use this for books you own. The pages go to a cloud model, so I would not use it for anything private.