ruby_spriter 0.6.6 → 0.6.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,11 +6,12 @@ require 'tmpdir'
6
6
  module RubySpriter
7
7
  # Processes images with GIMP
8
8
  class GimpProcessor
9
- attr_reader :options, :gimp_path
9
+ attr_reader :options, :gimp_path, :gimp_version
10
10
 
11
11
  def initialize(gimp_path, options = {})
12
12
  @gimp_path = gimp_path
13
13
  @options = options
14
+ @gimp_version = options[:gimp_version] || { major: 3, minor: 0 } # Default to GIMP 3
14
15
  end
15
16
 
16
17
  # Process image with GIMP operations
@@ -21,6 +22,11 @@ module RubySpriter
21
22
 
22
23
  Utils::OutputFormatter.header("GIMP Processing")
23
24
 
25
+ # Inform about Xvfb usage on Linux
26
+ if Platform.linux? && gimp_path.start_with?('flatpak:')
27
+ Utils::OutputFormatter.note("Using GIMP via Xvfb (virtual display)")
28
+ end
29
+
24
30
  # Inform user if automatic operation order optimization is applied
25
31
  if options[:scale_percent] && options[:remove_bg] &&
26
32
  options[:operation_order] == :scale_then_remove_bg
@@ -45,6 +51,18 @@ module RubySpriter
45
51
 
46
52
  private
47
53
 
54
+ def gimp_major_version
55
+ @gimp_version[:major]
56
+ end
57
+
58
+ def gimp2?
59
+ gimp_major_version == 2
60
+ end
61
+
62
+ def gimp3?
63
+ gimp_major_version == 3
64
+ end
65
+
48
66
  def determine_operations
49
67
  ops = []
50
68
 
@@ -101,6 +119,14 @@ module RubySpriter
101
119
  end
102
120
 
103
121
  def generate_scale_script(input_file, output_file, percent)
122
+ if gimp2?
123
+ generate_scale_script_gimp2(input_file, output_file, percent)
124
+ else
125
+ generate_scale_script_gimp3(input_file, output_file, percent)
126
+ end
127
+ end
128
+
129
+ def generate_scale_script_gimp3(input_file, output_file, percent)
104
130
  input_path = Utils::PathHelper.normalize_for_python(input_file)
105
131
  output_path = Utils::PathHelper.normalize_for_python(output_file)
106
132
  interpolation = map_interpolation_method(options[:scale_interpolation] || 'nohalo')
@@ -216,7 +242,75 @@ module RubySpriter
216
242
  PYTHON
217
243
  end
218
244
 
