1
0
Fork 0

초기 커밋: Phase 1 — 레지스트리 기반 컨텍스트 메뉴 + 핵심 변환 파이프라인

- Provider 추상화(IConverterProvider + ProviderCapability) — Available/RequiresExternal/ComingSoon 1급 시민
- 변환 구현: Magick(이미지·RAW·GIF·TIFF), HEIC/HEIF(libheif decode→Magick encode), PDF(PDFium), DOCX(Word COM 또는 LibreOffice 자동 감지)
- ComingSoon 스텁: HTML, HWP/HWPX (UI 노출, 컨텍스트 메뉴 등록 자동 제외)
- WPF + WPF-UI 4.3 Fluent 2 UI: 메인 창(드래그&드롭, Provider 카드), 변환 창(썸네일·옵션·진행률), 빠른 변환 진행 창, 진단 창
- 단일 EXE에 verb 라우팅: quick / dialog / register / unregister / diagnose / help
- HKCU SystemFileAssociations 기반 컨텍스트 메뉴 등록 (Win11 "추가 옵션 표시"에 노출)
- README, packaging/ Phase 2 placeholder 포함

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yun Chan 2026-05-06 12:50:35 +09:00
commit 8fa4a613d7
37 changed files with 2727 additions and 0 deletions

View file

@ -0,0 +1,59 @@
<Application x:Class="EverythingToJpeg.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
xmlns:local="clr-namespace:EverythingToJpeg.App"
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemesDictionary Theme="Dark"/>
<ui:ControlsDictionary/>
</ResourceDictionary.MergedDictionaries>
<!-- Spacing tokens (Fluent 8pt grid) -->
<Thickness x:Key="SpacingXS">4</Thickness>
<Thickness x:Key="SpacingS">8</Thickness>
<Thickness x:Key="SpacingM">16</Thickness>
<Thickness x:Key="SpacingL">24</Thickness>
<Thickness x:Key="SpacingXL">32</Thickness>
<!-- Typography (Fluent 2 type ramp, Segoe UI Variable) -->
<Style x:Key="TextTitleLarge" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI Variable Display, Segoe UI"/>
<Setter Property="FontSize" Value="28"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="TextTitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI Variable Display, Segoe UI"/>
<Setter Property="FontSize" Value="20"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="TextSubtitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="TextBodyStrong" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
<Style x:Key="TextBody" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
</Style>
<Style x:Key="TextCaption" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Segoe UI Variable Small, Segoe UI"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}"/>
</Style>
<!-- Status badge brushes -->
<SolidColorBrush x:Key="BadgeReadyBrush" Color="#10B981"/>
<SolidColorBrush x:Key="BadgeWarnBrush" Color="#F59E0B"/>
<SolidColorBrush x:Key="BadgeInfoBrush" Color="#3B82F6"/>
<SolidColorBrush x:Key="BadgeMutedBrush" Color="#6B7280"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -0,0 +1,100 @@
using System.Windows;
using EverythingToJpeg.App.Cli;
using EverythingToJpeg.App.Views;
using EverythingToJpeg.Core;
namespace EverythingToJpeg.App;
public partial class App : Application
{
public ConversionEngine Engine { get; } = EverythingToJpegBootstrap.CreateDefault();
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var parsed = CliRouter.Parse(e.Args);
switch (parsed.Mode)
{
case CliRouter.Mode.Help:
ConsoleHelper.WriteLine(CliRouter.HelpText());
Environment.Exit(0);
return;
case CliRouter.Mode.Register:
Environment.Exit(CliRouter.RunRegister(register: true));
return;
case CliRouter.Mode.Unregister:
Environment.Exit(CliRouter.RunRegister(register: false));
return;
case CliRouter.Mode.Diagnose:
ShowDiagnoseWindow();
return;
case CliRouter.Mode.Quick:
if (parsed.Files.Count == 0)
{
MessageBox.Show("변환할 파일이 없습니다.", "EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Information);
Shutdown(1);
return;
}
await RunQuickAsync(parsed.Files);
return;
case CliRouter.Mode.Dialog:
ShowConvertDialog(parsed.Files);
return;
case CliRouter.Mode.ShowMain:
default:
ShowMainWindow();
return;
}
}
private void ShowMainWindow()
{
var window = new MainWindow();
MainWindow = window;
window.Show();
}
private void ShowConvertDialog(IReadOnlyList<string> files)
{
var window = new ConvertWindow(Engine, files);
MainWindow = window;
window.Show();
}
private void ShowDiagnoseWindow()
{
var window = new DiagnoseWindow(Engine);
MainWindow = window;
window.Show();
}
private async Task RunQuickAsync(IReadOnlyList<string> files)
{
var progress = new QuickProgressWindow(files.Count);
progress.Show();
try
{
var options = ConvertOptions.Quick();
var reporter = new Progress<ConvertProgress>(p => progress.Report(p));
var results = await Engine.ConvertManyAsync(files, options, reporter);
progress.Finish(results);
}
catch (Exception ex)
{
MessageBox.Show($"변환 중 오류: {ex.Message}", "EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Error);
progress.Close();
Shutdown(1);
}
}
}

View file

@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View file

@ -0,0 +1,120 @@
using EverythingToJpeg.App.Shell;
using EverythingToJpeg.Core;
namespace EverythingToJpeg.App.Cli;
internal static class CliRouter
{
public enum Mode
{
ShowMain,
Quick,
Dialog,
Register,
Unregister,
Diagnose,
Help,
}
public sealed record ParsedArgs(Mode Mode, IReadOnlyList<string> Files);
public static ParsedArgs Parse(string[] args)
{
if (args is null || args.Length == 0)
return new ParsedArgs(Mode.ShowMain, Array.Empty<string>());
var verb = args[0].Trim().ToLowerInvariant();
var rest = args.Skip(1).Where(a => !string.IsNullOrWhiteSpace(a)).ToList();
return verb switch
{
"quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest)),
"dialog" => new ParsedArgs(Mode.Dialog, ExpandFiles(rest)),
"register" => new ParsedArgs(Mode.Register, Array.Empty<string>()),
"unregister" => new ParsedArgs(Mode.Unregister, Array.Empty<string>()),
"diagnose" or "doctor" => new ParsedArgs(Mode.Diagnose, Array.Empty<string>()),
"help" or "--help" or "-h" or "/?" => new ParsedArgs(Mode.Help, Array.Empty<string>()),
_ when File.Exists(args[0]) => new ParsedArgs(Mode.Dialog, ExpandFiles(args)),
_ => new ParsedArgs(Mode.ShowMain, Array.Empty<string>()),
};
}
private static IReadOnlyList<string> ExpandFiles(IEnumerable<string> raw)
{
var list = new List<string>();
foreach (var arg in raw)
{
if (string.IsNullOrWhiteSpace(arg)) continue;
try
{
if (File.Exists(arg)) { list.Add(Path.GetFullPath(arg)); continue; }
if (Directory.Exists(arg))
{
foreach (var f in Directory.EnumerateFiles(arg, "*", SearchOption.TopDirectoryOnly))
list.Add(Path.GetFullPath(f));
}
}
catch { }
}
return list;
}
public static string HelpText()
{
var engine = EverythingToJpegBootstrap.CreateDefault();
var supported = string.Join(", ",
engine.Providers.Implemented.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
var coming = string.Join(", ",
engine.Providers.ComingSoon.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
return $"""
EverythingToJpeg JPEG로
:
EverythingToJpeg.exe quick <...> ( )
EverythingToJpeg.exe dialog <...>
EverythingToJpeg.exe register ( )
EverythingToJpeg.exe unregister
EverythingToJpeg.exe diagnose ·
EverythingToJpeg.exe
(): {supported}
: {coming}
""";
}
public static int RunRegister(bool register)
{
var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_register.log");
var log = new System.Text.StringBuilder();
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] RunRegister start, register={register}");
try
{
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Creating engine...");
var engine = EverythingToJpegBootstrap.CreateDefault();
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Engine OK. Implemented providers: {string.Join(",", engine.Providers.Implemented.Select(p => p.Capability.Id))}");
if (register)
{
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Calling Register...");
ContextMenuRegistrar.Register(engine);
}
else
{
ContextMenuRegistrar.Unregister(engine);
}
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] OK");
File.WriteAllText(logPath, log.ToString());
Console.Out.WriteLine($"등록 완료. 로그: {logPath}");
return 0;
}
catch (Exception ex)
{
log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] EXCEPTION {ex.GetType().Name}: {ex.Message}");
log.AppendLine(ex.ToString());
try { File.WriteAllText(logPath, log.ToString()); } catch { }
Console.Error.WriteLine(ex.Message);
return 1;
}
}
}

