초기 커밋: 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:
commit
8fa4a613d7
37 changed files with 2727 additions and 0 deletions
59
src/EverythingToJpeg.App/App.xaml
Normal file
59
src/EverythingToJpeg.App/App.xaml
Normal 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>
|
||||
100
src/EverythingToJpeg.App/App.xaml.cs
Normal file
100
src/EverythingToJpeg.App/App.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
src/EverythingToJpeg.App/AssemblyInfo.cs
Normal file
10
src/EverythingToJpeg.App/AssemblyInfo.cs
Normal 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)
|
||||
)]
|
||||
120
src/EverythingToJpeg.App/Cli/CliRouter.cs
Normal file
120
src/EverythingToJpeg.App/Cli/CliRouter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
Normal file
24
src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
Normal 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);
|
||||
}
|
||||
29
src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
Normal file
29
src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
Normal 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>
|
||||
5
src/EverythingToJpeg.App/GlobalUsings.cs
Normal file
5
src/EverythingToJpeg.App/GlobalUsings.cs
Normal 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;
|
||||
96
src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
Normal file
96
src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
141
src/EverythingToJpeg.App/Views/ConvertWindow.xaml
Normal file
141
src/EverythingToJpeg.App/Views/ConvertWindow.xaml
Normal 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>
|
||||
360
src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
Normal file
360
src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
Normal 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));
|
||||
}
|
||||
32
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
Normal file
32
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
Normal 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>
|
||||
118
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
Normal file
118
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
Normal 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();
|
||||
}
|
||||
111
src/EverythingToJpeg.App/Views/MainWindow.xaml
Normal file
111
src/EverythingToJpeg.App/Views/MainWindow.xaml
Normal 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>
|
||||
273
src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
Normal file
273
src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
Normal 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));
|
||||
}
|
||||
40
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
Normal file
40
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
Normal 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>
|
||||
81
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs
Normal file
81
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
27
src/EverythingToJpeg.App/app.manifest
Normal file
27
src/EverythingToJpeg.App/app.manifest
Normal 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>
|
||||
99
src/EverythingToJpeg.Core/ConversionEngine.cs
Normal file
99
src/EverythingToJpeg.Core/ConversionEngine.cs
Normal 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);
|
||||
40
src/EverythingToJpeg.Core/ConvertOptions.cs
Normal file
40
src/EverythingToJpeg.Core/ConvertOptions.cs
Normal 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();
|
||||
}
|
||||
25
src/EverythingToJpeg.Core/ConvertResult.cs
Normal file
25
src/EverythingToJpeg.Core/ConvertResult.cs
Normal 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);
|
||||
}
|
||||
169
src/EverythingToJpeg.Core/Converters/DocxProvider.cs
Normal file
169
src/EverythingToJpeg.Core/Converters/DocxProvider.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
Normal file
51
src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/EverythingToJpeg.Core/Converters/HeicProvider.cs
Normal file
73
src/EverythingToJpeg.Core/Converters/HeicProvider.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
30
src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
Normal file
30
src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
Normal 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 변환은 곧 지원 예정입니다."));
|
||||
}
|
||||
30
src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
Normal file
30
src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
Normal 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 변환은 곧 지원 예정입니다."));
|
||||
}
|
||||
131
src/EverythingToJpeg.Core/Converters/MagickProvider.cs
Normal file
131
src/EverythingToJpeg.Core/Converters/MagickProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
98
src/EverythingToJpeg.Core/Converters/PdfProvider.cs
Normal file
98
src/EverythingToJpeg.Core/Converters/PdfProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
Normal file
20
src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
Normal 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>
|
||||
22
src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
Normal file
22
src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
49
src/EverythingToJpeg.Core/OutputPathHelper.cs
Normal file
49
src/EverythingToJpeg.Core/OutputPathHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
Normal file
26
src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
Normal 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);
|
||||
}
|
||||
29
src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
Normal file
29
src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
Normal 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;
|
||||
}
|
||||
40
src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
Normal file
40
src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue