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>

View file

@ -0,0 +1,99 @@
using EverythingToJpeg.Core.Providers;
namespace EverythingToJpeg.Core;
public sealed class ConversionEngine
{
private readonly ProviderRegistry _registry;
public ConversionEngine(ProviderRegistry registry)
{
_registry = registry;
}
public ProviderRegistry Providers => _registry;
public async Task<IReadOnlyList<ConvertResult>> ConvertManyAsync(
IEnumerable<string> sources,
ConvertOptions options,
IProgress<ConvertProgress>? progress = null,
CancellationToken cancellationToken = default)
{
var sourceList = sources.ToList();
var results = new List<ConvertResult>(sourceList.Count);
for (var i = 0; i < sourceList.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var source = sourceList[i];
progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0));
var result = await ConvertOneAsync(source, options,
new Progress<double>(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))),
cancellationToken).ConfigureAwait(false);
results.Add(result);
progress?.Report(new ConvertProgress(i + 1, sourceList.Count, source, 1));
}
return results;
}
public async Task<ConvertResult> ConvertOneAsync(
string sourcePath,
ConvertOptions options,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (!File.Exists(sourcePath))
return ConvertResult.Fail(sourcePath, "파일을 찾을 수 없습니다.");
if (!_registry.TryGetForFile(sourcePath, out var provider) || provider is null)
return ConvertResult.Fail(sourcePath, $"지원하지 않는 형식입니다: {Path.GetExtension(sourcePath)}");
var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
{
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
return ConvertResult.Fail(sourcePath, detail);
}
var outputDir = ResolveOutputDirectory(sourcePath, options);
Directory.CreateDirectory(outputDir);
try
{
return await provider.ConvertAsync(sourcePath, outputDir, options, progress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath, ex.Message, ex);
}
}
private static string ResolveOutputDirectory(string sourcePath, ConvertOptions options)
{
var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath))
?? throw new InvalidOperationException("소스 경로에서 폴더를 결정할 수 없습니다.");
return options.OutputLocation switch
{
OutputLocation.SameFolderAsSource => sourceDir,
OutputLocation.Custom => string.IsNullOrWhiteSpace(options.CustomOutputDirectory)
? sourceDir
: options.CustomOutputDirectory!,
_ => Path.Combine(sourceDir,
Path.GetFileNameWithoutExtension(sourcePath) + options.SubfolderSuffix),
};
}
}
public sealed record ConvertProgress(int Index, int Total, string CurrentPath, double FileProgress);

View file

@ -0,0 +1,40 @@
namespace EverythingToJpeg.Core;
public enum OutputLocation
{
SubfolderBesideSource,
SameFolderAsSource,
Custom
}
public enum NameCollision
{
AppendNumber,
Overwrite,
Skip
}
public sealed class ConvertOptions
{
public int Quality { get; set; } = 92;
public OutputLocation OutputLocation { get; set; } = OutputLocation.SubfolderBesideSource;
public string SubfolderSuffix { get; set; } = "_jpeg";
public string? CustomOutputDirectory { get; set; }
public NameCollision OnCollision { get; set; } = NameCollision.AppendNumber;
public int? MaxLongEdgePixels { get; set; }
public int PdfDpi { get; set; } = 200;
public bool KeepExifWhenPossible { get; set; } = true;
public bool FlattenTransparency { get; set; } = true;
public string TransparencyBackground { get; set; } = "#FFFFFF";
public static ConvertOptions Quick() => new();
}

View file

@ -0,0 +1,25 @@
namespace EverythingToJpeg.Core;
public enum ConvertStatus
{
Success,
Skipped,
Failed
}
public sealed record ConvertResult(
string SourcePath,
IReadOnlyList<string> OutputPaths,
ConvertStatus Status,
string? Message = null,
Exception? Error = null)
{
public static ConvertResult Ok(string source, IReadOnlyList<string> outputs)
=> new(source, outputs, ConvertStatus.Success);
public static ConvertResult Fail(string source, string message, Exception? ex = null)
=> new(source, Array.Empty<string>(), ConvertStatus.Failed, message, ex);
public static ConvertResult Skip(string source, string message)
=> new(source, Array.Empty<string>(), ConvertStatus.Skipped, message);
}

View file

@ -0,0 +1,169 @@
using System.Diagnostics;
using EverythingToJpeg.Core.Providers;
namespace EverythingToJpeg.Core.Converters;
public sealed class DocxProvider : IConverterProvider
{
private readonly PdfProvider _pdfProvider;
public DocxProvider(PdfProvider pdfProvider)
{
_pdfProvider = pdfProvider;
}
public ProviderCapability Capability { get; } = new(
Id: "docx",
DisplayName: "Word 문서 (DOCX)",
Extensions: new[] { ".docx", ".doc" },
Status: ProviderStatus.RequiresExternal,
Summary: "DOCX/DOC 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
Name: "Microsoft Word 또는 LibreOffice",
Description: "DOCX → PDF 변환에 둘 중 하나가 필요합니다. 둘 다 없으면 LibreOffice 설치를 권장합니다.",
DownloadUrl: "https://www.libreoffice.org/download/",
IsRequired: true),
},
RoadmapNote: "향후 OpenXML 기반 자체 렌더링 검토.");
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
{
if (ExternalToolDetector.IsWordComAvailable())
return Task.FromResult(ProviderAvailability.Ready);
if (ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
return Task.FromResult(ProviderAvailability.Ready);
return Task.FromResult(ProviderAvailability.NotReady(
"Microsoft Word 또는 LibreOffice가 설치되어 있어야 합니다.",
Capability.ExternalDependencies));
}
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var tempPdf = Path.Combine(Path.GetTempPath(),
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
try
{
progress?.Report(0.05);
var converted = false;
string? failureReason = null;
if (ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
{
converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
.ConfigureAwait(false);
if (!converted) failureReason = "LibreOffice 변환에 실패했습니다.";
}
if (!converted && ExternalToolDetector.IsWordComAvailable())
{
try
{
converted = ConvertWithWordCom(sourcePath, tempPdf);
if (!converted) failureReason = "Microsoft Word 변환에 실패했습니다.";
}
catch (Exception ex)
{
failureReason = $"Microsoft Word 변환 오류: {ex.Message}";
}
}
if (!converted)
return ConvertResult.Fail(sourcePath, failureReason ?? "DOCX → PDF 외부 변환 도구가 필요합니다.");
progress?.Report(0.55);
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
with { SourcePath = sourcePath };
}
finally
{
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
}
}
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
{
var outDir = Path.GetDirectoryName(targetPdf)!;
var psi = new ProcessStartInfo
{
FileName = sofficePath,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.ArgumentList.Add("--headless");
psi.ArgumentList.Add("--norestore");
psi.ArgumentList.Add("--nofirststartwizard");
psi.ArgumentList.Add("--convert-to");
psi.ArgumentList.Add("pdf");
psi.ArgumentList.Add("--outdir");
psi.ArgumentList.Add(outDir);
psi.ArgumentList.Add(sourcePath);
using var proc = Process.Start(psi);
if (proc is null) return false;
try
{
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
try { proc.Kill(true); } catch { }
throw;
}
if (proc.ExitCode != 0) return false;
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
if (!File.Exists(produced)) return false;
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
{
if (File.Exists(targetPdf)) File.Delete(targetPdf);
File.Move(produced, targetPdf);
}
return File.Exists(targetPdf);
}
private static bool ConvertWithWordCom(string sourcePath, string targetPdf)
{
const int wdFormatPDF = 17;
var wordType = Type.GetTypeFromProgID("Word.Application");
if (wordType is null) return false;
dynamic? word = Activator.CreateInstance(wordType);
if (word is null) return false;
try
{
word.Visible = false;
word.DisplayAlerts = 0;
dynamic doc = word.Documents.Open(sourcePath, ReadOnly: true, Visible: false);
try
{
doc.SaveAs2(targetPdf, wdFormatPDF);
}
finally
{
doc.Close(false);
}
return File.Exists(targetPdf);
}
finally
{
try { word.Quit(); } catch { }
}
}
}

View file

@ -0,0 +1,51 @@
using Microsoft.Win32;
namespace EverythingToJpeg.Core.Converters;
internal static class ExternalToolDetector
{
public static bool TryFindLibreOfficeSoffice(out string sofficePath)
{
sofficePath = "";
var candidates = new List<string>();
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
foreach (var root in new[] { pf, pfx86 })
{
if (string.IsNullOrEmpty(root)) continue;
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.com"));
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.exe"));
}
try
{
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\LibreOffice\UNO\InstallPath");
if (key?.GetValue(null) is string installPath)
{
candidates.Add(Path.Combine(installPath, "soffice.com"));
candidates.Add(Path.Combine(installPath, "soffice.exe"));
}
}
catch { }
foreach (var path in candidates.Distinct())
{
if (File.Exists(path)) { sofficePath = path; return true; }
}
return false;
}
public static bool IsWordComAvailable()
{
try
{
using var key = Registry.ClassesRoot.OpenSubKey("Word.Application");
return key is not null;
}
catch
{
return false;
}
}
}

View file

@ -0,0 +1,73 @@
using EverythingToJpeg.Core.Providers;
using PhotoSauce.MagicScaler;
using PhotoSauce.NativeCodecs.Libheif;
namespace EverythingToJpeg.Core.Converters;
public sealed class HeicProvider : IConverterProvider
{
private static int _codecConfigured;
private readonly MagickProvider _magickProvider;
public HeicProvider() : this(new MagickProvider()) { }
public HeicProvider(MagickProvider magickProvider)
{
_magickProvider = magickProvider;
}
public ProviderCapability Capability { get; } = new(
Id: "heic",
DisplayName: "HEIC / HEIF",
Extensions: new[] { ".heic", ".heif" },
Status: ProviderStatus.Available,
Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 JPEG로 변환합니다.",
ExternalDependencies: Array.Empty<ExternalDependency>(),
RoadmapNote: null);
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
{
EnsureCodec();
return Task.FromResult(ProviderAvailability.Ready);
}
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
EnsureCodec();
var tempPng = Path.Combine(Path.GetTempPath(),
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.png");
try
{
await Task.Run(() =>
{
MagicImageProcessor.ProcessImage(sourcePath, tempPng, ProcessImageSettings.Default);
}, cancellationToken).ConfigureAwait(false);
progress?.Report(0.5);
var inner = new Progress<double>(p => progress?.Report(0.5 + p * 0.5));
var result = await _magickProvider
.ConvertAsync(tempPng, outputDirectory, options, inner, cancellationToken)
.ConfigureAwait(false);
return result with { SourcePath = sourcePath };
}
finally
{
try { if (File.Exists(tempPng)) File.Delete(tempPng); } catch { }
}
}
private static void EnsureCodec()
{
if (Interlocked.Exchange(ref _codecConfigured, 1) == 1) return;
CodecManager.Configure(codecs => codecs.UseLibheif());
}
}

View file

@ -0,0 +1,30 @@
using EverythingToJpeg.Core.Providers;
namespace EverythingToJpeg.Core.Converters;
public sealed class HtmlProvider : IConverterProvider
{
public ProviderCapability Capability { get; } = new(
Id: "html",
DisplayName: "HTML / 웹 페이지",
Extensions: new[] { ".html", ".htm" },
Status: ProviderStatus.ComingSoon,
Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 JPEG로 캡처합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
Name: "Microsoft Edge WebView2 Runtime",
Description: "Windows 11에는 기본 포함되어 있습니다.",
DownloadUrl: "https://developer.microsoft.com/microsoft-edge/webview2/",
IsRequired: true),
},
RoadmapNote: "Phase 2 — WebView2 헤드리스 캡처 + 사용자 정의 viewport.");
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다."));
public Task<ConvertResult> ConvertAsync(
string sourcePath, string outputDirectory, ConvertOptions options,
IProgress<double>? progress, CancellationToken cancellationToken)
=> Task.FromResult(ConvertResult.Skip(sourcePath, "HTML 변환은 곧 지원 예정입니다."));
}