View file

@ -0,0 +1,24 @@
using System.Runtime.InteropServices;
namespace EverythingToJpeg.App.Cli;
internal static class ConsoleHelper
{
private static bool _attached;
public static void WriteLine(string text)
{
EnsureAttached();
Console.Out.WriteLine(text);
Console.Out.Flush();
}
private static void EnsureAttached()
{
if (_attached) return;
_attached = AttachConsole(-1);
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AttachConsole(int dwProcessId);
}

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<AssemblyName>EverythingToJpeg</AssemblyName>
<RootNamespace>EverythingToJpeg.App</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\EverythingToJpeg.Core\EverythingToJpeg.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="WPF-UI" Version="4.3.0" />
</ItemGroup>
<ItemGroup>
<Using Include="System.IO" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,5 @@
global using MessageBox = System.Windows.MessageBox;
global using MessageBoxButton = System.Windows.MessageBoxButton;
global using MessageBoxImage = System.Windows.MessageBoxImage;
global using MessageBoxResult = System.Windows.MessageBoxResult;
global using TextBlock = System.Windows.Controls.TextBlock;

View file

@ -0,0 +1,96 @@
using EverythingToJpeg.Core;
using EverythingToJpeg.Core.Providers;
using Microsoft.Win32;
namespace EverythingToJpeg.App.Shell;
internal static class ContextMenuRegistrar
{
private const string QuickVerb = "EverythingToJpeg.Quick";
private const string DialogVerb = "EverythingToJpeg.Dialog";
private const string QuickLabel = "JPEG로 빠른 변환";
private const string DialogLabel = "JPEG로 변환…";
public static void Register(ConversionEngine engine)
{
var exe = GetAppExecutablePath();
var icon = exe + ",0";
foreach (var ext in CollectExtensions(engine))
{
WriteVerb(ext, QuickVerb, QuickLabel, icon, $"\"{exe}\" quick \"%1\"");
WriteVerb(ext, DialogVerb, DialogLabel, icon, $"\"{exe}\" dialog \"%1\"");
}
NotifyShell();
}
public static void Unregister(ConversionEngine engine)
{
foreach (var ext in CollectExtensions(engine))
{
DeleteVerb(ext, QuickVerb);
DeleteVerb(ext, DialogVerb);
}
NotifyShell();
}
private static IEnumerable<string> CollectExtensions(ConversionEngine engine)
{
return engine.Providers.Implemented
.Where(p => p.Capability.CanRegisterContextMenu)
.SelectMany(p => p.Capability.Extensions)
.Select(e => e.StartsWith('.') ? e : "." + e)
.Select(e => e.ToLowerInvariant())
.Distinct();
}
private static void WriteVerb(string ext, string verb, string label, string icon, string command)
{
var keyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{verb}";
using var verbKey = Registry.CurrentUser.CreateSubKey(keyPath, writable: true)
?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {keyPath}");
verbKey.SetValue(null, label, RegistryValueKind.String);
verbKey.SetValue("Icon", icon, RegistryValueKind.String);
verbKey.SetValue("MUIVerb", label, RegistryValueKind.String);
using var commandKey = verbKey.CreateSubKey("command", writable: true)
?? throw new InvalidOperationException("command 하위 키 생성 실패");
commandKey.SetValue(null, command, RegistryValueKind.String);
}
private static void DeleteVerb(string ext, string verb)
{
var parentPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell";
try
{
using var parent = Registry.CurrentUser.OpenSubKey(parentPath, writable: true);
parent?.DeleteSubKeyTree(verb, throwOnMissingSubKey: false);
}
catch
{
}
}
private static string GetAppExecutablePath()
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe) && File.Exists(exe)) return exe;
return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar)
+ Path.DirectorySeparatorChar + "EverythingToJpeg.exe";
}
private static void NotifyShell()
{
try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
catch { }
}
private static class NativeMethods
{
[System.Runtime.InteropServices.DllImport("shell32.dll")]
public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
}
}

