aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRitabrata Das <[email protected]>2026-10-14 12:24:00 +0530
committerRitabrata Das <[email protected]>2026-10-14 12:24:00 +0530
commitfc98054068b81a88b3967d5a15b1693d32f8ba63 (patch)
treec9d09c31591c54da35fceaddb8174585aa133068
initial commit
-rw-r--r--LICENSE19
-rw-r--r--README.md42
-rwxr-xr-xxcursor2svg.sh248
3 files changed, 309 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3fdb113
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 Ritabrata Das <[email protected]>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fe59851
--- /dev/null
+++ b/README.md
@@ -0,0 +1,42 @@
+# xcursor2svg
+
+Please note that Generative AI was used for making this project
+
+In GNOME >=51, it is now required to use only svg based cursor as xcursor got deprecated
+now, I didn't want to give up on my xcursor themes, which were drawn by artists and not
+something rasterized, what this script does is it takes each pixel of the original cursor
+and draws a svg pixel-by-pixel, I think desktop environment like GNOME and KDE cache the
+svg into bitmap, so having a thousand rectangles in a few hundred frame wont cause any
+performance issue. (atleast I hope so, idk I didn't check this).
+
+I tested this on [Hatsune Miku cursor theme by
+supermariofps](https://github.com/supermariofps/hatsune-miku-windows-linux-cursors) and a
+few other legacy xcursor themes. It worked pretty well for me on GNOME 51. Please open a
+Pull request or submit an issue if something goes wrong. Thank you ^^
+
+> [!IMPORTANT]
+> This project requires `xcur2png` and `imagemagick` which you can find in your standard distro
+> repos.
+
+## Usage :
+
+```bash
+./xcursor2svg.sh --res 64 ~/.icons/miku-cursor-linux # install from a old xcursor theme
+./xcursor2svg.sh --res 64 -y /path/to/theme # Convert and install automatically
+./xcursor2svg.sh --res 64 -n /path/to/theme # Convert only
+# For a theme already installed in a standard icon directory:
+./xcursor2svg.sh --res 64 miku-cursor-linux
+```
+
+`--res` sets the nominal cursor size (default: 32). For each cursor, the closest
+available Xcursor size is selected, preferring the larger size in a tie. All
+frames at that size are resized with nearest-neighbor sampling. Visible pixels
+become SVG rectangles with their original colors and transparency. A source at
+the requested size keeps its pixels exactly; resizing replicates or drops pixels,
+without smoothing. These SVGs retain the bitmap appearance when scaled.
+
+Animation order, delays, hotspots, and aliases are preserved in
+`cursors_scalable/`, with missing GNOME/CSS names filled from legacy equivalents.
+The original `cursors/` directory is copied for legacy applications. Canvas
+dimensions and hotspots scale together; cropped or rectangular source cursors
+are not stretched into squares.
diff --git a/xcursor2svg.sh b/xcursor2svg.sh
new file mode 100755
index 0000000..0cd00fb
--- /dev/null
+++ b/xcursor2svg.sh
@@ -0,0 +1,248 @@
+#!/usr/bin/env bash
+# Convert an Xcursor theme to pixel-based SVG cursors.
+set -euo pipefail
+export LC_ALL=C
+
+usage() {
+ echo "Usage: $0 [--res SIZE] [-y | -n] THEME_DIRECTORY_OR_NAME"
+ echo "Point to the Xcursor theme directory containing the cursors/ subdirectory."
+ echo "An installed theme name can also be used."
+ echo " --res SIZE Nominal cursor size (default: 32)."
+ echo " -y Automatically install after conversion."
+ echo " -n Convert only; do not install."
+ echo "Example: $0 --res 64 -y miku-cursor-linux"
+}
+
+die() { echo "Error: $*" >&2; exit 1; }
+
+res=32
+theme=
+install_mode=ask
+while (($#)); do
+ case "$1" in
+ --res)
+ (($# >= 2)) || die "--res needs a size."
+ res=$2
+ shift 2
+ ;;
+ -y) [[ $install_mode != n ]] || die "Use either -y or -n, not both."; install_mode=y; shift ;;
+ -n) [[ $install_mode != y ]] || die "Use either -y or -n, not both."; install_mode=n; shift ;;
+ -h|--help) usage; exit 0 ;;
+ --) shift; (($# == 1)) || die "Give one theme directory or name."; theme=$1; shift ;;
+ -*) die "Unknown option: $1" ;;
+ *) [[ -z $theme ]] || die "Give one theme directory or name."; theme=$1; shift ;;
+ esac
+done
+[[ -n $theme ]] || { usage >&2; exit 1; }
+[[ $res =~ ^[1-9][0-9]*$ && ${#res} -le 4 ]] || die "Resolution must be a positive integer (maximum 4096)."
+((res <= 4096)) || die "Resolution must be at most 4096."
+
+for command in xcur2png awk readlink; do
+ command -v "$command" >/dev/null || die "Required command not found: $command"
+done
+if command -v magick >/dev/null; then
+ image_command=magick
+elif command -v convert >/dev/null; then
+ image_command=convert
+else
+ die "Install ImageMagick (magick or convert)."
+fi
+
+icons=${XDG_DATA_HOME:-"$HOME/.local/share"}/icons
+if [[ ! -d $theme && $theme != */* ]]; then
+ for base in "$icons" "$HOME/.icons" /usr/local/share/icons /usr/share/icons; do
+ if [[ -d $base/$theme/cursors ]]; then
+ theme=$base/$theme
+ break
+ fi
+ done
+fi
+[[ -d $theme/cursors ]] || die "Theme has no cursors directory: $theme"
+theme=$(cd -- "$theme" && pwd -P)
+name=${theme##*/}-svg-${res}x${res}
+output=$PWD/$name
+[[ ! -e $output && ! -L $output ]] || die "Output already exists: $output"
+
+work=$(mktemp -d)
+trap 'rm -rf -- "$work"' EXIT
+build=$work/$name
+mkdir -p "$build/cursors_scalable" "$work/extract"
+cp -a -- "$theme/cursors" "$build/cursors"
+note="[converted using xcursor2svg.sh at ${res}x${res}]"
+# Keep the original metadata, adding only a note to the description.
+if [[ -f $theme/index.theme ]]; then
+ awk -v note="$note" -v name="$name" '
+ /^\[/ {
+ if (section && !comment) print "Comment=" note
+ section = ($0 ~ /^\[Icon Theme\]\r?$/)
+ if (section) {found=1; comment=0}
+ }
+ section && /^Comment(\[[^]]+\])?=/ {
+ if ($0 ~ /^Comment=/) comment=1
+ cr = sub(/\r$/, "") ? "\r" : ""
+ print $0 " " note cr
+ next
+ }
+ {print}
+ END {
+ if (!found) print "[Icon Theme]\nName=" name "\nComment=" note
+ else if (section && !comment) print "Comment=" note
+ }
+ ' "$theme/index.theme" > "$build/index.theme"
+else
+ printf '[Icon Theme]\nName=%s\nComment=%s\n' "$name" "$note" > "$build/index.theme"
+fi
+
+convert_cursor() {
+ local cursor=$1 shape=$2 selected size xhot yhot png delay
+ local width height scaled_width scaled_height hotspot_x hotspot_y extraction_log frame=0 comma=
+ local directory=$build/cursors_scalable/$shape
+
+ rm -f -- "$work/extract/"*
+ # A fixed input name keeps xcur2png's config paths free of spaces.
+ cp -- "$cursor" "$work/extract/cursor"
+ if ! extraction_log=$(cd "$work/extract" && xcur2png -q -d . -c cursor.conf cursor 2>&1); then
+ die "Cannot extract $cursor: $extraction_log"
+ fi
+ selected=$(awk -v res="$res" '
+ !/^#/ && NF >= 4 {
+ distance = $1 - res; if (distance < 0) distance = -distance
+ if (!found || distance < best || (distance == best && $1 > size)) {
+ found = 1; best = distance; size = $1
+ }
+ }
+ END {if (found) print size}
+ ' "$work/extract/cursor.conf")
+ [[ -n $selected ]] || die "No frames found in $cursor"
+ mkdir -p -- "$directory"
+ printf '[\n' > "$directory/metadata.json"
+ while read -r size xhot yhot png delay; do
+ [[ $size == "$selected" ]] || continue
+ png=$work/extract/${png#./}
+ read -r width height < <("$image_command" "$png" -format '%w %h\n' info:)
+ read -r scaled_width scaled_height < <(awk -v w="$width" -v h="$height" -v r="$res" -v s="$size" \
+ 'BEGIN {w=int(w*r/s+0.5); h=int(h*r/s+0.5); print (w<1?1:w), (h<1?1:h)}')
+ read -r hotspot_x hotspot_y < <(awk -v x="$xhot" -v y="$yhot" -v w="$width" -v h="$height" \
+ -v sw="$scaled_width" -v sh="$scaled_height" 'BEGIN {printf "%.8f %.8f\n", x*sw/w, y*sh/h}')
+
+ # Nearest-neighbor resizing preserves hard pixel edges. Each visible
+ # pixel becomes a unit rectangle, including its original alpha.
+ "$image_command" "$png" -filter point -resize "${scaled_width}x${scaled_height}!" \
+ -colorspace sRGB -alpha on -depth 8 txt:- |
+ awk -v w="$scaled_width" -v h="$scaled_height" '
+ BEGIN {
+ printf "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 %d %d\" shape-rendering=\"crispEdges\">\n", w,h,w,h
+ }
+ /^#/ {next}
+ {
+ split($1, xy, /[:,]/)
+ hex=""
+ for (i=2; i<=NF; i++) if ($i ~ /^#[[:xdigit:]]+$/) {hex=$i; break}
+ if (length(hex) != 9) exit 1
+ alpha=16*(index("0123456789ABCDEF", toupper(substr(hex,8,1)))-1) + index("0123456789ABCDEF", toupper(substr(hex,9,1)))-1
+ if (alpha == 0) next
+ printf "<rect x=\"%d\" y=\"%d\" width=\"1\" height=\"1\" fill=\"%s\"", xy[1],xy[2],substr(hex,1,7)
+ if (alpha < 255) printf " fill-opacity=\"%.8f\"", alpha/255
+ print "/>"
+ }
+ END {print "</svg>"}
+ ' > "$directory/frame-$frame.svg"
+ printf '%s {"filename":"frame-%s.svg","delay":%s,"hotspot_x":%s,"hotspot_y":%s,"nominal_size":%s}' \
+ "$comma" "$frame" "${delay:-0}" "$hotspot_x" "$hotspot_y" "$res" >> "$directory/metadata.json"
+ comma=$',\n'
+ frame=$((frame + 1))
+ done < "$work/extract/cursor.conf"
+ printf '\n]\n' >> "$directory/metadata.json"
+ printf 'Converted %s (%s frames, source size %s)\n' "$shape" "$frame" "$selected"
+}
+
+count=0
+for cursor in "$theme/cursors/"*; do
+ [[ -f $cursor && ! -L $cursor ]] || continue
+ convert_cursor "$cursor" "${cursor##*/}"
+ count=$((count + 1))
+done
+
+# Resolve alias chains to the final cursor, then use relative directory links.
+for cursor in "$theme/cursors/"*; do
+ [[ -L $cursor ]] || continue
+ if [[ ! -f $cursor ]]; then
+ printf 'Warning: skipping broken alias %s\n' "${cursor##*/}" >&2
+ continue
+ fi
+ target=$(readlink -f -- "$cursor")
+ target_name=${target##*/}
+ if [[ $target == "$theme/cursors/$target_name" && -d $build/cursors_scalable/$target_name ]]; then
+ ln -s -- "$target_name" "$build/cursors_scalable/${cursor##*/}"
+ else
+ # A theme can also link to an Xcursor file outside its own directory.
+ convert_cursor "$cursor" "${cursor##*/}"
+ count=$((count + 1))
+ fi
+done
+((count > 0)) || die "No readable Xcursor files found."
+
+# Supply GNOME/CSS names only when the theme has no explicit version.
+while read -r alias target; do
+ if [[ ! -e $build/cursors_scalable/$alias && -d $build/cursors_scalable/$target ]]; then
+ ln -s -- "$target" "$build/cursors_scalable/$alias"
+ fi
+done <<'ALIASES'
+default left_ptr
+context-menu left_ptr
+help question_arrow
+pointer hand
+pointer hand2
+pointer hand1
+progress left_ptr_watch
+wait watch
+cell crosshair
+crosshair cross
+text xterm
+vertical-text xterm
+alias dnd-link
+copy dnd-copy
+move dnd-move
+no-drop dnd-none
+not-allowed crossed_circle
+grab hand2
+grabbing hand2
+e-resize right_side
+n-resize top_side
+ne-resize top_right_corner
+nw-resize top_left_corner
+s-resize bottom_side
+se-resize bottom_right_corner
+sw-resize bottom_left_corner
+w-resize left_side
+ew-resize h_double_arrow
+ns-resize v_double_arrow
+nesw-resize fd_double_arrow
+nwse-resize bd_double_arrow
+col-resize h_double_arrow
+row-resize v_double_arrow
+all-scroll left_ptr
+zoom-in left_ptr
+zoom-out left_ptr
+dnd-ask dnd-copy
+all-resize dnd-move
+ALIASES
+
+mv -- "$build" "$output"
+printf '\nCreated: %s\n' "$output"
+answer=$install_mode
+if [[ $install_mode == ask ]]; then
+ printf 'Install in %s/%s? [y/N] ' "$icons" "$name"
+ answer=
+ read -r answer || true
+fi
+case "$answer" in
+ y|Y)
+ destination=$icons/$name
+ [[ ! -e $destination && ! -L $destination ]] || die "Installation already exists: $destination (converted theme kept at $output)"
+ mkdir -p -- "$icons"
+ cp -a -- "$output" "$destination"
+ printf 'Installed: %s\nSelect "%s" in your cursor settings.\n' "$destination" "$name"
+ ;;
+ *) echo "Not installed; the converted theme is available at $output." ;;
+esac