View file

@ -0,0 +1,30 @@
using EverythingToJpeg.Core.Providers;
namespace EverythingToJpeg.Core.Converters;
public sealed class HwpxProvider : IConverterProvider
{
public ProviderCapability Capability { get; } = new(
Id: "hwpx",
DisplayName: "한글 문서 (HWP / HWPX)",
Extensions: new[] { ".hwp", ".hwpx" },
Status: ProviderStatus.ComingSoon,
Summary: "한글(HWP/HWPX) 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
Name: "LibreOffice + H2Orestart 확장",
Description: "한글 파일을 LibreOffice가 읽도록 해 주는 오픈소스 확장입니다.",
DownloadUrl: "https://github.com/ebandal/H2Orestart",
IsRequired: true),
},
RoadmapNote: "Phase 2 — H2Orestart + soffice headless 파이프라인. 한컴오피스 SDK 연동도 검토.");
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다."));
public Task<ConvertResult> ConvertAsync(
string sourcePath, string outputDirectory, ConvertOptions options,
IProgress<double>? progress, CancellationToken cancellationToken)
=> Task.FromResult(ConvertResult.Skip(sourcePath, "HWP/HWPX 변환은 곧 지원 예정입니다."));
}

View file

@ -0,0 +1,131 @@
using EverythingToJpeg.Core.Providers;
using ImageMagick;
namespace EverythingToJpeg.Core.Converters;
public sealed class MagickProvider : IConverterProvider
{
private static readonly string[] SingleFrameExtensions =
{
".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd",
".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2", ".srw", ".pef", ".raw",
};
private static readonly string[] MultiFrameExtensions = { ".gif", ".tif", ".tiff" };
public ProviderCapability Capability { get; } = new(
Id: "magick",
DisplayName: "이미지·RAW·애니메이션",
Extensions: SingleFrameExtensions.Concat(MultiFrameExtensions).ToList(),
Status: ProviderStatus.Available,
Summary: "PNG, BMP, JPEG, WebP, AVIF, PSD, GIF, TIFF, RAW(NEF/CR2/ARW/DNG/RAF/ORF/RW2 등)을 JPEG로 변환합니다.",
ExternalDependencies: Array.Empty<ExternalDependency>(),
RoadmapNote: null);
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(ProviderAvailability.Ready);
public Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
}
private static ConvertResult ConvertCore(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var ext = Path.GetExtension(sourcePath).ToLowerInvariant();
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var isMultiFrame = MultiFrameExtensions.Contains(ext);
if (isMultiFrame)
{
using var collection = new MagickImageCollection(sourcePath);
if (collection.Count == 0)
return ConvertResult.Fail(sourcePath, "이미지 프레임을 읽지 못했습니다.");
if (collection.Count == 1)
{
var single = collection[0];
ApplyCommonTransforms(single, options);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
WriteJpeg(single, path, options.Quality);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
collection.Coalesce();
var outputs = new List<string>();
var width = (int)Math.Ceiling(Math.Log10(collection.Count + 1));
for (var i = 0; i < collection.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var frame = collection[i];
ApplyCommonTransforms(frame, options);
var suffix = $"_{(i + 1).ToString().PadLeft(width, '0')}";
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
WriteJpeg(frame, path, options.Quality);
outputs.Add(path);
progress?.Report((i + 1.0) / collection.Count);
}
return outputs.Count > 0
? ConvertResult.Ok(sourcePath, outputs)
: ConvertResult.Skip(sourcePath, "모든 프레임이 이미 존재해 건너뜁니다.");
}
else
{
using var image = new MagickImage(sourcePath);
ApplyCommonTransforms(image, options);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
WriteJpeg(image, path, options.Quality);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
}
private static void ApplyCommonTransforms(IMagickImage<ushort> image, ConvertOptions options)
{
try { image.AutoOrient(); } catch { }
if (options.FlattenTransparency && image.HasAlpha)
{
image.BackgroundColor = new MagickColor(options.TransparencyBackground);
image.Alpha(AlphaOption.Remove);
image.Alpha(AlphaOption.Off);
}
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0)
{
var w = (int)image.Width;
var h = (int)image.Height;
if (w > maxLong || h > maxLong)
{
var geom = new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false };
image.Resize(geom);
}
}
image.Format = MagickFormat.Jpeg;
}
private static void WriteJpeg(IMagickImage<ushort> image, string path, int quality)
{
image.Quality = (uint)Math.Clamp(quality, 1, 100);
image.Format = MagickFormat.Jpeg;
image.Write(path);
}
}