View file

@ -0,0 +1,141 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.ConvertWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="JPEG로 변환"
Width="940" Height="700"
MinWidth="720" MinHeight="540"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round"
WindowStartupLocation="CenterOwner"
AllowDrop="True"
Drop="OnFilesDropped"
DragOver="OnDragOver">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="JPEG로 변환"/>
<Grid Grid.Row="1" Margin="32,8,32,16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" MinWidth="320"/>
<ColumnDefinition Width="320"/>
</Grid.ColumnDefinitions>
<!-- File list panel -->
<ui:Card Grid.Column="0" Padding="0" Margin="0,0,16,0">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="20,16,20,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="대상 파일" Style="{StaticResource TextSubtitle}"/>
<TextBlock x:Name="FilesSummaryText" Style="{StaticResource TextCaption}"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<ui:Button Content="추가" Icon="{ui:SymbolIcon Add24}"
Click="OnAddFilesClick" Margin="0,0,8,0"/>
<ui:Button Content="비우기" Icon="{ui:SymbolIcon Delete24}"
Click="OnClearFilesClick"/>
</StackPanel>
</Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,0,20,16">
<ItemsControl x:Name="FilesList"/>
</ScrollViewer>
</DockPanel>
</ui:Card>
<!-- Options panel -->
<ui:Card Grid.Column="1" Padding="0">
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,16,20,16">
<StackPanel>
<TextBlock Text="옵션" Style="{StaticResource TextSubtitle}" Margin="0,0,0,12"/>
<TextBlock Text="JPEG 품질" Style="{StaticResource TextBodyStrong}" Margin="0,0,0,4"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="48"/>
</Grid.ColumnDefinitions>
<Slider x:Name="QualitySlider" Minimum="50" Maximum="100" Value="92"
SmallChange="1" LargeChange="5" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding ElementName=QualitySlider, Path=Value, StringFormat={}{0:0}}"
VerticalAlignment="Center" HorizontalAlignment="Right"
FontWeight="SemiBold"/>
</Grid>
<TextBlock Style="{StaticResource TextCaption}"
Text="92 권장. 90+에서 시각적으로 거의 무손실."/>
<TextBlock Text="출력 위치" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
<ComboBox x:Name="OutputModeCombo" SelectedIndex="0">
<ComboBoxItem Content="원본 폴더 안에 _jpeg 하위 폴더"/>
<ComboBoxItem Content="원본과 같은 폴더"/>
<ComboBoxItem Content="사용자 지정 폴더…"/>
</ComboBox>
<Grid Margin="0,8,0,0" x:Name="CustomFolderRow" Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="CustomFolderTextBox" />
<ui:Button Grid.Column="1" Content="찾아보기" Margin="6,0,0,0"
Click="OnBrowseClick"/>
</Grid>
<TextBlock Text="이름 충돌" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
<ComboBox x:Name="CollisionCombo" SelectedIndex="0">
<ComboBoxItem Content="번호 붙여 새 파일"/>
<ComboBoxItem Content="덮어쓰기"/>
<ComboBoxItem Content="건너뛰기"/>
</ComboBox>
<TextBlock Text="긴 변 최대 픽셀 (선택)" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
<TextBox x:Name="MaxLongEdgeTextBox" ToolTip="비워 두면 원본 크기 유지"/>
<TextBlock Text="PDF 렌더 DPI" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
<Slider x:Name="DpiSlider" Minimum="72" Maximum="450" Value="200"/>
<TextBlock Style="{StaticResource TextCaption}"
Text="{Binding ElementName=DpiSlider, Path=Value, StringFormat=현재 {0:0} DPI}"/>
<CheckBox x:Name="FlattenCheckBox" Content="투명 영역을 흰색으로 채우기"
IsChecked="True" Margin="0,16,0,0"/>
</StackPanel>
</ScrollViewer>
</ui:Card>
</Grid>
<!-- Bottom action bar -->
<Border Grid.Row="2" Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}"
Padding="32,16">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center">
<ProgressBar x:Name="OverallProgress" Height="6" Minimum="0" Maximum="1"
IsIndeterminate="False"/>
<TextBlock x:Name="ProgressStatusText" Style="{StaticResource TextCaption}"
Margin="0,6,0,0"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="20,0,0,0">
<ui:Button x:Name="CancelButton" Content="닫기" Click="OnCancelClick"
Margin="0,0,8,0" Padding="20,6"/>
<ui:Button x:Name="ConvertButton" Content="변환 시작"
Icon="{ui:SymbolIcon ArrowRight24}"
Appearance="Primary" Click="OnConvertClick"
Padding="24,6"/>
</StackPanel>
</Grid>
</Border>
</Grid>
</ui:FluentWindow>