245
+ def generate_scale_script_gimp2(input_file, output_file, percent)
246
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
247
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
248
+ interpolation = map_interpolation_method_gimp2(options[:scale_interpolation] || 'nohalo')
249
+
250
+ <<~PYTHON
251
+ from gimpfu import *
252
+ import sys
253
+
254
+ def scale_image():
255
+ try:
256
+ print "Loading image..."
257
+ img = pdb.gimp_file_load(r'#{input_path}', r'#{input_path}')
258
+
259
+ w = img.width
260
+ h = img.height
261
+ print "Image size: %dx%d" % (w, h)
262
+
263
+ if len(img.layers) == 0:
264
+ raise Exception("No layers found")
265
+ layer = img.layers[0]
266
+
267
+ # Calculate new dimensions
268
+ new_width = int(w * #{percent} / 100.0)
269
+ new_height = int(h * #{percent} / 100.0)
270
+ print "Scaling to: %dx%d" % (new_width, new_height)
271
+ print "Interpolation: #{interpolation}"
272
+
273
+ # Scale layer with interpolation
274
+ pdb.gimp_layer_scale(layer, new_width, new_height, False, #{interpolation})
275
+ print "Layer scaled with interpolation"
276
+
277
+ # Resize canvas to match layer
278
+ pdb.gimp_image_resize(img, new_width, new_height, 0, 0)
279
+ print "Canvas resized"
280
+
281
+ # Handle multiple layers while preserving alpha
282
+ if len(img.layers) > 1:
283
+ pdb.gimp_image_merge_visible_layers(img, EXPAND_AS_NECESSARY)
284
+ print "Multiple layers merged (alpha preserved)"
285
+ else:
286
+ print "Single layer - no merge needed, alpha preserved"
287
+
288
+ # Export with alpha channel intact
289
+ print "Exporting with alpha channel..."
290
+ pdb.file_png_save(img, img.layers[0], r'#{output_path}', r'#{output_path}',
291
+ 0, 9, 0, 0, 0, 0, 0)
292
+
293
+ print "SUCCESS - Image scaled!"
294
+
295
+ except Exception as e:
296
+ print "ERROR: %s" % str(e)
297
+ import traceback
298
+ traceback.print_exc()
299
+ sys.exit(1)
300
+
301
+ scale_image()
302
+ PYTHON
303
+ end
304
+
219
305
  def generate_remove_bg_script(input_file, output_file)
306
+ if gimp2?
307
+ generate_remove_bg_script_gimp2(input_file, output_file)
308
+ else
309
+ generate_remove_bg_script_gimp3(input_file, output_file)
310
+ end
311
+ end
312
+
313
+ def generate_remove_bg_script_gimp3(input_file, output_file)
220
314
  input_path = Utils::PathHelper.normalize_for_python(input_file)
221
315
  output_path = Utils::PathHelper.normalize_for_python(output_file)
222
316
 
@@ -328,6 +422,98 @@ module RubySpriter
328
422
  PYTHON
329
423
  end
330
424
 
425
+ def generate_remove_bg_script_gimp2(input_file, output_file)
426
+ input_path = Utils::PathHelper.normalize_for_python(input_file)
427
+ output_path = Utils::PathHelper.normalize_for_python(output_file)
428
+
429
+ use_fuzzy = options[:fuzzy_select]
430
+ grow = options[:grow_selection] || 1
431
+ feather = options[:bg_threshold] || 0.0
432
+
433
+ # Build selection method
434
+ if use_fuzzy
435
+ select_method = "CHANNEL_OP_REPLACE" # First corner
436
+ select_add = "CHANNEL_OP_ADD" # Additional corners
437
+ select_call = "pdb.gimp_image_select_contiguous_color(img, select_op, layer, x, y)"
438
+ else
439
+ select_method = "CHANNEL_OP_REPLACE"
440
+ select_add = "CHANNEL_OP_ADD"
441
+ select_call = "pdb.gimp_image_select_color(img, select_op, layer, color)"
442
+ end
443
+
444
+ <<~PYTHON
445
+ from gimpfu import *
446
+ import sys
447
+
448
+ def remove_background():
449
+ try:
450
+ print "Loading image..."
451
+ img = pdb.gimp_file_load(r'#{input_path}', r'#{input_path}')
452
+
453
+ w = img.width
454
+ h = img.height
455
+ print "Image size: %dx%d" % (w, h)
456
+
457
+ if len(img.layers) == 0:
458
+ raise Exception("No layers found")
459
+ layer = img.layers[0]
460
+
461
+ # Add alpha channel if needed
462
+ if not pdb.gimp_layer_has_alpha(layer):
463
+ pdb.gimp_layer_add_alpha(layer)
464
+ print "Added alpha channel"
465
+
466
+ # Sample all four corners
467
+ corners = [
468
+ (0, 0), # Top-left
469
+ (w-1, 0), # Top-right
470
+ (0, h-1), # Bottom-left
471
+ (w-1, h-1) # Bottom-right
472
+ ]
473
+
474
+ print "Sampling %d corners..." % len(corners)
475
+ #{"print \"Using FUZZY SELECT (contiguous regions only)\"" if use_fuzzy}
476
+ #{"print \"Using GLOBAL COLOR SELECT (all matching pixels)\"" unless use_fuzzy}
477
+
478
+ for i, (x, y) in enumerate(corners):
479
+ print " Corner %d at (%d, %d)" % (i+1, x, y)
480
+ select_op = CHANNEL_OP_REPLACE if i == 0 else CHANNEL_OP_ADD
481
+ #{use_fuzzy ? "pdb.gimp_image_select_contiguous_color(img, select_op, layer, x, y)" : "color = pdb.gimp_image_get_pixel_color(img, layer, x, y)[1]\n pdb.gimp_image_select_color(img, select_op, layer, color)"}
482
+
483
+ print "Selection complete"
484
+
485
+ # Grow selection if configured
486
+ #{grow > 0 ? "print \"Growing selection by #{grow} pixels...\"\n pdb.gimp_selection_grow(img, #{grow})\n print \"Selection grown\"" : "# No selection growth"}
487
+
488
+ # Feather selection if configured
489
+ #{feather > 0 ? "print \"Feathering selection by #{feather} pixels...\"\n pdb.gimp_selection_feather(img, #{feather})\n print \"Selection feathered\"" : "# No feathering"}
490
+
491
+ # Delete selection (clear background)
492
+ print "Removing background..."
493
+ pdb.gimp_edit_clear(layer)
494
+ print "Background removed"
495
+
496
+ # Deselect
497
+ print "Deselecting..."
498
+ pdb.gimp_selection_none(img)
499
+
500
+ # Export
501
+ print "Exporting..."
502
+ pdb.file_png_save(img, layer, r'#{output_path}', r'#{output_path}',
503
+ 0, 9, 0, 0, 0, 0, 0)
504
+
505
+ print "SUCCESS - Background removed!"
506
+
507
+ except Exception as e:
508
+ print "ERROR: %s" % str(e)
509
+ import traceback
510
+ traceback.print_exc()
511
+ sys.exit(1)
512
+
513
+ remove_background()
514
+ PYTHON
515
+ end
516
+
331
517
  def generate_fuzzy_select_code
332
518
  <<~PYTHON.chomp
333
519
  # Fuzzy select (contiguous regions only)
@@ -468,7 +654,7 @@ module RubySpriter
468
654
  @echo off
469
655
  REM Suppress GEGL debug output (known GIMP 3.x batch mode issue)
470
656
  set GEGL_DEBUG=
471
- "#{gimp_path}" --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
657
+ "#{gimp_path}" --no-splash --quit --batch-interpreter=python-fu-eval -b "exec(open(r'#{script_file}').read())" > "#{log_file}" 2>&1
472
658
  exit /b %errorlevel%
473
659
  BATCH
474
660
 
@@ -502,7 +688,25 @@ module RubySpriter
502
688
 
503
689
  # Unix execution (Linux/macOS)
504
690
  def execute_gimp_unix(script_file, log_file)
505
- cmd = "#{Utils::PathHelper.quote_path(gimp_path)} --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
691
+ # Check if we're using Flatpak GIMP (needs xvfb-run)
692
+ use_xvfb = gimp_path.start_with?('flatpak:')
693
+
694
+ if gimp2?
695
+ # GIMP 2.x: Use gimp-console for batch processing
696
+ gimp_console_path = gimp_path.sub('/gimp', '/gimp-console')
697
+ cmd = "#{Utils::PathHelper.quote_path(gimp_console_path)} -i --no-splash --batch-interpreter python-fu-eval -b 'exec(open(\"#{script_file}\").read())' -b '(gimp-quit 0)' > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
698
+ else
699
+ # GIMP 3.x command
700
+ if use_xvfb
701
+ # Flatpak GIMP needs xvfb-run to provide virtual display
702
+ # Use --nosocket options to prevent Flatpak from accessing host display
703
+ flatpak_app = gimp_path.sub('flatpak:', '')
704
+ cmd = "xvfb-run --auto-servernum --server-args='-screen 0 1024x768x24' flatpak run --nosocket=x11 --nosocket=wayland #{flatpak_app} --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
705
+ else
706
+ # Regular GIMP 3.x installation
707
+ cmd = "#{Utils::PathHelper.quote_path(gimp_path)} --no-splash --quit --batch-interpreter=python-fu-eval -b \"exec(open(r'#{script_file}').read())\" > #{Utils::PathHelper.quote_path(log_file)} 2>&1"
708
+ end
709
+ end
506
710
 
507
711
  if options[:debug]
508
712
  Utils::OutputFormatter.indent("DEBUG: GIMP command: #{cmd}")
@@ -532,6 +736,16 @@ module RubySpriter
532
736
  line.match?(/GeglBuffers leaked/) ||
533
737
  line.match?(/EEEEeEeek!/) ||
534
738
  line.match?(/batch command executed successfully/) ||
739
+ # Filter Linux/Wayland/Flatpak cosmetic warnings
740
+ line.match?(/Gdk-WARNING.*Failed to read portal settings/) ||
741
+ line.match?(/set device.*to mode: disabled/) ||
742
+ line.match?(/Gdk-WARNING.*Server is missing xdg_foreign support/) ||
743
+ line.match?(/gimp_widget_set_handle_on_mapped.*gdk_wayland_window_export_handle/) ||
744
+ line.match?(/It will not be possible to set windows in other processes/) ||
745
+ line.match?(/LibGimp-WARNING.*gimp_flush.*Broken pipe/) ||
746
+ line.match?(/Gimp-Core-WARNING.*gimp_finalize.*list of contexts not empty/) ||
747
+ line.match?(/stale context:/) ||
748
+ line.match?(/F: X11 socket.*does not exist in filesystem/) ||
535
749
  line.strip.empty?
536
750
  end
537
751
  lines.join
@@ -598,7 +812,7 @@ module RubySpriter
598
812
  end
599
813
  end
600
814
 
601
- # Map interpolation method names to GIMP interpolation type enum values
815
+ # Map interpolation method names to GIMP 3.x interpolation type enum values
602
816
  def map_interpolation_method(method)
603
817
  # GIMP 3.x GimpInterpolationType enum values
604
818
  case method.to_s.downcase
@@ -617,6 +831,183 @@ module RubySpriter
617
831
  end
618
832
  end
619
833
 
834
+ # Map interpolation method names to GIMP 2.x interpolation type constants
835
+ def map_interpolation_method_gimp2(method)
836
+ # GIMP 2.x interpolation constants
837
+ case method.to_s.downcase
838
+ when 'none'
839
+ 'INTERPOLATION_NONE'
840
+ when 'linear'
841
+ 'INTERPOLATION_LINEAR'
842
+ when 'cubic'
843
+ 'INTERPOLATION_CUBIC'
844
+ when 'nohalo'
845
+ 'INTERPOLATION_NOHALO'
846
+ when 'lohalo'
847
+ 'INTERPOLATION_LOHALO'
848
+ else
849
+ 'INTERPOLATION_NOHALO' # Default to NoHalo for quality
850
+ end
851
+ end
852
+
853
+ # Remove background using ImageMagick (fallback for Linux)
854
+ # Uses edge detection and multiple techniques for better results
855
+ def remove_background_imagemagick(input_file, output_file)
856
+ magick_cmd = Platform.imagemagick_convert_cmd
857
+ identify_cmd = Platform.imagemagick_identify_cmd
858
+
859
+ # Get options
860
+ use_fuzzy = options[:fuzzy_select]
861
+ fuzz_percent = options[:bg_threshold] || 15.0
862
+ grow = options[:grow_selection] || 1
863
+
864
+ # Get image dimensions
865
+ stdout, _stderr, status = Open3.capture3("#{identify_cmd} -format '%w %h' #{Utils::PathHelper.quote_path(input_file)}")
866
+ unless status.success?
867
+ raise ProcessingError, "Could not get image dimensions"
868
+ end
869
+ width, height = stdout.strip.split.map(&:to_i)
870
+
871
+ # Sample more points around the border (not just corners)
872
+ sample_points = [
873
+ # Corners
874
+ [0, 0], [width - 1, 0], [0, height - 1], [width - 1, height - 1],
875
+ # Mid-edges
876
+ [width / 2, 0], [width / 2, height - 1],
877
+ [0, height / 2], [width - 1, height / 2]
878
+ ]
879
+
880
+ # Get colors from all sample points
881
+ sampled_colors = []
882
+ sample_points.each do |x, y|
883
+ color_stdout, _stderr, status = Open3.capture3("#{identify_cmd} -format '%[pixel:p{#{x},#{y}}]' #{Utils::PathHelper.quote_path(input_file)}")
884
+ sampled_colors << color_stdout.strip if status.success? && !color_stdout.strip.empty?
885
+ end
886
+
887
+ # Find the most common color (likely the background)
888
+ bg_color = sampled_colors.group_by(&:itself).max_by { |_, v| v.size }.first
889
+
890
+ if options[:debug]
891
+ Utils::OutputFormatter.indent("DEBUG: Image size: #{width}x#{height}")
892
+ Utils::OutputFormatter.indent("DEBUG: Sampled #{sampled_colors.size} border colors")
893
+ Utils::OutputFormatter.indent("DEBUG: Unique colors: #{sampled_colors.uniq.size}")
894
+ Utils::OutputFormatter.indent("DEBUG: Using background color: #{bg_color}")
895
+ Utils::OutputFormatter.indent("DEBUG: Fuzz: #{fuzz_percent}%")
896
+ end
897
+
898
+ # Create a multi-pass approach for better results
899
+ # Pass 1: Use floodfill from edges with fuzz tolerance
900
+ temp_file1 = File.join(Dir.tmpdir, "bg_removal_pass1_#{Time.now.to_i}.png")
901
+
902
+ draw_commands = sample_points.map { |x, y| "'color #{x},#{y} floodfill'" }.join(' -draw ')
903
+
904
+ cmd1 = "#{magick_cmd} #{Utils::PathHelper.quote_path(input_file)} -alpha set -fuzz #{fuzz_percent}% -fill none -draw #{draw_commands} #{temp_file1}"
905
+
906
+ if options[:debug]
907
+ Utils::OutputFormatter.indent("DEBUG: Pass 1 - Floodfill from #{sample_points.size} points")
908
+ end
909
+
910
+ stdout, stderr, status = Open3.capture3(cmd1)
911
+ unless status.success?
912
+ File.delete(temp_file1) if File.exist?(temp_file1)
913
+ raise ProcessingError, "Background removal pass 1 failed: #{stderr}"
914
+ end
915
+
916
+ # Pass 2: Remove the detected background color globally with fuzz
917
+ temp_file2 = File.join(Dir.tmpdir, "bg_removal_pass2_#{Time.now.to_i}.png")
918
+
919
+ cmd2 = "#{magick_cmd} #{temp_file1} -fuzz #{fuzz_percent}% -transparent '#{bg_color}' #{temp_file2}"
920
+
921
+ if options[:debug]
922
+ Utils::OutputFormatter.indent("DEBUG: Pass 2 - Remove color #{bg_color} globally")
923
+ end
924
+
925
+ stdout, stderr, status = Open3.capture3(cmd2)
926
+ unless status.success?
927
+ File.delete(temp_file1) if File.exist?(temp_file1)
928
+ File.delete(temp_file2) if File.exist?(temp_file2)
929
+ raise ProcessingError, "Background removal pass 2 failed: #{stderr}"
930
+ end
931
+
932
+ # Pass 3: Minimal cleanup - preserve quality
933
+ cmd3_parts = [
934
+ magick_cmd,
935
+ temp_file2
936
+ ]
937
+
938
+ # Only clean up the alpha channel, don't touch the RGB data
939
+ # This preserves sprite quality while cleaning edges
940
+ cmd3_parts += [
941
+ '-channel', 'A',
942
+ # Very gentle cleanup - only remove nearly-transparent pixels
943
+ '-threshold', '5%', # Anything less than 5% alpha becomes fully transparent
944
+ '+channel'
945
+ ]
946
+
947
+ # Optionally grow the transparent areas
948
+ if grow > 0
949
+ cmd3_parts += ['-morphology', 'Dilate', "Disk:#{grow}"]
950
+ end
951
+
952
+ cmd3_parts << Utils::PathHelper.quote_path(output_file)
953
+ cmd3 = cmd3_parts.join(' ')
954
+
955
+ if options[:debug]
956
+ Utils::OutputFormatter.indent("DEBUG: Pass 3 - Minimal alpha cleanup (quality-preserving)")
957
+ end
958
+
959
+ stdout, stderr, status = Open3.capture3(cmd3)
960
+
961
+ # Cleanup temp files
962
+ File.delete(temp_file1) if File.exist?(temp_file1)
963
+ File.delete(temp_file2) if File.exist?(temp_file2)
964
+
965
+ unless status.success?
966
+ raise ProcessingError, "Background removal pass 3 failed: #{stderr}"
967
+ end
968
+
969
+ Utils::FileHelper.validate_exists!(output_file)
970
+ size = Utils::FileHelper.format_size(File.size(output_file))
971
+ Utils::OutputFormatter.success("Background removal complete (#{size})\n")
972
+ end
973
+
974
+ # Scale image using ImageMagick (fallback for GIMP 2.x)
975
+ def scale_with_imagemagick(input_file, output_file, percent)
976
+ magick_cmd = Platform.imagemagick_convert_cmd
977
+
978
+ # Map interpolation to ImageMagick filters
979
+ interpolation = options[:scale_interpolation] || 'nohalo'
980
+ filter = case interpolation.to_s.downcase
981
+ when 'none' then 'Point'
982
+ when 'linear' then 'Triangle'
983
+ when 'cubic' then 'Catrom'
984
+ when 'nohalo', 'lohalo' then 'Lanczos' # Best available quality
985
+ else 'Lanczos'
986
+ end
987
+
988
+ cmd = [
989
+ magick_cmd,
990
+ Utils::PathHelper.quote_path(input_file),
991
+ '-filter', filter,
992
+ '-resize', "#{percent}%",
993
+ Utils::PathHelper.quote_path(output_file)
994
+ ].join(' ')
995
+
996
+ if options[:debug]
997
+ Utils::OutputFormatter.indent("DEBUG: ImageMagick command: #{cmd}")
998
+ end
999
+
1000
+ stdout, stderr, status = Open3.capture3(cmd)
1001
+
1002
+ unless status.success?
1003
+ raise ProcessingError, "ImageMagick scaling failed: #{stderr}"
1004
+ end
1005
+
1006
+ Utils::FileHelper.validate_exists!(output_file)
1007
+ size = Utils::FileHelper.format_size(File.size(output_file))
1008
+ Utils::OutputFormatter.success("Scale complete (#{size})\n")
1009
+ end
1010
+
620
1011
  # Apply unsharp mask using ImageMagick
621
1012
  def apply_sharpen_imagemagick(input_file)
622
1013
  radius = options[:sharpen_radius] || 2.0
@@ -29,10 +29,13 @@ module RubySpriter
29
29
  '/usr/bin/gimp',
30
30
  '/usr/local/bin/gimp',
31
31
  '/snap/bin/gimp',
32
- '/opt/gimp/bin/gimp'
32
+ '/opt/gimp/bin/gimp',
33
+ 'flatpak:org.gimp.GIMP' # Flatpak GIMP
33
34
  ].freeze,
34
35
  macos: [
35
36
  '/Applications/GIMP.app/Contents/MacOS/gimp',
37
+ '/Applications/GIMP-2.99.app/Contents/MacOS/gimp', # GIMP 3.x dev
38
+ '/Applications/GIMP-3.0.app/Contents/MacOS/gimp', # GIMP 3.x release
36
39
  '/Applications/GIMP-2.10.app/Contents/MacOS/gimp'
37
40
  ].freeze
38
41
  }.freeze
@@ -77,6 +80,58 @@ module RubySpriter
77
80
  def imagemagick_identify_cmd
78
81
  windows? ? 'magick identify' : 'identify'
79
82
  end
83
+
84
+ # Detect GIMP version from version string output
85
+ # @param version_output [String] Output from gimp --version command
86
+ # @return [Hash] Version information with :major, :minor, :patch, :full keys, or nil if parse fails
87
+ def detect_gimp_version(version_output)
88
+ return nil if version_output.nil? || version_output.empty?
89
+
90
+ # Match version pattern: "version X.Y.Z" or "version X.Y"
91
+ match = version_output.match(/version\s+(\d+)\.(\d+)(?:\.(\d+))?/i)
92
+ return nil unless match
93
+
94
+ {
95
+ major: match[1].to_i,
96
+ minor: match[2].to_i,
97
+ patch: match[3]&.to_i || 0,
98
+ full: match[1..3].compact.join('.')
99
+ }
100
+ end
101
+
102
+ # Get GIMP version from executable path
103
+ # @param gimp_path [String] Path to GIMP executable or flatpak:app.id
104
+ # @return [Hash] Version information, or nil if detection fails
105
+ def get_gimp_version(gimp_path)
106
+ return nil if gimp_path.nil? || gimp_path.empty?
107
+
108
+ require 'open3'
109
+
110
+ # Handle Flatpak GIMP
111
+ if gimp_path.start_with?('flatpak:')
112
+ flatpak_app = gimp_path.sub('flatpak:', '')
113
+ stdout, stderr, status = Open3.capture3("flatpak run #{flatpak_app} --version")
114
+ return nil unless status.success?
115
+ return detect_gimp_version(stdout + stderr)
116
+ end
117
+
118
+ return nil unless File.exist?(gimp_path)
119
+
120
+ stdout, stderr, status = Open3.capture3("#{quote_path_simple(gimp_path)} --version")
121
+ return nil unless status.success?
122
+
123
+ detect_gimp_version(stdout + stderr)
124
+ rescue StandardError
125
+ nil
126
+ end
127
+
128
+ private
129
+
130
+ # Simple path quoting helper for Platform module
131
+ def quote_path_simple(path)
132
+ return path unless path.include?(' ')
133
+ windows? ? "\"#{path}\"" : "'#{path}'"
134
+ end
80
135
  end
81
136
  end
82
137
  end