images-convert 0.4.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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.md +15 -0
  4. data/.github/PULL_REQUEST_TEMPLATE.md +25 -0
  5. data/.github/workflows/ci.yml +79 -0
  6. data/.github/workflows/release.yml +122 -0
  7. data/.gitignore +14 -0
  8. data/CHANGELOG.md +146 -0
  9. data/Gemfile +9 -0
  10. data/LICENSE +41 -0
  11. data/README.md +378 -0
  12. data/RELEASE_NOTES_v0.2.12.md +66 -0
  13. data/RELEASE_NOTES_v0.2.3.md +19 -0
  14. data/RELEASE_NOTES_v0.3.0.md +66 -0
  15. data/RELEASE_NOTES_v0.4.0.md +14 -0
  16. data/RELEASE_NOTES_v0.4.1.md +13 -0
  17. data/Rakefile +13 -0
  18. data/bin/images-convert +8 -0
  19. data/bin/imgconv +8 -0
  20. data/images-convert.gemspec +39 -0
  21. data/lib/images_convert/cleanup.rb +31 -0
  22. data/lib/images_convert/configuration.rb +303 -0
  23. data/lib/images_convert/mini_magick_stub.rb +121 -0
  24. data/lib/images_convert/version.rb +5 -0
  25. data/lib/images_convert/waifu2x_test_stub.rb +93 -0
  26. data/lib/images_convert.rb +1557 -0
  27. data/lib/rubygems_plugin.rb +34 -0
  28. data/lib/waifu2x/downloader.rb +89 -0
  29. data/lib/waifu2x/pdf_builder.rb +105 -0
  30. data/lib/waifu2x/processor.rb +301 -0
  31. data/lib/waifu2x/setup.rb +127 -0
  32. data/lib/waifu2x/version.rb +5 -0
  33. data/lib/waifu2x.rb +221 -0
  34. data/test/images/autumn.jpg +0 -0
  35. data/test/images/spring.jpg +0 -0
  36. data/test/images/summer.jpg +0 -0
  37. data/test/images/winter.jpg +0 -0
  38. data/test/support/waifu2x_test_stub.rb +91 -0
  39. data/test/test_config.rb +143 -0
  40. data/test/test_fixtures.rb +144 -0
  41. data/test/test_formats.rb +213 -0
  42. data/test/test_help.rb +33 -0
  43. data/test/test_helper.rb +17 -0
  44. data/test/test_helper_mini_magick_stub.rb +4 -0
  45. data/test/test_selection.rb +142 -0
  46. data/test/test_version.rb +16 -0
  47. data/test/test_waifu2x.rb +81 -0
  48. metadata +179 -0
