From fc98054068b81a88b3967d5a15b1693d32f8ba63 Mon Sep 17 00:00:00 2001 From: Ritabrata Das Date: Wed, 14 Oct 2026 12:24:00 +0530 Subject: initial commit --- xcursor2svg.sh | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100755 xcursor2svg.sh (limited to 'xcursor2svg.sh') 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 "\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 "" + } + END {print ""} + ' > "$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 -- cgit v1.2.3