2026-10-05
Converting ANI files to KDE compatible cursors
I bought this cool eevee cursor pack from JDZombi. Unfortunately I use Linux/KDE Plasma and the cursor files are Windows ANI files. I managed to convert them into KDE compatible cursors and figured I would share how.
KDE Cursor Format
So first, we should probably cover the KDE cursor format. KDE cursors
are stored in either the user’s ~/.local/share/icons/
folder or the global /usr/share/icons/. We can use the
/usr/share/icons/breeze_cursors/ folder as an example of
what it should look like at the end. The folder structure
should be
theme_folder |
|- index.theme (Contains theme metadata)
|- cursors |
|- cursorname (x11 cursor format file)
|- cursorname2 (x11 cursor format file)
|- cursors_scalable |
|- cursornanme |
|- cursorframe.svg (For animated cursors, each frame is a seperate svg)
|- cursorframe...svg
|- metadata.json (Metadata file containing frame order, info, timing, etc)
The cursornames are important, as they are expected to match specific names like “default”, “text” and “pointer”.
So, we need to create a new theme. We need to convert the cursors from ANI to x11 cursor files for the standard cursors directory, and we need to export the cursor frames to SVG with timing metadata.
Theme and Index
First, lets start by creating the theme. Create a new directory, name
it something recognizable. In this case I chose
eevee_cursor. I will work out of my downloads folder and
show screenshots from there as examples. Then inside that directory
create a index.theme file.
This file is super easy, we only need 4 lines. Header, The theme name, a theme comment/descript, and a fallback theme to inherit for when a cursor is not provided by our theme.
[Icon Theme]
Name=Eevee Cursor
Comment=Eevee Cursor by JDZombi
Inherits=default
X11 Cursor conversion
Next, lets work on the x11 cursors. Create the cursors
directory.
Now we need to convert the ani files. This is thankfully easy thanks to the ani2xcur project. Install it with
cargo install --git https://github.com/nicdgonzalez/ani2xcurThen restart your terminal. Navigate to your ANI cursor files, and convert them to x11. Example converting one of the eevee cursors:
ani2xcur convert "[15] - Link Select - Eevee - by jdzombi.ani"Once you got them all converted, move them into the
cursors directory for your new theme, and rename them to
match the expected cursor file names. For the eevee cursors, I suggest
the following names:
| Original Name | New Name |
|---|---|
| [1] - Normal Select - Eevee - by jdzombi | default |
| [2] - Help Select - Shiny Eevee - by jdzombi | help |
| [3] - Working in Background - Eevee - by jdzombi | progress |
| [4] - Busy - Eevee - by jdzombi | wait |
| [5] - Precision Select - Eevee - by jdzombi | crosshair |
| [6] - Text Select - Shiny Eevee - by jdzombi | text |
| [7] - Handwriting - Eevee - by jdzombi | pencil |
| [8] - Unavailable - Eevee - by jdzombi | not-allowed |
| [9] - Vertical Resize - Ditto - by jdzombi | size_ver |
| [10] - Horizontal Resize - Ditto - by jdzombi | size_hor |
| [11] - Diagonal Resize 1 - Ditto - by jdzombi | size_fdiag |
| [12] - Diagonal Resize 2 - Ditto - by jdzombi | size_bdiag |
| [13] - Move - Ditto - by jdzombi | all-scroll |
| [14] - Alternate Select - Ditto - by jdzombi | up-arrow |
| [15] - Link Select - Eevee - by jdzombi | pointer |
This does not cover all names required, we will later create symbolic
links for the rest of the names, mirroring the
breeze_cursors theme structure.
SVG cursor conversion
Now we need to create the scalable cursors. This is not strictly
required. You could use the new theme as-is, however you would be stuck
to the singular size for the cursor. This section allows us to scale the
cursor to custom sizes. Start by creating the
cursors_scalable directory in your custom theme
directory.
Now we need to convert the ani files into individual SVGs for each frame of each cursor’s animation. This is a 3 step process.
- Convert the ANI file into PNG frames. We will use GIMP for this.
- Convert the PNG files into SVG frames. A custom python script will help here.
- Fill out the metadata for the animated cursor and add to theme. A bit of manual text editing required for this one.
Convert ANI to PNG frames
Choose your next cursor to convert. For the example I will use
[1] - Normal Select - Eevee - by jdzombi.ani. Create a
matching directory in your theme’s cursors_scalable
directory. The directory name should match the table in the prior
section, such as default for the normal cursor.
Open the ANI file in GIMP. Gimp natively understands ANI files, and
it will open the file with each frame of the animation as a new layer.
Click File->Export As.. and export it as a
.ora open raster image file.
This is a bit of a trick to easily export each layer as a separate image. The ORA file structure is effectively a zip file and each frame of the animation or layer as shown in gimp is stored as a separate png file inside the zip file.
Rename the new .ora file to .zip
Extract it, and navigate to the internal data folder.
This is the folder that stores the frames we need.
So, we have each frame, time to convert them into SVG files. For this, we will use a custom python script. Ensure you have python installed, then copy the script below into a new .py file.
from PIL import Image
from pathlib import Path
# Ask user for directories
sourceDir = input("Please enter image directory: ")
# Grab all files in source, assuming images, on user to make sure
files = [f for f in Path(sourceDir).iterdir() if f.is_file()]
# Loop through the images
for imgFile in files:
imgPath = str(imgFile.absolute())
savePath = imgPath[:-3]+"svg"
if not imgPath.lower().endswith("png"):
print(f"Skipping {imgPath}")
continue
print(f"Trying {imgPath}")
img = Image.open(imgPath)
img = img.convert("RGBA")
width, height = img.size
pixels = img.load()
svgFile = f"<svg id=\"convertedSVG\" height=\"{height}\" width=\"{width}\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n"
#Start at top left pixel, ignore if transparent, create square in svg otherwise until all pixels accounted for
y = 0
while y < height:
x = 0
while x < width:
r, g, b, a = pixels[x, y]
if a < 255:
x+=1
continue
hexcolor = "#"+hex(r)[2:]+hex(g)[2:]+hex(b)[2:]
svgFile = svgFile + f"<rect width=\"1\" height=\"1\" x=\"{x}\" y=\"{y}\" fill=\"{hexcolor}\" />\n"
x+=1
y+=1
svgFile = svgFile + "</svg>"
# Save SVG to new file path
print(f"Saving {savePath}")
with open(savePath, "w", encoding="utf-8") as file:
file.write(svgFile)When this script is run, it will ask for a directory. Provide it the data directory we just extracted as an absolute path. The script will automatically go through and convert the png pixel-by-pixel into a new SVG file in the same directory.
Copy the SVG files into the correct directory in your
cursors_scalable directory. Continuing my example I would
use default. My SVG frames would go into
eevee_cursor/cursors_scalable/default/
Filling out the cursor metadata
The final step to make the cursor work, is we need to tell kde how to
animate the cursor using the svg files. This is handled in a
metadata.json file stored alongside the svg files.
That file has the structure of
[
{
"filename": "000.svg",
"delay": 60,
"hotspot_x": 0,
"hotspot_y": 0,
"nominal_size": 32
},
{
"filename": "001.svg",
"delay": 60,
"hotspot_x": 0,
"hotspot_y": 0,
"nominal_size": 32
},
...
{
"filename": "016.svg",
"delay": 60,
"hotspot_x": 0,
"hotspot_y": 0,
"nominal_size": 32
}
}Each image should be listed in the json file by order of the animation. This lines up with the layer names as exported from gimp, so 000 is first, then 001, etc. Each image in the json has several properties we need to fill.
| Property | What is? |
|---|---|
| filename | Name of the svg file for this frame, needs to be in same folder |
| delay | how long in ms we show the frame |
| hotspot_x | x coord of the cursor’s clicking spot. Where the tip of the pointer is in the image for example. |
| hotspot_y | y coord of the cursor’s clicking spot. Where the tip of the pointer is in the image for example. |
| nominal_size | The “normal” or target size for the cursor in pixels. |
This is a lot of data to type out, so lets use python again.
import json
from pathlib import Path
# Ask user for directory
sourceDir = input("Please enter image directory: ")
savePath = sourceDir + "/metadata.json"
if sourceDir.endswith("/"):
savePath = sourceDir + "metadata.json"
# Ask user for metadata info
try:
delay = int(input("Please enter frame delay in whole milliseconds: "))
xhotspot = int(input("Please enter x spot: "))
yhotspot = int(input("Please enter y spot: "))
nominalSize = int(input("Please enter nominal size: "))
except:
print("Failed to convert input to number")
quit(-1)
# Grab all files in source, sort by name, assuming name is sequential
files = sorted([f for f in Path(sourceDir).iterdir() if f.is_file()], key=lambda x: x.name.lower())
dataToExport = []
# Loop through the images
for imgFile in files:
fileName = str(imgFile.name)
if not fileName.lower().endswith("svg"): # Ensure we only count the SVGs
print(f"Skipping {fileName}")
continue
newFrame = { "filename": fileName,
"delay": delay,
"hotspot_x": xhotspot,
"hotspot_y": yhotspot,
"nominal_size": nominalSize
}
dataToExport.append(newFrame)
json_string = json.dumps(dataToExport, indent=4)
print(f"Saving {savePath}")
with open(savePath, "w", encoding="utf-8") as file:
file.write(json_string)When you run the script, it will ask for the absolute path to the directory containing the SVG files. Then it will ask for the metadata info it needs.
You can find the metadata information using gimp thankfully. Open the
original .ANI file of the cursor you are converting, click
File>Export As.., choose a new name but keep the
.ani extension, then review the properties presented.
Per the example image, we can see each frame has a hotspot of
x=0 and y=0. The delay is 6 jiffies at 16.66ms
a jiffy we get about 100ms. When filling out the script I
would use those values. As for nominal_size, you can use the original
ani size. Im gimp, click Image>Canvas Size.. and note
the width and height. They should be the same, in my case, 32x32, so I
can use 32 as my nominal size.
Note, one limitation of this script, is it assumes none of these properties change between frames. If you notice in GIMP that there are different delays, or the hotspot moves within the same cursor, you will need to hand edit the metadata file or come up with another solution.
With that, we should have our first scalable animated cursor complete!
Rinse and repeat for the rest of your cursors before moving onto the last step.
Symlinking cursors
By now, you should have all your cursors converted to x11 cursors in
the cursors directory, and as bundles of svg files in their
own directories under cursors_scalable.
The last step to make this theme more useful by symlinking similar
cursors to the one(s) we already have. You can review the
breeze_cursors theme as an example for this.
If we do a ls -la in that dir as an example, we can see
many cursors are smylink’d to a matching cursor file (or folder for
cursors_scalable)
For each cursor we converted, we need to link it’s aliases using
ln -s -r <real file> <alias> such as
ln -s -r default left_ptr. Following
breeze_cursors we get:
ln -s -r progress 00000000000000020006000e7e9ffc3f
ln -s -r size_ver 00008160000006810000408080010102
ln -s -r circle 03b6e0fcb3499374a867c041f52298f0
ln -s -r progress 08e8e1c95fe2fc01f976f1e063a24ccd
ln -s -r copy 1081e37283d90000800003c07f3ef6bf
ln -s -r alias 3085a0e285430894940527032f8b26df
ln -s -r progress 3ecb610c1bf2410f44200f48c40d3599
ln -s -r dnd-move 4498f0e0c1937ffe01fd06f973665830
ln -s -r help 5c6cd98b3f3ebcb1f9c7f1c204630408
ln -s -r copy 6407b0e94181790501fd1e167b474872
ln -s -r alias 640fb0e74195791501fd1ed57b41487f
ln -s -r dnd-move 9081237383d90e509aa00f00170e968f
ln -s -r pointer 9d800788f1b08800ae810202380a0822
ln -s -r alias a2a266d0498c3104214a47bd64ab0fc8
ln -s -r default arrow
ln -s -r copy b66166c04f8c3109214a4fbd64a50fc8
ln -s -r not-allowed circle
ln -s -r dnd-move closedhand
ln -s -r crosshair cross
ln -s -r not-allowed crossed_circle
ln -s -r help d9ce0ab605698f320427677b458ad60b
ln -s -r copy dnd-copy
ln -s -r dnd-move dnd-none
ln -s -r pointer e29285e634086352946a0e7090d73106
ln -s -r size_hor e-resize
ln -s -r size_hor ew-resize
ln -s -r dnd-move fcf21c00b30f7e3f83fe0dfd12e71cff
ln -s -r no-drop forbidden
ln -s -r openhand grab
ln -s -r closedhand grabbing
ln -s -r progress half-busy
ln -s -r pointer hand1
ln -s -r pointer hand2
ln -s -r size_hor h_double_arrow
ln -s -r text ibeam
ln -s -r default left_ptr
ln -s -r help left_ptr_help
ln -s -r progress left_ptr_watch
ln -s -r alias link
ln -s -r dnd-move move
ln -s -r size_bdiag ne-resize
ln -s -r size_bdiag nesw-resize
ln -s -r size_ver n-resize
ln -s -r size_ver ns-resize
ln -s -r size_fdiag nw-resize
ln -s -r size_fdiag nwse-resize
ln -s -r cell plus
ln -s -r pointer pointing_hand
ln -s -r help question_arrow
ln -s -r size_hor sb_h_double_arrow
ln -s -r size_ver sb_v_double_arrow
ln -s -r size_fdiag se-resize
ln -s -r fleur size_all
ln -s -r default size-bdiag
ln -s -r default size-fdiag
ln -s -r default size-hor
ln -s -r default size-ver
ln -s -r col-resize split_h
ln -s -r row-resize split_v
ln -s -r size_ver s-resize
ln -s -r size_bdiag sw-resize
ln -s -r crosshair tcross
ln -s -r default top_left_arrow
ln -s -r size_ver v_double_arrow
ln -s -r wait watch
ln -s -r help whats_this
ln -s -r size_hor w-resize
ln -s -r text xtermBut for JDZombi’s eevee cursor, we only need the ones that reference one of the 15 new cursors:
ln -s -r progress 00000000000000020006000e7e9ffc3f
ln -s -r size_ver 00008160000006810000408080010102
ln -s -r progress 08e8e1c95fe2fc01f976f1e063a24ccd
ln -s -r progress 3ecb610c1bf2410f44200f48c40d3599
ln -s -r help 5c6cd98b3f3ebcb1f9c7f1c204630408
ln -s -r pointer 9d800788f1b08800ae810202380a0822
ln -s -r default arrow
ln -s -r not-allowed circle
ln -s -r crosshair cross
ln -s -r not-allowed crossed_circle
ln -s -r help d9ce0ab605698f320427677b458ad60b
ln -s -r pointer e29285e634086352946a0e7090d73106
ln -s -r size_hor e-resize
ln -s -r size_hor ew-resize
ln -s -r progress half-busy
ln -s -r pointer hand1
ln -s -r pointer hand2
ln -s -r size_hor h_double_arrow
ln -s -r text ibeam
ln -s -r default left_ptr
ln -s -r help left_ptr_help
ln -s -r progress left_ptr_watch
ln -s -r size_bdiag ne-resize
ln -s -r size_bdiag nesw-resize
ln -s -r size_ver n-resize
ln -s -r size_ver ns-resize
ln -s -r size_fdiag nw-resize
ln -s -r size_fdiag nwse-resize
ln -s -r pointer pointing_hand
ln -s -r help question_arrow
ln -s -r size_hor sb_h_double_arrow
ln -s -r size_ver sb_v_double_arrow
ln -s -r size_fdiag se-resize
ln -s -r default size-bdiag
ln -s -r default size-fdiag
ln -s -r default size-hor
ln -s -r default size-ver
ln -s -r size_ver s-resize
ln -s -r size_bdiag sw-resize
ln -s -r crosshair tcross
ln -s -r default top_left_arrow
ln -s -r size_ver v_double_arrow
ln -s -r wait watch
ln -s -r help whats_this
ln -s -r size_hor w-resize
ln -s -r text xtermRun those link commands once in the cursors and once in
the cursors_scalable directories to create the links. We
are done with creating our new theme based on the ani files! Now lets
install it!
Installing the new theme
Copy your whole theme folder to your icons folder, located at
~/.local/share/icons
Now open your settings panel, search for Pointers and
you should see your new eevee cursors as a theme!
Final notes
The default size when I select the new theme is 48 pixels. For the
JDZombi cursors, this is a fractional scaling that leads to seeing lines
between pixels of the cursor due to how the SVG is generated. The
cursors look best at whole scales like 32, 64, or 96 pixel sizes. It may
be possible to get it to look nice in the fractional size by messing
with the nominal_size value of the metadata.json files, but
I did not bother trying it out. Alternatively, if you use a different
method to generate the SVG files, those may also scale better.
The cursor is likely to display differently between x11 programs and wayland programs. Apps like Discord, running in xwayland will show a softer blurred or anti-aliased cursor. Wayland-native apps will show sharp pixel at all sizes.