agzam/clipimg.el

Deal with image in clipboard

0

stars

6

commits

Emacs Lisp

primary language

Sep 15, 2026

updated

README

#+title: clipimg.el

Take the image off the clipboard and do something with it, from a transient menu.

Today the menu recognizes text, uploads and saves. An attach column comes next.

* Requirements

- Emacs 29.1 and transient.
- macOS: nothing else. Text recognition goes through the Vision framework, which knows thirty languages out of the box.
- Elsewhere: [[https://github.com/tesseract-ocr/tesseract][tesseract]], plus the language data files you need.
- A terminal frame owns no selection, so there Emacs reads the clipboard through =wl-paste=, =xclip= or =osascript= instead.

* Installation

#+begin_src elisp
(use-package clipimg
  :vc (:url "https://github.com/agzam/clipimg.el")
  :bind ("C-c i" . clipimg))
#+end_src

* Usage

=M-x clipimg= reads the image on the clipboard and opens the menu on it. The image is read once, so nothing breaks when the clipboard moves on; =r= takes a fresh snapshot.

| key | what it does                                         |
|-----+------------------------------------------------------|
| =-b=  | engine to recognize with                             |
| =-l=  | language the engine should read                      |
| =-y=  | layout: =block=, =sparse= or =auto=                        |
| =RET= | show the text in a buffer                            |
| =w=   | put the text on the kill ring                        |
| =i=   | insert the text at point in the buffer you came from |
| =-s=  | host to upload to                                    |
| =u=   | upload, then put the URL on the kill ring            |
| =U=   | upload, then insert the URL at point                 |
| =s=   | save to a file                                       |
| =r=   | read the clipboard again                             |

=w= leaves the system clipboard alone, so the image survives and you can read it again with another layout.

The text buffer shows the image above the text on a graphical frame, and the summary line instead on a terminal frame. =g= recognizes again, =w= copies the text.

=M-x clipimg-ocr= skips the menu and shows the text straight away.

** Layout

=block= joins boxes that sit side by side into one line, which is what you want for a paragraph or a table row. =sparse= gives every box a line of its own, which suits scattered labels. =auto= leaves it to the engine.

* Upload

=u= sends the image to an image host and hands back the URL. Every upload asks first, naming the host, the size and how long the image stays there, because most of these hosts cannot take one back.

Unlike =w=, =u= puts the URL on the system clipboard as well, replacing the image there. Pasting the link somewhere else is the next thing you were going to do anyway, and the menu is unharmed either way: it works on the snapshot it took when it opened.

An upload goes out as PNG, converted first when the clipboard handed over something else - =clipimg-upload-format= decides, and nil sends the clip as it is. The size in the question is the size that goes over the wire, so a TIFF from a macOS clipboard is announced at its PNG size, not its own. Without a converter on PATH the clip goes as it is.

| service   | how long it stays         | what it needs              |
|-----------+---------------------------+----------------------------|
| =litterbox= | 72 hours                  | nothing                    |
| =catbox=    | kept, your address logged | nothing, userhash optional |
| =uguu=      | 3 hours                   | nothing                    |

Litterbox is the default because it needs no account and throws the image away after three days, which is the least an upload can commit you to. None of the three needs anything configured.

Every entry in that table has had this package's own bytes put on the real host and read back byte for byte. =make check-hosts= does it again, and nothing joins the table without passing it. A host that has quietly died, or that answers with a landing page instead of the image, fails there and nowhere else - a suite that stops at the transport cannot tell.

Three hosts were considered and are deliberately absent. 0x0.st disabled uploads. tmpfiles.org uploads fine but answers with a viewer page whose direct image link carries a signed token that only appears on that page, so there is no image URL to hand back. Imgur stopped issuing API credentials altogether, so no new user can configure it, and the endpoint now answers only to applications registered before they closed the door.

Adding any of them back is a table entry, shown under [[*Another service][Another service]]. For Imgur, if you hold one of those old keys: post to =https://api.imgur.com/3/image= with the file in an =image= field, =:credential (:host "api.imgur.com" :header "Authorization" :format "Client-ID %s")= and =:answer (data link)=.

** Credentials

A service that needs a credential reads it from auth-source, and only when you upload, so opening the menu never asks for your passphrase. Of the hosts that ship, only Catbox takes one, and it is optional:

#+begin_example
machine catbox.moe password YOUR-USERHASH
#+end_example

A Catbox userhash ties an upload to your account so you can delete it later. Catbox keeps the image either way.

An entry can also ship a key of its own in =:default=, which is what a package does when it registers once on behalf of everyone. A line in your authinfo always wins over it, so your own account never rides on a shared key.

** Another service

A service is an entry in =clipimg-upload-services=: where the POST goes, what the file field is called, and how to find the URL in the reply. =:answer= is =text= for a host that replies with a bare URL, a list of symbols for a path into JSON, or a function of the reply body.

#+begin_src elisp
(add-to-list 'clipimg-upload-services
             '(my-host
               :label "my host"
               :url "https://example.com/api/upload"
               :file-field "file"
               :fields (("kind" . "image"))
               :credential (:host "example.com" :header "Authorization" :format "Bearer %s")
               :answer (result url)
               :retention "a week"))
#+end_src

* Save

=s= asks for a file name and writes the image there. The prompt starts in =clipimg-save-directory= and offers a name stamped with the time the clip was taken, =clipimg-20260915-103000.png=. Edit as much of it as you like; the extension you leave decides the format.

That name ends in =.png= whatever the clipboard holds, because =clipimg-save-default-format= says =png=. A clipboard is not always asked: Qt programs on macOS publish every capture as TIFF, several times the size of the same image as PNG. Set the variable to nil to be offered the format the clip is actually in.

An extension naming the format the clip is already in costs nothing - the bytes go to disk as they are. Any other extension is a conversion, and that needs =magick=, =gm= or =convert= on PATH. Formats verified here: =png=, =jpg=, =tiff=, =webp= and =gif=. A converted file is never written until Emacs has read the converter's answer back and recognized it as the format you asked for.

Without one of those programs the prompt offers the format of the clip instead, so the suggested name is always one that can be written. Type an extension it cannot produce and the minibuffer says so while you type, rather than after you answer.

Emacs has =image-convert= for this and clipimg does not use it. It reaches for ffmpeg before ImageMagick, and ffmpeg names its encoders its own way, so a jpeg fails outright. Its ImageMagick path reads standard error back into the image data, and ImageMagick 7 writes a deprecation warning there whenever it is called as =convert= - which puts 107 bytes of English in front of your image and leaves a file no viewer opens.

=M-x clipimg-save= skips the menu and asks straight away.

* Configuration

| variable                    | what it decides                                 |
|-----------------------------+-------------------------------------------------|
| =clipimg-ocr-backend=         | engine, or =auto= for the first available one     |
| =clipimg-ocr-language=        | language the engine reads, nil for its default  |
| =clipimg-ocr-layout=          | starting layout                                 |
| =clipimg-ocr-backends=        | the engines themselves                          |
| =clipimg-upload-service=      | host an upload goes to                          |
| =clipimg-upload-services=     | the hosts themselves                            |
| =clipimg-upload-timeout=      | seconds to wait for a host to answer            |
| =clipimg-upload-format=       | format an upload goes out in, nil for the clip  |
| =clipimg-save-directory=      | directory the save prompt starts in             |
| =clipimg-save-default-format= | format the save prompt offers, nil for the clip |
| =clipimg-clipboard-types=     | clipboard media types, most wanted first        |
| =clipimg-preview-max-width=   | width the preview image is shrunk to            |
| =clipimg-ocr-buffer-name=     | name of the text buffer                         |

** Another engine

An engine is an entry in =clipimg-ocr-backends=: a label, a predicate saying whether this machine can run it, a function returning its languages, and a function taking a file, a language and a layout symbol.

#+begin_src elisp
(add-to-list 'clipimg-ocr-backends
             '(my-engine
               :label "my engine"
               :available-p (lambda () (and (executable-find "my-ocr") t))
               :languages (lambda () '("eng"))
               :recognize (lambda (file _language _layout)
                            (shell-command-to-string
                             (format "my-ocr %s" (shell-quote-argument file))))))
#+end_src

Entries earlier in the list win when =clipimg-ocr-backend= is =auto=.

* License

GPL-3.0-or-later. See [[file:LICENSE][LICENSE]].

Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.

Contributors

agzam

6 commits

agzam/clipimg.el

Deal with image in clipboard

0

stars

6

commits

Emacs Lisp

primary language

Sep 15, 2026

updated

README

#+title: clipimg.el

Take the image off the clipboard and do something with it, from a transient menu.

Today the menu recognizes text, uploads and saves. An attach column comes next.

* Requirements

- Emacs 29.1 and transient.
- macOS: nothing else. Text recognition goes through the Vision framework, which knows thirty languages out of the box.
- Elsewhere: [[https://github.com/tesseract-ocr/tesseract][tesseract]], plus the language data files you need.
- A terminal frame owns no selection, so there Emacs reads the clipboard through =wl-paste=, =xclip= or =osascript= instead.

* Installation

#+begin_src elisp
(use-package clipimg
  :vc (:url "https://github.com/agzam/clipimg.el")
  :bind ("C-c i" . clipimg))
#+end_src

* Usage

=M-x clipimg= reads the image on the clipboard and opens the menu on it. The image is read once, so nothing breaks when the clipboard moves on; =r= takes a fresh snapshot.

| key | what it does                                         |
|-----+------------------------------------------------------|
| =-b=  | engine to recognize with                             |
| =-l=  | language the engine should read                      |
| =-y=  | layout: =block=, =sparse= or =auto=                        |
| =RET= | show the text in a buffer                            |
| =w=   | put the text on the kill ring                        |
| =i=   | insert the text at point in the buffer you came from |
| =-s=  | host to upload to                                    |
| =u=   | upload, then put the URL on the kill ring            |
| =U=   | upload, then insert the URL at point                 |
| =s=   | save to a file                                       |
| =r=   | read the clipboard again                             |

=w= leaves the system clipboard alone, so the image survives and you can read it again with another layout.

The text buffer shows the image above the text on a graphical frame, and the summary line instead on a terminal frame. =g= recognizes again, =w= copies the text.

=M-x clipimg-ocr= skips the menu and shows the text straight away.

** Layout

=block= joins boxes that sit side by side into one line, which is what you want for a paragraph or a table row. =sparse= gives every box a line of its own, which suits scattered labels. =auto= leaves it to the engine.

* Upload

=u= sends the image to an image host and hands back the URL. Every upload asks first, naming the host, the size and how long the image stays there, because most of these hosts cannot take one back.

Unlike =w=, =u= puts the URL on the system clipboard as well, replacing the image there. Pasting the link somewhere else is the next thing you were going to do anyway, and the menu is unharmed either way: it works on the snapshot it took when it opened.

An upload goes out as PNG, converted first when the clipboard handed over something else - =clipimg-upload-format= decides, and nil sends the clip as it is. The size in the question is the size that goes over the wire, so a TIFF from a macOS clipboard is announced at its PNG size, not its own. Without a converter on PATH the clip goes as it is.

| service   | how long it stays         | what it needs              |
|-----------+---------------------------+----------------------------|
| =litterbox= | 72 hours                  | nothing                    |
| =catbox=    | kept, your address logged | nothing, userhash optional |
| =uguu=      | 3 hours                   | nothing                    |

Litterbox is the default because it needs no account and throws the image away after three days, which is the least an upload can commit you to. None of the three needs anything configured.

Every entry in that table has had this package's own bytes put on the real host and read back byte for byte. =make check-hosts= does it again, and nothing joins the table without passing it. A host that has quietly died, or that answers with a landing page instead of the image, fails there and nowhere else - a suite that stops at the transport cannot tell.

Three hosts were considered and are deliberately absent. 0x0.st disabled uploads. tmpfiles.org uploads fine but answers with a viewer page whose direct image link carries a signed token that only appears on that page, so there is no image URL to hand back. Imgur stopped issuing API credentials altogether, so no new user can configure it, and the endpoint now answers only to applications registered before they closed the door.

Adding any of them back is a table entry, shown under [[*Another service][Another service]]. For Imgur, if you hold one of those old keys: post to =https://api.imgur.com/3/image= with the file in an =image= field, =:credential (:host "api.imgur.com" :header "Authorization" :format "Client-ID %s")= and =:answer (data link)=.

** Credentials

A service that needs a credential reads it from auth-source, and only when you upload, so opening the menu never asks for your passphrase. Of the hosts that ship, only Catbox takes one, and it is optional:

#+begin_example
machine catbox.moe password YOUR-USERHASH
#+end_example

A Catbox userhash ties an upload to your account so you can delete it later. Catbox keeps the image either way.

An entry can also ship a key of its own in =:default=, which is what a package does when it registers once on behalf of everyone. A line in your authinfo always wins over it, so your own account never rides on a shared key.

** Another service

A service is an entry in =clipimg-upload-services=: where the POST goes, what the file field is called, and how to find the URL in the reply. =:answer= is =text= for a host that replies with a bare URL, a list of symbols for a path into JSON, or a function of the reply body.

#+begin_src elisp
(add-to-list 'clipimg-upload-services
             '(my-host
               :label "my host"
               :url "https://example.com/api/upload"
               :file-field "file"
               :fields (("kind" . "image"))
               :credential (:host "example.com" :header "Authorization" :format "Bearer %s")
               :answer (result url)
               :retention "a week"))
#+end_src

* Save

=s= asks for a file name and writes the image there. The prompt starts in =clipimg-save-directory= and offers a name stamped with the time the clip was taken, =clipimg-20260915-103000.png=. Edit as much of it as you like; the extension you leave decides the format.

That name ends in =.png= whatever the clipboard holds, because =clipimg-save-default-format= says =png=. A clipboard is not always asked: Qt programs on macOS publish every capture as TIFF, several times the size of the same image as PNG. Set the variable to nil to be offered the format the clip is actually in.

An extension naming the format the clip is already in costs nothing - the bytes go to disk as they are. Any other extension is a conversion, and that needs =magick=, =gm= or =convert= on PATH. Formats verified here: =png=, =jpg=, =tiff=, =webp= and =gif=. A converted file is never written until Emacs has read the converter's answer back and recognized it as the format you asked for.

Without one of those programs the prompt offers the format of the clip instead, so the suggested name is always one that can be written. Type an extension it cannot produce and the minibuffer says so while you type, rather than after you answer.

Emacs has =image-convert= for this and clipimg does not use it. It reaches for ffmpeg before ImageMagick, and ffmpeg names its encoders its own way, so a jpeg fails outright. Its ImageMagick path reads standard error back into the image data, and ImageMagick 7 writes a deprecation warning there whenever it is called as =convert= - which puts 107 bytes of English in front of your image and leaves a file no viewer opens.

=M-x clipimg-save= skips the menu and asks straight away.

* Configuration

| variable                    | what it decides                                 |
|-----------------------------+-------------------------------------------------|
| =clipimg-ocr-backend=         | engine, or =auto= for the first available one     |
| =clipimg-ocr-language=        | language the engine reads, nil for its default  |
| =clipimg-ocr-layout=          | starting layout                                 |
| =clipimg-ocr-backends=        | the engines themselves                          |
| =clipimg-upload-service=      | host an upload goes to                          |
| =clipimg-upload-services=     | the hosts themselves                            |
| =clipimg-upload-timeout=      | seconds to wait for a host to answer            |
| =clipimg-upload-format=       | format an upload goes out in, nil for the clip  |
| =clipimg-save-directory=      | directory the save prompt starts in             |
| =clipimg-save-default-format= | format the save prompt offers, nil for the clip |
| =clipimg-clipboard-types=     | clipboard media types, most wanted first        |
| =clipimg-preview-max-width=   | width the preview image is shrunk to            |
| =clipimg-ocr-buffer-name=     | name of the text buffer                         |

** Another engine

An engine is an entry in =clipimg-ocr-backends=: a label, a predicate saying whether this machine can run it, a function returning its languages, and a function taking a file, a language and a layout symbol.

#+begin_src elisp
(add-to-list 'clipimg-ocr-backends
             '(my-engine
               :label "my engine"
               :available-p (lambda () (and (executable-find "my-ocr") t))
               :languages (lambda () '("eng"))
               :recognize (lambda (file _language _layout)
                            (shell-command-to-string
                             (format "my-ocr %s" (shell-quote-argument file))))))
#+end_src

Entries earlier in the list win when =clipimg-ocr-backend= is =auto=.

* License

GPL-3.0-or-later. See [[file:LICENSE][LICENSE]].

Not written in Markdown, so it's shown here as plain text — view it formatted on GitHub.

Contributors

agzam

6 commits

Languages

Emacs Lisp

96.4%

Makefile

3.6%