View file

@ -0,0 +1,98 @@
using EverythingToJpeg.Core.Providers;
using PDFtoImage;
using SkiaSharp;
namespace EverythingToJpeg.Core.Converters;
public sealed class PdfProvider : IConverterProvider
{
public ProviderCapability Capability { get; } = new(
Id: "pdf",
DisplayName: "PDF",
Extensions: new[] { ".pdf" },
Status: ProviderStatus.Available,
Summary: "PDF 각 페이지를 JPEG로 변환합니다.",
ExternalDependencies: Array.Empty<ExternalDependency>(),
RoadmapNote: null);
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(ProviderAvailability.Ready);
public Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
}
internal ConvertResult ConvertCore(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
int pageCount;
using (var probe = File.OpenRead(sourcePath))
{
pageCount = Conversion.GetPageCount(probe);
}
if (pageCount <= 0)
return ConvertResult.Fail(sourcePath, "PDF에 페이지가 없습니다.");
var renderOptions = new RenderOptions
{
Dpi = options.PdfDpi,
BackgroundColor = SKColors.White,
WithAnnotations = true,
WithFormFill = true,
UseTiling = true,
};
var width = (int)Math.Ceiling(Math.Log10(pageCount + 1));
var outputs = new List<string>();
for (var i = 0; i < pageCount; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var suffix = pageCount == 1 ? null : $"_p{(i + 1).ToString().PadLeft(width, '0')}";
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
using (var input = File.OpenRead(sourcePath))
{
Conversion.SaveJpeg(path, input, page: i, leaveOpen: false, password: null, options: renderOptions);
}
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0)
{
ResizeIfNeeded(path, maxLong, options.Quality);
}
outputs.Add(path);
progress?.Report((i + 1.0) / pageCount);
}
return outputs.Count > 0
? ConvertResult.Ok(sourcePath, outputs)
: ConvertResult.Skip(sourcePath, "모든 페이지가 이미 존재해 건너뜁니다.");
}
private static void ResizeIfNeeded(string jpegPath, int maxLongEdge, int quality)
{
using var image = new ImageMagick.MagickImage(jpegPath);
if (image.Width <= (uint)maxLongEdge && image.Height <= (uint)maxLongEdge) return;
var geom = new ImageMagick.MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false };
image.Resize(geom);
image.Quality = (uint)Math.Clamp(quality, 1, 100);
image.Format = ImageMagick.MagickFormat.Jpeg;
image.Write(jpegPath);
}
}

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<UseWindowsForms>false</UseWindowsForms>
<UseWPF>false</UseWPF>
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.13.0" />
<PackageReference Include="PDFtoImage" Version="5.2.1" />
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,22 @@
using EverythingToJpeg.Core.Providers;
namespace EverythingToJpeg.Core;
public static class EverythingToJpegBootstrap
{
public static ConversionEngine CreateDefault()
{
var magick = new Converters.MagickProvider();
var pdf = new Converters.PdfProvider();
var providers = new IConverterProvider[]
{
magick,
new Converters.HeicProvider(magick),
pdf,
new Converters.DocxProvider(pdf),
new Converters.HtmlProvider(),
new Converters.HwpxProvider(),
};
return new ConversionEngine(new ProviderRegistry(providers));
}
}