View file

@ -0,0 +1,360 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using EverythingToJpeg.Core;
using EverythingToJpeg.Core.Providers;
using Wpf.Ui.Controls;
namespace EverythingToJpeg.App.Views;
public partial class ConvertWindow : FluentWindow
{
private readonly ConversionEngine _engine;
private readonly ObservableCollection<FileEntry> _entries = new();
private CancellationTokenSource? _cts;
public ConvertWindow(ConversionEngine engine, IReadOnlyList<string> initialFiles)
{
_engine = engine;
InitializeComponent();
FilesList.ItemsSource = _entries;
FilesList.ItemTemplate = (DataTemplate)CreateFileEntryTemplate();
AddFiles(initialFiles);
OutputModeCombo.SelectionChanged += (_, _) =>
CustomFolderRow.Visibility = OutputModeCombo.SelectedIndex == 2
? Visibility.Visible : Visibility.Collapsed;
}
private void AddFiles(IEnumerable<string> paths)
{
var existing = new HashSet<string>(_entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
foreach (var p in paths)
{
if (!File.Exists(p)) continue;
if (existing.Contains(p)) continue;
var entry = new FileEntry(p, _engine);
_entries.Add(entry);
_ = entry.LoadThumbnailAsync();
}
UpdateSummary();
}
private void UpdateSummary()
{
FilesSummaryText.Text = _entries.Count == 0
? "비어 있음 — 파일을 끌어다 놓거나 추가하세요"
: $"{_entries.Count}개 파일";
}
private void OnDragOver(object sender, DragEventArgs e)
{
e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop)
? DragDropEffects.Copy : DragDropEffects.None;
e.Handled = true;
}
private void OnFilesDropped(object sender, DragEventArgs e)
{
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return;
AddFiles(ExpandPaths(paths));
}
private void OnAddFilesClick(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Multiselect = true,
Title = "추가할 파일 선택",
Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc|모든 파일|*.*",
};
if (dlg.ShowDialog(this) == true) AddFiles(dlg.FileNames);
}
private void OnClearFilesClick(object sender, RoutedEventArgs e)
{
_entries.Clear();
UpdateSummary();
}
private void OnRemoveEntry(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is FileEntry entry)
{
_entries.Remove(entry);
UpdateSummary();
}
}
private void OnBrowseClick(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "출력 폴더 선택" };
if (dlg.ShowDialog(this) == true)
CustomFolderTextBox.Text = dlg.FolderName;
}
private static IEnumerable<string> ExpandPaths(IEnumerable<string> paths)
{
foreach (var p in paths)
{
if (File.Exists(p)) yield return p;
else if (Directory.Exists(p))
{
foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly))
yield return f;
}
}
}
private async void OnConvertClick(object sender, RoutedEventArgs e)
{
if (_entries.Count == 0)
{
ShowInfo("변환할 파일이 없습니다.");
return;
}
ConvertButton.IsEnabled = false;
CancelButton.Content = "취소";
_cts = new CancellationTokenSource();
var options = BuildOptions();
var reporter = new Progress<ConvertProgress>(p =>
{
var overall = p.Total == 0 ? 0 : (p.Index + p.FileProgress) / p.Total;
OverallProgress.Value = Math.Clamp(overall, 0, 1);
ProgressStatusText.Text = $"{Math.Min(p.Index + 1, p.Total)} / {p.Total} — {Path.GetFileName(p.CurrentPath)}";
UpdateEntryProgress(p);
});
try
{
var sources = _entries.Select(en => en.Path).ToList();
var results = await _engine.ConvertManyAsync(sources, options, reporter, _cts.Token);
ApplyResults(results);
ProgressStatusText.Text = SummarizeResults(results);
}
catch (OperationCanceledException)
{
ProgressStatusText.Text = "변환이 취소되었습니다.";
}
catch (Exception ex)
{
ProgressStatusText.Text = "오류: " + ex.Message;
}
finally
{
ConvertButton.IsEnabled = true;
CancelButton.Content = "닫기";
_cts = null;
}
}
private void UpdateEntryProgress(ConvertProgress p)
{
if (p.Index >= _entries.Count) return;
for (var i = 0; i < _entries.Count; i++)
{
if (i < p.Index) _entries[i].State = "완료";
else if (i == p.Index) _entries[i].State = "변환 중…";
else _entries[i].State = "대기";
}
}
private void ApplyResults(IReadOnlyList<ConvertResult> results)
{
foreach (var result in results)
{
var entry = _entries.FirstOrDefault(e => string.Equals(e.Path, result.SourcePath, StringComparison.OrdinalIgnoreCase));
if (entry is null) continue;
entry.State = result.Status switch
{
ConvertStatus.Success => $"성공 ({result.OutputPaths.Count}개)",
ConvertStatus.Skipped => "건너뜀",
ConvertStatus.Failed => "실패: " + result.Message,
_ => entry.State,
};
entry.IsFailed = result.Status == ConvertStatus.Failed;
}
}
private static string SummarizeResults(IReadOnlyList<ConvertResult> results)
{
var success = results.Count(r => r.Status == ConvertStatus.Success);
var skipped = results.Count(r => r.Status == ConvertStatus.Skipped);
var failed = results.Count(r => r.Status == ConvertStatus.Failed);
var outputs = results.Sum(r => r.OutputPaths.Count);
return $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}";
}
private void OnCancelClick(object sender, RoutedEventArgs e)
{
if (_cts is { } cts) { cts.Cancel(); return; }
Close();
}
private ConvertOptions BuildOptions()
{
var opts = new ConvertOptions
{
Quality = (int)QualitySlider.Value,
PdfDpi = (int)DpiSlider.Value,
FlattenTransparency = FlattenCheckBox.IsChecked == true,
};
opts.OutputLocation = OutputModeCombo.SelectedIndex switch
{
1 => OutputLocation.SameFolderAsSource,
2 => OutputLocation.Custom,
_ => OutputLocation.SubfolderBesideSource,
};
if (opts.OutputLocation == OutputLocation.Custom)
opts.CustomOutputDirectory = CustomFolderTextBox.Text;
opts.OnCollision = CollisionCombo.SelectedIndex switch
{
1 => NameCollision.Overwrite,
2 => NameCollision.Skip,
_ => NameCollision.AppendNumber,
};
if (int.TryParse(MaxLongEdgeTextBox.Text, out var maxEdge) && maxEdge > 0)
opts.MaxLongEdgePixels = maxEdge;
return opts;
}
private void ShowInfo(string message)
=> MessageBox.Show(this, message, "EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Information);
private object CreateFileEntryTemplate()
{
const string xaml = """
<DataTemplate xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
<Border Margin="0,0,0,6" Padding="12,8" CornerRadius="6"
Background="{DynamicResource SubtleFillColorTransparentBrush}"
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}"
BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="44"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Width="40" Height="40" CornerRadius="4"
Background="{DynamicResource ControlFillColorDefaultBrush}">
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill"/>
</Border>
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center">
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding SubText}" FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding State}" FontSize="11" Margin="0,2,0,0">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorTertiaryBrush}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsFailed}" Value="True">
<Setter Property="Foreground" Value="#F87171"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</StackPanel>
<ui:Button Grid.Column="2" Icon="{ui:SymbolIcon Dismiss20}"
Appearance="Transparent" Click="OnRemoveEntry"
ToolTip="목록에서 제거"/>
</Grid>
</Border>
</DataTemplate>
""";
return System.Windows.Markup.XamlReader.Parse(xaml);
}
}
public sealed class FileEntry : System.ComponentModel.INotifyPropertyChanged
{
private readonly ConversionEngine _engine;
private string _state = "대기";
private bool _isFailed;
private ImageSource? _thumbnail;
public FileEntry(string path, ConversionEngine engine)
{
Path = path;
_engine = engine;
}
public string Path { get; }
public string FileName => System.IO.Path.GetFileName(Path);
public string SubText
{
get
{
var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant();
string handler;
if (_engine.Providers.TryGetForFile(Path, out var provider) && provider is not null)
handler = provider.Capability.DisplayName;
else
handler = "지원되지 않음";
try
{
var size = new FileInfo(Path).Length;
return $"{ext} · {handler} · {FormatBytes(size)}";
}
catch
{
return $"{ext} · {handler}";
}
}
}
public string State { get => _state; set { _state = value; Raise(nameof(State)); } }
public bool IsFailed { get => _isFailed; set { _isFailed = value; Raise(nameof(IsFailed)); } }
public ImageSource? Thumbnail { get => _thumbnail; set { _thumbnail = value; Raise(nameof(Thumbnail)); } }
public Task LoadThumbnailAsync() => Task.Run(() =>
{
try
{
var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant();
if (ext is ".png" or ".jpg" or ".jpeg" or ".bmp" or ".gif")
{
var bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = new Uri(Path);
bmp.DecodePixelWidth = 80;
bmp.CacheOption = BitmapCacheOption.OnLoad;
bmp.EndInit();
bmp.Freeze();
System.Windows.Application.Current.Dispatcher.Invoke(() => Thumbnail = bmp);
}
}
catch { }
});
private static string FormatBytes(long bytes)
{
string[] units = { "B", "KB", "MB", "GB" };
double size = bytes;
var unit = 0;
while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; }
return $"{size:0.#} {units[unit]}";
}
public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;
private void Raise(string n) => PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(n));
}

