I had an old Arabic book stored as a PDF. I wanted to upload it to Gemini or ChatGPT, then ask questions about its content.
There was one problem: the PDF was not really a text document. It was a collection of badly scanned page images. I could open it and read it myself, but selecting text did not work, searching did not work, and neither Gemini nor ChatGPT could reliably read the pages.
I started looking for an OCR application. OCR means Optical Character Recognition. It is the process of turning text inside an image into actual computer-readable characters.
I tried several applications, including paid ones. Most of them were disappointing with this Arabic book. One service produced acceptable text, but it took about 45 minutes for only 15 pages. The result was still not good enough to justify processing a book with almost 300 pages.
Then I remembered seeing an article title about OCR language models that could run locally. I searched for a model trained specifically for Arabic and found Qari-OCR v0.3.
Qari-OCR accepts images. That changed the problem from “How do I extract text from this broken PDF?” to a simpler pipeline: convert every page to an image, send the image to the local model, and save the returned Arabic text.
That is what I built.
TL;DR – SmtC
Too Long; Didn’t Read – Show me the Code: arabic-pdf-ocr-omlx repository.
What I ended up with
The final Python script:
- Opens an image-based PDF.
- Renders one page at a time as an image.
- Keeps the image below a safe size.
- Sends it to Qari-OCR through a local oMLX server.
- Saves Arabic text as UTF-8 plain text and Markdown.
- Creates a checkpoint after every page.
- Resumes after an interruption without starting again.
- Retries pages when the model reaches its token limit.
- Records failed pages without losing the pages that already worked.
The flow looks like this:
Scanned Arabic PDF
|
v
Render one page with PyMuPDF
|
v
Resize to a safe pixel budget
|
v
Encode the page as a JPEG data URL
|
v
POST to localhost:8000/v1/chat/completions
|
v
Qari-OCR returns Arabic text
|
v
Save page checkpoint, book.txt, and book.md
The main run processed 277 pages in 11,292.37 seconds, a little over 3 hours, on my M4 Pro MacBook Pro with 48 GB of memory. That averaged 40.77 seconds per page. Some pages were already present from my test runs, and two pages needed separate attention afterward.
This was not instant, but it was unattended, local, resumable, and much faster per page than the OCR service I had tried. More importantly, the Arabic output was useful enough for my goal.
What is special about Qari-OCR?
Qari-OCR v0.3 is a 2-billion-parameter vision-language model built on Qwen2-VL-2B-Instruct and fine-tuned for Arabic OCR. Its model card focuses on Arabic document structure, diacritics, varied fonts, and degraded images.
A normal language model receives text and generates text. A vision-language model can also receive an image. In this case, the input is a scanned page and the desired output is its textual representation.
The model card is also refreshingly honest. Qari v0.3 focuses more on preserving layout and structure, while an earlier version reports better raw character accuracy. No OCR model is perfect, especially on an old scan. I treated its output as a useful transcription that still deserves review, not as an archival copy guaranteed to match every character.
Why I used oMLX
I ran the model with oMLX, an inference server designed for Apple Silicon. It supports vision-language models and exposes an OpenAI-compatible API.
An API is simply a defined way for one program to talk to another. OpenAI compatibility means my Python script can send a familiar chat-completions request to:
http://localhost:8000/v1/chat/completions
The important word is localhost. The server and model run on my Mac. The script does not need to upload every page to a hosted OCR provider.
Running locally does not mean there is no cost. The model uses memory, CPU and GPU resources, energy, storage, and time. It does mean there is no per-page cloud API fee, and the scanned pages remain on the machine when oMLX is bound to localhost.
At the time of this experiment I was using oMLX 0.6.2. The project changes quickly, so check its current documentation rather than copying version-specific setup assumptions.
Installing the pieces
First install the oMLX macOS app, or install oMLX with Homebrew:
brew tap jundot/omlx https://github.com/jundot/omlx
brew install omlx
omlx start
The local admin dashboard is available at:
http://localhost:8000/admin
I used its model downloader to install:
NAMAA-Space/Qari-OCR-v0.3-VL-2B-Instruct
If API-key authentication is enabled, I export the local key first. The following command then shows the model identifiers that the local server exposes:
export OMLX_API_KEY='your-local-omlx-key'
curl -H "Authorization: Bearer $OMLX_API_KEY" \
http://localhost:8000/v1/models
When authentication is disabled for localhost, the header can be omitted and a plain curl http://localhost:8000/v1/models is enough.
Next, clone the script and install its Python dependencies in a virtual environment:
git clone https://github.com/iseif/arabic-pdf-ocr-omlx.git
cd arabic-pdf-ocr-omlx
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -r requirements.txt
A virtual environment keeps the packages for this script separate from other Python projects on the machine.
The script depends on four small Python packages:
- PyMuPDF reads and renders PDF pages.
- Pillow works with the rendered images.
- Requests calls the local HTTP API.
- tqdm displays the progress bar.
Start with a few pages
I strongly recommend testing a small range before processing a complete book:
export OMLX_API_KEY='your-local-omlx-key'
python3 arabic_pdf_ocr.py \
--pdf my-arabic-book.pdf \
--start-page 1 \
--end-page 10
If authentication is disabled in your local oMLX configuration, you can omit the environment variable.
I use an environment variable because placing a key directly in a command can leave it in shell history or expose it through process-inspection tools. I also keep .env files, PDFs, generated text, and page checkpoints out of Git.
The first test tells you several useful things:
- Does oMLX see the model?
- Is the scan clear enough for this OCR model?
- How long does one page take on your Mac?
- Does the Arabic text and paragraph order look useful?
- Are the default image and token limits appropriate?
Step 1: Render each PDF page as an image
The OCR model does not receive a PDF file. It receives one page image at a time.
PyMuPDF describes a PDF page in points, where 72 points represent one inch. The script converts the requested DPI into a scale, then calculates the resulting pixel dimensions:
def calculate_render_geometry(
width_points: float,
height_points: float,
dpi: int,
max_image_pixels: int,
) -> Tuple[float, int, int]:
"""Return a render scale and dimensions capped to a pixel budget."""
if width_points <= 0 or height_points <= 0:
raise ValueError("PDF page dimensions must be positive")
if dpi <= 0:
raise ValueError("DPI must be positive")
if max_image_pixels <= 0:
raise ValueError("Maximum image pixels must be positive")
requested_scale = dpi / 72.0
requested_pixels = width_points * height_points * requested_scale * requested_scale
if requested_pixels <= max_image_pixels:
scale = requested_scale
else:
scale = math.sqrt(max_image_pixels / (width_points * height_points))
width = max(1, int(width_points * scale))
height = max(1, int(height_points * scale))
return scale, width, height
This pixel cap came from a real failure. My first version appeared to be stuck on the first page. The page dimensions and requested resolution produced an unnecessarily large image. Large images take more memory, create bigger API requests, and give the vision model more work.
The final default allows at most 4.5 million rendered pixels. It preserves enough detail for the scan while preventing one unusual page from dominating the run.
This is an important general lesson: more resolution is not automatically better. Once the text is readable, extra pixels can add cost without adding useful information.
Step 2: Turn the image into an API request
The rendered page exists as a Pillow image in memory. The script converts it to JPEG, then Base64-encodes its bytes.
Base64 is a way to represent binary data using text characters. It makes it possible to place the JPEG inside a JSON request as a data URL.
This is the relevant code from the script:
buffered = io.BytesIO()
image.save(buffered, format="JPEG", quality=jpeg_quality, optimize=True)
encoded_image = buffered.getvalue()
img_b64 = base64.b64encode(encoded_image).decode("utf-8")
data_uri = f"data:image/jpeg;base64,{img_b64}"
chat_url = f"{api_url.rstrip('/')}/chat/completions"
payload = {
"model": model_name,
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": data_uri}
},
{
"type": "text",
"text": prompt
}
]
}
],
"temperature": 0.0,
}
The message has two parts: the page image and a text instruction. The prompt asks the model to return only the document text and not hallucinate missing content.
temperature is set to zero because OCR is a transcription task. I want a stable reading of the page, not creative variations.
The request goes to oMLX, and oMLX runs the local Qari-OCR model. The returned JSON contains the extracted text in the assistant message.
Step 3: Save Arabic correctly
Arabic characters work well in normal text files as long as the program uses UTF-8 encoding.
The script writes two combined outputs:
book.txtfor simple search, copy, and upload.book.mdwith a heading and marker for every page.
It opens text files with encoding="utf-8". That small detail avoids the platform-dependent encodings that can turn Arabic into unreadable symbols.
The Markdown output keeps page boundaries such as:
## الصفحة 37 / Page 37
...the extracted Arabic text...
Page markers matter later. If an answer looks suspicious, I can return to the corresponding scan and compare it.
Step 4: Make a long process resumable
A script that works for ten pages can still be unsafe for 296 pages.
If I saved only one large output file at the very end, a crash after three hours could lose everything. If I appended directly to the same file, retrying a range could duplicate pages or place them out of order.
The final script uses one canonical checkpoint file per page:
def save_page_checkpoint(checkpoint_dir: Path, page_num: int, text: str) -> None:
"""Atomically save the canonical OCR result for one page."""
if page_num < 1:
raise ValueError("Page numbers must be positive")
checkpoint_dir.mkdir(parents=True, exist_ok=True)
atomic_write_text(checkpoint_dir / f"page_{page_num:04d}.txt", text)
“Atomic” means the script writes a temporary file completely, flushes it to disk, then replaces the destination in one operation. An interruption should leave either the old complete checkpoint or the new complete checkpoint, not half a page.
After saving a page, the script rebuilds the combined outputs from all checkpoints in sorted page order:
save_page_checkpoint(checkpoint_dir, page_num, ocr_text)
checkpoint_pages[page_num] = ocr_text
clear_page_failure(failures_path, page_num)
rebuild_aggregate_outputs(
out_txt_path,
out_md_path,
pdf_path.stem,
model_to_use,
total_pages,
checkpoint_pages,
)
When I run the same command again, existing checkpoints are skipped. Only missing pages are sent to the model.
The checkpoint directory also contains a manifest tied to the PDF hash, model, prompt, DPI, and image settings. The script refuses to silently combine pages created with incompatible settings.
This resume behavior became more important than raw speed. A three-hour local job is manageable when every completed page is safe.
The failure that needed more than max tokens
The most interesting bug appeared when some dense pages reached the model’s output limit.
Language models generate tokens. A token is a small unit of text, not necessarily a word. If the response uses the whole configured budget, oMLX reports finish_reason as length.
My first response was to increase max_tokens. That was necessary, but it was not sufficient.
On one page, the model started repeating a short Arabic fragment. Giving it more tokens only gave it more room to repeat the same fragment. The page still failed after reaching 8,000 tokens.
The fix was adaptive retries with both a larger token budget and a stronger repetition penalty:
if finish_reason == "length":
next_max_tokens = max(
current_max_tokens,
min(current_max_tokens * 2, MAX_ADAPTIVE_TOKENS),
)
next_repetition_penalty = (
1.1
if current_repetition_penalty == 1.0
else min(1.2, round(current_repetition_penalty + 0.1, 2))
)
The three attempts are:
| Attempt | Maximum tokens | Repetition penalty |
|---|---|---|
| 1 | 2,000 | 1.0 |
| 2 | 4,000 | 1.1 |
| 3 | 8,000 | 1.2 |
A repetition penalty makes tokens that the model has already produced slightly less attractive. A value of 1.0 means no penalty. I kept the increase small because a strong penalty could damage legitimate Arabic repetition or formatting.
Page 37 was the real test. It failed three times with the earlier final penalty of 1.15. A diagnostic request with 1.2 stopped normally and returned 1,332 characters. After updating the retry sequence, the page completed automatically on its third attempt.
This is why I do not treat max_tokens reached as proof that the page simply contains too much text. It can also be a generation loop.
What happened to the last failed page?
The other failure was page 280. It was not a normal page of paragraphs. It contained a radial diagram with small Arabic labels at several angles.
The model repeatedly produced unusable text. I tried the complete page and enlarged overlapping crops, but the result did not become trustworthy. I recorded that page as intentionally empty and kept its page marker in the output.
That choice is also part of building reliable automation. A system should know when to stop retrying. Spending another hour on a page does not make a weak result correct.
For a production archival workflow, I would add a manual-review queue and transcribe that diagram separately. For my goal, asking questions about the main book content, preserving the page position and moving on was the practical decision.
The final command
After testing a range, processing the whole PDF is one command:
python3 arabic_pdf_ocr.py --pdf my-arabic-book.pdf
If a page fails, I can rerun only that page:
python3 arabic_pdf_ocr.py \
--pdf my-arabic-book.pdf \
--start-page 37 \
--end-page 37
The recovered checkpoint is inserted in the right place when book.txt and book.md are rebuilt.
The script also has offline unit tests:
python3 -m unittest -q test_arabic_pdf_ocr.py
The current repository has 21 tests covering image sizing, blank-page detection, API requests, authentication errors, safe credential defaults, token and repetition retries, UTF-8 output, checkpoint recovery, and model selection.
Uploading the result to an AI assistant
Once OCR finished, I had a normal UTF-8 text file instead of a PDF full of page images.
That makes the document much easier for Gemini, ChatGPT, local retrieval tools, text editors, and command-line search. The Markdown version also gives the assistant page boundaries that can be referenced in answers.
OCR does not guarantee that every answer will be correct. I still ask the assistant to mention page numbers, quote the relevant extracted section briefly, and say when the text is unclear. For important claims, I compare the OCR output with the original scan.
There is another practical limit: a book may be larger than the context an assistant can use effectively in one request. In that case, I can split the Markdown by chapter or use a retrieval workflow that sends only relevant sections to the model.
What I learned
The model was only one part of the solution. The reliable result came from the surrounding engineering:
- Use a model specialized for the language and document type.
- Test a few representative pages before committing to the whole book.
- Cap image size instead of assuming higher resolution is always better.
- Treat token-limit failures and repetition loops as different symptoms.
- Save every page independently and atomically.
- Rebuild combined files from canonical checkpoints instead of appending blindly.
- Keep page numbers so OCR output can be checked against the scan.
- Continue after isolated failures, but make failed pages visible.
- Accept that diagrams and rotated text may need manual review.
- Keep input books, generated content, and API keys out of a public repository.
I started with a PDF that modern AI assistants could not read. A small local Arabic OCR model, a local OpenAI-compatible server, and a resumable Python script turned it into searchable text.
The complete script, tests, setup instructions, and retry logic are available in arabic-pdf-ocr-omlx.

