1
0
Fork 0

feat: Everything2Everything 양방향 변환 매트릭스 피보팅

EverythingToJpeg(N→JPEG 단방향)에서 Everything2Everything(N×M 매트릭스)으로
앱을 피보팅. 7개 Provider, 200+ 변환 쌍 지원.

리네이밍
- 디렉토리/솔루션/csproj/namespace/AssemblyName/MSIX Identity 일괄 치환
- 임시파일 prefix e2j_ → e2e_, 로고 라벨 E2J → E2E

아키텍처
- ConversionPair(input, output) 신규 + ProviderCapability.SupportedConversions
- IConverterProvider.ConvertAsync에 outputExtension 파라미터 추가
- ProviderRegistry를 Dictionary<(input, output), Provider> 매트릭스로 재작성
  + OutputsForInput / OutputsForFile 쿼리 API
- ConversionEngine: 출력 ext 라우팅, 동일 입출력 자동 skip,
  서브폴더 suffix를 출력 ext에서 자동 도출
- ConvertOptions를 형식별 sub-record로 분리
  (Jpeg/Png/Webp/Avif/Tiff/Pdf*/Html*/Ocr)

Provider 매트릭스
- MagickProvider: PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF/PDF 양방향 + RAW/PSD 디코딩
  (단일이미지→1페이지 PDF, GIF/TIFF→다페이지 PDF)
- HeicProvider: HEIC/HEIF → 이미지 7종
- PdfProvider: PDF → 이미지 6종 (PNG임베드 후 ImageMagick 인코딩)
- HtmlProvider: HTML/HTM → 이미지 + PDF (CDP printToPDF)
- DocxProvider/HwpxProvider: PDF + 이미지 7종 (LibreOffice 활용)
- OcrProvider 신규: 이미지/PDF → TXT/DOCX
  (Windows.Media.Ocr + DocumentFormat.OpenXml, PDF는 페이지별 OCR 후 결합)

UI
- 사이드바 TARGET FORMAT을 동적 ComboBox로 (큐 파일 매트릭스의 교집합만 표시)
- 출력 형식별 색상 배지 + Quality 패널 라벨 동적
- OUTPUT DESTINATION hint도 출력 ext 기반 동적

CLI
- 신규 'to <ext> <files...>' verb (예: to png photo.heic doc.docx)
- help 텍스트 양방향 컨셉으로 갱신

셸 통합
- ContextMenuRegistrar 카스케이드로 재설계
  (ExtendedSubCommandsKey + Everything2Everything.SubMenu.<ext>)
- 입력별 매트릭스에 따라 인기 출력 10종(JPEG/PNG/WebP/PDF/TXT/DOCX/AVIF/GIF/TIFF/BMP)
  동적 노출, 마지막에 '변환…' 옵션
- 메뉴 라벨 'JPEG로 변환' → 'Everything2Everything으로 변환'

기타
- Core.csproj TFM net9.0-windows → net9.0-windows10.0.19041.0 (WinRT API용)
- DocumentFormat.OpenXml 3.1.0 NuGet 추가
- README 양방향 매트릭스 기준 전면 개편
- MSIX appxmanifest Description / IExplorerCommand DLL 라벨 갱신

빌드: 0 errors, 0 warnings
This commit is contained in:
Yun Chan 2026-05-07 13:40:08 +09:00
parent 96861e627d
commit 9b7e5f0d0c
63 changed files with 1788 additions and 733 deletions

View file

@ -1,8 +1,8 @@
<Application x:Class="EverythingToJpeg.App.App"
<Application x:Class="Everything2Everything.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"
xmlns:local="clr-namespace:Everything2Everything.App"
ShutdownMode="OnLastWindowClose">
<Application.Resources>
<ResourceDictionary>

View file