View file

@ -0,0 +1,32 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.DiagnoseWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="진단"
Width="640" Height="540"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round"
WindowStartupLocation="CenterOwner">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="진단"/>
<ScrollViewer Grid.Row="1" Margin="32,8,32,16" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="ItemsPanel"/>
</ScrollViewer>
<Border Grid.Row="2" Padding="32,16"
Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
BorderThickness="0,1,0,0"
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<ui:Button Content="새로고침" Icon="{ui:SymbolIcon ArrowClockwise24}"
Click="OnRefreshClick" Margin="0,0,8,0"/>
<ui:Button Content="닫기" Click="OnCloseClick" Appearance="Primary"/>
</StackPanel>
</Border>
</Grid>
</ui:FluentWindow>

View file

@ -0,0 +1,118 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using EverythingToJpeg.Core;
using EverythingToJpeg.Core.Providers;
using Wpf.Ui.Controls;
namespace EverythingToJpeg.App.Views;
public partial class DiagnoseWindow : FluentWindow
{
private readonly ConversionEngine _engine;
public DiagnoseWindow(ConversionEngine engine)
{
_engine = engine;
InitializeComponent();
Loaded += async (_, _) => await PopulateAsync();
}
private async Task PopulateAsync()
{
ItemsPanel.Children.Clear();
ItemsPanel.Children.Add(BuildSection("환경", new[]
{
("OS", Environment.OSVersion.VersionString),
(".NET", Environment.Version.ToString()),
("실행 경로", Environment.ProcessPath ?? AppContext.BaseDirectory),
}));
foreach (var provider in _engine.Providers.All)
{
var availability = await provider.CheckAvailabilityAsync();
ItemsPanel.Children.Add(BuildProviderCard(provider, availability));
}
}
private static UIElement BuildSection(string title, IEnumerable<(string Key, string Value)> items)
{
var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) };
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = title,
FontSize = 14,
FontWeight = FontWeights.SemiBold,
Margin = new Thickness(0, 0, 0, 8),
});
foreach (var (k, v) in items)
{
var row = new Grid { Margin = new Thickness(0, 2, 0, 2) };
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) });
row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var keyText = new TextBlock { Text = k, FontSize = 12, Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush") };
var valueText = new TextBlock { Text = v, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis };
Grid.SetColumn(valueText, 1);
row.Children.Add(keyText);
row.Children.Add(valueText);
stack.Children.Add(row);
}
card.Content = stack;
return card;
}
private UIElement BuildProviderCard(IConverterProvider provider, ProviderAvailability availability)
{
var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) };
var stack = new StackPanel();
var header = new StackPanel { Orientation = Orientation.Horizontal };
header.Children.Add(new TextBlock
{
Text = provider.Capability.DisplayName,
FontSize = 14,
FontWeight = FontWeights.SemiBold,
});
var (badgeText, brushKey) = (provider.Capability.Status, availability.IsReady) switch
{
(ProviderStatus.ComingSoon, _) => ("개발 중", "BadgeMutedBrush"),
(ProviderStatus.Disabled, _) => ("비활성", "BadgeMutedBrush"),
(_, true) => ("준비됨", "BadgeReadyBrush"),
_ => ("점검 필요", "BadgeWarnBrush"),
};
header.Children.Add(new Border
{
Background = (Brush)Application.Current.FindResource(brushKey),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(8, 2, 8, 2),
Margin = new Thickness(8, 0, 0, 0),
Child = new TextBlock { Text = badgeText, FontSize = 11, Foreground = Brushes.White },
});
stack.Children.Add(header);
stack.Children.Add(new TextBlock
{
Text = $"확장자: {string.Join(", ", provider.Capability.Extensions)}",
FontSize = 11,
Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"),
Margin = new Thickness(0, 4, 0, 0),
});
if (!availability.IsReady && !string.IsNullOrEmpty(availability.Reason))
{
stack.Children.Add(new TextBlock
{
Text = availability.Reason,
FontSize = 11,
Foreground = (Brush)Application.Current.FindResource("BadgeWarnBrush"),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 0),
});
}
card.Content = stack;
return card;
}
private async void OnRefreshClick(object sender, RoutedEventArgs e) => await PopulateAsync();
private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
}