View file

@ -0,0 +1,49 @@
namespace EverythingToJpeg.Core;
internal static class OutputPathHelper
{
public static string ResolveOutputPath(
string outputDirectory,
string baseName,
string? pageSuffix,
NameCollision collision)
{
var safe = SanitizeFileName(baseName);
var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}.jpg" : $"{safe}{pageSuffix}.jpg";
var fullPath = Path.Combine(outputDirectory, fileName);
if (!File.Exists(fullPath)) return fullPath;
switch (collision)
{
case NameCollision.Overwrite:
return fullPath;
case NameCollision.Skip:
return fullPath;
case NameCollision.AppendNumber:
default:
for (var i = 1; i < 10000; i++)
{
var candidate = string.IsNullOrEmpty(pageSuffix)
? Path.Combine(outputDirectory, $"{safe} ({i}).jpg")
: Path.Combine(outputDirectory, $"{safe}{pageSuffix} ({i}).jpg");
if (!File.Exists(candidate)) return candidate;
}
return fullPath;
}
}
public static bool ShouldSkip(string finalPath, NameCollision collision)
=> collision == NameCollision.Skip && File.Exists(finalPath);
private static string SanitizeFileName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
Span<char> buffer = stackalloc char[name.Length];
for (var i = 0; i < name.Length; i++)
{
buffer[i] = Array.IndexOf(invalid, name[i]) >= 0 ? '_' : name[i];
}
return new string(buffer);
}
}

