Snap any image, screenshot, or webpage into plaintext. No GPU. No cloud. One command.
Python
186
6 commits
updated Sep 29, 2026
Snap any image, screenshot, or webpage into plaintext. No GPU. No cloud. One command.

textsnap screenshot.png
That's it. You get a .txt next to your shell, recognized on your CPU, from a screenshot, a photo, an image URL, or even a webpage.
--plaintext to flatten it.# Install
pip install textsnap
# Snap something
textsnap screenshot.png
textsnap https://example.com/article --plaintext
textsnap photo.jpg -o ~/notes/receipt.txt
textsnap scans/*.png -o ~/notes/scans/ # batch: model loads once
The first run downloads the model (~1.1 GB). Every run after is offline.
| Source | Example |
|---|---|
| Clipboard | textsnap (no argument) |
| Local image file | textsnap path/to/img.png |
| Direct image URL | textsnap https://example.com/x.png |
| Webpage URL | textsnap https://example.com/article |
| Several of the above | textsnap a.png b.jpg https://example.com/x.png |
Local files cover anything Pillow can decode: .png, .jpg, .jpeg, .webp, .bmp, .gif, .tiff, and friends. For webpage URLs, textsnap uses readability to isolate the main content, then picks the most prominent image on the page and OCRs that.
Run textsnap with no argument and it reads the image currently on your clipboard. The recognized text is then copied straight back to the clipboard, so a screenshot-to-text round trip is just: snap → textsnap → paste.
The .txt file is still written as well (and its path still printed to stdout), so nothing about scripting changes — the clipboard copy is a pure convenience layered on top.
Clipboard-out uses your platform's native tool — pbcopy (macOS), clip (Windows), or wl-copy / xclip / xsel (Linux) — so it needs no extra Python package. If none of those is installed, textsnap simply skips the clipboard copy; the .txt file is always there regardless. (Run with -v to see whether the copy succeeded.)
By default textsnap downloads its model files to an OS cache directory (~/.cache/textsnap/). But if it finds the model files sitting next to the script, it uses those directly — no download, no --model-dir flag, no setup at all.
"Next to the script" means a layout like:
textsnap/
├── textsnap.py
├── onnx/
│ ├── vision_encoder_q8.onnx
│ ├── decoder_q4.onnx
│ ├── embedding.onnx
│ └── embedding.onnx.data
└── tokenizer.json
Drop those files in, and you can copy the entire textsnap/ folder to any machine — a USB stick, an air-gapped box, a fresh laptop — and run it immediately, fully offline, with zero install steps.
Model-directory resolution order:
--model-dir DIR — if you pass it explicitly, it always wins.~/.cache/textsnap/, downloading on first run if needed.Like
--model-dir, portable-mode files are not SHA-256 verified — files you placed there yourself are trusted by definition. Integrity verification applies to files textsnap downloads. See Security.
pip install textsnap
Installs two equivalent commands on your PATH: textsnap (canonical) and ocr (alias, for when the name slips your mind).
To install from a local source checkout instead:
pip install .
For a reproducible install with exact pinned dependency versions:
pip install -r requirements-lock.txt
pip install .
Clipboard note. Reading images from the clipboard relies on Pillow's
ImageGrab; on Linux you may needxcliporwl-clipboardinstalled. Writing recognized text back to the clipboard usespbcopy/clip/wl-copy/xclip/xsel. macOS and Windows work out of the box.
# Clipboard (no argument) — text is also copied back to the clipboard
textsnap
# Local image file
textsnap path/to/screenshot.png
# Direct image URL
textsnap "https://example.com/diagram.png"
# Webpage — OCRs the most prominent image on the page
textsnap "https://example.com/article"
# Flatten the model's markdown to plain text
textsnap input.png --plaintext
# Custom output path
textsnap input.png -o ./out/extracted.txt
# Raise the token cap for very dense pages
textsnap dense-page.png --max-tokens 4096
# Trade accuracy for speed by shrinking the image budget
textsnap input.png --max-pixels 250000
# Use a local model directory instead of downloading
textsnap input.png --model-dir ~/models/paddleocr-vl
# Batch: many inputs, one model load. Globs work even where the shell
# doesn't expand them (Windows).
textsnap page1.png page2.png "scans/*.jpg" -o ./ocr-out/
# Use the smaller 4-bit vision encoder (see "Vision encoder variants")
textsnap input.png --vision q4
Plaintext, UTF-8. Default location is ./textsnaps/ (created if missing) under the current working directory; override with -o. The filename is derived from the image filename stem (receipt_ocr.txt), or from the webpage slug for URL inputs.
textsnap is quiet by default, Unix-style: the only thing printed to stdout is the path to the file it wrote, so it composes cleanly —
OUT=$(textsnap receipt.png) # capture the path
textsnap receipt.png | xargs cat # print the recognized text
With more than one input, the model is loaded once and each image is processed in turn. stdout gets one path per line, printed as each result is written, so textsnap *.png | while read f; do ...; done starts working before the batch finishes.
-o names a directory (created if missing); without it, results go to ./textsnaps/ as usual.a/page.png, b/page.png) don't overwrite each other: the second becomes page_ocr_2.txt.[textsnap] FAILED <input>: <reason> and skipped; the rest still run, and the exit status is 1 at the end. A model that fails to load stops the whole batch.When the input is the clipboard, the recognized text is also placed on the clipboard — see Clipboard in, clipboard out.
Pass -v to send progress diagnostics (input type, image size, decode speed, token counts) to stderr; stdout stays just the path either way.
Default file output is the model's native markdown — it preserves tables, headings, and document structure:
# Quarterly Report
| Region | Revenue |
| ------ | ------- |
| EMEA | $1.2M |
| APAC | $0.9M |
With --plaintext, markdown is flattened to bare text:
Quarterly Report
Region Revenue
EMEA $1.2M
APAC $0.9M
| Flag | Description |
|---|---|
-o, --output | Output .txt path, or a directory (required to be a directory with several inputs). Default: ./textsnaps/<name>_ocr.txt. |
-v, --verbose | Print progress diagnostics to stderr. Off by default. |
--plaintext | Flatten the model's native markdown to plain text. |
--model-dir | Use ONNX/config files from this directory. Overrides portable mode and the OS cache. |
--max-tokens | Cap generated tokens. Default 2048. Raise it for very dense pages. |
--max-pixels | Image pixel budget fed to the vision encoder. Default is the model's maximum. Lower trades accuracy for speed; too low makes the model hallucinate. The image is only ever shrunk, never enlarged. |
--vision | Vision-encoder variant: q8 (default) or q4. See Vision encoder variants. |
--no-verify | Skip SHA-256 verification of downloaded model files (not advised). |
--generate-checksums | Download the pinned model files, write a fresh manifest, and exit. |
Environment variables:
| Variable | Effect |
|---|---|
TEXTSNAP_DECODE_THREADS | Decoder intra-op thread count, for tuning CPU decode on a specific machine. Default: a sensible value from your physical core count. |
TEXTSNAP_VISION | Default for --vision. |
TEXTSNAP_EMBED=ort | Run token embeddings through an ONNX Runtime session instead of the memory-mapped table (for debugging; output is identical). |
TEXTSNAP_LOGITS_SLICE=0 | Use the decoder graph exactly as shipped instead of the last-position patch (for debugging; output is identical). |
Two builds of the vision encoder are available, selectable with --vision:
| Variant | Download | Notes |
|---|---|---|
q8 (default) | 443 MB | 8-bit dynamic quantization. The encoder is compute-bound, and integer GEMMs make it faster: ~1.3x over q4 in testing, with fewer misreads. |
q4 | 231 MB | 4-bit weight-only. Smallest download. |
Only the variant you use is downloaded, and every variant is SHA-256 pinned. The variants produce slightly different image features, so switching changes output. To compare them on images like yours:
python bench_vision.py samples/*.png # q4 vs q8
python bench_vision.py samples/*.png --truth truth/ # plus CER vs truth/<stem>.txt
It reports encoder time, total time, and character error rate against the q4 output, and against ground truth if given. Ground truth is the better yardstick, since neither variant is full precision.
textsnap auto-downloads ~1.1 GB of model weights from the Hugging Face Hub on first run, so it treats those files as untrusted until proven otherwise:
main can't silently swap the weights.embedding.onnx.data, which holds the actual embedding weights — is hashed and checked against known-good digests before it's loaded. A mismatch aborts the run with a clear error rather than executing unverified weights. Digests live in model_checksums.sha256 and are also embedded in the script; entries in the manifest override the embedded ones, and files the manifest doesn't mention still fall back to the embedded digest, so verification works whether you install from source or from a wheel.requirements-lock.txt pins exact dependency versions for reproducible installs; the file documents how to add per-wheel --hash entries with pip-compile --generate-hashes for full supply-chain pinning.Verification applies to files textsnap downloads. Model files you supply yourself — via --model-dir or portable mode — are trusted as-is and not re-hashed; you are responsible for their provenance.
Regenerate the checksum manifest after a deliberate model-revision bump:
textsnap --generate-checksums
To bypass verification (for local experimentation with a modified model), pass --no-verify.
--max-pixels) and snaps it to the patch grid — textsnap does not pre-shrink beyond that, since starving the encoder of resolution makes the model hallucinate rather than degrade gracefully.--plaintext reduces it to bare text.No image is sent anywhere. No state is kept between runs except the cached model.
The PaddleOCR-VL-1.5 ONNX components are downloaded on first run to ~/.cache/textsnap/:
onnx/vision_encoder_q8.onnx — vision encoder + spatial-merge projector (or vision_encoder_q4.onnx with --vision q4)onnx/decoder_q4.onnx — autoregressive decoderonnx/embedding.onnx + onnx/embedding.onnx.data — token-embedding graph and its fp32 table (no q4 variant exists)tokenizer.json, config.jsonTogether ~1.1 GB. To use your own copy, either point --model-dir at a directory containing the same onnx/ files plus tokenizer.json and config.json, or place those files next to the script for portable mode.
--max-tokens caps the output. Very dense pages can hit the default 2048-token cap and truncate; raise it if the tail of a page is missing.--max-pixels is a speed/accuracy dial. Lowering it speeds up the vision encoder but feeds the model a coarser image; set it too low and recognition quality drops sharply. The default (the model's full budget) is the safe choice.MIT for this project — see LICENSE.
The model is PaddleOCR-VL-1.5, distributed under Apache-2.0 by PaddlePaddle; textsnap downloads the ONNX export from kouhxp/PaddleOCR-VL-1.5-ONNX, an unmodified, pinned mirror of selected files from onnx-community/PaddleOCR-VL-1.5-ONNX. See the original model card for model terms. Powered by onnxruntime and huggingface_hub.
161 followers · starred Jun 2026
11 followers · starred Jun 2026
31 followers · starred Jun 2026
Python
100.0%
Snap any image, screenshot, or webpage into plaintext. No GPU. No cloud. One command.
Python
186
6 commits
updated Sep 29, 2026
Snap any image, screenshot, or webpage into plaintext. No GPU. No cloud. One command.

textsnap screenshot.png
That's it. You get a .txt next to your shell, recognized on your CPU, from a screenshot, a photo, an image URL, or even a webpage.
--plaintext to flatten it.# Install
pip install textsnap
# Snap something
textsnap screenshot.png
textsnap https://example.com/article --plaintext
textsnap photo.jpg -o ~/notes/receipt.txt
textsnap scans/*.png -o ~/notes/scans/ # batch: model loads once
The first run downloads the model (~1.1 GB). Every run after is offline.
| Source | Example |
|---|---|
| Clipboard | textsnap (no argument) |
| Local image file | textsnap path/to/img.png |
| Direct image URL | textsnap https://example.com/x.png |
| Webpage URL | textsnap https://example.com/article |
| Several of the above | textsnap a.png b.jpg https://example.com/x.png |
Local files cover anything Pillow can decode: .png, .jpg, .jpeg, .webp, .bmp, .gif, .tiff, and friends. For webpage URLs, textsnap uses readability to isolate the main content, then picks the most prominent image on the page and OCRs that.
Run textsnap with no argument and it reads the image currently on your clipboard. The recognized text is then copied straight back to the clipboard, so a screenshot-to-text round trip is just: snap → textsnap → paste.
The .txt file is still written as well (and its path still printed to stdout), so nothing about scripting changes — the clipboard copy is a pure convenience layered on top.
Clipboard-out uses your platform's native tool — pbcopy (macOS), clip (Windows), or wl-copy / xclip / xsel (Linux) — so it needs no extra Python package. If none of those is installed, textsnap simply skips the clipboard copy; the .txt file is always there regardless. (Run with -v to see whether the copy succeeded.)
By default textsnap downloads its model files to an OS cache directory (~/.cache/textsnap/). But if it finds the model files sitting next to the script, it uses those directly — no download, no --model-dir flag, no setup at all.
"Next to the script" means a layout like:
textsnap/
├── textsnap.py
├── onnx/
│ ├── vision_encoder_q8.onnx
│ ├── decoder_q4.onnx
│ ├── embedding.onnx
│ └── embedding.onnx.data
└── tokenizer.json
Drop those files in, and you can copy the entire textsnap/ folder to any machine — a USB stick, an air-gapped box, a fresh laptop — and run it immediately, fully offline, with zero install steps.
Model-directory resolution order:
--model-dir DIR — if you pass it explicitly, it always wins.~/.cache/textsnap/, downloading on first run if needed.Like
--model-dir, portable-mode files are not SHA-256 verified — files you placed there yourself are trusted by definition. Integrity verification applies to files textsnap downloads. See Security.
pip install textsnap
Installs two equivalent commands on your PATH: textsnap (canonical) and ocr (alias, for when the name slips your mind).
To install from a local source checkout instead:
pip install .
For a reproducible install with exact pinned dependency versions:
pip install -r requirements-lock.txt
pip install .
Clipboard note. Reading images from the clipboard relies on Pillow's
ImageGrab; on Linux you may needxcliporwl-clipboardinstalled. Writing recognized text back to the clipboard usespbcopy/clip/wl-copy/xclip/xsel. macOS and Windows work out of the box.
# Clipboard (no argument) — text is also copied back to the clipboard
textsnap
# Local image file
textsnap path/to/screenshot.png
# Direct image URL
textsnap "https://example.com/diagram.png"
# Webpage — OCRs the most prominent image on the page
textsnap "https://example.com/article"
# Flatten the model's markdown to plain text
textsnap input.png --plaintext
# Custom output path
textsnap input.png -o ./out/extracted.txt
# Raise the token cap for very dense pages
textsnap dense-page.png --max-tokens 4096
# Trade accuracy for speed by shrinking the image budget
textsnap input.png --max-pixels 250000
# Use a local model directory instead of downloading
textsnap input.png --model-dir ~/models/paddleocr-vl
# Batch: many inputs, one model load. Globs work even where the shell
# doesn't expand them (Windows).
textsnap page1.png page2.png "scans/*.jpg" -o ./ocr-out/
# Use the smaller 4-bit vision encoder (see "Vision encoder variants")
textsnap input.png --vision q4
Plaintext, UTF-8. Default location is ./textsnaps/ (created if missing) under the current working directory; override with -o. The filename is derived from the image filename stem (receipt_ocr.txt), or from the webpage slug for URL inputs.
textsnap is quiet by default, Unix-style: the only thing printed to stdout is the path to the file it wrote, so it composes cleanly —
OUT=$(textsnap receipt.png) # capture the path
textsnap receipt.png | xargs cat # print the recognized text
With more than one input, the model is loaded once and each image is processed in turn. stdout gets one path per line, printed as each result is written, so textsnap *.png | while read f; do ...; done starts working before the batch finishes.
-o names a directory (created if missing); without it, results go to ./textsnaps/ as usual.a/page.png, b/page.png) don't overwrite each other: the second becomes page_ocr_2.txt.[textsnap] FAILED <input>: <reason> and skipped; the rest still run, and the exit status is 1 at the end. A model that fails to load stops the whole batch.When the input is the clipboard, the recognized text is also placed on the clipboard — see Clipboard in, clipboard out.
Pass -v to send progress diagnostics (input type, image size, decode speed, token counts) to stderr; stdout stays just the path either way.
Default file output is the model's native markdown — it preserves tables, headings, and document structure:
# Quarterly Report
| Region | Revenue |
| ------ | ------- |
| EMEA | $1.2M |
| APAC | $0.9M |
With --plaintext, markdown is flattened to bare text:
Quarterly Report
Region Revenue
EMEA $1.2M
APAC $0.9M
| Flag | Description |
|---|---|
-o, --output | Output .txt path, or a directory (required to be a directory with several inputs). Default: ./textsnaps/<name>_ocr.txt. |
-v, --verbose | Print progress diagnostics to stderr. Off by default. |
--plaintext | Flatten the model's native markdown to plain text. |
--model-dir | Use ONNX/config files from this directory. Overrides portable mode and the OS cache. |
--max-tokens | Cap generated tokens. Default 2048. Raise it for very dense pages. |
--max-pixels | Image pixel budget fed to the vision encoder. Default is the model's maximum. Lower trades accuracy for speed; too low makes the model hallucinate. The image is only ever shrunk, never enlarged. |
--vision | Vision-encoder variant: q8 (default) or q4. See Vision encoder variants. |
--no-verify | Skip SHA-256 verification of downloaded model files (not advised). |
--generate-checksums | Download the pinned model files, write a fresh manifest, and exit. |
Environment variables:
| Variable | Effect |
|---|---|
TEXTSNAP_DECODE_THREADS | Decoder intra-op thread count, for tuning CPU decode on a specific machine. Default: a sensible value from your physical core count. |
TEXTSNAP_VISION | Default for --vision. |
TEXTSNAP_EMBED=ort | Run token embeddings through an ONNX Runtime session instead of the memory-mapped table (for debugging; output is identical). |
TEXTSNAP_LOGITS_SLICE=0 | Use the decoder graph exactly as shipped instead of the last-position patch (for debugging; output is identical). |
Two builds of the vision encoder are available, selectable with --vision:
| Variant | Download | Notes |
|---|---|---|
q8 (default) | 443 MB | 8-bit dynamic quantization. The encoder is compute-bound, and integer GEMMs make it faster: ~1.3x over q4 in testing, with fewer misreads. |
q4 | 231 MB | 4-bit weight-only. Smallest download. |
Only the variant you use is downloaded, and every variant is SHA-256 pinned. The variants produce slightly different image features, so switching changes output. To compare them on images like yours:
python bench_vision.py samples/*.png # q4 vs q8
python bench_vision.py samples/*.png --truth truth/ # plus CER vs truth/<stem>.txt
It reports encoder time, total time, and character error rate against the q4 output, and against ground truth if given. Ground truth is the better yardstick, since neither variant is full precision.
textsnap auto-downloads ~1.1 GB of model weights from the Hugging Face Hub on first run, so it treats those files as untrusted until proven otherwise:
main can't silently swap the weights.embedding.onnx.data, which holds the actual embedding weights — is hashed and checked against known-good digests before it's loaded. A mismatch aborts the run with a clear error rather than executing unverified weights. Digests live in model_checksums.sha256 and are also embedded in the script; entries in the manifest override the embedded ones, and files the manifest doesn't mention still fall back to the embedded digest, so verification works whether you install from source or from a wheel.requirements-lock.txt pins exact dependency versions for reproducible installs; the file documents how to add per-wheel --hash entries with pip-compile --generate-hashes for full supply-chain pinning.Verification applies to files textsnap downloads. Model files you supply yourself — via --model-dir or portable mode — are trusted as-is and not re-hashed; you are responsible for their provenance.
Regenerate the checksum manifest after a deliberate model-revision bump:
textsnap --generate-checksums
To bypass verification (for local experimentation with a modified model), pass --no-verify.
--max-pixels) and snaps it to the patch grid — textsnap does not pre-shrink beyond that, since starving the encoder of resolution makes the model hallucinate rather than degrade gracefully.--plaintext reduces it to bare text.No image is sent anywhere. No state is kept between runs except the cached model.
The PaddleOCR-VL-1.5 ONNX components are downloaded on first run to ~/.cache/textsnap/:
onnx/vision_encoder_q8.onnx — vision encoder + spatial-merge projector (or vision_encoder_q4.onnx with --vision q4)onnx/decoder_q4.onnx — autoregressive decoderonnx/embedding.onnx + onnx/embedding.onnx.data — token-embedding graph and its fp32 table (no q4 variant exists)tokenizer.json, config.jsonTogether ~1.1 GB. To use your own copy, either point --model-dir at a directory containing the same onnx/ files plus tokenizer.json and config.json, or place those files next to the script for portable mode.
--max-tokens caps the output. Very dense pages can hit the default 2048-token cap and truncate; raise it if the tail of a page is missing.--max-pixels is a speed/accuracy dial. Lowering it speeds up the vision encoder but feeds the model a coarser image; set it too low and recognition quality drops sharply. The default (the model's full budget) is the safe choice.MIT for this project — see LICENSE.
The model is PaddleOCR-VL-1.5, distributed under Apache-2.0 by PaddlePaddle; textsnap downloads the ONNX export from kouhxp/PaddleOCR-VL-1.5-ONNX, an unmodified, pinned mirror of selected files from onnx-community/PaddleOCR-VL-1.5-ONNX. See the original model card for model terms. Powered by onnxruntime and huggingface_hub.
161 followers · starred Jun 2026
11 followers · starred Jun 2026
31 followers · starred Jun 2026
Python
100.0%