View file

@ -0,0 +1,111 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="EverythingToJpeg"
Width="880" Height="640"
MinWidth="640" MinHeight="480"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round"
WindowStartupLocation="CenterScreen"
AllowDrop="True"
Drop="OnFilesDropped"
DragOver="OnDragOver"
DragLeave="OnDragLeave">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="EverythingToJpeg">
<ui:TitleBar.Icon>
<ui:SymbolIcon Symbol="ImageMultiple24"/>
</ui:TitleBar.Icon>
</ui:TitleBar>
<Grid Grid.Row="1" Margin="32,16,32,32">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Hero -->
<StackPanel Grid.Row="0" Margin="0,0,0,24">
<TextBlock Text="모든 것을 JPEG로." Style="{StaticResource TextTitleLarge}"/>
<TextBlock Margin="0,4,0,0" Style="{StaticResource TextBody}"
Foreground="{DynamicResource TextFillColorSecondaryBrush}">
파일을 우클릭하면 끝. PNG · GIF · HEIC · RAW · PDF · DOCX 가 모두 한 번에 변환됩니다.
</TextBlock>
</StackPanel>
<!-- Drop zone -->
<Border x:Name="DropZone" Grid.Row="1"
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}"
BorderThickness="2"
Background="{DynamicResource ControlFillColorDefaultBrush}"
CornerRadius="12"
Padding="32,24"
Margin="0,0,0,20">
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<DataTrigger Binding="{Binding IsDraggingOver, RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="BorderBrush" Value="{DynamicResource SystemFillColorAttentionBrush}"/>
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel HorizontalAlignment="Center">
<ui:SymbolIcon Symbol="ImageArrowCounterclockwise24" FontSize="36"
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
HorizontalAlignment="Center"/>
<TextBlock Text="여기에 파일이나 폴더를 끌어다 놓으세요"
Style="{StaticResource TextSubtitle}"
HorizontalAlignment="Center" Margin="0,12,0,4"/>
<TextBlock Style="{StaticResource TextCaption}" HorizontalAlignment="Center">
또는
<Hyperlink Click="OnPickFilesClick">파일 선택</Hyperlink>
</TextBlock>
</StackPanel>
</Border>
<!-- Provider list -->
<ui:Card Grid.Row="2" Padding="0">
<DockPanel>
<Grid DockPanel.Dock="Top" Margin="20,16,20,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="지원 형식" Style="{StaticResource TextSubtitle}"/>
<TextBlock Grid.Column="1" Style="{StaticResource TextCaption}"
VerticalAlignment="Center">
준비된 형식만 우클릭 메뉴에 등록됩니다
</TextBlock>
</Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,0,20,16">
<ItemsControl x:Name="ProvidersList"/>
</ScrollViewer>
</DockPanel>
</ui:Card>
<!-- Action bar -->
<StackPanel Grid.Row="3" Orientation="Horizontal"
HorizontalAlignment="Right" Margin="0,20,0,0">
<ui:Button Content="진단" Icon="{ui:SymbolIcon Stethoscope24}"
Click="OnDiagnoseClick" Margin="0,0,8,0"/>
<ui:Button x:Name="UnregisterButton" Content="컨텍스트 메뉴 해제"
Icon="{ui:SymbolIcon DismissCircle24}"
Click="OnUnregisterClick" Margin="0,0,8,0"/>
<ui:Button x:Name="RegisterButton" Content="컨텍스트 메뉴 등록"
Icon="{ui:SymbolIcon CheckmarkCircle24}"
Appearance="Primary" Click="OnRegisterClick"/>
</StackPanel>
</Grid>
</Grid>
</ui:FluentWindow>