View file

@ -0,0 +1,26 @@
namespace EverythingToJpeg.Core.Providers;
public interface IConverterProvider
{
ProviderCapability Capability { get; }
Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default);
Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken);
}
public sealed record ProviderAvailability(
bool IsReady,
string? Reason = null,
IReadOnlyList<ExternalDependency>? MissingDependencies = null)
{
public static ProviderAvailability Ready { get; } = new(true);
public static ProviderAvailability NotReady(string reason, IReadOnlyList<ExternalDependency>? missing = null)
=> new(false, reason, missing);
}

View file

@ -0,0 +1,29 @@
namespace EverythingToJpeg.Core.Providers;
public enum ProviderStatus
{
Available,
Preview,
RequiresExternal,
ComingSoon,
Disabled,
}
public sealed record ExternalDependency(
string Name,
string Description,
string? DownloadUrl = null,
bool IsRequired = true);
public sealed record ProviderCapability(
string Id,
string DisplayName,
IReadOnlyList<string> Extensions,
ProviderStatus Status,
string Summary,
IReadOnlyList<ExternalDependency> ExternalDependencies,
string? RoadmapNote = null)
{
public bool CanRegisterContextMenu => Status is ProviderStatus.Available or ProviderStatus.Preview or ProviderStatus.RequiresExternal;
public bool IsImplemented => Status is not ProviderStatus.ComingSoon and not ProviderStatus.Disabled;
}

View file

@ -0,0 +1,40 @@
namespace EverythingToJpeg.Core.Providers;
public sealed class ProviderRegistry
{
private readonly List<IConverterProvider> _providers;
private readonly Dictionary<string, IConverterProvider> _byExtension = new(StringComparer.OrdinalIgnoreCase);
public ProviderRegistry(IEnumerable<IConverterProvider> providers)
{
_providers = providers.ToList();
foreach (var provider in _providers)
{
if (!provider.Capability.IsImplemented) continue;
foreach (var ext in provider.Capability.Extensions)
{
_byExtension[Normalize(ext)] = provider;
}
}
}
public IReadOnlyList<IConverterProvider> All => _providers;
public IEnumerable<IConverterProvider> Implemented => _providers.Where(p => p.Capability.IsImplemented);
public IEnumerable<IConverterProvider> ComingSoon => _providers.Where(p => p.Capability.Status == ProviderStatus.ComingSoon);
public bool TryGetForFile(string sourcePath, out IConverterProvider? provider)
{
var ext = Normalize(Path.GetExtension(sourcePath));
return _byExtension.TryGetValue(ext, out provider);
}
public IConverterProvider? FindByExtension(string ext)
=> _byExtension.TryGetValue(Normalize(ext), out var p) ? p : null;
public IReadOnlyCollection<string> ImplementedExtensions => _byExtension.Keys;
private static string Normalize(string ext)
=> ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant();
}