Converting ANI files

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
Project structure so far

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/ani2xcur

Then 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.

Project structure showing cursors are filled

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.

  1. Convert the ANI file into PNG frames. We will use GIMP for this.
  2. Convert the PNG files into SVG frames. A custom python script will help here.
  3. 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.

Image showing the eevee default cursor with ora extension

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

Zip file after ora renamed

Extract it, and navigate to the internal data folder. This is the folder that stores the frames we need.

data directory screenshot

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.

Showing the script in action

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/

Project should look like this, with eevee_cursor/cursors_scalable/default/000.svg
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.

Example of the file properties for exporting an ani showing hotspot and delay info

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.

Example of the file canvas 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!

Example of project directory showing completed structure for animated svg cursor

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.

Project directory after all ani files converted

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)

Image showing output of ls -la and several linked files

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 xterm

But 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 xterm

Run 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

Image showing new theme in the local icons directory

Now open your settings panel, search for Pointers and you should see your new eevee cursors as a theme!

KDE Settings window showing new eevee cursors

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.