@@ -0,0 +1,1557 @@
1
+ def clamp_pdf_density(value)
2
+ return ImagesConvert::Configuration.default_output_pdf_density if value.nil?
3
+ v = value.to_i
4
+ v = 1200 if v > 1200
5
+ v = 72 if v < 72
6
+ v
7
+ end
8
+ #!/usr/bin/env ruby
9
+ # -*- coding: utf-8 -*-
10
+
11
+ require 'thor'
12
+ require 'fileutils'
13
+ require 'time'
14
+ if ENV['IMGCONV_TEST_FAKE_MINIMAGICK'] == '1'
15
+ require_relative 'images_convert/mini_magick_stub'
16
+ else
17
+ require 'mini_magick'
18
+ end
19
+ require 'rbconfig'
20
+ require_relative 'images_convert/version'
21
+ require_relative 'images_convert/configuration'
22
+ require_relative 'images_convert/cleanup'
23
+ require_relative 'waifu2x'
24
+
25
+ # ImageMagickがインストールされているかチェック(未導入なら最小限の対話で導入を試みる)
26
+ def imagemagick_installed?
27
+ system('which magick > /dev/null 2>&1') || system('which convert > /dev/null 2>&1')
28
+ end
29
+
30
+ unless imagemagick_installed?
31
+ puts "ImageMagick が見つかりません。今すぐインストールしますか? [Y/n]"
32
+ print "> "
33
+ answer = (STDIN.gets || "").strip
34
+ answer = 'y' if answer.empty?
35
+
36
+ if answer.downcase.start_with?('y')
37
+ ostype = RbConfig::CONFIG['host_os']
38
+ success = false
39
+
40
+ if ostype =~ /darwin/
41
+ if system('which brew > /dev/null 2>&1')
42
+ puts "Homebrew で ImageMagick をインストールします..."
43
+ success = system('brew install imagemagick')
44
+ else
45
+ puts "Homebrew が見つかりません。https://brew.sh/ からインストールしてください。"
46
+ end
47
+ elsif ostype =~ /linux/
48
+ if system('which apt-get > /dev/null 2>&1')
49
+ puts "apt-get で ImageMagick をインストールします(sudo が必要な場合があります)..."
50
+ success = system('sudo apt-get update && sudo apt-get install -y imagemagick')
51
+ elsif system('which yum > /dev/null 2>&1')
52
+ puts "yum で ImageMagick をインストールします(sudo が必要な場合があります)..."
53
+ success = system('sudo yum install -y ImageMagick')
54
+ elsif system('which dnf > /dev/null 2>&1')
55
+ puts "dnf で ImageMagick をインストールします(sudo が必要な場合があります)..."
56
+ success = system('sudo dnf install -y ImageMagick')
57
+ else
58
+ puts "対応するパッケージマネージャが見つかりませんでした。"
59
+ end
60
+ else
61
+ puts "このOSでは自動インストールに対応していません。"
62
+ puts "Windows はインストーラから導入してください: https://imagemagick.org/script/download.php#windows"
63
+ end
64
+
65
+ if success && imagemagick_installed?
66
+ puts "ImageMagick のインストールが完了しました。"
67
+ else
68
+ puts "ImageMagick の自動インストールに失敗しました。手動でのインストールをお願いします。"
69
+ puts " macOS: brew install imagemagick"
70
+ puts " Ubuntu/Debian: sudo apt-get install -y imagemagick"
71
+ puts " CentOS/RHEL/Fedora: sudo yum/dnf install -y ImageMagick"
72
+ exit 1
73
+ end
74
+ else
75
+ puts "ImageMagick が必要です。後で以下のいずれかでインストールしてください:"
76
+ puts " macOS: brew install imagemagick"
77
+ puts " Ubuntu/Debian: sudo apt-get install -y imagemagick"
78
+ puts " CentOS/RHEL/Fedora: sudo yum/dnf install -y ImageMagick"
79
+ puts " Windows: https://imagemagick.org/script/download.php#windows"
80
+ exit 1
81
+ end
82
+ end
83
+
84
+ # images_convert.rb: Thorベースの画像変換スクリプト
85
+ # 形式やリサイズ、入出力ディレクトリは設定値を使用(config コマンドで変更可能)
86
+
87
+ class ImagesConvertCLI < Thor
88
+ include Thor::Actions
89
+
90
+ DOWNLOADS_DIR = "#{ENV['HOME']}/Downloads"
91
+
92
+ # コマンド名を imgconv に設定(短縮形として imgconv を使用)
93
+ def self.command_name
94
+ 'imgconv'
95
+ end
96
+
97
+ # `images-convert --version` / `images-convert -v` に対応
98
+ map %w[-v --version] => :version
99
+ # `images-convert --help` / `images-convert -h` に対応(Thor の help にマップ)
100
+ map %w[-h --help] => :help
101
+ # 引数なし実行時は help を表示
102
+ default_task :help
103
+ desc 'version', 'バージョン情報を表示'
104
+ def version
105
+ say "images-convert #{ImagesConvert::VERSION}"
106
+ end
107
+
108
+ def help(command = nil, subcommand = false)
109
+ if command
110
+ if command.to_s == 'convert'
111
+ say <<~HELP
112
+ Usage:
113
+ imgconv convert TARGET [DEST_DIR]
114
+
115
+ Options:
116
+ -i, --input-dir=INPUT_DIR 入力ディレクトリ(デフォルト: 設定値または ~/Downloads)
117
+ -o, --output=OUTPUT 出力ファイルパス(単一ファイル変換時、ディレクトリ指定時は出力ディレクトリ)
118
+ --output-dir=OUTPUT_DIR 出力ディレクトリ(単一ファイル時)
119
+ -d, --delete 変換後に元のファイルを削除
120
+ -f, --overwrite 既存の出力ファイルがある場合に上書き
121
+ -s, --suffix=SUFFIX 出力ファイル名の末尾に付与するサフィックス(例: _converted)
122
+ --recursive ディレクトリを再帰的に処理(一括変換時)
123
+ --resize=RESIZE リサイズ値(例: 1920x, 50%)
124
+ --latest=N 最新の画像ファイル N 件を変換(入力ディレクトリ内から)
125
+ --latest-exif 最新 N 件の選定を EXIF 撮影日時に基づいて行う(--latest と併用)
126
+ --all 入力ディレクトリ内の対象画像をすべて変換
127
+ --from=FROM 入力形式(設定を一時的に上書き)
128
+ --to=TO 出力形式(設定を一時的に上書き)
129
+
130
+ Description:
131
+ 使い方:
132
+
133
+ images-convert [convert] TARGET [DEST_DIR] [オプション]
134
+ imgconv [convert] TARGET [DEST_DIR] [オプション]
135
+
136
+ 動作:
137
+ - TARGET がファイル名/番号の場合は単一ファイル変換(-i/-o で入出力ディレクトリ指定可)
138
+ - TARGET がディレクトリの場合は一括変換(DEST_DIR 未指定時は in-place 変換)
139
+
140
+ 主なオプション:
141
+ -i, --input-dir=DIR 入力ディレクトリ(単一ファイル時)
142
+ -o, --output-dir=DIR 出力ディレクトリ(単一ファイル時)
143
+ -d, --delete 変換後に元ファイルを削除
144
+ -f, --overwrite 既存の出力ファイルがある場合に上書き
145
+ -s, --suffix=SUFFIX 出力ファイル名の末尾に付与(例: _web)
146
+ --recursive ディレクトリを再帰的に処理(一括変換時)
147
+
148
+ 例:
149
+ # 1枚だけ変換(番号指定、入出力ディレクトリや変換形式は既定値)
150
+ images-convert [convert] 1234
151
+ # または短縮コマンド
152
+ imgconv [convert] 1234
153
+
154
+ # 1枚だけ変換(ファイル名指定、入出力ディレクトリを明示)
155
+ images-convert [convert] IMG_0001 ~/Downloads ~/Downloads
156
+
157
+ # 一括変換(in-place)
158
+ images-convert [convert] ./photos
159
+
160
+ # 一括変換(出力先を別ディレクトリ、再帰処理、サフィックス付与)
161
+ images-convert [convert] ./photos ./export --recursive --suffix _resized
162
+ HELP
163
+ else
164
+ super
165
+ end
166
+ else
167
+ say <<~HELP
168
+ Commands:
169
+ imgconv [convert] TARGET [INPUT_DIR] [OUTPUT_DIR] # 単一ファイル変換(TARGET=ファイル名/番号)
170
+ imgconv [convert] INPUT_DIR [OUTPUT_DIR] # 一括変換
171
+ imgconv config # 現在の設定を表示
172
+ imgconv set FROM TO [RESIZE] # 変換形式、リサイズ値の既定値を設定
173
+ imgconv set-dir INPUT_DIR OUTPUT_DIR # 入出力ディレクトリの既定値を設定
174
+ imgconv --help # 利用可能なコマンド一覧を表示
175
+ imgconv [COMMAND] --help # 各コマンドの詳細を表示
176
+ imgconv --version # バージョン情報を表示
177
+ HELP
178
+ end
179
+ end
180
+
181
+ method_option :scale, type: :numeric, desc: 'waifu2x の既定倍率を設定(set-waifu2x サブコマンドで使用)'
182
+ method_option :noise, type: :numeric, desc: 'waifu2x のノイズ除去レベル(set-waifu2x サブコマンドで使用)'
183
+ method_option :model, type: :string, desc: 'waifu2x のモデルエイリアス(set-waifu2x サブコマンドで使用)'
184
+ method_option :auto_scale_a4, type: :boolean, desc: 'A4自動スケールの有効/無効(set-waifu2x サブコマンド)'
185
+ method_option :a4_print, type: :boolean, desc: 'A4レイアウトPDFの有効/無効(set-waifu2x サブコマンド)'
186
+ method_option :image_format, type: :string, desc: 'waifu2x 出力フォーマット(set-waifu2x サブコマンドで使用)'
187
+ method_option :on_error, type: :string, enum: %w[skip stop], desc: 'waifu2x エラー時の動作(set-waifu2x サブコマンド)'
188
+ method_option :quality, type: :numeric, desc: 'waifu2x 出力品質(set-waifu2x サブコマンド)'
189
+ desc 'config [SUBCOMMAND]', '現在の設定を表示、または設定を変更(subcommand: set/set-dir/set-waifu2x/set-output/reset)'
190
+ method_option :force, type: :boolean, default: false, desc: '確認なしでリセットを実行'
191
+ def config(*args)
192
+ if args.first.to_s == 'reset'
193
+ unless options[:force]
194
+ say '設定ファイルを既定値に戻します。よろしいですか? [y/N]'
195
+ print '> '
196
+ answer = (STDIN.gets || '').strip.downcase
197
+ unless %w[y yes].include?(answer)
198
+ say 'リセットをキャンセルしました。'
199
+ return
200
+ end
201
+ end
202
+
203
+ ImagesConvert::Configuration.reset_to_defaults
204
+ say '設定を既定値にリセットしました。'
205
+ say ''
206
+ say '現在の設定:'
207
+ show_config_summary
208
+ return
209
+ end
210
+
211
+ # サブコマンド: set FROM TO RESIZE
212
+ if args.first.to_s == 'set'
213
+ _, from, to, resize = args
214
+ unless from && to && resize
215
+ say "使い方: images-convert config set FROM TO RESIZE", :red
216
+ exit 1
217
+ end
218
+ ImagesConvert::Configuration.save_defaults(from: from, to: to, resize: resize)
219
+ say "設定を変更しました:"
220
+ say " デフォルト入力形式: #{from}"
221
+ say " デフォルト出力形式: #{to}"
222
+ say " デフォルトリサイズ: #{resize}"
223
+ return
224
+ end
225
+
226
+ # サブコマンド: set-dir INPUT_DIR OUTPUT_DIR
227
+ if args.first.to_s == 'set-dir'
228
+ _, input_dir, output_dir = args
229
+ unless input_dir && output_dir
230
+ say "使い方: images-convert config set-dir INPUT_DIR OUTPUT_DIR", :red
231
+ exit 1
232
+ end
233
+ ImagesConvert::Configuration.save_directories(input_dir: input_dir, output_dir: output_dir)
234
+ say "ディレクトリ設定を変更しました:"
235
+ say " デフォルト入力ディレクトリ: #{input_dir}"
236
+ say " デフォルト出力ディレクトリ: #{output_dir}"
237
+ return
238
+ end
239
+
240
+ if args.first.to_s == 'set-latest-mode'
241
+ _, mode = args
242
+ unless mode
243
+ say "使い方: images-convert config set-latest-mode MODE (mtime/exif)", :red
244
+ exit 1
245
+ end
246
+
247
+ normalized = mode.to_s.downcase
248
+ begin
249
+ ImagesConvert::Configuration.save_latest_mode(normalized)
250
+ rescue ArgumentError => e
251
+ say "エラー: #{e.message}", :red
252
+ exit 1
253
+ end
254
+
255
+ say "最新判定モードを #{normalized} に設定しました。"
256
+ return
257
+ end
258
+
259
+ if args.first.to_s == 'set-waifu2x'
260
+ new_scale = if options[:scale]
261
+ clamp_waifu2x_scale(options[:scale])
262
+ else
263
+ ImagesConvert::Configuration.default_waifu2x_scale
264
+ end
265
+
266
+ new_noise = if options[:noise]
267
+ clamp_waifu2x_noise(options[:noise])
268
+ else
269
+ ImagesConvert::Configuration.default_waifu2x_noise
270
+ end
271
+
272
+ new_model = options[:model] || ImagesConvert::Configuration.default_waifu2x_model
273
+
274
+ auto_scale_default = ImagesConvert::Configuration.default_waifu2x_auto_scale_a4
275
+ new_auto_scale = options[:auto_scale_a4].nil? ? auto_scale_default : options[:auto_scale_a4]
276
+
277
+ a4_print_default = ImagesConvert::Configuration.default_waifu2x_a4_print
278
+ new_a4_print = options[:a4_print].nil? ? a4_print_default : options[:a4_print]
279
+
280
+ if options[:image_format]
281
+ normalized_format = normalize_waifu2x_output_format(options[:image_format])
282
+ unless %w[png jpg jpeg webp].include?(normalized_format)
283
+ say "エラー: waifu2x 出力フォーマットは png/jpg/jpeg/webp のいずれかを指定してください。", :red
284
+ exit 1
285
+ end
286
+ end
287
+ new_image_format = if options[:image_format]
288
+ normalize_waifu2x_output_format(options[:image_format])
289
+ else
290
+ normalize_waifu2x_output_format(ImagesConvert::Configuration.default_waifu2x_image_format)
291
+ end
292
+
293
+ new_on_error = options[:on_error] || ImagesConvert::Configuration.default_waifu2x_on_error
294
+
295
+ ImagesConvert::Configuration.save_waifu2x_settings(
296
+ scale: new_scale,
297
+ noise: new_noise,
298
+ model: new_model,
299
+ auto_scale_a4: new_auto_scale,
300
+ a4_print: new_a4_print,
301
+ image_format: new_image_format,
302
+ on_error: new_on_error
303
+ )
304
+
305
+ say "waifu2x 設定を更新しました:"
306
+ say " 拡大率: #{new_scale}"
307
+ say " ノイズ除去レベル: #{new_noise}"
308
+ say " モデル: #{new_model}"
309
+ say " A4自動スケール: #{new_auto_scale}"
310
+ say " A4レイアウトPDF: #{new_a4_print}"
311
+ say " 出力フォーマット: #{new_image_format}"
312
+ say " エラー時の動作: #{new_on_error}"
313
+ return
314
+ end
315
+
316
+ if args.first.to_s == 'set-output'
317
+ new_image_quality = if options[:image_quality]
318
+ clamp_output_quality(options[:image_quality])
319
+ else
320
+ ImagesConvert::Configuration.default_output_image_quality
321
+ end
322
+
323
+ new_pdf_quality = if options[:pdf_quality]
324
+ clamp_output_quality(options[:pdf_quality])
325
+ else
326
+ ImagesConvert::Configuration.default_output_pdf_quality
327
+ end
328
+
329
+ new_pdf_compression = if options[:pdf_compression]
330
+ normalized = options[:pdf_compression].to_s.strip.downcase
331
+ %w[jpeg jpeg2000 zip].include?(normalized) ? normalized : ImagesConvert::Configuration.default_output_pdf_compression
332
+ else
333
+ ImagesConvert::Configuration.default_output_pdf_compression
334
+ end
335
+
336
+ new_pdf_density = if options[:pdf_density]
337
+ clamp_pdf_density(options[:pdf_density])
338
+ else
339
+ ImagesConvert::Configuration.default_output_pdf_density
340
+ end
341
+
342
+ ImagesConvert::Configuration.save_output_settings(
343
+ image_quality: new_image_quality,
344
+ pdf_quality: new_pdf_quality,
345
+ pdf_compression: new_pdf_compression,
346
+ pdf_density: new_pdf_density
347
+ )
348
+
349
+ say "出力設定を更新しました:"
350
+ say " 画像品質: #{new_image_quality}"
351
+ say " PDF 品質: #{new_pdf_quality}"
352
+ say " PDF 圧縮方式: #{new_pdf_compression}"
353
+ say " PDF DPI: #{new_pdf_density}"
354
+ return
355
+ end
356
+
357
+ # それ以外(引数なし)は現在の設定を表示
358
+ settings = ImagesConvert::Configuration.show_all_settings
359
+
360
+ # 初回実行時はメッセージが既に表示されているので、改行だけ
361
+ puts "" unless ImagesConvert::Configuration.first_run_message_displayed?
362
+
363
+ show_config_summary(settings)
364
+
365
+ say "設定を変更するには:"
366
+ say " images-convert set FROM TO RESIZE"
367
+ say " images-convert set-dir INPUT_DIR OUTPUT_DIR"
368
+ say " images-convert set-latest-mode MODE (mtime/exif)"
369
+ say " images-convert set-waifu2x [--scale N --noise N --model TYPE --no-auto-scale-a4 --no-a4-print --image-format FORMAT --on-error skip]"
370
+ say " images-convert set-output [--image-quality N --pdf-quality N --pdf-compression TYPE --pdf-density DPI]"
371
+ end
372
+
373
+ desc 'setup', '対話形式で設定を更新'
374
+ def setup
375
+ say '=== images-convert 設定ウィザード ===', :green
376
+ say 'Enter キーのみで現在の設定値を維持できます。'
377
+ say ''
378
+
379
+ ImagesConvert::Configuration.load
380
+
381
+ updated_sections = []
382
+ changes = []
383
+
384
+ if prompt_yes_no('1) 入出力形式とリサイズを変更しますか?', default: true)
385
+ current_from = ImagesConvert::Configuration.default_from
386
+ current_to = ImagesConvert::Configuration.default_to
387
+ current_resize = ImagesConvert::Configuration.default_resize
388
+
389
+ new_from = prompt_string(' 入力形式', current_from)
390
+ new_to = prompt_string(' 出力形式', current_to)
391
+ new_resize = prompt_string(' リサイズ値', current_resize)
392
+
393
+ if [new_from, new_to, new_resize] != [current_from, current_to, current_resize]
394
+ record_change(changes, 'デフォルト入力形式', current_from, new_from) if new_from != current_from
395
+ record_change(changes, 'デフォルト出力形式', current_to, new_to) if new_to != current_to
396
+ record_change(changes, 'デフォルトリサイズ', current_resize, new_resize) if new_resize != current_resize
397
+ ImagesConvert::Configuration.save_defaults(from: new_from, to: new_to, resize: new_resize)
398
+ updated_sections << '基本設定'
399
+ end
400
+ end
401
+
402
+ if prompt_yes_no('2) 入出力ディレクトリを変更しますか?', default: false)
403
+ current_input = ImagesConvert::Configuration.default_input_dir
404
+ current_output = ImagesConvert::Configuration.default_output_dir
405
+
406
+ new_input = prompt_path(' 入力ディレクトリ', current_input)
407
+ new_output = prompt_path(' 出力ディレクトリ', current_output)
408
+
409
+ if [new_input, new_output] != [current_input, current_output]
410
+ record_change(changes, 'デフォルト入力ディレクトリ', current_input, new_input) if new_input != current_input
411
+ record_change(changes, 'デフォルト出力ディレクトリ', current_output, new_output) if new_output != current_output
412
+ ImagesConvert::Configuration.save_directories(input_dir: new_input, output_dir: new_output)
413
+ updated_sections << 'ディレクトリ'
414
+ end
415
+ end
416
+
417
+ if prompt_yes_no('3) 最新判定モードを変更しますか?', default: false)
418
+ current_latest = ImagesConvert::Configuration.default_latest_mode
419
+ new_latest = prompt_choice(' 最新判定モード (mtime/exif)', current_latest, %w[mtime exif])
420
+
421
+ if new_latest != current_latest
422
+ record_change(changes, '最新判定モード', current_latest, new_latest)
423
+ ImagesConvert::Configuration.save_latest_mode(new_latest)
424
+ updated_sections << '最新判定モード'
425
+ end
426
+ end
427
+
428
+ if prompt_yes_no('4) 出力品質を変更しますか?', default: false)
429
+ current_image_quality = ImagesConvert::Configuration.default_output_image_quality
430
+ current_pdf_quality = ImagesConvert::Configuration.default_output_pdf_quality
431
+ current_pdf_compression = ImagesConvert::Configuration.default_output_pdf_compression
432
+ current_pdf_density = ImagesConvert::Configuration.default_output_pdf_density
433
+
434
+ new_image_quality = prompt_integer(' 出力画像品質 (1-100)', current_image_quality, min: 1, max: 100)
435
+ new_pdf_quality = prompt_integer(' PDF 品質 (1-100)', current_pdf_quality, min: 1, max: 100)
436
+ new_pdf_compression = prompt_choice(' PDF 圧縮方式 (jpeg/jpeg2000/zip)', current_pdf_compression, %w[jpeg jpeg2000 zip])
437
+ new_pdf_density = prompt_integer(' PDF DPI (72-1200)', current_pdf_density, min: 72, max: 1200)
438
+
439
+ new_image_quality = clamp_output_quality(new_image_quality)
440
+ new_pdf_quality = clamp_output_quality(new_pdf_quality)
441
+ new_pdf_density = clamp_pdf_density(new_pdf_density)
442
+
443
+ if [new_image_quality, new_pdf_quality, new_pdf_compression, new_pdf_density] != [current_image_quality, current_pdf_quality, current_pdf_compression, current_pdf_density]
444
+ record_change(changes, '出力画像品質', current_image_quality, new_image_quality) if new_image_quality != current_image_quality
445
+ record_change(changes, 'PDF 品質', current_pdf_quality, new_pdf_quality) if new_pdf_quality != current_pdf_quality
446
+ record_change(changes, 'PDF 圧縮方式', current_pdf_compression, new_pdf_compression) if new_pdf_compression != current_pdf_compression
447
+ record_change(changes, 'PDF DPI', current_pdf_density, new_pdf_density) if new_pdf_density != current_pdf_density
448
+ ImagesConvert::Configuration.save_output_settings(
449
+ image_quality: new_image_quality,
450
+ pdf_quality: new_pdf_quality,
451
+ pdf_compression: new_pdf_compression,
452
+ pdf_density: new_pdf_density
453
+ )
454
+ updated_sections << '出力品質'
455
+ end
456
+ end
457
+
458
+ if prompt_yes_no('5) waifu2x の設定を変更しますか?', default: false)
459
+ current_scale = ImagesConvert::Configuration.default_waifu2x_scale
460
+ current_noise = ImagesConvert::Configuration.default_waifu2x_noise
461
+ current_model = ImagesConvert::Configuration.default_waifu2x_model
462
+ current_auto_scale = ImagesConvert::Configuration.default_waifu2x_auto_scale_a4
463
+ current_a4_print = ImagesConvert::Configuration.default_waifu2x_a4_print
464
+ current_image_format = ImagesConvert::Configuration.default_waifu2x_image_format
465
+ current_on_error = ImagesConvert::Configuration.default_waifu2x_on_error
466
+
467
+ new_scale = prompt_integer(' waifu2x 拡大率 (1-4)', current_scale, min: 1, max: 4)
468
+ new_noise = prompt_integer(' waifu2x ノイズ除去レベル (0-4)', current_noise, min: 0, max: 4)
469
+ new_model = prompt_string(' waifu2x モデル (anime/photo/anime_rgb/cunet/upresnet10)', current_model)
470
+ new_auto_scale = prompt_yes_no(' A4自動スケールを有効にしますか?', default: current_auto_scale, current_value: current_auto_scale)
471
+ new_a4_print = prompt_yes_no(' A4レイアウトPDFを適用しますか?', default: current_a4_print, current_value: current_a4_print)
472
+ new_image_format_raw = prompt_choice(' waifu2x 出力フォーマット (png/jpg/webp)', current_image_format, %w[png jpg jpeg webp])
473
+ new_on_error = prompt_choice(' waifu2x エラー時の動作 (stop/skip)', current_on_error, %w[stop skip])
474
+
475
+ new_scale = clamp_waifu2x_scale(new_scale)
476
+ new_noise = clamp_waifu2x_noise(new_noise)
477
+ new_image_format = normalize_waifu2x_output_format(new_image_format_raw)
478
+ current_image_format_normalized = normalize_waifu2x_output_format(current_image_format)
479
+
480
+ if [new_scale, new_noise, new_model, new_auto_scale, new_a4_print, new_image_format, new_on_error] != [current_scale, current_noise, current_model, current_auto_scale, current_a4_print, current_image_format_normalized, current_on_error]
481
+ record_change(changes, 'waifu2x 既定倍率', current_scale, new_scale) if new_scale != current_scale
482
+ record_change(changes, 'waifu2x ノイズ除去', current_noise, new_noise) if new_noise != current_noise
483
+ record_change(changes, 'waifu2x モデル', current_model, new_model) if new_model != current_model
484
+ record_change(changes, 'waifu2x A4自動スケール', current_auto_scale, new_auto_scale) if new_auto_scale != current_auto_scale
485
+ record_change(changes, 'waifu2x A4レイアウトPDF', current_a4_print, new_a4_print) if new_a4_print != current_a4_print
486
+ record_change(changes, 'waifu2x 出力フォーマット', current_image_format_normalized, new_image_format) if new_image_format != current_image_format_normalized
487
+ record_change(changes, 'waifu2x エラー時動作', current_on_error, new_on_error) if new_on_error != current_on_error
488
+ ImagesConvert::Configuration.save_waifu2x_settings(
489
+ scale: new_scale,
490
+ noise: new_noise,
491
+ model: new_model,
492
+ auto_scale_a4: new_auto_scale,
493
+ a4_print: new_a4_print,
494
+ image_format: new_image_format,
495
+ on_error: new_on_error
496
+ )
497
+ updated_sections << 'waifu2x'
498
+ end
499
+ end
500
+
501
+ if updated_sections.empty?
502
+ say ''
503
+ say '設定は変更されませんでした。', :yellow
504
+ else
505
+ say ''
506
+ say '更新された設定:'
507
+ updated_sections.each do |label|
508
+ say " - #{label}"
509
+ end
510
+ if changes.any?
511
+ say ''
512
+ say '変更点の詳細:'
513
+ changes.each do |change|
514
+ say " - #{change[:label]}: #{change[:before]} => #{set_color(change[:after], :green)}"
515
+ end
516
+ end
517
+ end
518
+
519
+ say ''
520
+ invoke :config
521
+ end
522
+
523
+ desc 'set FROM TO [RESIZE]', '変換形式やリサイズ値の既定値を設定'
524
+ def set(from = nil, to = nil, resize = nil)
525
+ unless from && to
526
+ say "使い方: imgconv set FROM TO [RESIZE]", :red
527
+ exit 1
528
+ end
529
+
530
+ resize ||= ImagesConvert::Configuration.default_resize
531
+ config('set', from, to, resize)
532
+ end
533
+
534
+ desc 'set-dir INPUT_DIR OUTPUT_DIR', '入出力ディレクトリの既定値を設定'
535
+ def set_dir(input_dir = nil, output_dir = nil)
536
+ unless input_dir && output_dir
537
+ say "使い方: imgconv set-dir INPUT_DIR OUTPUT_DIR", :red
538
+ exit 1
539
+ end
540
+
541
+ config('set-dir', input_dir, output_dir)
542
+ end
543
+
544
+ desc 'set-latest-mode MODE', '最新件数選定時の判定基準を設定 (mtime/exif)'
545
+ def set_latest_mode(mode = nil)
546
+ unless mode
547
+ say "使い方: imgconv set-latest-mode MODE (mtime/exif)", :red
548
+ exit 1
549
+ end
550
+
551
+ normalized = mode.to_s.downcase
552
+ unless %w[mtime exif].include?(normalized)
553
+ say "エラー: MODE には mtime または exif を指定してください。", :red
554
+ exit 1
555
+ end
556
+
557
+ ImagesConvert::Configuration.save_latest_mode(normalized)
558
+ say "最新判定モードを #{normalized} に設定しました。"
559
+ end
560
+
561
+ desc 'cleanup', '設定ファイルとディレクトリを削除'
562
+ def cleanup
563
+ ImagesConvert::Cleanup.run
564
+ end
565
+
566
+ # 自己アンインストール: 設定削除 → gem アンインストール
567
+ map 'self-uninstall' => :self_uninstall
568
+ desc 'self-uninstall', '設定を削除して gem もアンインストール'
569
+ def self_uninstall
570
+ ImagesConvert::Cleanup.run
571
+ say ''
572
+ say 'images-convert の gem をアンインストールしますか? [Y/n]'
573
+ print '> '
574
+ answer = (STDIN.gets || '').strip
575
+ answer = 'y' if answer.empty?
576
+ if answer.downcase.start_with?('y')
577
+ system('gem uninstall images-convert')
578
+ else
579
+ say 'gem のアンインストールをキャンセルしました。'
580
+ end
581
+ end
582
+
583
+ desc 'convert TARGET [DEST_DIR]', '単一ファイル変換(TARGET=ファイル名/番号)または一括変換(TARGET=入力ディレクトリ, DEST_DIR=出力ディレクトリ)'
584
+ long_desc <<~DESC
585
+ 使い方:
586
+
587
+ images-convert [convert] TARGET [DEST_DIR] [オプション]
588
+ imgconv [convert] TARGET [DEST_DIR] [オプション]
589
+
590
+ 動作:
591
+ - TARGET がファイル名/番号の場合は単一ファイル変換(-i/-o で入出力ディレクトリ指定可)
592
+ - TARGET がディレクトリの場合は一括変換(DEST_DIR 未指定時は in-place 変換)
593
+
594
+ 主なオプション:
595
+ -i, --input-dir=DIR 入力ディレクトリ(単一ファイル時)
596
+ -o, --output-dir=DIR 出力ディレクトリ(単一ファイル時)
597
+ -d, --delete 変換後に元ファイルを削除
598
+ -f, --overwrite 既存の出力ファイルがある場合に上書き
599
+ -s, --suffix=SUFFIX 出力ファイル名の末尾に付与(例: _web)
600
+ --recursive ディレクトリを再帰的に処理(一括変換時)
601
+
602
+ 例:
603
+ # 1枚だけ変換(番号指定、入出力ディレクトリや変換形式は既定値)
604
+ images-convert [convert] 1234
605
+ # または短縮コマンド
606
+ imgconv [convert] 1234
607
+
608
+ # 1枚だけ変換(ファイル名指定、入出力ディレクトリを明示)
609
+ images-convert [convert] IMG_0001 ~/Downloads ~/Downloads
610
+
611
+ # 一括変換(in-place)
612
+ images-convert [convert] ./photos
613
+
614
+ # 一括変換(出力先を別ディレクトリ、再帰処理、サフィックス付与)
615
+ images-convert [convert] ./photos ./export --recursive --suffix _resized
616
+ DESC
617
+ method_option :input_dir, aliases: '-i', desc: '入力ディレクトリ(デフォルト: 設定値または ~/Downloads)'
618
+ method_option :output, aliases: '-o', desc: '出力ファイルパス(単一ファイル変換時、ディレクトリ指定時は出力ディレクトリ)'
619
+ method_option :output_dir, type: :string, desc: '出力ディレクトリ(単一ファイル時)'
620
+ method_option :delete, aliases: '-d', type: :boolean, desc: '変換後に元のファイルを削除'
621
+ method_option :overwrite, aliases: '-f', type: :boolean, desc: '既存の出力ファイルがある場合に上書き'
622
+ method_option :suffix, aliases: '-s', type: :string, desc: '出力ファイル名の末尾に付与するサフィックス(例: _converted)'
623
+ method_option :recursive, type: :boolean, desc: 'ディレクトリを再帰的に処理(一括変換時)'
624
+ method_option :resize, type: :string, desc: 'リサイズ値(例: 1920x, 50%)'
625
+ method_option :output_mode, type: :string, enum: %w[images pdf both], default: 'images', desc: '出力モード(images/pdf/both)'
626
+ method_option :waifu2x_scale, type: :numeric, desc: 'waifu2x の拡大率 (1-4)'
627
+ method_option :waifu2x_noise, type: :numeric, desc: 'waifu2x のノイズ除去レベル (0-4)'
628
+ method_option :waifu2x_model, type: :string, desc: 'waifu2x モデルエイリアス(photo/anime/anime_rgb/cunet/upresnet10)'
629
+ method_option :waifu2x_processor, type: :string, default: 'waifu2x_ncnn_vulkan', desc: 'waifu2x プロセッサ'
630
+ method_option :waifu2x_bin, type: :string, desc: 'waifu2x バイナリへのパス'
631
+ method_option :waifu2x_models_path, type: :string, desc: 'waifu2x モデルディレクトリへのパス'
632
+ method_option :waifu2x_download_url, type: :string, desc: 'waifu2x 自動ダウンロード用URL'
633
+ method_option :waifu2x_auto_scale_a4, type: :boolean, desc: 'A4印刷を目安に拡大率を自動調整'
634
+ method_option :waifu2x_a4_print, type: :boolean, desc: 'A4レイアウトPDFを適用(--output-mode pdf/both)'
635
+ method_option :waifu2x_image_format, type: :string, desc: 'waifu2x 出力画像フォーマット(png/jpg/webp)'
636
+ method_option :waifu2x_verbose, type: :boolean, desc: 'waifu2x の詳細ログを表示'
637
+ method_option :waifu2x_on_error, type: :string, enum: %w[skip stop], desc: 'waifu2x 処理失敗時の動作'
638
+ method_option :quality, type: :numeric, desc: '出力画像の品質 (0-100, JPEG/WebP など)'
639
+ method_option :pdf_compression, type: :string, enum: %w[jpeg jpeg2000 zip], desc: 'PDF 埋め込み画像の圧縮方式'
640
+ method_option :pdf_quality, type: :numeric, desc: 'PDF 埋め込み画像の品質(0-100)'
641
+ method_option :pdf_density, type: :numeric, desc: 'PDF ページの DPI'
642
+ # 追加機能: 最新N件/全件変換をサポート(-N, -0/--all)
643
+ method_option :latest, type: :numeric, desc: '最新の画像ファイル N 件を変換(入力ディレクトリ内から)'
644
+ method_option :latest_exif, type: :boolean, default: false, negatable: false, desc: '最新 N 件の選定を EXIF 撮影日時に基づいて行う(--latest と併用)'
645
+ method_option :all, type: :boolean, default: false, negatable: false, desc: '入力ディレクトリ内の対象画像をすべて変換'
646
+ # オプションで from/to を一時的に上書き(ショートハンドや内部用途)
647
+ method_option :from, type: :string, desc: '入力形式(設定を一時的に上書き)'
648
+ method_option :to, type: :string, desc: '出力形式(設定を一時的に上書き)'
649
+ def convert(target = nil, dest_dir = nil)
650
+ from = options[:from] || ImagesConvert::Configuration.default_from
651
+ to = options[:to] || ImagesConvert::Configuration.default_to
652
+ resize = options[:resize] || ImagesConvert::Configuration.default_resize
653
+ latest_mode = latest_mode_from_options
654
+ pdf_density = clamp_pdf_density(options[:pdf_density])
655
+
656
+ # 最新N件/全件変換(入力ディレクトリを走査)
657
+ if options[:all] || (options.key?(:latest) && options[:latest].to_s.strip != '')
658
+ # コマンドライン引数でディレクトリが指定されている場合は優先
659
+ input_dir = if target && File.directory?(target)
660
+ File.expand_path(target)
661
+ else
662
+ options[:input_dir] || ImagesConvert::Configuration.default_input_dir
663
+ end
664
+ output_dir = if dest_dir
665
+ File.expand_path(dest_dir)
666
+ else
667
+ options[:output_dir] || ImagesConvert::Configuration.default_output_dir
668
+ end
669
+
670
+ unless Dir.exist?(input_dir)
671
+ say "エラー: 入力ディレクトリ '#{input_dir}' が存在しません。", :red
672
+ exit 1
673
+ end
674
+
675
+ # 入力候補の列挙
676
+ exts = candidate_exts(from)
677
+ candidates = list_input_candidates(input_dir, exts)
678
+
679
+ if candidates.empty?
680
+ say "対象ファイルが見つかりませんでした (#{input_dir}, 拡張子: #{exts.uniq.join('/')})."
681
+ return
682
+ end
683
+
684
+ # 最新順の並べ替え(EXIF 対応)
685
+ sorted, exif_fallback_used = sort_candidates_by_mode(candidates, latest_mode)
686
+ sorted = sorted.uniq
687
+ mode, count = resolve_selection(sorted)
688
+ case mode
689
+ when :latest
690
+ say "最新の #{count} 件を変換します (#{input_dir}, 基準: #{latest_mode})"
691
+ if latest_mode == 'exif' && exif_fallback_used
692
+ say ' ※ EXIF 情報がないファイルはファイル更新日時で判定しました。'
693
+ end
694
+ when :all
695
+ say "すべての画像ファイルを変換します (#{input_dir})"
696
+ else
697
+ say "エラー: --latest N (N>=1) または --all / -0 を指定してください(--latest 0 は全件として扱います)", :red
698
+ exit 1
699
+ end
700
+
701
+ targets = sorted.first(count)
702
+
703
+ targets.each do |filepath|
704
+ base = File.basename(filepath, '.*')
705
+ perform_conversion(base, from, to, resize, input_dir, output_dir, suffix: options[:suffix], pdf_density: pdf_density)
706
+ end
707
+ return
708
+ end
709
+
710
+ # 一括変換(ディレクトリ指定)
711
+ if target && File.directory?(target)
712
+ src_dir = File.expand_path(target)
713
+ out_dir = dest_dir ? File.expand_path(dest_dir) : src_dir # 単一引数時は in-place
714
+ unless Dir.exist?(src_dir)
715
+ say "エラー: 入力ディレクトリ '#{src_dir}' が存在しません。", :red
716
+ exit 1
717
+ end
718
+ FileUtils.mkdir_p(out_dir)
719
+ perform_bulk_conversion(
720
+ src_dir,
721
+ out_dir,
722
+ from,
723
+ to,
724
+ resize,
725
+ overwrite: options[:overwrite],
726
+ suffix: options[:suffix],
727
+ recursive: options[:recursive],
728
+ delete: options[:delete],
729
+ pdf_density: pdf_density
730
+ )
731
+ return
732
+ end
733
+
734
+ # 単一ファイル変換: target はファイル名または番号(IMG_1234 を想定)
735
+ input_dir = options[:input_dir] || ImagesConvert::Configuration.default_input_dir
736
+ output_dir = if dest_dir
737
+ File.expand_path(dest_dir)
738
+ else
739
+ options[:output_dir] || ImagesConvert::Configuration.default_output_dir
740
+ end
741
+
742
+ # --output オプションが指定されている場合は出力ファイルを直接指定
743
+ if options[:output] && target
744
+ # --output で指定されたパスをフルパスに変換
745
+ output_file_path = File.expand_path(options[:output])
746
+ output_dir = File.dirname(output_file_path)
747
+ output_basename = File.basename(output_file_path, '.*')
748
+ # 出力ファイル名を指定されたものに変更
749
+ def self.custom_output_filename(input_file, to, output_dir, custom_basename, suffix: nil, create_dir: true)
750
+ basename = suffix && !suffix.empty? ? "#{custom_basename}#{suffix}" : custom_basename
751
+ FileUtils.mkdir_p(output_dir) if create_dir && !Dir.exist?(output_dir)
752
+ "#{output_dir}/#{basename}.#{to.downcase}"
753
+ end
754
+
755
+ # 入力がフルパス/実在パスなら input_dir 検証をスキップしてそのまま使う
756
+ input_path_like = target.to_s.include?('/') || File.exist?(target.to_s)
757
+ input_file = nil
758
+ if input_path_like
759
+ candidate = File.expand_path(target.to_s)
760
+ if File.exist?(candidate)
761
+ input_file = candidate
762
+ end
763
+ end
764
+
765
+ # まだ見つからない場合は従来どおり input_dir + base 名から探索
766
+ unless input_file
767
+ validate_arguments(target, input_dir)
768
+ input_file = find_input_file(target, from, input_dir)
769
+ unless input_file
770
+ say "エラー: 指定されたファイル '#{target}' (#{from} 形式) は #{input_dir} に存在しません。", :red
771
+ exit 1
772
+ end
773
+ end
774
+
775
+ normalized_to = normalized_to_format(to)
776
+ output_file = custom_output_filename(input_file, normalized_to, output_dir, output_basename)
777
+
778
+ if attempt_waifu2x_single(
779
+ input_file: input_file,
780
+ normalized_to: normalized_to,
781
+ resize: resize,
782
+ output_path: output_file,
783
+ overwrite: options[:overwrite],
784
+ delete_original: options[:delete],
785
+ settings: waifu2x_runtime_settings(target_format: normalized_to),
786
+ explicit_scale: options[:waifu2x_scale]
787
+ )
788
+ return
789
+ end
790
+
791
+ if File.exist?(output_file) && !options[:overwrite]
792
+ say "- 既存のためスキップ: #{output_file}(--overwrite で上書き可能)", :yellow
793
+ return
794
+ end
795
+
796
+ say "#{input_file} を #{output_file} に変換しています..."
797
+
798
+ begin
799
+ image = MiniMagick::Image.open(input_file)
800
+ image.resize resize unless resize.empty?
801
+ image.format normalized_to
802
+ apply_image_quality(image, effective_output_quality)
803
+ image.write output_file
804
+
805
+ say "変換成功! 新しいファイルは #{output_file} です。", :green
806
+
807
+ if options[:delete]
808
+ FileUtils.rm(input_file)
809
+ say "元のファイル #{input_file} は削除されました。", :yellow
810
+ else
811
+ say '元のファイルは削除されませんでした。削除するには --delete オプションを使用してください。', :blue
812
+ end
813
+ rescue => e
814
+ say "エラー: 変換に失敗しました。#{e.message}", :red
815
+ say 'ImageMagick のインストール状況を確認してください。', :red
816
+ return
817
+ end
818
+ return
819
+ end
820
+
821
+ perform_conversion(target, from, to, resize, input_dir, output_dir, suffix: options[:suffix], pdf_density: pdf_density)
822
+ end
823
+
824
+ private
825
+
826
+ # 最新N件/全件の選定ロジック
827
+ # @return [Symbol,Integer] mode (:latest|:all|:none), count
828
+ def resolve_selection(sorted)
829
+ # --latest の明示(-N は preprocess で --latest N に展開済み)
830
+ latest_val = options[:latest]
831
+ if !(latest_val.nil? || latest_val.to_s.strip.empty?)
832
+ n = latest_val.to_i
833
+ return [:all, sorted.size] if n <= 0
834
+ return [:latest, n]
835
+ end
836
+
837
+ # --all
838
+ return [:all, sorted.size] if options[:all]
839
+
840
+ [:none, 0]
841
+ end
842
+
843
+ # 入力拡張子の候補(jpeg の場合は jpg と jpeg の両方、大文字拡張子も含めて検出)
844
+ def candidate_exts(from)
845
+ from.to_s.match?(/\Ajpe?g\z/i) ? %w[jpg jpeg JPG JPEG] : [from.downcase, from.upcase]
846
+ end
847
+
848
+ # 出力拡張子の正規化(jpeg 系は jpg に統一)
849
+ def normalized_to_format(to)
850
+ to.to_s.match?(/\Ajpe?g\z/i) ? 'jpg' : to.downcase
851
+ end
852
+
853
+ def validate_arguments(base_name, input_dir)
854
+ unless base_name
855
+ say 'エラー: ファイル名または番号を指定してください。', :red
856
+ exit 1
857
+ end
858
+
859
+ unless Dir.exist?(input_dir)
860
+ say "エラー: 入力ディレクトリ '#{input_dir}' が存在しません。", :red
861
+ exit 1
862
+ end
863
+ end
864
+
865
+ def perform_conversion(base_name, from, to, resize, input_dir, output_dir, suffix: nil, pdf_density: ImagesConvert::Configuration.default_output_pdf_density)
866
+ input_file = find_input_file(base_name, from, input_dir)
867
+
868
+ unless input_file
869
+ say "エラー: 指定されたファイル '#{base_name}' (#{from} 形式) は #{input_dir} に存在しません。", :red
870
+ exit 1
871
+ end
872
+
873
+ # 拡張子とフォーマット名の正規化(jpeg は jpg に統一)
874
+ normalized_to = normalized_to_format(to)
875
+ output_file = output_filename(input_file, normalized_to, output_dir, suffix: suffix)
876
+
877
+ if attempt_waifu2x_single(
878
+ input_file: input_file,
879
+ normalized_to: normalized_to,
880
+ resize: resize,
881
+ output_path: output_file,
882
+ overwrite: options[:overwrite],
883
+ delete_original: options[:delete],
884
+ settings: waifu2x_runtime_settings(target_format: normalized_to),
885
+ explicit_scale: options[:waifu2x_scale]
886
+ )
887
+ return
888
+ end
889
+
890
+ if File.exist?(output_file) && !options[:overwrite]
891
+ say "- 既存のためスキップ: #{output_file}(--overwrite で上書き可能)", :yellow
892
+ return
893
+ end
894
+
895
+ say "#{input_file} を #{output_file} に変換しています..."
896
+
897
+ # MiniMagick で変換(リサイズを追加)
898
+ begin
899
+ image = MiniMagick::Image.open(input_file)
900
+ image.resize resize unless resize.empty?
901
+ image.format normalized_to
902
+ apply_image_quality(image, effective_output_quality)
903
+ image.write output_file
904
+
905
+ say "変換成功! 新しいファイルは #{output_file} です。", :green
906
+
907
+ if options[:delete]
908
+ FileUtils.rm(input_file)
909
+ say "元のファイル #{input_file} は削除されました。", :yellow
910
+ else
911
+ say '元のファイルは削除されませんでした。削除するには --delete オプションを使用してください。', :blue
912
+ end
913
+ rescue => e
914
+ say "エラー: 変換に失敗しました。#{e.message}", :red
915
+ say 'ImageMagick のインストール状況を確認してください。', :red
916
+ # バッチ処理継続のため、ここでは終了せず次へ
917
+ return
918
+ end
919
+ end
920
+
921
+ def find_input_file(base_name, from, input_dir)
922
+ input_file = nil
923
+
924
+ normalized_exts = candidate_exts(from).flat_map do |ext|
925
+ ext = ext.delete_prefix('.')
926
+ [ext.downcase, ext.upcase]
927
+ end.uniq
928
+
929
+ if base_name.match?(/\A\d+\z/)
930
+ # 数字のみの場合、IMG_プレフィックスを試す
931
+ candidates = normalized_exts.map { |ext| "#{input_dir}/IMG_#{base_name}.#{ext}" }
932
+ else
933
+ # 数字以外の場合、そのままファイル名を試す
934
+ candidates = normalized_exts.map { |ext| "#{input_dir}/#{base_name}.#{ext}" } + [
935
+ "#{input_dir}/#{base_name}"
936
+ ]
937
+ end
938
+
939
+ candidates.each do |candidate|
940
+ if File.exist?(candidate)
941
+ input_file = candidate
942
+ break
943
+ end
944
+ end
945
+
946
+ input_file
947
+ end
948
+
949
+ def output_filename(input_file, to, output_dir, suffix: nil, create_dir: true)
950
+ basename = File.basename(input_file, ".*")
951
+ basename = suffix && !suffix.empty? ? "#{basename}#{suffix}" : basename
952
+ FileUtils.mkdir_p(output_dir) if create_dir && !Dir.exist?(output_dir)
953
+ "#{output_dir}/#{basename}.#{to.downcase}"
954
+ end
955
+
956
+ # オプションから最新判定モードを決定する
957
+ def latest_mode_from_options
958
+ flag = options[:latest_exif]
959
+ flag = options['latest_exif'] if flag.nil?
960
+ flag = options['latest-exif'] if flag.nil?
961
+ flag ? 'exif' : ImagesConvert::Configuration.default_latest_mode
962
+ end
963
+
964
+ def waifu2x_runtime_settings(target_format: nil)
965
+ explicit_scale = options[:waifu2x_scale]
966
+ noise = options[:waifu2x_noise]
967
+ model = options[:waifu2x_model]
968
+ auto_scale_a4 = options.key?(:waifu2x_auto_scale_a4) ? options[:waifu2x_auto_scale_a4] : nil
969
+ a4_print = options.key?(:waifu2x_a4_print) ? options[:waifu2x_a4_print] : nil
970
+ image_format = options[:waifu2x_image_format]
971
+ on_error = options[:waifu2x_on_error]
972
+
973
+ {
974
+ scale: clamp_waifu2x_scale(explicit_scale || ImagesConvert::Configuration.default_waifu2x_scale),
975
+ noise: clamp_waifu2x_noise(noise.nil? ? ImagesConvert::Configuration.default_waifu2x_noise : noise.to_i),
976
+ model: model || ImagesConvert::Configuration.default_waifu2x_model,
977
+ auto_scale_a4: auto_scale_a4.nil? ? ImagesConvert::Configuration.default_waifu2x_auto_scale_a4 : auto_scale_a4,
978
+ a4_print: a4_print.nil? ? ImagesConvert::Configuration.default_waifu2x_a4_print : a4_print,
979
+ image_format: normalize_waifu2x_output_format(image_format || target_format || ImagesConvert::Configuration.default_waifu2x_image_format),
980
+ on_error: on_error || ImagesConvert::Configuration.default_waifu2x_on_error,
981
+ processor: options[:waifu2x_processor] || 'waifu2x_ncnn_vulkan',
982
+ download_url: options[:waifu2x_download_url],
983
+ bin: options[:waifu2x_bin],
984
+ models_path: options[:waifu2x_models_path],
985
+ quality: clamp_output_quality(options[:quality] || ImagesConvert::Configuration.default_output_image_quality)
986
+ }
987
+ end
988
+
989
+ def normalize_waifu2x_output_format(fmt)
990
+ return ImagesConvert::Configuration.default_waifu2x_image_format unless fmt
991
+ fmt.to_s.strip.downcase.then { |v| v == 'jpeg' ? 'jpg' : v }
992
+ end
993
+
994
+ def attempt_waifu2x_single(input_file:, normalized_to:, resize:, output_path:, overwrite:, delete_original:, settings:, explicit_scale: nil)
995
+ return false unless (options[:output_mode] || 'images').to_s == 'images'
996
+ return false unless waifu2x_supported_format?(normalized_to)
997
+
998
+ ratio = resize_ratio_for(input_file, resize)
999
+ return false if ratio <= 1.0 && explicit_scale.nil?
1000
+
1001
+ configured_scale = settings[:scale] || 1
1002
+ auto_scale = waifu2x_scale_from_ratio(ratio, nil)
1003
+
1004
+ scale = if explicit_scale
1005
+ clamp_waifu2x_scale(explicit_scale)
1006
+ else
1007
+ clamp_waifu2x_scale([auto_scale, configured_scale].max)
1008
+ end
1009
+ return false if scale <= 1
1010
+
1011
+ if File.exist?(output_path)
1012
+ unless overwrite
1013
+ say "- 既存のためスキップ: #{output_path}(--overwrite で上書き可能)", :yellow
1014
+ return true
1015
+ end
1016
+ FileUtils.rm_f(output_path)
1017
+ end
1018
+
1019
+ resolved = resolve_waifu2x_paths(settings: settings)
1020
+ return false unless resolved
1021
+
1022
+ begin
1023
+ Dir.mktmpdir('imgconv-waifu2x-in') do |tmp_in|
1024
+ Dir.mktmpdir('imgconv-waifu2x-out') do |tmp_out|
1025
+ temp_input = File.join(tmp_in, File.basename(input_file))
1026
+ FileUtils.cp(input_file, temp_input)
1027
+
1028
+ Waifu2x::Processor.process_images(
1029
+ tmp_in,
1030
+ tmp_out,
1031
+ scale: scale,
1032
+ noise: settings[:noise],
1033
+ model: settings[:model],
1034
+ processor: settings[:processor].to_sym,
1035
+ waifu2x_bin: resolved[:bin],
1036
+ models_path: resolved[:models],
1037
+ recursive: false,
1038
+ auto_scale_a4: settings[:auto_scale_a4],
1039
+ pdf_density: clamp_pdf_density(options[:pdf_density]),
1040
+ verbose: options[:waifu2x_verbose],
1041
+ image_format: settings[:image_format],
1042
+ on_error: settings[:on_error]
1043
+ )
1044
+
1045
+ produced = Dir.glob(File.join(tmp_out, "*.#{settings[:image_format]}"))
1046
+ produced = produced.first || Dir.glob(File.join(tmp_out, '*')).first
1047
+
1048
+ unless produced && File.exist?(produced)
1049
+ say 'waifu2x の出力が見つかりませんでした。MiniMagick で再試行します。', :yellow
1050
+ return false
1051
+ end
1052
+
1053
+ FileUtils.mkdir_p(File.dirname(output_path))
1054
+ FileUtils.mv(produced, output_path, force: true)
1055
+ say "waifu2x を使用して #{output_path} を生成しました。", :green
1056
+
1057
+ enforce_resize_after_waifu2x(output_path, resize, settings[:quality])
1058
+
1059
+ if delete_original
1060
+ FileUtils.rm_f(input_file)
1061
+ say "元のファイル #{input_file} は削除されました。", :yellow
1062
+ end
1063
+ return true
1064
+ end
1065
+ end
1066
+ rescue StandardError => e
1067
+ safe_msg = begin
1068
+ e.message.to_s.encode('UTF-8', invalid: :replace, undef: :replace)
1069
+ rescue StandardError
1070
+ e.message.to_s
1071
+ end
1072
+ say "waifu2x の実行に失敗しました: #{safe_msg}。MiniMagick で再試行します。", :yellow
1073
+ false
1074
+ end
1075
+ end
1076
+
1077
+ def waifu2x_supported_format?(normalized_to)
1078
+ %w[png jpg jpeg webp].include?(normalized_to.to_s.downcase)
1079
+ end
1080
+
1081
+ def resolve_waifu2x_paths(settings:)
1082
+ Waifu2x::Setup.resolve_paths(
1083
+ waifu2x_bin: settings[:bin],
1084
+ models_path: settings[:models_path],
1085
+ auto_download: true,
1086
+ download_url: settings[:download_url],
1087
+ model: settings[:model]
1088
+ )
1089
+ rescue StandardError => e
1090
+ safe_msg = begin
1091
+ e.message.to_s.encode('UTF-8', invalid: :replace, undef: :replace)
1092
+ rescue StandardError
1093
+ e.message.to_s
1094
+ end
1095
+ say "waifu2x の準備に失敗しました: #{safe_msg}。MiniMagick でフォールバックします。", :yellow
1096
+ nil
1097
+ end
1098
+
1099
+ def resize_ratio_for(path, resize)
1100
+ resize = resize.to_s.strip
1101
+ return 1.0 if resize.empty?
1102
+
1103
+ if (percent = resize.match(/\A(\d+(?:\.\d+)?)%\z/))
1104
+ return percent[1].to_f / 100.0
1105
+ end
1106
+
1107
+ width, height = image_dimensions(path)
1108
+ return 1.0 if width.to_i <= 0 || height.to_i <= 0
1109
+
1110
+ cleaned = resize.tr('^0-9x', '')
1111
+ parts = cleaned.split('x', 2)
1112
+ target_w = parts[0].to_s.empty? ? nil : parts[0].to_i
1113
+ target_h = parts[1].to_s.empty? ? nil : parts[1].to_i
1114
+
1115
+ ratios = []
1116
+ ratios << (target_w.to_f / width) if target_w && width.positive?
1117
+ ratios << (target_h.to_f / height) if target_h && height.positive?
1118
+ ratio = ratios.compact.max
1119
+ ratio && ratio > 0 ? ratio : 1.0
1120
+ rescue StandardError
1121
+ 1.0
1122
+ end
1123
+
1124
+ def image_dimensions(path)
1125
+ @_waifu2x_dimension_cache ||= {}
1126
+ return @_waifu2x_dimension_cache[path] if @_waifu2x_dimension_cache.key?(path)
1127
+
1128
+ image = MiniMagick::Image.ping(path)
1129
+ dims = [image.width.to_i, image.height.to_i]
1130
+ @_waifu2x_dimension_cache[path] = dims
1131
+ rescue StandardError
1132
+ @_waifu2x_dimension_cache[path] = [0, 0]
1133
+ end
1134
+
1135
+ def waifu2x_scale_from_ratio(ratio, explicit_scale)
1136
+ return clamp_waifu2x_scale(explicit_scale.to_i) if explicit_scale
1137
+
1138
+ return 1 if ratio <= 1.0
1139
+ return 2 if ratio <= 2.0
1140
+ 4
1141
+ end
1142
+
1143
+ def clamp_waifu2x_scale(value)
1144
+ v = value.to_i
1145
+ v = 4 if v > 4
1146
+ v = 1 if v < 1
1147
+ v == 3 ? 4 : v
1148
+ end
1149
+
1150
+ def clamp_waifu2x_noise(value)
1151
+ v = value.to_i
1152
+ v = 4 if v > 4
1153
+ v = 0 if v < 0
1154
+ v
1155
+ end
1156
+
1157
+ def clamp_output_quality(value)
1158
+ return nil if value.nil?
1159
+ v = value.to_i
1160
+ v = 100 if v > 100
1161
+ v = 1 if v < 1
1162
+ v
1163
+ end
1164
+
1165
+ def effective_output_quality
1166
+ clamp_output_quality(options[:quality] || ImagesConvert::Configuration.default_output_image_quality)
1167
+ end
1168
+
1169
+ def apply_image_quality(image, quality)
1170
+ return if quality.nil?
1171
+ image.quality(quality)
1172
+ rescue StandardError
1173
+ # ignore
1174
+ end
1175
+
1176
+ def enforce_resize_after_waifu2x(path, resize, quality)
1177
+ resize = resize.to_s.strip
1178
+ return if resize.empty?
1179
+
1180
+ begin
1181
+ image = MiniMagick::Image.open(path)
1182
+ image.resize(resize)
1183
+ apply_image_quality(image, quality)
1184
+ image.write(path)
1185
+ rescue StandardError => e
1186
+ say "警告: waifu2x 出力の最終リサイズに失敗しました: #{e.message}", :yellow
1187
+ end
1188
+ end
1189
+
1190
+ # 入力ディレクトリから対象拡張子の候補を列挙
1191
+ def list_input_candidates(input_dir, exts)
1192
+ pattern = "*.{#{exts.join(',')}}"
1193
+ Dir.chdir(input_dir) { Dir.glob(pattern) }
1194
+ .map { |p| File.join(input_dir, p) }
1195
+ .select { |f| File.file?(f) }
1196
+ end
1197
+
1198
+ def perform_bulk_conversion(src_dir, out_dir, from, to, resize, overwrite:, suffix:, recursive:, delete:, pdf_density: ImagesConvert::Configuration.default_output_pdf_density)
1199
+ start_time = Time.now
1200
+ # 入力拡張子の候補(jpeg の場合は jpg と jpeg の両方)
1201
+ exts = candidate_exts(from)
1202
+ pattern = recursive ? "**/*.{#{exts.join(',')}}" : "*.{#{exts.join(',')}}"
1203
+
1204
+ candidates = Dir.chdir(src_dir) { Dir.glob(pattern) }.uniq
1205
+ .select { |p| File.file?(File.join(src_dir, p)) }
1206
+
1207
+ total = candidates.size
1208
+ converted = 0
1209
+ skipped = 0
1210
+ failed = 0
1211
+ deleted = 0
1212
+
1213
+ normalized_to = to.to_s.match?(/\Ajpe?g\z/i) ? 'jpg' : to.downcase
1214
+
1215
+ if total.zero?
1216
+ say "対象ファイルが見つかりませんでした (#{src_dir}, 拡張子: #{exts.uniq.join('/')})."
1217
+ end
1218
+
1219
+ candidates.each do |rel_path|
1220
+ begin
1221
+ in_path = File.join(src_dir, rel_path)
1222
+ # 出力は out_dir 直下に相対パスを維持
1223
+ out_path_dir = File.join(out_dir, File.dirname(rel_path))
1224
+ FileUtils.mkdir_p(out_path_dir) unless Dir.exist?(out_path_dir)
1225
+ out_basename = File.basename(rel_path, ".*")
1226
+ out_basename = suffix && !suffix.empty? ? "#{out_basename}#{suffix}" : out_basename
1227
+ out_path = File.join(out_path_dir, "#{out_basename}.#{normalized_to}")
1228
+
1229
+ if File.exist?(out_path) && !overwrite
1230
+ skipped += 1
1231
+ say "- 既存のためスキップ: #{out_path}(--overwrite で上書き可能)", :yellow
1232
+ next
1233
+ end
1234
+
1235
+ say "#{in_path} を #{out_path} に変換しています..."
1236
+ image = MiniMagick::Image.open(in_path)
1237
+ image.resize resize unless resize.to_s.empty?
1238
+ image.format normalized_to
1239
+ apply_image_quality(image, effective_output_quality)
1240
+ image.write out_path
1241
+ converted += 1
1242
+ say "変換成功! 新しいファイルは #{out_path} です。", :green
1243
+
1244
+ if delete
1245
+ FileUtils.rm(in_path)
1246
+ deleted += 1
1247
+ say "元のファイル #{in_path} は削除されました。", :yellow
1248
+ end
1249
+ rescue => e
1250
+ failed += 1
1251
+ say "エラー: 変換に失敗しました: #{rel_path} — #{e.message}", :red
1252
+ end
1253
+ end
1254
+
1255
+ elapsed = Time.now - start_time
1256
+ say ""
1257
+ say "処理結果サマリ:", :bold if respond_to?(:say)
1258
+ say " 対象ファイル数: #{total}"
1259
+ say " 変換成功: #{converted}"
1260
+ say " スキップ: #{skipped}"
1261
+ say " 失敗: #{failed}"
1262
+ say " 削除: #{deleted}"
1263
+ say " 所要時間: #{format('%.2f', elapsed)} 秒"
1264
+ end
1265
+
1266
+ def prompt_yes_no(question, default: true, current_value: nil)
1267
+ suffix = ' [y/n]'
1268
+ meta = []
1269
+ unless current_value.nil?
1270
+ meta << "現在: #{current_value ? 'はい' : 'いいえ'}"
1271
+ end
1272
+ meta << "既定: #{default ? 'はい' : 'いいえ'}"
1273
+ meta_text = meta.empty? ? '' : " (#{meta.join(' / ')})"
1274
+ loop do
1275
+ answer = ask("#{question}#{suffix}#{meta_text}:").to_s.strip.downcase
1276
+ return default if answer.empty?
1277
+ return true if %w[y yes].include?(answer)
1278
+ return false if %w[n no].include?(answer)
1279
+ say 'y または n を入力してください。', :yellow
1280
+ end
1281
+ end
1282
+
1283
+ def prompt_string(label, current_value, hint: nil)
1284
+ hint_text = hint ? " #{hint}" : ''
1285
+ response = ask("#{label} [現在: #{current_value}]#{hint_text} (Enterで維持):").to_s
1286
+ trimmed = response.strip
1287
+ trimmed.empty? ? current_value : trimmed
1288
+ end
1289
+
1290
+ def prompt_integer(label, current_value, min:, max:)
1291
+ loop do
1292
+ response = ask("#{label} [現在: #{current_value}] (Enterで維持):").to_s.strip
1293
+ return current_value if response.empty?
1294
+ unless response.match?(/\A-?\d+\z/)
1295
+ say '数値を入力してください。', :yellow
1296
+ next
1297
+ end
1298
+ value = response.to_i
1299
+ value = max if value > max
1300
+ value = min if value < min
1301
+ return value
1302
+ end
1303
+ end
1304
+
1305
+ def prompt_choice(label, current_value, choices)
1306
+ normalized_choices = choices.map(&:downcase)
1307
+ loop do
1308
+ response = ask("#{label} [現在: #{current_value}] (Enterで維持):").to_s.strip
1309
+ return current_value if response.empty?
1310
+ idx = normalized_choices.index(response.downcase)
1311
+ if idx
1312
+ return choices[idx]
1313
+ end
1314
+ say "#{choices.join('/')} のいずれかを入力してください。", :yellow
1315
+ end
1316
+ end
1317
+
1318
+ def prompt_path(label, current_value)
1319
+ loop do
1320
+ response = ask("#{label} [現在: #{current_value}] (Enterで維持):").to_s.strip
1321
+ return current_value if response.empty?
1322
+ begin
1323
+ expanded = File.expand_path(response)
1324
+ return expanded
1325
+ rescue StandardError => e
1326
+ say "パスを解釈できませんでした: #{e.message}", :yellow
1327
+ end
1328
+ end
1329
+ end
1330
+
1331
+ def show_config_summary(settings = nil)
1332
+ settings ||= ImagesConvert::Configuration.show_all_settings
1333
+
1334
+ say "現在の設定:"
1335
+ say " デフォルト入力形式: #{settings[:defaults][:from]}"
1336
+ say " デフォルト出力形式: #{settings[:defaults][:to]}"
1337
+ say " デフォルトリサイズ: #{settings[:defaults][:resize]}"
1338
+ say " デフォルト入力ディレクトリ: #{settings[:directories][:input]}"
1339
+ say " デフォルト出力ディレクトリ: #{settings[:directories][:output]}"
1340
+ say " 最新判定モード: #{settings[:selection][:latest_mode]}"
1341
+ say " waifu2x 既定倍率: #{settings[:waifu2x][:scale]}"
1342
+ say " waifu2x ノイズ除去: #{settings[:waifu2x][:noise]}"
1343
+ say " waifu2x モデル: #{settings[:waifu2x][:model]}"
1344
+ say " waifu2x A4自動スケール: #{settings[:waifu2x][:auto_scale_a4]}"
1345
+ say " waifu2x A4レイアウトPDF: #{settings[:waifu2x][:a4_print]}"
1346
+ say " waifu2x 出力フォーマット: #{settings[:waifu2x][:image_format]}"
1347
+ say " waifu2x エラー時動作: #{settings[:waifu2x][:on_error]}"
1348
+ say " 出力画像品質: #{settings[:output][:image_quality]}"
1349
+ say " PDF 品質: #{settings[:output][:pdf_quality]}"
1350
+ say " PDF 圧縮方式: #{settings[:output][:pdf_compression]}"
1351
+ say " PDF DPI: #{settings[:output][:pdf_density]}"
1352
+ say ""
1353
+ end
1354
+
1355
+ def record_change(collection, label, before, after)
1356
+ collection << {
1357
+ label: label,
1358
+ before: format_value(before),
1359
+ after: format_value(after)
1360
+ }
1361
+ end
1362
+
1363
+ def format_value(value)
1364
+ case value
1365
+ when TrueClass
1366
+ 'はい'
1367
+ when FalseClass
1368
+ 'いいえ'
1369
+ when NilClass
1370
+ 'なし'
1371
+ else
1372
+ value.to_s
1373
+ end
1374
+ end
1375
+
1376
+ def sort_candidates_by_mode(candidates, mode)
1377
+ if mode == 'exif'
1378
+ fallback_used = false
1379
+ sorted = candidates.sort_by do |path|
1380
+ ts = exif_timestamp_for(path)
1381
+ if ts.nil?
1382
+ fallback_used = true
1383
+ File.mtime(path)
1384
+ else
1385
+ ts
1386
+ end
1387
+ end.reverse
1388
+ [sorted, fallback_used]
1389
+ else
1390
+ [candidates.sort_by { |f| File.mtime(f) }.reverse, false]
1391
+ end
1392
+ end
1393
+
1394
+ def exif_timestamp_for(path)
1395
+ @_exif_timestamp_cache ||= {}
1396
+ return @_exif_timestamp_cache[path] if @_exif_timestamp_cache.key?(path)
1397
+
1398
+ @_exif_timestamp_cache[path] = extract_exif_timestamp(path)
1399
+ end
1400
+
1401
+ def extract_exif_timestamp(path)
1402
+ image = MiniMagick::Image.open(path)
1403
+ candidates = [
1404
+ image['EXIF:DateTimeOriginal'],
1405
+ image['EXIF:CreateDate'],
1406
+ image['EXIF:DateTimeDigitized'],
1407
+ image['EXIF:DateTime']
1408
+ ]
1409
+
1410
+ candidates.each do |value|
1411
+ parsed = parse_exif_datetime(value)
1412
+ return parsed if parsed
1413
+ end
1414
+
1415
+ nil
1416
+ rescue StandardError
1417
+ nil
1418
+ end
1419
+
1420
+ def parse_exif_datetime(value)
1421
+ return nil unless value
1422
+ str = value.to_s.strip
1423
+ return nil if str.empty?
1424
+
1425
+ str = str.delete("\u0000")
1426
+
1427
+ Time.strptime(str, '%Y:%m:%d %H:%M:%S')
1428
+ rescue ArgumentError
1429
+ Time.parse(str)
1430
+ rescue ArgumentError
1431
+ nil
1432
+ end
1433
+ end
1434
+
1435
+ # Thor 起動前に ARGV を前処理して、数値短縮フラグ(-1, -5, -0)を正式オプションへ変換
1436
+ def preprocess_argv!(argv)
1437
+ if argv.empty?
1438
+ argv.replace(['help'])
1439
+ return
1440
+ end
1441
+
1442
+ converted = []
1443
+ argv.each do |arg|
1444
+ if arg.is_a?(String) && (m = arg.match(/^(?:-)(\d+)$/))
1445
+ n = m[1].to_i
1446
+ if n == 0
1447
+ converted << '--all'
1448
+ else
1449
+ converted << '--latest' << n.to_s
1450
+ end
1451
+ else
1452
+ converted << arg
1453
+ end
1454
+ end
1455
+
1456
+ # 先頭にサブコマンドが無い場合(例: `images-convert -1`)は convert を補う
1457
+ known_tasks = %w[convert config cleanup self-uninstall version help set-latest-mode setup]
1458
+ first_non_option = converted.find { |a| !(a.to_s.start_with?('-')) }
1459
+ global_flags = %w[-v --version -h --help]
1460
+ has_global_flag = converted.any? { |a| global_flags.include?(a) }
1461
+
1462
+ if !has_global_flag && (first_non_option.nil? || !known_tasks.include?(first_non_option))
1463
+ # FROM TO BASENAME のショートハンドをサポート(既知フォーマットに限る)
1464
+ # 例: heic jpeg 1000 -> config set HEIC JPG <resize>; convert 1000
1465
+ # 許可フォーマットとエイリアス
1466
+ aliases = {
1467
+ 'jpeg' => 'JPG',
1468
+ 'jpg' => 'JPG',
1469
+ 'png' => 'PNG',
1470
+ 'gif' => 'GIF',
1471
+ 'webp' => 'WEBP',
1472
+ 'heic' => 'HEIC',
1473
+ 'tiff' => 'TIFF',
1474
+ 'bmp' => 'BMP',
1475
+ 'ico' => 'ICO',
1476
+ 'pdf' => 'PDF',
1477
+ }
1478
+ known_formats = aliases.values.uniq
1479
+
1480
+ non_opts = converted.select { |a| !(a.to_s.start_with?('-')) }
1481
+ if non_opts.size >= 3
1482
+ first_arg, second_arg, third_arg = non_opts[0,3]
1483
+ # リサイズ値のパターン(数字+x、x数字、数字+%)
1484
+ is_resize_value = third_arg.to_s.match?(/^(?:\d+[x%]|[x%]\d+)$/) || third_arg.to_s.match?(/^\d+%$/)
1485
+
1486
+ if is_resize_value
1487
+ # INPUT_FILE OUTPUT_FILE RESIZE パターン
1488
+ # 例: flower.jpg flower.webp 1024x -> convert flower.jpg --output flower.webp --resize 1024x
1489
+ input_file = first_arg
1490
+ output_file = second_arg
1491
+ resize_value = third_arg
1492
+
1493
+ # 残りのオプション(元の順序を維持しつつ、最初の3つの非オプショントークンのみ取り除く)
1494
+ rest = []
1495
+ non_opt_seen = 0
1496
+ converted.each do |tok|
1497
+ if !(tok.to_s.start_with?('-')) && non_opt_seen < 3
1498
+ non_opt_seen += 1
1499
+ next
1500
+ end
1501
+ rest << tok
1502
+ end
1503
+ converted = ['convert', input_file, '--output', output_file, '--resize', resize_value] + rest
1504
+ elsif (from_raw, to_raw, base_raw = non_opts[0,3])
1505
+ from_nm = aliases[from_raw.to_s.downcase]
1506
+ to_nm = aliases[to_raw.to_s.downcase]
1507
+ is_digits = base_raw.to_s.match?(/^\d+$/)
1508
+ if from_nm && !to_nm && is_digits
1509
+ # FROM は既知だが TO が未知(例: heic tif 1000)の場合は明示的にエラー
1510
+ puts "エラー: 未知の出力形式 '#{to_raw}'. 利用可能な形式: #{known_formats.join('/')}"
1511
+ exit 1
1512
+ elsif from_nm && to_nm && is_digits && known_formats.include?(from_nm) && known_formats.include?(to_nm)
1513
+ # 現在のリサイズ設定値(未作成ならここで作成される)
1514
+ resize = ImagesConvert::Configuration.default_resize
1515
+ # 構築: convert BASENAME --from FROM --to TO [残りのオプション]
1516
+ # 残りのオプション(元の順序を維持しつつ、最初の3つの非オプショントークンのみ取り除く)
1517
+ rest = []
1518
+ non_opt_seen = 0
1519
+ converted.each do |tok|
1520
+ if !(tok.to_s.start_with?('-')) && non_opt_seen < 3
1521
+ non_opt_seen += 1
1522
+ next
1523
+ end
1524
+ rest << tok
1525
+ end
1526
+ converted = ['convert', base_raw, '--from', from_nm, '--to', to_nm] + rest
1527
+ else
1528
+ # 通常の convert 補完にフォールバック
1529
+ converted.unshift('convert')
1530
+ end
1531
+ else
1532
+ converted.unshift('convert')
1533
+ end
1534
+ else
1535
+ converted.unshift('convert')
1536
+ end
1537
+ end
1538
+
1539
+ help_flags = (Thor::HELP_MAPPINGS + ['--help']).uniq
1540
+ if converted.any? { |tok| help_flags.include?(tok) }
1541
+ first_command = converted.find { |tok| !(tok.to_s.start_with?('-')) && !help_flags.include?(tok) }
1542
+ if first_command
1543
+ argv.replace(['help', first_command])
1544
+ else
1545
+ argv.replace(['help'])
1546
+ end
1547
+ return
1548
+ end
1549
+
1550
+ argv.replace(converted)
1551
+ end
1552
+
1553
+ # Thor コマンドを実行
1554
+ if __FILE__ == $0
1555
+ preprocess_argv!(ARGV)
1556
+ ImagesConvertCLI.start(ARGV)
1557
+ end