Posted on 2025-03-15
I recently purchased a Wacom graphics tablet for note taking. Especially on Linux, I am not satisfied with existing note taking programs. Being a stubborn programmer, I have decided to build my own solution by extending my favourite image editor, the GIMP.
I am a programmer with sophistication, so when I learned that the GIMP has its own built-in scheme for scripting I was thrilled. I committed to learn this elegant, parenthesized language. That was over 10 years ago.
Script-Fu is exactly what I would expect from a scheme scripting language. Here is the example script from the GIMP documentation:
define (script-fu-text-box inText inFont inFontSize inTextColor))
(
(script-fu-register"script-fu-text-box" ;function name
"Text Box" ;menu label
"Creates a simple text box, sized to fit\
around the user's choice of text,\
font, font size, and color." ;description
"Michael Terry" ;author
"copyright 1997, Michael Terry;\
2009, the GIMP Documentation Team" ;copyright notice
"October 27, 1997" ;date created
"" ;image type that the script works on
"Text" "Text Box" ;a string variable
SF-STRING "Font" "Charter" ;a font variable
SF-FONT "Font size" '(50 1 1000 1 10 0 1)
SF-ADJUSTMENT ;a spin-button
"Color" '(0 0 0) ;color variable
SF-COLOR
)"script-fu-text-box" "<Image>/File/Create/Text") (script-fu-menu-register
Here a function script-fu-text-box
is defined, which I assume draws text in a box.
This script goes into the GIMP scripts folder, eg .config/GIMP/2.10/scripts
with the extension .scm
. It should appear in the File menu after Filters>Script-Fu>Refresh Scripts
. As expected, this adds a menu item under File>Create>Text
.
I want to be able to flip between pages of a notebook with button presses. These will be scripts bound to buttons. We don’t need to worry about the key bindings yet, just the scripts that drive the actions.
Each “page” will be a layer, named page-{n}
. To swap to the next or previous page, I will search for a layer called page-{n+1}
or page-{n-1}
respectively. If no such page exists (for n > 0
), I will create a new layer.
The functions gimp-image-get-layer-by-name
, gimp-layer-new
and gimp-image-insert-layer
seem to satisfy all of this functionality.
First, I make an example script to try adding a layer to an open image. I want to experiment in the script console, however I can’t seem to use gimp-layer-new
here because it takes an image parameter. I can’t work out where to get the current, active image from - to write a script that works with the open image, it seems like the script has to declare a parameter of type SF-IMAGE when being registered.
So I put my example script into the directory and click-ops loading it:
define (script-fu-pen-add-layer image)
(let* ( (layer (gimp-layer-new image 100 100 RGBA-IMAGE "pg" 100.0 LAYER-MODE-NORMAL)) )
(0 0)))
(gimp-image-insert-layer image layer
(script-fu-register"script-fu-pen-add-layer" ;function name
"Pen: Add Layer (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
"Image" ""
SF-IMAGE
)"script-fu-pen-add-layer" "<Image>/Pen/Test") (script-fu-menu-register
Clicking to refresh scripts, I am greeted with a popup:
Error while loading /home/tom/.config/GIMP/2.10/scripts/test.scm:
Error: (/home/tom/.config/GIMP/2.10/scripts/test.scm : 15) script-fu-register: default IDs must be integer values
How mysterious. This is not the best experience for debugging.
Minutes pass, and I work out that the default argument for an SF-IMAGE should be 0
, not an empty string. I definitely saw examples that used empty string here. I can’t find any official documentation with an opinion, or any detail on what this function expects. Even the official function reference opened from the scripting console has no entry for script-fu-register
(this may be because you can’t register a script from the console?).
Overall trying to find any documentation for script-fu is painful. Now that I have a script that I can run from a new top-level submenu (defining these menu entries is extremely exciting for some reason) it’s time for the next error.
Execution error for 'Pen: Add Layer (Test)':
Error: Invalid type for argument 2 to gimp-image-insert-layer
At least it’s precise about the function. I can’t be sure if the arguments are 1-indexed or 0-indexed, but I’m going to assume this is the first 0
I am passing in to gimp-image-insert-layer
.
The third argument is described in the help:
parent LAYER The parent layer
What if I don’t have a parent layer? I was hoping 0
would be acceptable, but perhaps an integer is not valid here. I could try getting the current active layer first and making this a child? Or maybe create a new layer group to contain all of my pages?
define (script-fu-pen-add-layer image)
(let* (
(100 100 RGBA-IMAGE "pg" 100.0 LAYER-MODE-NORMAL))
(layer (gimp-layer-new image
(group (gimp-layer-group-new image))
)0)))
(gimp-image-insert-layer image layer group
(script-fu-register"script-fu-pen-add-layer" ;function name
"Pen: Add Layer (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
"Image" 0
SF-IMAGE
)"script-fu-pen-add-layer" "<Image>/Pen/Test") (script-fu-menu-register
I am greeted with the same error. I also have to confront the painful experience of forgetting to click to reload the scripts. This would be much better from a REPL in the console.
After giving up to play some chess and consume lunch, I came back to stare at the REPL. With a sudden wave of inspiration, I started typing:
> (gimp-image-get-layers 0)
Error: Procedure execution of gimp-image-get-layers failed on invalid input arguments: Procedure 'gimp-image-get-layers' has been called with an invalid ID for argument 'image'. Most likely a plug-in is trying to work on an image that doesn't exist any longer.
> (gimp-image-get-layers 1)
Error: Procedure execution of gimp-image-get-layers failed on invalid input arguments: Procedure 'gimp-image-get-layers' has been called with an invalid ID for argument 'image'. Most likely a plug-in is trying to work on an image that doesn't exist any longer.
> (gimp-image-get-layers 2)
(1 #(8))
Aha! Images are just an integer index, and with some trial and error I can grab the current image! I had this instance of the GIMP open for a while, and had opened at least one image previously, so it is possible that these indices are simple counting numbers that increment by 1.
> (gimp-image-get-layer-by-name 2 "Background")
(8)
Now we’re really getting somewhere. Let’s try adding a layer again:
> (define nl (gimp-layer-new 2 100 100 RGBA-IMAGE "page-0" 100 LAYER-MODE-NORMAL))
nl
> nl
(11)
> (gimp-image-insert-layer 2 nl 8 0)
Error: Invalid type for argument 2 to gimp-image-insert-layer
What if I used the layer index 11
directly:
> (gimp-image-insert-layer 2 11 8 0)
Error: Procedure execution of gimp-image-insert-layer failed on invalid input arguments: Item 'Background' (8) cannot be used because it is not a group item
> (gimp-image-insert-layer 2 11 0 0)
(#t)
That was unexpected. Why can’t I insert a layer by variable reference? Is this a macro which stops the variable nl
from being evaluated? I go back over the history and notice a critical, easy to miss detail.
> (define nl (gimp-layer-new 2 100 100 RGBA-IMAGE "page-0" 100 LAYER-MODE-NORMAL))
nl
> nl
(11)
Do you see it? (11)
, aka a list containing one value, the number 11
. So now, if I unwrap that list with a car
, I have everything I need to make some progress:
let* ((page-group 0)
(#t))
(once-guard
define (script-pre image)
(if once-guard
(do
(set! page-group (gimp-layer-group-new image))
(set! once-guard #f))))
(
define (script-fu-pen-add-layer image)
(
(script-pre image)let* (
(car (gimp-layer-new image 100 100 RGBA-IMAGE "pg" 100.0 LAYER-MODE-NORMAL)))
(layer (
)0)))
(gimp-image-insert-layer image layer page-group
(script-fu-register"script-fu-pen-add-layer" ;function name
"Pen: Add Layer (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
"Image" 0
SF-IMAGE
)
"script-fu-pen-add-layer" "<Image>/Pen/Test")) (script-fu-menu-register
I tried being clever with scopes, but this didn’t work. It seems like either the function, or the call to -register
need to be at the top level. So I re-write my file-local variables with define
and it works:
(define page-group 0)
(define once-guard #t)
(define page-counter 0)
(define (script-pre image)
(if once-guard
(do
(set! page-group (gimp-layer-group-new image))
(set! once-guard #f))))
(define (script-fu-pen-add-layer image)
(script-pre image)
(let* (
(layer (gimp-layer-new image 100 100 RGBA-IMAGE "pg" 100.0 LAYER-MODE-NORMAL))
)
(gimp-image-insert-layer image (car layer) page-group 0)))
(script-fu-register
"script-fu-pen-add-layer" ;function name
"Pen: Add Layer (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
SF-IMAGE "Image" 0
)
(script-fu-menu-register "script-fu-pen-add-layer" "<Image>/Pen/Test")
Now let’s get back to the goal of flipping between pages. I add a helper function to step forwards and backwards, updating the active layer and toggling visibility as required:
2)
(gimp-message-set-handler
define page-group 0)
(define once-guard #t)
(define page-counter 0)
(
define (script-pre image)
(if once-guard
(do
(set! page-group (gimp-layer-group-new image))
(set! once-guard #f))))
(
; returns a string of the active layer name
define (get-active-layer-name image)
(car (gimp-layer-get-name (car (gimp-image-get-active-layer image)))))
(
; true if any numeric page is active
define (is-on-any-page image)
(not (not (string->number (get-active-layer-name image)))))
(
define (next-page-number image step)
(let* (
(
(layer-name (get-active-layer-name image))string->number layer-name))
(layer-num-raw (if (layer-num-raw) (+ layer-num-raw step) 0))
(next-num (if (< next-num 0) 0 next-num)))
(next-bounded ("set visible")
(gimp-message car (gimp-image-get-active-layer image)) 0)
(gimp-layer-set-visible ("test bounded")
(gimp-message if (> next-bounded page-counter)
(; create new layer if out of bounds of our counter
let ((layer (car (gimp-layer-new image 1 1 RGBA-IMAGE (number->string next-bounded) 100.0 LAYER-MODE-NORMAL))))
("resize and insert new layer")
(gimp-message
(gimp-layer-resize-to-image-size layer)0))
(gimp-image-insert-layer image layer page-group set! page-counter next-bounded)
(; else just make the old layer active and visible
let ((tgt (car (gimp-get-layer-by-name (number->string next-bounded)))))
("set visible")
(gimp-message 1)
(gimp-image-layer-set-visible tgt
(gimp-image-set-active-layer image tgt)))))
define (script-fu-pen-next-page image)
(
(script-pre image)1))
(next-page-number image
define (script-fu-pen-prev-page image)
(
(script-pre image)1))
(next-page-number image -
(script-fu-register"script-fu-pen-next-page" ;function name
"Pen: Next Page (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
"Image" 0
SF-IMAGE
)
(script-fu-register"script-fu-pen-prev-page" ;function name
"Pen: Prev Page (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
"Image" 0
SF-IMAGE
)
"script-fu-pen-next-page" "<Image>/Pen/Pages")
(script-fu-menu-register "script-fu-pen-prev-page" "<Image>/Pen/Pages") (script-fu-menu-register
Take note of the first line, and gimp-message
. Usually gimp-message
shows information in the GUI, but if you set the handler to 2 it shows up in the error console. This was extremely useful in ironing out the following few issues. The first thing I see in there when running this is another extremely unhelpful message:
Execution error for 'Pen: Next Page (Test)':
Error: illegal function
I stare at my screen for about 15 minutes before noticing:
...
(let* (
(layer-name (get-active-layer-name image))
(layer-num-raw (string->number layer-name))
>>>> (next-num (if (layer-num-raw) (+ layer-num-raw step) 0)) <<<< THIS LINE HERE
...
I was trying to call layer-num-raw
as a function, but it is a number! I suppose a number really is an illegal function, in a way. I wish it gave me a line number and/or the name of the function that I tried to call which cased the error but you take what you get.
Also I had a misplaced bracket in an if statement, but there was no warning that I was skipping out a third form in the body. That was a very hard mistake to spot!
Finally after some trial and error, I cleanup several remaining errors and typos, and everything works! I can move backward and forwards through pages, adding new ones as required, but never going below 0.
The final script:
2)
(gimp-message-set-handler
define page-group 0)
(define once-guard #t)
(define page-counter -1)
(
define (script-pre image)
(if once-guard
(do
(set! page-group (gimp-layer-group-new image))
(set! once-guard #f))))
(
; returns a string of the active layer name
define (get-active-layer-name image)
(car (gimp-layer-get-name (car (gimp-image-get-active-layer image)))))
(
; true if any numeric page is active
define (is-on-any-page image)
(not (not (string->number (get-active-layer-name image)))))
(
define (next-page-number image step)
(let* (
(
(layer-name (get-active-layer-name image))string->number layer-name))
(layer-num-raw (if layer-num-raw (+ layer-num-raw step) 0))
(next-num (if (< next-num 0) 0 next-num)))
(next-bounded ("set invisible")
(gimp-message car (gimp-image-get-active-layer image)) 0)
(gimp-layer-set-visible ("test bounded")
(gimp-message if (> next-bounded page-counter)
(; create new layer if out of bounds of our counter
let ((layer (car (gimp-layer-new image 1 1 RGBA-IMAGE (number->string next-bounded) 100.0 LAYER-MODE-NORMAL))))
("resize and insert new layer")
(gimp-message 0)
(gimp-image-insert-layer image layer page-group
(gimp-layer-resize-to-image-size layer)set! page-counter next-bounded))
(; else just make the old layer active and visible
let ((tgt (car (gimp-image-get-layer-by-name image (number->string next-bounded)))))
(string-append "set existing layer visible: " (number->string next-bounded)))
(gimp-message (1)
(gimp-layer-set-visible tgt
(gimp-image-set-active-layer image tgt)))))
define (script-fu-pen-next-page image)
(
(script-pre image)1))
(next-page-number image
define (script-fu-pen-prev-page image)
(
(script-pre image)1))
(next-page-number image -
(script-fu-register"script-fu-pen-next-page" ;function name
"Pen: Next Page (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
"Image" 0
SF-IMAGE
)
(script-fu-register"script-fu-pen-prev-page" ;function name
"Pen: Prev Page (Test)" ;menu label
"(WIP)"
"Tom Marks (phy1um)" ;author
"" ;copyright notice
"March 15th, 2025" ;date created
"" ;image type that the script works on
"Image" 0
SF-IMAGE
)
"script-fu-pen-next-page" "<Image>/Pen/Pages")
(script-fu-menu-register "script-fu-pen-prev-page" "<Image>/Pen/Pages") (script-fu-menu-register
Script-Fu is an interesting and powerful scripting environment. However, the poor developer experience around error reporting and documentation makes it extremely rough to work with. There is a serious lack of examples, and very limited official documentation showing how to actually do anything.
Simple problems I encountered such as not unwrapping results with car
create unnecessary friction. If your target audience are experienced programmers, it shouldn’t be hard to document or teach by example that this kind of unwrapping is required. I am still very confused by why these functions return lists at all!
I wish the REPL was a little bit friendlier. Hitting escape closes the window immediately, at least on my system (arch btw), and any history you have is instantly forgotten. I think the console experience would be better if I could connect to the script-fu server, but I didn’t end up trying that today.
Ultimately my script works, and I’m happy with that. It just took about 1 hour longer than it should have :D