View file

@ -0,0 +1,273 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using System.Windows.Media;
using EverythingToJpeg.App.Shell;
using EverythingToJpeg.Core.Providers;
using Wpf.Ui.Controls;
namespace EverythingToJpeg.App.Views;
public partial class MainWindow : FluentWindow, INotifyPropertyChanged
{
private bool _isDraggingOver;
public bool IsDraggingOver
{
get => _isDraggingOver;
set { _isDraggingOver = value; OnPropertyChanged(); }
}
public MainWindow()
{
InitializeComponent();
DataContext = this;
Loaded += async (_, _) => await PopulateAsync();
}
private async Task PopulateAsync()
{
var engine = ((App)Application.Current).Engine;
ProvidersList.Items.Clear();
foreach (var provider in engine.Providers.All)
{
var availability = await provider.CheckAvailabilityAsync();
ProvidersList.Items.Add(BuildProviderRow(provider.Capability, availability));
}
}
private static UIElement BuildProviderRow(ProviderCapability cap, ProviderAvailability availability)
{
var (badge, badgeKey) = cap.Status switch
{
ProviderStatus.Available => availability.IsReady
? ("준비됨", "BadgeReadyBrush")
: ("점검 필요", "BadgeWarnBrush"),
ProviderStatus.Preview => ("프리뷰", "BadgeInfoBrush"),
ProviderStatus.RequiresExternal => availability.IsReady
? ("외부 도구 감지됨", "BadgeReadyBrush")
: ("외부 도구 필요", "BadgeWarnBrush"),
ProviderStatus.ComingSoon => ("개발 중", "BadgeMutedBrush"),
_ => ("비활성", "BadgeMutedBrush"),
};
var card = new CardControl
{
Padding = new Thickness(16, 12, 16, 12),
Margin = new Thickness(0, 0, 0, 8),
};
var stack = new StackPanel();
var headerStack = new StackPanel { Orientation = Orientation.Horizontal };
headerStack.Children.Add(new TextBlock
{
Text = cap.DisplayName,
FontFamily = new FontFamily("Segoe UI Variable Text, Segoe UI"),
FontSize = 14,
FontWeight = FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center,
});
headerStack.Children.Add(new Border
{
Background = (Brush)Application.Current.FindResource(badgeKey),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(8, 2, 8, 2),
Margin = new Thickness(8, 0, 0, 0),
Child = new TextBlock { Text = badge, FontSize = 11, Foreground = Brushes.White },
});
stack.Children.Add(headerStack);
stack.Children.Add(new TextBlock
{
Text = cap.Summary,
FontFamily = new FontFamily("Segoe UI Variable Text, Segoe UI"),
FontSize = 12,
Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush"),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 0),
});
stack.Children.Add(new TextBlock
{
Text = "확장자: " + string.Join(", ", cap.Extensions),
FontSize = 11,
Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"),
Margin = new Thickness(0, 4, 0, 0),
});
if (!availability.IsReady && !string.IsNullOrEmpty(availability.Reason))
{
stack.Children.Add(new TextBlock
{
Text = availability.Reason,
FontSize = 11,
Foreground = (Brush)Application.Current.FindResource("BadgeWarnBrush"),
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(0, 4, 0, 0),
});
}
if (cap.ExternalDependencies.Count > 0)
{
foreach (var dep in cap.ExternalDependencies)
{
var line = new TextBlock
{
FontSize = 11,
Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush"),
Margin = new Thickness(0, 2, 0, 0),
TextWrapping = TextWrapping.Wrap,
};
line.Inlines.Add(new Run($"• {dep.Name} — {dep.Description} "));
if (!string.IsNullOrEmpty(dep.DownloadUrl))
{
var hl = new Hyperlink(new Run(dep.DownloadUrl)) { NavigateUri = new Uri(dep.DownloadUrl) };
hl.RequestNavigate += (_, e) =>
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = e.Uri.AbsoluteUri,
UseShellExecute = true
});
}
catch { }
e.Handled = true;
};
line.Inlines.Add(hl);
}
stack.Children.Add(line);
}
}
if (!string.IsNullOrEmpty(cap.RoadmapNote))
{
stack.Children.Add(new TextBlock
{
Text = "로드맵: " + cap.RoadmapNote,
FontSize = 11,
FontStyle = FontStyles.Italic,
Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"),
Margin = new Thickness(0, 4, 0, 0),
TextWrapping = TextWrapping.Wrap,
});
}
card.Content = stack;
return card;
}
private void OnDragOver(object sender, DragEventArgs e)
{
if (e.Data.GetDataPresent(DataFormats.FileDrop))
{
e.Effects = DragDropEffects.Copy;
IsDraggingOver = true;
}
else
{
e.Effects = DragDropEffects.None;
}
e.Handled = true;
}
private void OnDragLeave(object sender, DragEventArgs e)
{
IsDraggingOver = false;
}
private void OnFilesDropped(object sender, DragEventArgs e)
{
IsDraggingOver = false;
if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;
if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return;
OpenConvertWindow(paths);
}
private void OnPickFilesClick(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "변환할 파일 선택",
Multiselect = true,
Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc|모든 파일|*.*",
};
if (dlg.ShowDialog(this) == true)
{
OpenConvertWindow(dlg.FileNames);
}
}
private void OpenConvertWindow(string[] paths)
{
var files = ExpandPaths(paths);
if (files.Count == 0) return;
var window = new ConvertWindow(((App)Application.Current).Engine, files) { Owner = this };
window.ShowDialog();
}
private static List<string> ExpandPaths(IEnumerable<string> paths)
{
var list = new List<string>();
foreach (var p in paths)
{
try
{
if (File.Exists(p)) list.Add(p);
else if (Directory.Exists(p))
list.AddRange(Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly));
}
catch { }
}
return list;
}
private async void OnRegisterClick(object sender, RoutedEventArgs e)
{
try
{
ContextMenuRegistrar.Register(((App)Application.Current).Engine);
ShowToast("컨텍스트 메뉴를 등록했습니다.\n파일 위에서 우클릭 → \"추가 옵션 표시\"에서 보입니다.");
await PopulateAsync();
}
catch (Exception ex)
{
MessageBox.Show("등록 중 오류: " + ex.Message, "EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private async void OnUnregisterClick(object sender, RoutedEventArgs e)
{
try
{
ContextMenuRegistrar.Unregister(((App)Application.Current).Engine);
ShowToast("컨텍스트 메뉴를 해제했습니다.");
await PopulateAsync();
}
catch (Exception ex)
{
MessageBox.Show("해제 중 오류: " + ex.Message, "EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void OnDiagnoseClick(object sender, RoutedEventArgs e)
{
var window = new DiagnoseWindow(((App)Application.Current).Engine) { Owner = this };
window.ShowDialog();
}
private void ShowToast(string message)
{
MessageBox.Show(this, message, "EverythingToJpeg",
MessageBoxButton.OK, MessageBoxImage.Information);
}
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View file

@ -0,0 +1,40 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.QuickProgressWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
Title="JPEG로 빠른 변환"
Width="560" Height="220"
ExtendsContentIntoTitleBar="True"
WindowBackdropType="Mica"
WindowCornerPreference="Round"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<ui:TitleBar Grid.Row="0" Title="JPEG로 빠른 변환"/>
<Grid Grid.Row="1" Margin="32,12,32,24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Text="변환 중…" Style="{StaticResource TextSubtitle}"/>
<TextBlock x:Name="StatusText" Grid.Row="1" Margin="0,4,0,12"
Style="{StaticResource TextCaption}" TextTrimming="CharacterEllipsis"/>
<ProgressBar x:Name="OverallProgress" Grid.Row="2" Height="6"
Minimum="0" Maximum="1"/>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<ui:Button x:Name="OpenFolderButton" Content="결과 폴더 열기"
Icon="{ui:SymbolIcon Folder24}"
Click="OnOpenFolderClick" IsEnabled="False" Margin="0,0,8,0"/>
<ui:Button x:Name="CloseButton" Content="닫기" Click="OnCloseClick"
Appearance="Primary" IsEnabled="False"/>
</StackPanel>
</Grid>
</Grid>
</ui:FluentWindow>

View file

@ -0,0 +1,81 @@
using System.Windows;
using EverythingToJpeg.Core;
using Wpf.Ui.Controls;
namespace EverythingToJpeg.App.Views;
public partial class QuickProgressWindow : FluentWindow
{
private readonly int _total;
private string? _firstSuccessOutput;
public QuickProgressWindow(int total)
{
_total = total;
InitializeComponent();
StatusText.Text = $"0 / {_total}";
}
public void Report(ConvertProgress p)
{
if (!CheckAccess()) { Dispatcher.Invoke(() => Report(p)); return; }
var overall = _total == 0 ? 0 : (p.Index + p.FileProgress) / _total;
OverallProgress.Value = Math.Clamp(overall, 0, 1);
StatusText.Text = $"{Math.Min(p.Index + 1, _total)} / {_total} — {Path.GetFileName(p.CurrentPath)}";
}
public void Finish(IReadOnlyList<ConvertResult> results)
{
if (!CheckAccess()) { Dispatcher.Invoke(() => Finish(results)); return; }
var success = results.Count(r => r.Status == ConvertStatus.Success);
var skipped = results.Count(r => r.Status == ConvertStatus.Skipped);
var failed = results.Count(r => r.Status == ConvertStatus.Failed);
var outputs = results.Sum(r => r.OutputPaths.Count);
OverallProgress.Value = 1;
StatusText.Text = $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}";
CloseButton.IsEnabled = true;
_firstSuccessOutput = results
.FirstOrDefault(r => r.Status == ConvertStatus.Success)?
.OutputPaths.FirstOrDefault();
OpenFolderButton.IsEnabled = _firstSuccessOutput is not null;
if (failed > 0)
{
var detail = string.Join("\n",
results.Where(r => r.Status == ConvertStatus.Failed)
.Take(5)
.Select(r => $"• {Path.GetFileName(r.SourcePath)}: {r.Message}"));
MessageBox.Show(this, "일부 파일 변환에 실패했습니다.\n\n" + detail,
"EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Warning);
}
else if (failed == 0 && skipped == 0 && _firstSuccessOutput is not null)
{
OpenInExplorer(_firstSuccessOutput);
Close();
}
}
private void OnOpenFolderClick(object sender, RoutedEventArgs e)
{
if (_firstSuccessOutput is not null) OpenInExplorer(_firstSuccessOutput);
}
private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
private static void OpenInExplorer(string path)
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "explorer.exe",
Arguments = $"/select,\"{path}\"",
UseShellExecute = true,
});
}
catch { }
}
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="EverythingToJpeg.App"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- Win10/11 -->
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
</assembly>