@ -1,13 +1,13 @@
using System.Windows;
using EverythingToJpeg.App.Cli;
using EverythingToJpeg.App.Views;
using EverythingToJpeg.Core;
using Everything2Everything.App.Cli;
using Everything2Everything.App.Views;
using Everything2Everything.Core;
namespace EverythingToJpeg.App;
namespace Everything2Everything.App;
public partial class App : Application
{
public ConversionEngine Engine { get; } = EverythingToJpegBootstrap.CreateDefault();
public ConversionEngine Engine { get; } = Everything2EverythingBootstrap.CreateDefault();
protected override async void OnStartup(StartupEventArgs e)
{
@ -39,12 +39,12 @@ public partial class App : Application
case CliRouter.Mode.Quick:
if (parsed.Files.Count == 0)
{
MessageBox.Show("변환할 파일이 없습니다.", "EverythingToJpeg",
MessageBox.Show("변환할 파일이 없습니다.", "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Information);
Shutdown(1);
return;
}
await RunQuickAsync(parsed.Files);
await RunQuickAsync(parsed.Files, parsed.OutputExtension ?? ".jpg");
return;
case CliRouter.Mode.Dialog:
@ -79,11 +79,11 @@ public partial class App : Application
window.Show();
}
private async Task RunQuickAsync(IReadOnlyList<string> files)
private async Task RunQuickAsync(IReadOnlyList<string> files, string outputExtension)
{
var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_quick.log");
var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_quick.log");
var log = new System.Text.StringBuilder();
log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start, {files.Count} file(s)");
log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start → {outputExtension}, {files.Count} file(s)");
foreach (var f in files) log.AppendLine($" src: {f}");
var progress = new QuickProgressWindow(files.Count);
@ -93,7 +93,7 @@ public partial class App : Application
{
var options = ConvertOptions.Quick();
var reporter = new Progress<ConvertProgress>(p => progress.Report(p));
var results = await Engine.ConvertManyAsync(files, options, reporter);
var results = await Engine.ConvertManyAsync(files, ".jpg", options, reporter);
foreach (var r in results)
{
@ -110,7 +110,7 @@ public partial class App : Application
log.AppendLine($" EXCEPTION {ex.GetType().Name}: {ex.Message}");
log.AppendLine(ex.ToString());
try { progress.Close(); } catch { }
MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "EverythingToJpeg",
MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
@ -121,7 +121,7 @@ public partial class App : Application
private static void WireGlobalExceptionLogging()
{
var path = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_unhandled.log");
var path = Path.Combine(Path.GetTempPath(), "Everything2Everything_unhandled.log");
void Append(string source, Exception? ex)
{
@ -141,7 +141,7 @@ public partial class App : Application
Append("Application.DispatcherUnhandledException", e.Exception);
MessageBox.Show(
"예기치 못한 오류:\n\n" + e.Exception.Message + "\n\n로그: " + path,
"EverythingToJpeg",
"Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true;
};

View file

@ -1,7 +1,7 @@
using EverythingToJpeg.App.Shell;
using EverythingToJpeg.Core;
using Everything2Everything.App.Shell;
using Everything2Everything.Core;
namespace EverythingToJpeg.App.Cli;
namespace Everything2Everything.App.Cli;
internal static class CliRouter
{
@ -16,7 +16,7 @@ internal static class CliRouter
Help,
}
public sealed record ParsedArgs(Mode Mode, IReadOnlyList<string> Files);
public sealed record ParsedArgs(Mode Mode, IReadOnlyList<string> Files, string? OutputExtension = null);
public static ParsedArgs Parse(string[] args)
{
@ -26,9 +26,18 @@ internal static class CliRouter
var verb = args[0].Trim().ToLowerInvariant();
var rest = args.Skip(1).Where(a => !string.IsNullOrWhiteSpace(a)).ToList();
if (verb == "to")
{
if (rest.Count < 2)
return new ParsedArgs(Mode.Help, Array.Empty<string>());
var outputExt = NormalizeExt(rest[0]);
var files = ExpandFiles(rest.Skip(1));
return new ParsedArgs(Mode.Quick, files, outputExt);
}
return verb switch
{
"quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest)),
"quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest), ".jpg"),
"dialog" => new ParsedArgs(Mode.Dialog, ExpandFiles(rest)),
"register" => new ParsedArgs(Mode.Register, Array.Empty<string>()),
"unregister" => new ParsedArgs(Mode.Unregister, Array.Empty<string>()),
@ -39,6 +48,12 @@ internal static class CliRouter
};
}
private static string NormalizeExt(string ext)
{
var trimmed = ext.Trim().ToLowerInvariant();
return trimmed.StartsWith('.') ? trimmed : "." + trimmed;
}
private static IReadOnlyList<string> ExpandFiles(IEnumerable<string> raw)
{
var list = new List<string>();
@ -61,37 +76,41 @@ internal static class CliRouter
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 engine = Everything2EverythingBootstrap.CreateDefault();
var inputs = string.Join(", ",
engine.Providers.Implemented.SelectMany(p => p.Capability.InputExtensions).Distinct().OrderBy(e => e));
var outputs = string.Join(", ",
engine.Providers.Implemented.SelectMany(p => p.Capability.OutputExtensions).Distinct().OrderBy(e => e));
var coming = string.Join(", ",
engine.Providers.ComingSoon.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e));
engine.Providers.ComingSoon.SelectMany(p => p.Capability.InputExtensions).Distinct().OrderBy(e => e));
return $"""
EverythingToJpeg JPEG로
Everything2Everything ( )
:
EverythingToJpeg.exe quick <...> ( )
EverythingToJpeg.exe dialog <...>
EverythingToJpeg.exe register ( )
EverythingToJpeg.exe unregister
EverythingToJpeg.exe diagnose ·
EverythingToJpeg.exe
Everything2Everything.exe to <ext> <...> <ext> (: to png a.jpg b.heic)
Everything2Everything.exe quick <...> ( .jpg)
Everything2Everything.exe dialog <...>
Everything2Everything.exe register ( )
Everything2Everything.exe unregister
Everything2Everything.exe diagnose ·
Everything2Everything.exe
(): {supported}
: {coming}
: {inputs}
: {outputs}
: {coming}
""";
}
public static int RunRegister(bool register)
{
var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_register.log");
var logPath = Path.Combine(Path.GetTempPath(), "Everything2Everything_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();
var engine = Everything2EverythingBootstrap.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)

View file

@ -1,6 +1,6 @@
using System.Runtime.InteropServices;
namespace EverythingToJpeg.App.Cli;
namespace Everything2Everything.App.Cli;
internal static class ConsoleHelper
{

View file

@ -6,8 +6,8 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<AssemblyName>EverythingToJpeg</AssemblyName>
<RootNamespace>EverythingToJpeg.App</RootNamespace>
<AssemblyName>Everything2Everything</AssemblyName>
<RootNamespace>Everything2Everything.App</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
@ -15,7 +15,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\EverythingToJpeg.Core\EverythingToJpeg.Core.csproj" />
<ProjectReference Include="..\Everything2Everything.Core\Everything2Everything.Core.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,177 @@
using Everything2Everything.Core;
using Everything2Everything.Core.Providers;
using Microsoft.Win32;
namespace Everything2Everything.App.Shell;
internal static class ContextMenuRegistrar
{
private const string MainVerb = "Everything2Everything";
private const string MainLabel = "Everything2Everything으로 변환";
private const string SubmenuKeyPrefix = "Everything2Everything.SubMenu.";
private static readonly (string Ext, string Label, string SortPrefix)[] PopularOutputs =
{
(".jpg", "JPEG (.jpg)", "01"),
(".png", "PNG (.png)", "02"),
(".webp", "WebP (.webp)", "03"),
(".pdf", "PDF (.pdf)", "04"),
(".txt", "텍스트 (.txt) — OCR", "05"),
(".docx", "Word (.docx) — OCR", "06"),
(".avif", "AVIF (.avif)", "07"),
(".gif", "GIF (.gif)", "08"),
(".tif", "TIFF (.tif)", "09"),
(".bmp", "BMP (.bmp)", "10"),
};
public static void Register(ConversionEngine engine)
{
var exe = GetAppExecutablePath();
var icon = exe + ",0";
foreach (var ext in CollectInputExtensions(engine))
{
var outputs = engine.Providers.OutputsForInput(ext)
.Select(o => o.ToLowerInvariant())
.ToHashSet();
var availableOutputs = PopularOutputs
.Where(p => outputs.Contains(p.Ext) && !string.Equals(p.Ext, ext, StringComparison.OrdinalIgnoreCase))
.ToList();
if (availableOutputs.Count == 0) continue;
WriteCascade(ext, exe, icon, availableOutputs);
}
NotifyShell();
}
public static void Unregister(ConversionEngine engine)
{
foreach (var ext in CollectInputExtensions(engine))
{
DeleteVerb(ext, MainVerb);
DeleteVerb(ext, "Everything2Everything.Quick");
DeleteVerb(ext, "Everything2Everything.Dialog");
DeleteSubmenuTree(ext);
}
NotifyShell();
}
private static IEnumerable<string> CollectInputExtensions(ConversionEngine engine)
{
return engine.Providers.Implemented
.Where(p => p.Capability.CanRegisterContextMenu)
.SelectMany(p => p.Capability.InputExtensions)
.Select(e => e.StartsWith('.') ? e : "." + e)
.Select(e => e.ToLowerInvariant())
.Distinct();
}
private static void WriteCascade(
string ext,
string exe,
string icon,
IReadOnlyList<(string Ext, string Label, string SortPrefix)> availableOutputs)
{
var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.');
var verbPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{MainVerb}";
using (var verbKey = Registry.CurrentUser.CreateSubKey(verbPath, writable: true)
?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {verbPath}"))
{
verbKey.SetValue(null, MainLabel, RegistryValueKind.String);
verbKey.SetValue("MUIVerb", MainLabel, RegistryValueKind.String);
verbKey.SetValue("Icon", icon, RegistryValueKind.String);
verbKey.SetValue("SubCommands", "", RegistryValueKind.String);
verbKey.SetValue("ExtendedSubCommandsKey", submenuKeyName, RegistryValueKind.String);
try { verbKey.DeleteSubKeyTree("command", throwOnMissingSubKey: false); } catch { }
}
var submenuShellPath = $@"Software\Classes\{submenuKeyName}\shell";
using (var existing = Registry.CurrentUser.OpenSubKey(submenuShellPath, writable: true))
{
if (existing is not null)
{
foreach (var name in existing.GetSubKeyNames())
{
try { existing.DeleteSubKeyTree(name, throwOnMissingSubKey: false); } catch { }
}
}
}
foreach (var (outExt, outLabel, sortPrefix) in availableOutputs)
{
var subVerbName = $"{sortPrefix}_{outExt.TrimStart('.')}";
var cliExt = outExt.TrimStart('.');
WriteSubmenuItem(submenuKeyName, subVerbName, outLabel, icon,
$"\"{exe}\" to {cliExt} \"%1\"");
}
WriteSubmenuItem(submenuKeyName, "98_dialog", "변환… (옵션 선택)", icon,
$"\"{exe}\" dialog \"%1\"");
}
private static void WriteSubmenuItem(string submenuKeyName, string verbName, string label, string icon, string command)
{
var path = $@"Software\Classes\{submenuKeyName}\shell\{verbName}";
using var key = Registry.CurrentUser.CreateSubKey(path, writable: true)
?? throw new InvalidOperationException($"서브메뉴 키 생성 실패: {path}");
key.SetValue(null, label, RegistryValueKind.String);
key.SetValue("MUIVerb", label, RegistryValueKind.String);
key.SetValue("Icon", icon, RegistryValueKind.String);
using var commandKey = key.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 void DeleteSubmenuTree(string ext)
{
var submenuKeyName = SubmenuKeyPrefix + ext.TrimStart('.');
var path = $@"Software\Classes\{submenuKeyName}";
try
{
Registry.CurrentUser.DeleteSubKeyTree(path, 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 + "Everything2Everything.exe";
}
private static void NotifyShell()
{
try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); }
catch { }
}
private static class NativeMethods
{
[System.Runtime.InteropServices.DllImport("shell32.dll")]
public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2);
}
}

View file

@ -1,4 +1,4 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.DiagnoseWindow"
<ui:FluentWindow x:Class="Everything2Everything.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"

View file

@ -1,11 +1,11 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using EverythingToJpeg.Core;
using EverythingToJpeg.Core.Providers;
using Everything2Everything.Core;
using Everything2Everything.Core.Providers;
using Wpf.Ui.Controls;
namespace EverythingToJpeg.App.Views;
namespace Everything2Everything.App.Views;
public partial class DiagnoseWindow : FluentWindow
{
@ -93,7 +93,7 @@ public partial class DiagnoseWindow : FluentWindow
stack.Children.Add(new TextBlock
{
Text = $"확장자: {string.Join(", ", provider.Capability.Extensions)}",
Text = $"입력: {string.Join(", ", provider.Capability.InputExtensions)} → 출력: {string.Join(", ", provider.Capability.OutputExtensions)}",
FontSize = 11,
Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"),
Margin = new Thickness(0, 4, 0, 0),

View file

@ -36,6 +36,7 @@
<SolidColorBrush x:Key="FsFmtTiff" Color="#38B2AC"/>
<SolidColorBrush x:Key="FsFmtWebp" Color="#48BB78"/>
<SolidColorBrush x:Key="FsFmtBmp" Color="#718096"/>
<SolidColorBrush x:Key="FsFmtAvif" Color="#ED64A6"/>
<SolidColorBrush x:Key="FsFmtHwp" Color="#9B2C2C"/>
<SolidColorBrush x:Key="FsFmtOther" Color="#4A5568"/>

View file

@ -1,4 +1,4 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.MainWindow"
<ui:FluentWindow x:Class="Everything2Everything.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"
@ -81,27 +81,37 @@
<!-- Target Format -->
<StackPanel Margin="0,0,0,32">
<TextBlock Text="TARGET FORMAT" Style="{StaticResource FsLabelStyle}"/>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0" VerticalAlignment="Center">
<Border Width="24" Height="24" CornerRadius="4"
Background="{StaticResource FsFmtJpg}">
<TextBlock Text="JPG" Foreground="White" FontSize="8"
<Grid Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border x:Name="OutputFormatBadge" Grid.Column="0"
Width="36" Height="28" CornerRadius="4"
Background="{StaticResource FsFmtJpg}"
VerticalAlignment="Center">
<TextBlock x:Name="OutputFormatBadgeText"
Text="JPG" Foreground="White" FontSize="9"
FontWeight="SemiBold"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<TextBlock Text="JPEG Image" Margin="8,0,0,0"
Style="{StaticResource FsBodyStyle}"
FontWeight="Medium" VerticalAlignment="Center"/>
</StackPanel>
<ComboBox x:Name="OutputFormatCombo" Grid.Column="1"
Margin="8,0,0,0" VerticalAlignment="Center"
SelectionChanged="OnOutputFormatChanged"/>
</Grid>
<TextBlock x:Name="OutputFormatHint" Margin="0,6,0,0"
Style="{StaticResource FsCaptionStyle}"
Text="모든 입력에서 변환 가능한 형식이 표시됩니다"/>
</StackPanel>
<!-- Encoding Quality -->
<StackPanel Margin="0,0,0,32">
<StackPanel x:Name="QualityPanel" Margin="0,0,0,32">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="ENCODING QUALITY" Style="{StaticResource FsLabelStyle}"/>
<TextBlock x:Name="QualityLabelText" Text="ENCODING QUALITY" Style="{StaticResource FsLabelStyle}"/>
<Border Grid.Column="1" CornerRadius="4"
Background="{StaticResource FsBgInput}"
BorderBrush="{StaticResource FsBorderSubtle}"
@ -136,14 +146,14 @@
</Grid.ColumnDefinitions>
<TextBox x:Name="OutputPathTextBox"
Style="{StaticResource FsPathInputStyle}"
ToolTip="비워두면 원본 폴더 안 _jpeg 하위에 저장"/>
ToolTip="비워두면 원본 폴더 옆 서브폴더에 저장"/>
<Button Grid.Column="1" Margin="8,0,0,0" Width="36"
Content="…" Style="{StaticResource FsSecondaryButtonStyle}"
Click="OnPickOutputFolderClick"/>
</Grid>
<TextBlock Margin="0,6,0,0"
<TextBlock x:Name="OutputDestHint" Margin="0,6,0,0"
Style="{StaticResource FsCaptionStyle}"
Text="비워두면 원본 옆 _jpeg 폴더에 저장됩니다"/>
Text="비워두면 원본 옆 _jpg 폴더에 저장됩니다"/>
</StackPanel>
<!-- File Conflict Rule -->

View file

@ -8,10 +8,10 @@ using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using EverythingToJpeg.App.Shell;
using EverythingToJpeg.Core;
using Everything2Everything.App.Shell;
using Everything2Everything.Core;
namespace EverythingToJpeg.App.Views;
namespace Everything2Everything.App.Views;
public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
{
@ -20,6 +20,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
private CancellationTokenSource? _cts;
private NameCollision _conflictRule = NameCollision.AppendNumber;
public string? SelectedOutputExtension { get; set; } = ".jpg";
public ICommand AddFilesCommand { get; }
public ICommand ProcessQueueCommand { get; }
public ICommand CloseCommand { get; }
@ -40,6 +42,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
ActiveQueueList.ItemsSource = _activeQueue;
PastResultsList.ItemsSource = _pastResults;
InitializeOutputFormats();
LoadHistory();
UpdateBadges();
UpdateProcessQueueButton();
@ -66,7 +69,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
var notReady = new List<string>();
foreach (var p in engine.Providers.All)
{
if (p.Capability.Status == EverythingToJpeg.Core.Providers.ProviderStatus.RequiresExternal)
if (p.Capability.Status == Everything2Everything.Core.Providers.ProviderStatus.RequiresExternal)
{
var availability = await p.CheckAvailabilityAsync();
if (!availability.IsReady)
@ -169,6 +172,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateBadges();
UpdateProcessQueueButton();
UpdateActiveQueueVisibility();
RefreshAvailableOutputFormats();
if (wasEmpty && _activeQueue.Count > 0 && _selectedPreviewItem is null)
{
@ -185,6 +189,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateBadges();
UpdateProcessQueueButton();
UpdateActiveQueueVisibility();
RefreshAvailableOutputFormats();
}
}
@ -255,9 +260,11 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
{
var opts = new ConvertOptions
{
Quality = (int)QualitySlider.Value,
OnCollision = _conflictRule,
};
opts.Jpeg.Quality = (int)QualitySlider.Value;
opts.Webp.Quality = (int)QualitySlider.Value;
opts.Avif.Quality = Math.Clamp((int)QualitySlider.Value - 30, 1, 100);
var custom = OutputPathTextBox.Text?.Trim();
if (!string.IsNullOrEmpty(custom))
@ -421,7 +428,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
{
if (_pastResults.Count == 0)
{
MessageBox.Show(this, "저장할 이력이 없습니다.", "EverythingToJpeg",
MessageBox.Show(this, "저장할 이력이 없습니다.", "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
@ -429,7 +436,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
var dlg = new Microsoft.Win32.SaveFileDialog
{
Title = "Export Log",
FileName = $"EverythingToJpeg-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv",
FileName = $"Everything2Everything-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv",
DefaultExt = ".csv",
Filter = "CSV (*.csv)|*.csv|JSON (*.json)|*.json",
};
@ -442,12 +449,12 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
else
ExportCsv(dlg.FileName);
MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "EverythingToJpeg",
MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, "저장 중 오류: " + ex.Message, "EverythingToJpeg",
MessageBox.Show(this, "저장 중 오류: " + ex.Message, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@ -525,7 +532,8 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
try
{
var sources = snapshot.Select(s => s.SourcePath).ToList();
var results = await engine.ConvertManyAsync(sources, options, reporter, _cts.Token);
var outputExt = SelectedOutputExtension ?? ".jpg";
var results = await engine.ConvertManyAsync(sources, outputExt, options, reporter, _cts.Token);
foreach (var (item, result) in snapshot.Zip(results))
{
@ -552,7 +560,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
catch (OperationCanceledException) { }
catch (Exception ex)
{
MessageBox.Show(this, "변환 중 오류: " + ex.Message, "EverythingToJpeg",
MessageBox.Show(this, "변환 중 오류: " + ex.Message, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
@ -674,11 +682,11 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
ContextMenuRegistrar.Register(((App)Application.Current).Engine);
MessageBox.Show(this,
"컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".",
"EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Information);
"Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
MessageBox.Show(this, "등록 중 오류: " + ex.Message, "EverythingToJpeg",
MessageBox.Show(this, "등록 중 오류: " + ex.Message, "Everything2Everything",
MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@ -694,12 +702,16 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
if (TabActiveBtn.IsChecked == true)
{
_activeQueue.Clear();
UpdateBadges();
UpdateProcessQueueButton();
UpdateActiveQueueVisibility();
RefreshAvailableOutputFormats();
}
else if (TabPastBtn.IsChecked == true)
{
var confirm = MessageBox.Show(this,
"Past Results 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.",
"EverythingToJpeg",
"Everything2Everything",
MessageBoxButton.OKCancel, MessageBoxImage.Question);
if (confirm != MessageBoxResult.OK) return;
@ -738,6 +750,152 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; }
return $"{size:0.#} {units[unit]}";
}
// ========================================================================
// Output format selection (피보팅: 양방향 매트릭스)
// ========================================================================
private static readonly OutputFormatInfo[] AllFormats =
{
new(".jpg", "JPEG", "JPG", "FsFmtJpg"),
new(".png", "PNG", "PNG", "FsFmtPng"),
new(".webp", "WebP", "WEBP", "FsFmtWebp"),
new(".avif", "AVIF", "AVIF", "FsFmtAvif"),
new(".bmp", "BMP", "BMP", "FsFmtBmp"),
new(".tif", "TIFF", "TIF", "FsFmtTiff"),
new(".gif", "GIF", "GIF", "FsFmtGif"),
new(".pdf", "PDF", "PDF", "FsFmtPdf"),
new(".txt", "텍스트 (OCR)", "TXT", "FsFmtOther"),
new(".docx", "Word (OCR)", "DOCX", "FsFmtDocx"),
};
private bool _suppressFormatChanged;
private void InitializeOutputFormats()
{
RefreshAvailableOutputFormats();
}
private void RefreshAvailableOutputFormats()
{
if (OutputFormatCombo is null) return;
var engine = ((App)Application.Current).Engine;
IReadOnlyCollection<string> available;
if (_activeQueue.Count == 0)
{
available = engine.Providers.AllOutputExtensions;
}
else
{
HashSet<string>? intersection = null;
foreach (var item in _activeQueue)
{
var outs = engine.Providers.OutputsForFile(item.SourcePath);
var outSet = new HashSet<string>(outs, StringComparer.OrdinalIgnoreCase);
if (intersection is null) intersection = outSet;
else intersection.IntersectWith(outSet);
}
available = intersection ?? new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var visible = AllFormats.Where(f => available.Contains(f.Extension)).ToList();
if (visible.Count == 0)
visible = AllFormats.Where(f => f.Extension == ".jpg").ToList();
var keepExt = SelectedOutputExtension;
if (keepExt is null || !visible.Any(v => string.Equals(v.Extension, keepExt, StringComparison.OrdinalIgnoreCase)))
keepExt = visible[0].Extension;
_suppressFormatChanged = true;
try
{
OutputFormatCombo.Items.Clear();
foreach (var f in visible)
{
OutputFormatCombo.Items.Add(new ComboBoxItem
{
Content = $"{f.DisplayName} ({f.Extension})",
Tag = f.Extension,
});
}
for (var i = 0; i < OutputFormatCombo.Items.Count; i++)
{
if (((ComboBoxItem)OutputFormatCombo.Items[i]!).Tag is string tag
&& string.Equals(tag, keepExt, StringComparison.OrdinalIgnoreCase))
{
OutputFormatCombo.SelectedIndex = i;
break;
}
}
}
finally
{
_suppressFormatChanged = false;
}
SelectedOutputExtension = keepExt;
UpdateOutputFormatBadge(keepExt);
UpdateQualityPanelForFormat(keepExt);
UpdateOutputDestHint(keepExt);
if (OutputFormatHint is not null)
{
OutputFormatHint.Text = _activeQueue.Count == 0
? "큐에 파일을 추가하면 변환 가능한 형식으로 자동 필터링됩니다"
: $"큐의 모든 파일이 변환 가능한 형식 ({visible.Count}개)";
}
}
private void OnOutputFormatChanged(object sender, SelectionChangedEventArgs e)
{
if (_suppressFormatChanged) return;
if (OutputFormatCombo.SelectedItem is ComboBoxItem item && item.Tag is string ext)
{
SelectedOutputExtension = ext;
UpdateOutputFormatBadge(ext);
UpdateQualityPanelForFormat(ext);
UpdateOutputDestHint(ext);
}
}
private void UpdateOutputFormatBadge(string? extension)
{
if (OutputFormatBadge is null || OutputFormatBadgeText is null) return;
var info = AllFormats.FirstOrDefault(f =>
string.Equals(f.Extension, extension, StringComparison.OrdinalIgnoreCase));
if (info is null) return;
OutputFormatBadgeText.Text = info.BadgeText;
var resource = TryFindResource(info.ColorResource);
if (resource is System.Windows.Media.Brush brush)
OutputFormatBadge.Background = brush;
}
private void UpdateQualityPanelForFormat(string? extension)
{
if (QualityPanel is null || QualityLabelText is null) return;
var ext = extension?.ToLowerInvariant();
var supportsQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif";
QualityPanel.Visibility = supportsQuality ? Visibility.Visible : Visibility.Collapsed;
QualityLabelText.Text = ext switch
{
".jpg" or ".jpeg" => "JPEG QUALITY",
".webp" => "WEBP QUALITY",
".avif" => "AVIF QUALITY",
_ => "ENCODING QUALITY",
};
}
private void UpdateOutputDestHint(string? extension)
{
if (OutputDestHint is null) return;
var folder = (extension ?? ".jpg").TrimStart('.').ToLowerInvariant();
OutputDestHint.Text = $"비워두면 원본 옆 _{folder} 폴더에 저장됩니다";
}
private sealed record OutputFormatInfo(string Extension, string DisplayName, string BadgeText, string ColorResource);
}
// ============================================================

View file

@ -1,4 +1,4 @@
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.QuickProgressWindow"
<ui:FluentWindow x:Class="Everything2Everything.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"

View file

@ -1,8 +1,8 @@
using System.Windows;
using EverythingToJpeg.Core;
using Everything2Everything.Core;
using Wpf.Ui.Controls;
namespace EverythingToJpeg.App.Views;
namespace Everything2Everything.App.Views;
public partial class QuickProgressWindow : FluentWindow
{
@ -49,7 +49,7 @@ public partial class QuickProgressWindow : FluentWindow
.Take(5)
.Select(r => $"• {Path.GetFileName(r.SourcePath)}: {r.Message}"));
MessageBox.Show(this, "일부 파일 변환에 실패했습니다.\n\n" + detail,
"EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Warning);
"Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Warning);
}
else if (failed == 0 && skipped == 0 && _firstSuccessOutput is not null)
{

View file

@ -1,6 +1,6 @@
<?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"/>
<assemblyIdentity version="1.0.0.0" name="Everything2Everything.App"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>

View file

@ -1,6 +1,6 @@
using EverythingToJpeg.Core.Providers;
using Everything2Everything.Core.Providers;
namespace EverythingToJpeg.Core;
namespace Everything2Everything.Core;
public sealed class ConversionEngine
{
@ -15,6 +15,7 @@ public sealed class ConversionEngine
public async Task<IReadOnlyList<ConvertResult>> ConvertManyAsync(
IEnumerable<string> sources,
string outputExtension,
ConvertOptions options,
IProgress<ConvertProgress>? progress = null,
CancellationToken cancellationToken = default)
@ -29,7 +30,7 @@ public sealed class ConversionEngine
var source = sourceList[i];
progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0));
var result = await ConvertOneAsync(source, options,
var result = await ConvertOneAsync(source, outputExtension, options,
new Progress<double>(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))),
cancellationToken).ConfigureAwait(false);
@ -42,6 +43,7 @@ public sealed class ConversionEngine
public async Task<ConvertResult> ConvertOneAsync(
string sourcePath,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
@ -49,8 +51,20 @@ public sealed class ConversionEngine
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 output = ConversionPair.Normalize(outputExtension);
var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
if (!_registry.TryGet(sourcePath, output, out var provider) || provider is null)
{
var available = _registry.OutputsForFile(sourcePath);
var hint = available.Count > 0
? $" 가능한 출력: {string.Join(", ", available)}"
: string.Empty;
return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환을 지원하지 않습니다.{hint}");
}
var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
@ -61,12 +75,12 @@ public sealed class ConversionEngine
return ConvertResult.Fail(sourcePath, detail);
}
var outputDir = ResolveOutputDirectory(sourcePath, options);
var outputDir = ResolveOutputDirectory(sourcePath, output, options);
Directory.CreateDirectory(outputDir);
try
{
return await provider.ConvertAsync(sourcePath, outputDir, options, progress, cancellationToken)
return await provider.ConvertAsync(sourcePath, outputDir, output, options, progress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
@ -79,7 +93,7 @@ public sealed class ConversionEngine
}
}
private static string ResolveOutputDirectory(string sourcePath, ConvertOptions options)
private static string ResolveOutputDirectory(string sourcePath, string outputExtension, ConvertOptions options)
{
var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath))
?? throw new InvalidOperationException("소스 경로에서 폴더를 결정할 수 없습니다.");
@ -91,9 +105,18 @@ public sealed class ConversionEngine
? sourceDir
: options.CustomOutputDirectory!,
_ => Path.Combine(sourceDir,
Path.GetFileNameWithoutExtension(sourcePath) + options.SubfolderSuffix),
Path.GetFileNameWithoutExtension(sourcePath) + ResolveSubfolderSuffix(outputExtension, options)),
};
}
private static string ResolveSubfolderSuffix(string outputExtension, ConvertOptions options)
{
if (!string.IsNullOrWhiteSpace(options.SubfolderSuffix) && options.SubfolderSuffix != "_converted")
return options.SubfolderSuffix;
var ext = outputExtension.TrimStart('.').ToLowerInvariant();
return string.IsNullOrEmpty(ext) ? "_converted" : "_" + ext;
}
}
public sealed record ConvertProgress(int Index, int Total, string CurrentPath, double FileProgress);

View file

@ -0,0 +1,124 @@
namespace Everything2Everything.Core;
public enum OutputLocation
{
SubfolderBesideSource,
SameFolderAsSource,
Custom
}
public enum NameCollision
{
AppendNumber,
Overwrite,
Skip
}
public sealed class ConvertOptions
{
public OutputLocation OutputLocation { get; set; } = OutputLocation.SubfolderBesideSource;
public string SubfolderSuffix { get; set; } = "_converted";
public string? CustomOutputDirectory { get; set; }
public NameCollision OnCollision { get; set; } = NameCollision.AppendNumber;
public int? MaxLongEdgePixels { get; set; }
public bool KeepExifWhenPossible { get; set; } = true;
public bool FlattenTransparency { get; set; } = false;
public string TransparencyBackground { get; set; } = "#FFFFFF";
public JpegEncodingOptions Jpeg { get; set; } = new();
public PngEncodingOptions Png { get; set; } = new();
public WebpEncodingOptions Webp { get; set; } = new();
public AvifEncodingOptions Avif { get; set; } = new();
public TiffEncodingOptions Tiff { get; set; } = new();
public BmpEncodingOptions Bmp { get; set; } = new();
public GifEncodingOptions Gif { get; set; } = new();
public PdfRenderOptions PdfRender { get; set; } = new();
public PdfBuildOptions PdfBuild { get; set; } = new();
public HtmlRenderOptions HtmlRender { get; set; } = new();
public OcrOptions Ocr { get; set; } = new();
public static ConvertOptions Quick() => new();
}
public sealed class JpegEncodingOptions
{
public int Quality { get; set; } = 92;
public bool Progressive { get; set; } = false;
}
public sealed class PngEncodingOptions
{
public int Compression { get; set; } = 7;
public bool Interlace { get; set; } = false;
}
public sealed class WebpEncodingOptions
{
public int Quality { get; set; } = 90;
public bool Lossless { get; set; } = false;
}
public sealed class AvifEncodingOptions
{
public int Quality { get; set; } = 60;
public int Speed { get; set; } = 6;
}
public sealed class TiffEncodingOptions
{
public string Compression { get; set; } = "lzw";
}
public sealed class BmpEncodingOptions
{
}
public sealed class GifEncodingOptions
{
}
public sealed class PdfRenderOptions
{
public int Dpi { get; set; } = 200;
public bool WithAnnotations { get; set; } = true;
public bool WithFormFill { get; set; } = true;
}
public sealed class PdfBuildOptions
{
public string PageSize { get; set; } = "Auto";
public int MarginPoints { get; set; } = 24;
public bool FitToPage { get; set; } = true;
}
public sealed class HtmlRenderOptions
{
public int ViewportWidth { get; set; } = 1280;
public int? ViewportHeight { get; set; }
public int WaitMilliseconds { get; set; } = 2000;
public bool FullPage { get; set; } = true;
}
public sealed class OcrOptions
{
public string Language { get; set; } = "ko+en";
public bool PreserveLayout { get; set; } = true;
public string Backend { get; set; } = "auto";
}

View file

@ -1,4 +1,4 @@
namespace EverythingToJpeg.Core;
namespace Everything2Everything.Core;
public enum ConvertStatus
{

View file

@ -1,10 +1,15 @@
using System.Diagnostics;
using EverythingToJpeg.Core.Providers;
using Everything2Everything.Core.Providers;
namespace EverythingToJpeg.Core.Converters;
namespace Everything2Everything.Core.Converters;
public sealed class DocxProvider : IConverterProvider
{
private static readonly string[] DocxInputs = { ".docx", ".doc" };
private static readonly string[] DocxOutputs =
{ ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
private readonly PdfProvider _pdfProvider;
public DocxProvider(PdfProvider pdfProvider)
@ -15,9 +20,9 @@ public sealed class DocxProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "docx",
DisplayName: "Word 문서 (DOCX)",
Extensions: new[] { ".docx", ".doc" },
SupportedConversions: ProviderCapability.PairsFromMatrix(DocxInputs, DocxOutputs),
Status: ProviderStatus.RequiresExternal,
Summary: "DOCX/DOC 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.",
Summary: "DOCX/DOC을 PDF로 변환하거나 페이지별 이미지로 렌더링합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@ -43,12 +48,14 @@ public sealed class DocxProvider : IConverterProvider
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var outExt = ConversionPair.Normalize(outputExtension);
var tempPdf = Path.Combine(Path.GetTempPath(),
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
try
{
@ -82,8 +89,19 @@ public sealed class DocxProvider : IConverterProvider
progress?.Report(0.55);
if (outExt == ".pdf")
{
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { finalPath });
}
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
with { SourcePath = sourcePath };
}
finally

View file

@ -1,6 +1,6 @@
using Microsoft.Win32;
namespace EverythingToJpeg.Core.Converters;
namespace Everything2Everything.Core.Converters;
internal static class ExternalToolDetector
{

View file

@ -1,11 +1,16 @@
using EverythingToJpeg.Core.Providers;
using Everything2Everything.Core.Providers;
using PhotoSauce.MagicScaler;
using PhotoSauce.NativeCodecs.Libheif;
namespace EverythingToJpeg.Core.Converters;
namespace Everything2Everything.Core.Converters;
public sealed class HeicProvider : IConverterProvider
{
private static readonly string[] HeicInputs = { ".heic", ".heif" };
private static readonly string[] PassThroughOutputs =
{ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".gif" };
private static int _codecConfigured;
private readonly MagickProvider _magickProvider;
@ -19,9 +24,9 @@ public sealed class HeicProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "heic",
DisplayName: "HEIC / HEIF",
Extensions: new[] { ".heic", ".heif" },
SupportedConversions: ProviderCapability.PairsFromMatrix(HeicInputs, PassThroughOutputs),
Status: ProviderStatus.Available,
Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 JPEG로 변환합니다.",
Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF로 변환합니다.",
ExternalDependencies: Array.Empty<ExternalDependency>(),
RoadmapNote: null);
@ -34,6 +39,7 @@ public sealed class HeicProvider : IConverterProvider
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
@ -41,7 +47,7 @@ public sealed class HeicProvider : IConverterProvider
EnsureCodec();
var tempPng = Path.Combine(Path.GetTempPath(),
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.png");
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.png");
try
{
@ -54,7 +60,7 @@ public sealed class HeicProvider : IConverterProvider
var inner = new Progress<double>(p => progress?.Report(0.5 + p * 0.5));
var result = await _magickProvider
.ConvertAsync(tempPng, outputDirectory, options, inner, cancellationToken)
.ConvertAsync(tempPng, outputDirectory, outputExtension, options, inner, cancellationToken)
.ConfigureAwait(false);
return result with { SourcePath = sourcePath };

View file

@ -2,20 +2,25 @@ using System.Text.Json;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
using EverythingToJpeg.Core.Providers;
using Everything2Everything.Core.Providers;
using ImageMagick;
using Microsoft.Web.WebView2.Core;
namespace EverythingToJpeg.Core.Converters;
namespace Everything2Everything.Core.Converters;
public sealed class HtmlProvider : IConverterProvider
{
private static readonly string[] HtmlInputs = { ".html", ".htm" };
private static readonly string[] HtmlOutputs =
{ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".pdf" };
public ProviderCapability Capability { get; } = new(
Id: "html",
DisplayName: "HTML / 웹 페이지",
Extensions: new[] { ".html", ".htm" },
SupportedConversions: ProviderCapability.PairsFromMatrix(HtmlInputs, HtmlOutputs),
Status: ProviderStatus.Available,
Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 풀페이지 JPEG로 캡처합니다.",
Summary: "HTML/HTM을 WebView2로 헤드리스 렌더링하여 이미지 또는 PDF로 저장합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@ -48,15 +53,26 @@ public sealed class HtmlProvider : IConverterProvider
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var outExt = ConversionPair.Normalize(outputExtension);
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
if (outExt == ".pdf")
{
var pdfBytes = await CapturePdfAsync(sourcePath, options, progress, cancellationToken)
.ConfigureAwait(false);
await File.WriteAllBytesAsync(path, pdfBytes, cancellationToken).ConfigureAwait(false);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
var pngBytes = await CapturePngAsync(sourcePath, options, progress, cancellationToken)
.ConfigureAwait(false);
@ -65,7 +81,8 @@ public sealed class HtmlProvider : IConverterProvider
await Task.Run(() =>
{
using var image = new MagickImage(pngBytes);
if (options.FlattenTransparency && image.HasAlpha)
var alphaCapable = outExt is ".png" or ".webp" or ".avif" or ".tif" or ".tiff";
if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha)
{
image.BackgroundColor = new MagickColor(options.TransparencyBackground);
image.Alpha(AlphaOption.Remove);
@ -76,8 +93,7 @@ public sealed class HtmlProvider : IConverterProvider
{
image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
}
image.Quality = (uint)Math.Clamp(options.Quality, 1, 100);
image.Format = MagickFormat.Jpeg;
ApplyEncoding(image, outExt, options);
image.Write(path);
}, cancellationToken).ConfigureAwait(false);
@ -85,11 +101,61 @@ public sealed class HtmlProvider : IConverterProvider
return ConvertResult.Ok(sourcePath, new[] { path });
}
private static void ApplyEncoding(IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
{
switch (outputExtension)
{
case ".jpg":
case ".jpeg":
image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
image.Format = MagickFormat.Jpeg;
break;
case ".png":
image.Format = MagickFormat.Png;
break;
case ".webp":
image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
if (options.Webp.Lossless)
image.Settings.SetDefine(MagickFormat.WebP, "lossless", "true");
image.Format = MagickFormat.WebP;
break;
case ".avif":
image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
image.Settings.SetDefine(MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
image.Format = MagickFormat.Avif;
break;
case ".bmp":
image.Format = MagickFormat.Bmp;
break;
case ".tif":
case ".tiff":
if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression);
image.Format = MagickFormat.Tiff;
break;
}
}
private static Task<byte[]> CapturePngAsync(
string sourcePath,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
=> RunOnDispatcherThreadAsync((web, p) => CaptureScreenshotAsync(web, options, p), sourcePath, options, progress, cancellationToken);
private static Task<byte[]> CapturePdfAsync(
string sourcePath,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
=> RunOnDispatcherThreadAsync((web, p) => PrintToPdfAsync(web, options, p), sourcePath, options, progress, cancellationToken);
private static Task<byte[]> RunOnDispatcherThreadAsync(
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
string sourcePath,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
@ -98,7 +164,7 @@ public sealed class HtmlProvider : IConverterProvider
try
{
var dispatcher = Dispatcher.CurrentDispatcher;
_ = RunCaptureOnDispatcher(dispatcher, sourcePath, options, progress, cancellationToken, tcs);
_ = RunCaptureOnDispatcher(capture, dispatcher, sourcePath, options, progress, cancellationToken, tcs);
Dispatcher.Run();
}
catch (Exception ex)
@ -108,13 +174,14 @@ public sealed class HtmlProvider : IConverterProvider
});
thread.SetApartmentState(ApartmentState.STA);
thread.IsBackground = true;
thread.Name = "EverythingToJpeg.HtmlCapture";
thread.Name = "Everything2Everything.HtmlCapture";
thread.Start();
return tcs.Task;
}
private static async Task RunCaptureOnDispatcher(
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
Dispatcher dispatcher,
string sourcePath,
ConvertOptions options,
@ -127,17 +194,16 @@ public sealed class HtmlProvider : IConverterProvider
{
var userDataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EverythingToJpeg", "WebView2");
"Everything2Everything", "WebView2");
Directory.CreateDirectory(userDataFolder);
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true);
progress?.Report(0.15);
// HWND_MESSAGE = (IntPtr)(-3) → headless message-only parent
controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true);
int width = options.HtmlViewportWidth > 0 ? options.HtmlViewportWidth : 1280;
int height = options.HtmlViewportHeight ?? 720;
int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280;
int height = options.HtmlRender.ViewportHeight ?? 720;
controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
controller.IsVisible = false;
@ -164,27 +230,13 @@ public sealed class HtmlProvider : IConverterProvider
}
progress?.Report(0.5);
if (options.HtmlWaitMilliseconds > 0)
await Task.Delay(options.HtmlWaitMilliseconds, cancellationToken).ConfigureAwait(true);
if (options.HtmlRender.WaitMilliseconds > 0)
await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken).ConfigureAwait(true);
progress?.Report(0.65);
// Use CDP for full-page screenshot beyond viewport
var captureParams = options.HtmlFullPage
? "{\"captureBeyondViewport\":true,\"format\":\"png\"}"
: "{\"format\":\"png\"}";
var resultJson = await web
.CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams)
.ConfigureAwait(true);
progress?.Report(0.8);
using var doc = JsonDocument.Parse(resultJson);
var b64 = doc.RootElement.GetProperty("data").GetString()
?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다.");
var pngBytes = Convert.FromBase64String(b64);
tcs.TrySetResult(pngBytes);
var bytes = await capture(web, progress).ConfigureAwait(true);
tcs.TrySetResult(bytes);
}
catch (Exception ex)
{
@ -196,4 +248,34 @@ public sealed class HtmlProvider : IConverterProvider
dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
}
}
private static async Task<byte[]> CaptureScreenshotAsync(CoreWebView2 web, ConvertOptions options, IProgress<double>? progress)
{
var captureParams = options.HtmlRender.FullPage
? "{\"captureBeyondViewport\":true,\"format\":\"png\"}"
: "{\"format\":\"png\"}";
var resultJson = await web
.CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams)
.ConfigureAwait(true);
progress?.Report(0.8);
using var doc = JsonDocument.Parse(resultJson);
var b64 = doc.RootElement.GetProperty("data").GetString()
?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다.");
return Convert.FromBase64String(b64);
}
private static async Task<byte[]> PrintToPdfAsync(CoreWebView2 web, ConvertOptions options, IProgress<double>? progress)
{
var resultJson = await web
.CallDevToolsProtocolMethodAsync("Page.printToPDF", "{\"printBackground\":true,\"preferCSSPageSize\":true}")
.ConfigureAwait(true);
progress?.Report(0.8);
using var doc = JsonDocument.Parse(resultJson);
var b64 = doc.RootElement.GetProperty("data").GetString()
?? throw new InvalidOperationException("CDP printToPDF가 빈 결과를 반환했습니다.");
return Convert.FromBase64String(b64);
}
}

View file

@ -1,10 +1,15 @@
using System.Diagnostics;
using EverythingToJpeg.Core.Providers;
using Everything2Everything.Core.Providers;
namespace EverythingToJpeg.Core.Converters;
namespace Everything2Everything.Core.Converters;
public sealed class HwpxProvider : IConverterProvider
{
private static readonly string[] HwpInputs = { ".hwp", ".hwpx" };
private static readonly string[] HwpOutputs =
{ ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
private readonly PdfProvider _pdfProvider;
public HwpxProvider() : this(new PdfProvider()) { }
@ -17,9 +22,9 @@ public sealed class HwpxProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "hwpx",
DisplayName: "한글 문서 (HWP / HWPX)",
Extensions: new[] { ".hwp", ".hwpx" },
SupportedConversions: ProviderCapability.PairsFromMatrix(HwpInputs, HwpOutputs),
Status: ProviderStatus.RequiresExternal,
Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 페이지별 JPEG로 저장합니다.",
Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 PDF 또는 페이지별 이미지로 저장합니다.",
ExternalDependencies: new[]
{
new ExternalDependency(
@ -53,6 +58,7 @@ public sealed class HwpxProvider : IConverterProvider
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
@ -60,8 +66,9 @@ public sealed class HwpxProvider : IConverterProvider
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
var outExt = ConversionPair.Normalize(outputExtension);
var tempPdf = Path.Combine(Path.GetTempPath(),
$"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
try
{
@ -76,8 +83,19 @@ public sealed class HwpxProvider : IConverterProvider
progress?.Report(0.55);
if (outExt == ".pdf")
{
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { finalPath });
}
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken)
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
with { SourcePath = sourcePath };
}
finally

View file

@ -0,0 +1,254 @@
using Everything2Everything.Core.Providers;
using ImageMagick;
namespace Everything2Everything.Core.Converters;
public sealed class MagickProvider : IConverterProvider
{
private static readonly string[] SingleFrameInputs =
{
".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd",
".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2", ".srw", ".pef", ".raw",
};
private static readonly string[] MultiFrameInputs = { ".gif", ".tif", ".tiff" };
private static readonly string[] AllInputs = SingleFrameInputs.Concat(MultiFrameInputs).ToArray();
private static readonly string[] WritableOutputs =
{ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".gif", ".pdf" };
private static readonly HashSet<string> AlphaCapableOutputs = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".webp", ".avif", ".tif", ".tiff", ".gif",
};
public ProviderCapability Capability { get; } = new(
Id: "magick",
DisplayName: "이미지·RAW·애니메이션",
SupportedConversions: ProviderCapability.PairsFromMatrix(AllInputs, WritableOutputs),
Status: ProviderStatus.Available,
Summary: "PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF/PDF 사이의 양방향 변환 + RAW(NEF/CR2/ARW/DNG…)·PSD 디코딩.",
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,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
return Task.Run(
() => ConvertCore(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken),
cancellationToken);
}
private static ConvertResult ConvertCore(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var inputExt = Path.GetExtension(sourcePath).ToLowerInvariant();
var outExt = ConversionPair.Normalize(outputExtension);
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var isMultiFrameInput = MultiFrameInputs.Contains(inputExt);
var format = ResolveFormat(outExt);
var isMultiFrameOutput = outExt is ".gif" or ".tif" or ".tiff" or ".pdf";
if (isMultiFrameInput)
{
using var collection = new MagickImageCollection(sourcePath);
if (collection.Count == 0)
return ConvertResult.Fail(sourcePath, "이미지 프레임을 읽지 못했습니다.");
if (collection.Count == 1)
{
var single = collection[0];
ApplyCommonTransforms(single, outExt, options);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
WriteSingle(single, path, format, outExt, options);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
collection.Coalesce();
if (isMultiFrameOutput)
{
foreach (var frame in collection)
ApplyCommonTransforms(frame, outExt, options);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
ApplyCollectionEncoding(collection, format, outExt, options);
collection.Write(path);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
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, outExt, options);
var suffix = $"_{(i + 1).ToString().PadLeft(width, '0')}";
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
WriteSingle(frame, path, format, outExt, options);
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, outExt, options);
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
WriteSingle(image, path, format, outExt, options);
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
}
private static MagickFormat ResolveFormat(string outputExtension) => outputExtension switch
{
".png" => MagickFormat.Png,
".jpg" or ".jpeg" => MagickFormat.Jpeg,
".webp" => MagickFormat.WebP,
".avif" => MagickFormat.Avif,
".bmp" => MagickFormat.Bmp,
".tif" or ".tiff" => MagickFormat.Tiff,
".gif" => MagickFormat.Gif,
".pdf" => MagickFormat.Pdf,
_ => throw new NotSupportedException($"지원하지 않는 출력 형식: {outputExtension}"),
};
private static void ApplyCommonTransforms(IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
{
try { image.AutoOrient(); } catch { }
var flattenForOutput = !AlphaCapableOutputs.Contains(outputExtension);
if ((flattenForOutput || 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);
}
}
}
private static void WriteSingle(IMagickImage<ushort> image, string path, MagickFormat format, string outputExtension, ConvertOptions options)
{
ApplySingleEncoding(image, format, outputExtension, options);
image.Write(path);
}
private static void ApplySingleEncoding(IMagickImage<ushort> image, MagickFormat format, string outputExtension, ConvertOptions options)
{
image.Format = format;
switch (outputExtension)
{
case ".jpg":
case ".jpeg":
image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
if (options.Jpeg.Progressive)
image.Settings.Interlace = Interlace.Jpeg;
break;
case ".png":
image.Quality = (uint)Math.Clamp((options.Png.Compression * 10) + 5, 1, 100);
if (options.Png.Interlace)
image.Settings.Interlace = Interlace.Png;
break;
case ".webp":
image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
if (options.Webp.Lossless)
image.Settings.SetDefine(MagickFormat.WebP, "lossless", "true");
break;
case ".avif":
image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
image.Settings.SetDefine(MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
break;
case ".tif":
case ".tiff":
if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression);
break;
case ".pdf":
ApplyPdfPageSettings(image, options);
break;
}
}
private static void ApplyPdfPageSettings(IMagickImage<ushort> image, ConvertOptions options)
{
var pageSize = options.PdfBuild.PageSize;
if (string.Equals(pageSize, "Auto", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(pageSize))
{
return;
}
if (TryGetPagePoints(pageSize, out var widthPt, out var heightPt))
{
var marginPt = Math.Max(0, options.PdfBuild.MarginPoints);
var contentW = (uint)Math.Max(1, widthPt - marginPt * 2);
var contentH = (uint)Math.Max(1, heightPt - marginPt * 2);
if (options.PdfBuild.FitToPage)
{
var geom = new MagickGeometry(contentW, contentH) { IgnoreAspectRatio = false };
image.Resize(geom);
}
image.Page = new MagickGeometry(
(int)marginPt, (int)marginPt,
(uint)widthPt, (uint)heightPt);
}
}
private static bool TryGetPagePoints(string pageSize, out int widthPt, out int heightPt)
{
switch (pageSize.ToUpperInvariant())
{
case "A4": widthPt = 595; heightPt = 842; return true;
case "A3": widthPt = 842; heightPt = 1191; return true;
case "A5": widthPt = 420; heightPt = 595; return true;
case "LETTER": widthPt = 612; heightPt = 792; return true;
case "LEGAL": widthPt = 612; heightPt = 1008; return true;
default: widthPt = 0; heightPt = 0; return false;
}
}
private static void ApplyCollectionEncoding(MagickImageCollection collection, MagickFormat format, string outputExtension, ConvertOptions options)
{
foreach (var img in collection)
ApplySingleEncoding(img, format, outputExtension, options);
}
}

View file

@ -0,0 +1,220 @@
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Everything2Everything.Core.Providers;
using Windows.Globalization;
using Windows.Graphics.Imaging;
using Windows.Media.Ocr;
using Windows.Storage.Streams;
namespace Everything2Everything.Core.Converters;
public sealed class OcrProvider : IConverterProvider
{
private static readonly string[] OcrInputs =
{ ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp", ".gif", ".heic", ".heif", ".pdf" };
private static readonly string[] OcrOutputs = { ".txt", ".docx" };
private readonly PdfProvider _pdfProvider;
public OcrProvider() : this(new PdfProvider()) { }
public OcrProvider(PdfProvider pdfProvider)
{
_pdfProvider = pdfProvider;
}
public ProviderCapability Capability { get; } = new(
Id: "ocr",
DisplayName: "OCR (이미지/PDF → 텍스트·DOCX)",
SupportedConversions: ProviderCapability.PairsFromMatrix(OcrInputs, OcrOutputs),
Status: ProviderStatus.Available,
Summary: "Windows OCR 엔진으로 이미지 또는 PDF 페이지에서 텍스트를 추출해 .txt 또는 .docx로 저장합니다.",
ExternalDependencies: Array.Empty<ExternalDependency>(),
RoadmapNote: "Windows에 설치된 OCR 언어 팩을 사용 — 한국어/영어는 Windows 11 기본 포함.");
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
{
try
{
var langs = OcrEngine.AvailableRecognizerLanguages;
if (langs is null || langs.Count == 0)
return Task.FromResult(ProviderAvailability.NotReady(
"Windows OCR 언어 팩이 설치되어 있지 않습니다. 설정 → 시간 및 언어 → 언어에서 OCR 기능을 추가하세요."));
return Task.FromResult(ProviderAvailability.Ready);
}
catch (Exception ex)
{
return Task.FromResult(ProviderAvailability.NotReady("Windows OCR 엔진 초기화 실패: " + ex.Message));
}
}
public async Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var outExt = ConversionPair.Normalize(outputExtension);
var inputExt = Path.GetExtension(sourcePath).ToLowerInvariant();
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
var pages = inputExt == ".pdf"
? await ExtractPdfPagesAsync(sourcePath, options, progress, cancellationToken).ConfigureAwait(false)
: new List<string> { sourcePath };
if (pages.Count == 0)
return ConvertResult.Fail(sourcePath, "OCR 입력 페이지를 추출하지 못했습니다.");
try
{
var engine = ResolveEngine(options.Ocr.Language);
if (engine is null)
return ConvertResult.Fail(sourcePath,
$"요청한 언어({options.Ocr.Language})에 맞는 OCR 엔진을 찾을 수 없습니다. 사용 가능: {string.Join(", ", OcrEngine.AvailableRecognizerLanguages.Select(l => l.LanguageTag))}");
var pageTexts = new List<string>(pages.Count);
for (var i = 0; i < pages.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var text = await RecognizeAsync(engine, pages[i], cancellationToken).ConfigureAwait(false);
pageTexts.Add(text);
progress?.Report((i + 1.0) / pages.Count * 0.9);
}
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
if (outExt == ".txt")
{
var combined = pages.Count == 1
? pageTexts[0]
: string.Join(Environment.NewLine + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine, pageTexts);
await File.WriteAllTextAsync(path, combined, System.Text.Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
else if (outExt == ".docx")
{
WriteDocx(path, pageTexts);
}
else
{
return ConvertResult.Fail(sourcePath, $"지원하지 않는 출력 형식: {outExt}");
}
progress?.Report(1.0);
return ConvertResult.Ok(sourcePath, new[] { path });
}
finally
{
if (inputExt == ".pdf")
{
foreach (var p in pages)
{
try { if (File.Exists(p)) File.Delete(p); } catch { }
}
}
}
}
private static OcrEngine? ResolveEngine(string requestedLanguage)
{
if (string.IsNullOrWhiteSpace(requestedLanguage)
|| string.Equals(requestedLanguage, "auto", StringComparison.OrdinalIgnoreCase))
{
return OcrEngine.TryCreateFromUserProfileLanguages();
}
var preferences = requestedLanguage.Split(new[] { '+', ',', ';' },
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var available = OcrEngine.AvailableRecognizerLanguages;
foreach (var pref in preferences)
{
var matched = available.FirstOrDefault(l =>
l.LanguageTag.StartsWith(pref, StringComparison.OrdinalIgnoreCase));
if (matched is not null)
return OcrEngine.TryCreateFromLanguage(matched);
}
return OcrEngine.TryCreateFromUserProfileLanguages();
}
private static async Task<string> RecognizeAsync(OcrEngine engine, string imagePath, CancellationToken cancellationToken)
{
using var fileStream = File.OpenRead(imagePath);
using var memory = new MemoryStream();
await fileStream.CopyToAsync(memory, cancellationToken).ConfigureAwait(false);
memory.Position = 0;
using var randomAccess = new InMemoryRandomAccessStream();
using (var writer = new DataWriter(randomAccess.GetOutputStreamAt(0)))
{
writer.WriteBytes(memory.ToArray());
await writer.StoreAsync();
}
randomAccess.Seek(0);
var decoder = await BitmapDecoder.CreateAsync(randomAccess);
using var bitmap = await decoder.GetSoftwareBitmapAsync();
var ocrResult = await engine.RecognizeAsync(bitmap);
return ocrResult?.Text ?? string.Empty;
}
private async Task<List<string>> ExtractPdfPagesAsync(
string pdfPath,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var tempDir = Path.Combine(Path.GetTempPath(), $"e2e_ocr_{Guid.NewGuid():N}");
Directory.CreateDirectory(tempDir);
var renderOptions = new ConvertOptions
{
OutputLocation = OutputLocation.Custom,
CustomOutputDirectory = tempDir,
OnCollision = NameCollision.Overwrite,
};
renderOptions.PdfRender.Dpi = Math.Max(150, options.PdfRender.Dpi);
var inner = new Progress<double>(p => progress?.Report(p * 0.4));
var result = _pdfProvider.ConvertCore(pdfPath, tempDir, ".png", renderOptions, inner, cancellationToken);
if (result.Status != ConvertStatus.Success)
return new List<string>();
return result.OutputPaths.ToList();
}
private static void WriteDocx(string path, IReadOnlyList<string> pageTexts)
{
using var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
mainPart.Document = new Document();
var body = mainPart.Document.AppendChild(new Body());
for (var pageIndex = 0; pageIndex < pageTexts.Count; pageIndex++)
{
var pageText = pageTexts[pageIndex] ?? string.Empty;
foreach (var line in pageText.Split('\n', StringSplitOptions.None))
{
var paragraph = body.AppendChild(new Paragraph());
var run = paragraph.AppendChild(new Run());
run.AppendChild(new Text(line.TrimEnd('\r')) { Space = SpaceProcessingModeValues.Preserve });
}
if (pageIndex < pageTexts.Count - 1)
{
var pageBreakPara = body.AppendChild(new Paragraph());
var pageBreakRun = pageBreakPara.AppendChild(new Run());
pageBreakRun.AppendChild(new Break { Type = BreakValues.Page });
}
}
mainPart.Document.Save();
}
}

View file

@ -0,0 +1,150 @@
using Everything2Everything.Core.Providers;
using PDFtoImage;
using SkiaSharp;
namespace Everything2Everything.Core.Converters;
public sealed class PdfProvider : IConverterProvider
{
private static readonly string[] PdfInputs = { ".pdf" };
private static readonly string[] PdfRenderOutputs =
{ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
public ProviderCapability Capability { get; } = new(
Id: "pdf",
DisplayName: "PDF",
SupportedConversions: ProviderCapability.PairsFromMatrix(PdfInputs, PdfRenderOutputs),
Status: ProviderStatus.Available,
Summary: "PDF 각 페이지를 PNG/JPEG/WebP/AVIF/BMP/TIFF로 렌더링합니다.",
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,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
return Task.Run(
() => ConvertCore(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken),
cancellationToken);
}
internal ConvertResult ConvertCore(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
var outExt = ConversionPair.Normalize(outputExtension);
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.PdfRender.Dpi,
BackgroundColor = SKColors.White,
WithAnnotations = options.PdfRender.WithAnnotations,
WithFormFill = options.PdfRender.WithFormFill,
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, outExt, options.OnCollision);
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
using (var input = File.OpenRead(sourcePath))
using (var pngStream = new MemoryStream())
{
Conversion.SavePng(pngStream, input, page: i, leaveOpen: false, password: null, options: renderOptions);
pngStream.Position = 0;
using var image = new ImageMagick.MagickImage(pngStream);
ApplyTransforms(image, outExt, options);
ApplyEncoding(image, outExt, options);
image.Write(path);
}
outputs.Add(path);
progress?.Report((i + 1.0) / pageCount);
}
return outputs.Count > 0
? ConvertResult.Ok(sourcePath, outputs)
: ConvertResult.Skip(sourcePath, "모든 페이지가 이미 존재해 건너뜁니다.");
}
private static void ApplyTransforms(ImageMagick.IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
{
var alphaCapable = outputExtension is ".png" or ".webp" or ".avif" or ".tif" or ".tiff";
if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha)
{
image.BackgroundColor = new ImageMagick.MagickColor(options.TransparencyBackground);
image.Alpha(ImageMagick.AlphaOption.Remove);
image.Alpha(ImageMagick.AlphaOption.Off);
}
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0
&& (image.Width > (uint)maxLong || image.Height > (uint)maxLong))
{
image.Resize(new ImageMagick.MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
}
}
private static void ApplyEncoding(ImageMagick.IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
{
switch (outputExtension)
{
case ".jpg":
case ".jpeg":
image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
image.Format = ImageMagick.MagickFormat.Jpeg;
break;
case ".png":
image.Format = ImageMagick.MagickFormat.Png;
break;
case ".webp":
image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
if (options.Webp.Lossless)
image.Settings.SetDefine(ImageMagick.MagickFormat.WebP, "lossless", "true");
image.Format = ImageMagick.MagickFormat.WebP;
break;
case ".avif":
image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
image.Settings.SetDefine(ImageMagick.MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
image.Format = ImageMagick.MagickFormat.Avif;
break;
case ".bmp":
image.Format = ImageMagick.MagickFormat.Bmp;
break;
case ".tif":
case ".tiff":
if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
image.Settings.SetDefine(ImageMagick.MagickFormat.Tiff, "compression", options.Tiff.Compression);
image.Format = ImageMagick.MagickFormat.Tiff;
break;
}
}
}

View file

@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0-windows</TargetFramework>
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
@ -16,6 +17,7 @@
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
</ItemGroup>
<ItemGroup>

View file

@ -1,8 +1,8 @@
using EverythingToJpeg.Core.Providers;
using Everything2Everything.Core.Providers;
namespace EverythingToJpeg.Core;
namespace Everything2Everything.Core;
public static class EverythingToJpegBootstrap
public static class Everything2EverythingBootstrap
{
public static ConversionEngine CreateDefault()
{
@ -16,6 +16,7 @@ public static class EverythingToJpegBootstrap
new Converters.DocxProvider(pdf),
new Converters.HtmlProvider(),
new Converters.HwpxProvider(),
new Converters.OcrProvider(pdf),
};
return new ConversionEngine(new ProviderRegistry(providers));
}

View file

@ -1,13 +1,13 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace EverythingToJpeg.Core;
namespace Everything2Everything.Core;
public static class HistoryStorage
{
private static readonly string Dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"EverythingToJpeg");
"Everything2Everything");
private static readonly string FilePath = Path.Combine(Dir, "history.jsonl");

View file

@ -1,6 +1,6 @@
using System.Collections.ObjectModel;
namespace EverythingToJpeg.Core;
namespace Everything2Everything.Core;
public sealed record HistoryEntry(
DateTime Timestamp,

View file

@ -1,4 +1,4 @@
namespace EverythingToJpeg.Core;
namespace Everything2Everything.Core;
internal static class OutputPathHelper
{
@ -6,10 +6,12 @@ internal static class OutputPathHelper
string outputDirectory,
string baseName,
string? pageSuffix,
string outputExtension,
NameCollision collision)
{
var safe = SanitizeFileName(baseName);
var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}.jpg" : $"{safe}{pageSuffix}.jpg";
var ext = NormalizeExtension(outputExtension);
var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}{ext}" : $"{safe}{pageSuffix}{ext}";
var fullPath = Path.Combine(outputDirectory, fileName);
if (!File.Exists(fullPath)) return fullPath;
@ -25,8 +27,8 @@ internal static class OutputPathHelper
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");
? Path.Combine(outputDirectory, $"{safe} ({i}){ext}")
: Path.Combine(outputDirectory, $"{safe}{pageSuffix} ({i}){ext}");
if (!File.Exists(candidate)) return candidate;
}
return fullPath;
@ -46,4 +48,10 @@ internal static class OutputPathHelper
}
return new string(buffer);
}
private static string NormalizeExtension(string ext)
{
if (string.IsNullOrWhiteSpace(ext)) return ".jpg";
return ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant();
}
}

View file

@ -5,7 +5,7 @@ using PhotoSauce.MagicScaler;
using PhotoSauce.NativeCodecs.Libheif;
using SkiaSharp;
namespace EverythingToJpeg.Core;
namespace Everything2Everything.Core;
public sealed record PreviewResult(BitmapSource? Image, string? Reason, string? Dimensions, int? PageCount);
@ -64,7 +64,7 @@ public static class PreviewService
if (Interlocked.Exchange(ref _heifConfigured, 1) == 0)
CodecManager.Configure(c => c.UseLibheif());
var tempPng = Path.Combine(Path.GetTempPath(), $"e2j_pv_{Guid.NewGuid():N}.png");
var tempPng = Path.Combine(Path.GetTempPath(), $"e2e_pv_{Guid.NewGuid():N}.png");
try
{
MagicImageProcessor.ProcessImage(path, tempPng, ProcessImageSettings.Default);

View file

@ -0,0 +1,15 @@
namespace Everything2Everything.Core.Providers;
public sealed record ConversionPair(string InputExtension, string OutputExtension)
{
public static ConversionPair Of(string input, string output)
=> new(Normalize(input), Normalize(output));
public static string Normalize(string ext)
{
if (string.IsNullOrWhiteSpace(ext))
throw new ArgumentException("확장자가 비어 있습니다.", nameof(ext));
var trimmed = ext.Trim().ToLowerInvariant();
return trimmed.StartsWith('.') ? trimmed : "." + trimmed;
}
}

View file

@ -1,4 +1,4 @@
namespace EverythingToJpeg.Core.Providers;
namespace Everything2Everything.Core.Providers;
public interface IConverterProvider
{
@ -9,6 +9,7 @@ public interface IConverterProvider
Task<ConvertResult> ConvertAsync(
string sourcePath,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken);

View file

@ -0,0 +1,75 @@
namespace Everything2Everything.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<ConversionPair> SupportedConversions,
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;
public IReadOnlyList<string> InputExtensions
=> SupportedConversions
.Select(p => p.InputExtension)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
.ToList();
public IReadOnlyList<string> OutputExtensions
=> SupportedConversions
.Select(p => p.OutputExtension)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
.ToList();
public bool Supports(string inputExtension, string outputExtension)
{
var input = ConversionPair.Normalize(inputExtension);
var output = ConversionPair.Normalize(outputExtension);
return SupportedConversions.Any(p =>
string.Equals(p.InputExtension, input, StringComparison.OrdinalIgnoreCase) &&
string.Equals(p.OutputExtension, output, StringComparison.OrdinalIgnoreCase));
}
public IEnumerable<string> OutputsFor(string inputExtension)
{
var input = ConversionPair.Normalize(inputExtension);
return SupportedConversions
.Where(p => string.Equals(p.InputExtension, input, StringComparison.OrdinalIgnoreCase))
.Select(p => p.OutputExtension)
.Distinct(StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyList<ConversionPair> PairsFromMatrix(IEnumerable<string> inputs, IEnumerable<string> outputs)
{
var inputList = inputs.Select(ConversionPair.Normalize).ToList();
var outputList = outputs.Select(ConversionPair.Normalize).ToList();
var pairs = new List<ConversionPair>(inputList.Count * outputList.Count);
foreach (var i in inputList)
foreach (var o in outputList)
pairs.Add(new ConversionPair(i, o));
return pairs;
}
public static IReadOnlyList<ConversionPair> PairsToSingleOutput(IEnumerable<string> inputs, string output)
=> inputs.Select(i => ConversionPair.Of(i, output)).ToList();
}

View file

@ -0,0 +1,80 @@
namespace Everything2Everything.Core.Providers;
public sealed class ProviderRegistry
{
private readonly List<IConverterProvider> _providers;
private readonly Dictionary<(string Input, string Output), IConverterProvider> _byPair
= new(PairComparer.Instance);
private readonly Dictionary<string, List<string>> _outputsByInput
= new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _allInputs = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _allOutputs = new(StringComparer.OrdinalIgnoreCase);
public ProviderRegistry(IEnumerable<IConverterProvider> providers)
{
_providers = providers.ToList();
foreach (var provider in _providers)
{
if (!provider.Capability.IsImplemented) continue;
foreach (var pair in provider.Capability.SupportedConversions)
{
var key = (pair.InputExtension, pair.OutputExtension);
_byPair.TryAdd(key, provider);
if (!_outputsByInput.TryGetValue(pair.InputExtension, out var list))
_outputsByInput[pair.InputExtension] = list = new List<string>();
if (!list.Contains(pair.OutputExtension, StringComparer.OrdinalIgnoreCase))
list.Add(pair.OutputExtension);
_allInputs.Add(pair.InputExtension);
_allOutputs.Add(pair.OutputExtension);
}
}
}
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 TryGet(string sourcePath, string outputExtension, out IConverterProvider? provider)
{
var input = ConversionPair.Normalize(Path.GetExtension(sourcePath));
var output = ConversionPair.Normalize(outputExtension);
return _byPair.TryGetValue((input, output), out provider);
}
public IConverterProvider? FindByPair(string inputExtension, string outputExtension)
{
var key = (ConversionPair.Normalize(inputExtension), ConversionPair.Normalize(outputExtension));
return _byPair.TryGetValue(key, out var p) ? p : null;
}
public IReadOnlyList<string> OutputsForInput(string inputExtension)
{
var input = ConversionPair.Normalize(inputExtension);
return _outputsByInput.TryGetValue(input, out var list)
? list.OrderBy(e => e, StringComparer.OrdinalIgnoreCase).ToList()
: Array.Empty<string>();
}
public IReadOnlyList<string> OutputsForFile(string sourcePath)
=> OutputsForInput(Path.GetExtension(sourcePath));
public IReadOnlyCollection<string> AllInputExtensions => _allInputs;
public IReadOnlyCollection<string> AllOutputExtensions => _allOutputs;
private sealed class PairComparer : IEqualityComparer<(string, string)>
{
public static readonly PairComparer Instance = new();
public bool Equals((string, string) x, (string, string) y)
=> StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1)
&& StringComparer.OrdinalIgnoreCase.Equals(x.Item2, y.Item2);
public int GetHashCode((string, string) obj)
=> HashCode.Combine(
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1 ?? ""),
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item2 ?? ""));
}
}

View file

@ -19,7 +19,7 @@
<VCProjectVersion>17.0</VCProjectVersion>
<Keyword>Win32Proj</Keyword>
<ProjectGuid>{1A2B3C4D-5E6F-7A8B-9C0D-EF1234567890}</ProjectGuid>
<RootNamespace>EverythingToJpegShell</RootNamespace>
<RootNamespace>Everything2EverythingShell</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
@ -55,7 +55,7 @@
<PrecompiledHeader>Use</PrecompiledHeader>
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
<LanguageStandard>stdcpp20</LanguageStandard>
<PreprocessorDefinitions>EVERYTHINGTOJPEG_SHELL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<PreprocessorDefinitions>Everything2Everything_SHELL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<AdditionalOptions>/utf-8 %(AdditionalOptions)</AdditionalOptions>
</ClCompile>
<Link>

View file

@ -1,7 +1,7 @@
// EverythingToJpeg shell extension — IExplorerCommand handlers
// Everything2Everything shell extension — IExplorerCommand handlers
// Two verbs:
// - QuickCommandHandler → "EverythingToJpeg.exe quick "<paths>""
// - DialogCommandHandler → "EverythingToJpeg.exe dialog "<paths>""
// - QuickCommandHandler → "Everything2Everything.exe quick "<paths>""
// - DialogCommandHandler → "Everything2Everything.exe dialog "<paths>""
#include "pch.h"
@ -17,7 +17,7 @@ using Microsoft::WRL::RuntimeClassFlags;
namespace {
constexpr const wchar_t* kExeFileName = L"EverythingToJpeg.exe";
constexpr const wchar_t* kExeFileName = L"Everything2Everything.exe";
std::wstring QuoteForCommandLineArg(const std::wstring& arg) {
const std::wstring quotable_chars(L" \\\"");
@ -153,7 +153,7 @@ class __declspec(uuid("801B2DD3-632C-4731-9510-AEAE09345264"))
: public CommandHandlerBase<QuickCommandHandler>
{
public:
static constexpr const wchar_t* Title() { return L"JPEG로 빠른 변환"; }
static constexpr const wchar_t* Title() { return L"Everything2Everything: 빠른 변환 (JPEG)"; }
static constexpr const wchar_t* Verb() { return L"quick"; }
};
@ -162,7 +162,7 @@ class __declspec(uuid("CEBA1DB7-9175-4DF6-A362-490DEA49B598"))
: public CommandHandlerBase<DialogCommandHandler>
{
public:
static constexpr const wchar_t* Title() { return L"JPEG로 변환…"; }
static constexpr const wchar_t* Title() { return L"Everything2Everything: 변환…"; }
static constexpr const wchar_t* Verb() { return L"dialog"; }
};

View file

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

View file

@ -1,48 +0,0 @@
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 int HtmlViewportWidth { get; set; } = 1280;
public int? HtmlViewportHeight { get; set; }
public int HtmlWaitMilliseconds { get; set; } = 2000;
public bool HtmlFullPage { get; set; } = true;
public static ConvertOptions Quick() => new();
}

View file

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

View file

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

View file

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

View file

@ -1,40 +0,0 @@
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();
}