feat: 배치 결합 모드 + IExplorerCommand DLL 카스케이드
#10 다중 이미지 → 단일 PDF/TIFF/GIF 결합 - ConversionEngine: BatchMode { Independent, CombineToSingle } enum 추가 - ConvertManyAsync에 batchMode 파라미터, CombineToSingle 시 N:1 라우팅 - CombineAsync: MagickImageCollection으로 모든 입력 디코드 후 단일 다중-페이지 파일로 출력 (PDF/TIFF/GIF만 지원) - 결합 가능 검증: ImageMagick 디코드 가능 입력 + 결합 가능 출력 - 정적 헬퍼 CanCombine(outputExt), CanCombineInput(sourcePath) UI - MainWindow.xaml에 '단일 파일로 결합' CheckBox 추가 (출력이 결합 가능 + 큐 ≥2 + 모든 입력 결합 가능일 때 활성화) - 큐 변경/형식 변경 시 토글 상태 + 안내 텍스트 자동 갱신 - MainWindow.xaml.cs UpdateCombineState / OnCombineToggleChanged #9 IExplorerCommand DLL 카스케이드 (MSIX 메인 메뉴) - dllmain.cpp 전면 재작성: RootCascadeCommand (CLSID F1A2B3C4-...-2B3C4D5E6F70) + 11개 동적 SubVerbCommand + SubCommandEnumerator (IEnumExplorerCommand) + ECF_HASSUBCOMMANDS 플래그 - 서브메뉴 항목: JPEG/PNG/WebP/PDF/TXT/DOCX/AVIF/GIF/TIFF/BMP/변환… - 기존 Quick/Dialog 핸들러는 backward compat용으로 유지 - 빌드: x64 Release Everything2Everything.Shell.dll (207KB) Package.appxmanifest - com:Class에 RootCascadeCommand CLSID 등록 - 모든 desktop5:ItemType verb를 Quick+Dialog 두 개에서 단일 Convert로 통합 (29개 ItemType × 2 verbs → 29 × 1 verb) quick CLI는 기본 .jpg 호환 유지, dialog는 메인 창에서 형식 선택 빌드: dotnet 0 errors, MSBuild C++ 0 errors
This commit is contained in:
parent
9b7e5f0d0c
commit
2633a928c1
7 changed files with 452 additions and 68 deletions
|
|
@ -70,6 +70,8 @@ dotnet publish src\Everything2Everything.App\Everything2Everything.App.csproj `
|
|||
#### 메인 창
|
||||
파일을 드래그 & 드롭하거나 Ctrl+O. 사이드바의 **TARGET FORMAT** ComboBox는 **큐의 모든 파일이 변환 가능한 출력의 교집합**만 보여줍니다 (이종 입력을 섞으면 자동 필터링).
|
||||
|
||||
**단일 파일로 결합** 체크박스 — 출력이 PDF/TIFF/GIF이고 큐가 2개 이상의 이미지일 때 활성화. 큐의 모든 이미지를 단일 다중 페이지 파일로 결합합니다 (예: 스크린샷 5장 → 1개 PDF).
|
||||
|
||||
#### CLI
|
||||
|
||||
```powershell
|
||||
|
|
|
|||
|
|
@ -44,10 +44,15 @@
|
|||
</uap:VisualElements>
|
||||
|
||||
<Extensions>
|
||||
<!-- COM Surrogate Server: hosts the two IExplorerCommand handlers -->
|
||||
<!-- COM Surrogate Server: hosts the IExplorerCommand handlers -->
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
<com:SurrogateServer DisplayName="Everything2Everything Shell Extension">
|
||||
<!-- Root cascade ("Everything2Everything으로 변환" + 11 sub-items) -->
|
||||
<com:Class Id="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"
|
||||
Path="Everything2Everything.Shell.dll"
|
||||
ThreadingModel="STA" />
|
||||
<!-- Legacy non-cascade verbs (kept for backward compat) -->
|
||||
<com:Class Id="801B2DD3-632C-4731-9510-AEAE09345264"
|
||||
Path="Everything2Everything.Shell.dll"
|
||||
ThreadingModel="STA" />
|
||||
|
|
@ -63,132 +68,103 @@
|
|||
<desktop4:FileExplorerContextMenus>
|
||||
<!-- 이미지 -->
|
||||
<desktop5:ItemType Type=".png">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".jpg">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".jpeg">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".gif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".bmp">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".tif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".tiff">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".webp">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".avif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".psd">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HEIC/HEIF -->
|
||||
<desktop5:ItemType Type=".heic">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".heif">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- RAW -->
|
||||
<desktop5:ItemType Type=".dng">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".nef">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".cr2">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".cr3">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".arw">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".raf">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".orf">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".rw2">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".srw">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".pef">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- PDF -->
|
||||
<desktop5:ItemType Type=".pdf">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- DOCX -->
|
||||
<desktop5:ItemType Type=".docx">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".doc">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HTML -->
|
||||
<desktop5:ItemType Type=".html">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".htm">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
|
||||
<!-- HWP / HWPX -->
|
||||
<desktop5:ItemType Type=".hwp">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
<desktop5:ItemType Type=".hwpx">
|
||||
<desktop5:Verb Id="Quick" Clsid="801B2DD3-632C-4731-9510-AEAE09345264"/>
|
||||
<desktop5:Verb Id="Dialog" Clsid="CEBA1DB7-9175-4DF6-A362-490DEA49B598"/>
|
||||
<desktop5:Verb Id="Convert" Clsid="F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"/>
|
||||
</desktop5:ItemType>
|
||||
</desktop4:FileExplorerContextMenus>
|
||||
</desktop4:Extension>
|
||||
|
|
|
|||
|
|
@ -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, ".jpg", options, reporter);
|
||||
var results = await Engine.ConvertManyAsync(files, outputExtension, options, reporter);
|
||||
|
||||
foreach (var r in results)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -102,6 +102,15 @@
|
|||
<TextBlock x:Name="OutputFormatHint" Margin="0,6,0,0"
|
||||
Style="{StaticResource FsCaptionStyle}"
|
||||
Text="모든 입력에서 변환 가능한 형식이 표시됩니다"/>
|
||||
<CheckBox x:Name="CombineToSingleCheck"
|
||||
Margin="0,12,0,0"
|
||||
Content="단일 파일로 결합 (큐 전체 → 한 파일)"
|
||||
IsEnabled="False"
|
||||
Checked="OnCombineToggleChanged"
|
||||
Unchecked="OnCombineToggleChanged"/>
|
||||
<TextBlock x:Name="CombineHint" Margin="0,4,0,0"
|
||||
Style="{StaticResource FsCaptionStyle}"
|
||||
Text="PDF/TIFF/GIF 출력에 한해 큐의 이미지들을 한 파일로 결합합니다"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Encoding Quality -->
|
||||
|
|
|
|||
|
|
@ -533,7 +533,10 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
{
|
||||
var sources = snapshot.Select(s => s.SourcePath).ToList();
|
||||
var outputExt = SelectedOutputExtension ?? ".jpg";
|
||||
var results = await engine.ConvertManyAsync(sources, outputExt, options, reporter, _cts.Token);
|
||||
var batchMode = (CombineToSingleCheck?.IsChecked == true)
|
||||
? BatchMode.CombineToSingle
|
||||
: BatchMode.Independent;
|
||||
var results = await engine.ConvertManyAsync(sources, outputExt, options, reporter, batchMode, _cts.Token);
|
||||
|
||||
foreach (var (item, result) in snapshot.Zip(results))
|
||||
{
|
||||
|
|
@ -839,6 +842,7 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
UpdateOutputFormatBadge(keepExt);
|
||||
UpdateQualityPanelForFormat(keepExt);
|
||||
UpdateOutputDestHint(keepExt);
|
||||
UpdateCombineState(keepExt);
|
||||
|
||||
if (OutputFormatHint is not null)
|
||||
{
|
||||
|
|
@ -848,6 +852,35 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
|
|||
}
|
||||
}
|
||||
|
||||
private void UpdateCombineState(string? extension)
|
||||
{
|
||||
if (CombineToSingleCheck is null || CombineHint is null) return;
|
||||
|
||||
var ext = extension ?? string.Empty;
|
||||
var combinableOutput = ConversionEngine.CanCombine(ext);
|
||||
var allInputsCombinable = _activeQueue.Count > 0
|
||||
&& _activeQueue.All(q => ConversionEngine.CanCombineInput(q.SourcePath));
|
||||
var enabled = combinableOutput && allInputsCombinable && _activeQueue.Count >= 2;
|
||||
|
||||
CombineToSingleCheck.IsEnabled = enabled;
|
||||
if (!enabled && CombineToSingleCheck.IsChecked == true)
|
||||
CombineToSingleCheck.IsChecked = false;
|
||||
|
||||
CombineHint.Text = (combinableOutput, _activeQueue.Count) switch
|
||||
{
|
||||
(false, _) => "PDF/TIFF/GIF 출력일 때만 단일 파일 결합이 가능합니다",
|
||||
(true, 0) => "PDF/TIFF/GIF 출력에 한해 큐의 이미지들을 한 파일로 결합합니다",
|
||||
(true, 1) => "결합하려면 큐에 2개 이상의 이미지가 필요합니다",
|
||||
(true, _) when !allInputsCombinable => "결합은 이미지 입력만 지원합니다 (PDF/DOCX 등 제외)",
|
||||
_ => $"체크 시 큐의 {_activeQueue.Count}개 이미지를 단일 {ext.TrimStart('.').ToUpperInvariant()}로 결합",
|
||||
};
|
||||
}
|
||||
|
||||
private void OnCombineToggleChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
UpdateCombineState(SelectedOutputExtension);
|
||||
}
|
||||
|
||||
private void OnOutputFormatChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressFormatChanged) return;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
using ImageMagick;
|
||||
|
||||
namespace Everything2Everything.Core;
|
||||
|
||||
public enum BatchMode
|
||||
{
|
||||
Independent,
|
||||
CombineToSingle,
|
||||
}
|
||||
|
||||
public sealed class ConversionEngine
|
||||
{
|
||||
private static readonly HashSet<string> CombinableInputs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".bmp",
|
||||
".tif", ".tiff", ".gif", ".heic", ".heif", ".psd",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> CombinableOutputs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".pdf", ".tif", ".tiff", ".gif",
|
||||
};
|
||||
|
||||
private readonly ProviderRegistry _registry;
|
||||
|
||||
public ConversionEngine(ProviderRegistry registry)
|
||||
|
|
@ -18,11 +36,24 @@ public sealed class ConversionEngine
|
|||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<ConvertProgress>? progress = null,
|
||||
BatchMode batchMode = BatchMode.Independent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sourceList = sources.ToList();
|
||||
var results = new List<ConvertResult>(sourceList.Count);
|
||||
|
||||
if (batchMode == BatchMode.CombineToSingle && sourceList.Count > 1)
|
||||
{
|
||||
if (IsCombineSupported(sourceList, outputExtension, out var unsupportedReason))
|
||||
{
|
||||
var combined = await CombineAsync(sourceList, outputExtension, options, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new[] { combined };
|
||||
}
|
||||
|
||||
return new[] { ConvertResult.Fail(sourceList[0], unsupportedReason ?? "단일 파일 결합을 지원하지 않습니다.") };
|
||||
}
|
||||
|
||||
var results = new List<ConvertResult>(sourceList.Count);
|
||||
for (var i = 0; i < sourceList.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
|
@ -93,6 +124,138 @@ public sealed class ConversionEngine
|
|||
}
|
||||
}
|
||||
|
||||
public static bool CanCombine(string outputExtension)
|
||||
=> CombinableOutputs.Contains(ConversionPair.Normalize(outputExtension));
|
||||
|
||||
public static bool CanCombineInput(string sourcePath)
|
||||
=> CombinableInputs.Contains(Path.GetExtension(sourcePath).ToLowerInvariant());
|
||||
|
||||
private static bool IsCombineSupported(
|
||||
IReadOnlyList<string> sources,
|
||||
string outputExtension,
|
||||
out string? unsupportedReason)
|
||||
{
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
if (!CombinableOutputs.Contains(outExt))
|
||||
{
|
||||
unsupportedReason = $"단일 파일 결합은 {string.Join(", ", CombinableOutputs.OrderBy(e => e))}만 지원합니다.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var path in sources)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
unsupportedReason = $"파일을 찾을 수 없습니다: {path}";
|
||||
return false;
|
||||
}
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
if (!CombinableInputs.Contains(ext))
|
||||
{
|
||||
unsupportedReason = $"단일 파일 결합은 이미지 입력만 지원합니다 (지원 외: {ext}).";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
unsupportedReason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<ConvertResult> CombineAsync(
|
||||
IReadOnlyList<string> sources,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<ConvertProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var firstSource = sources[0];
|
||||
var outputDir = ResolveOutputDirectory(firstSource, outExt, options);
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var baseName = sources.Count == 1
|
||||
? Path.GetFileNameWithoutExtension(firstSource)
|
||||
: $"combined_{sources.Count}files_{DateTime.Now:yyyyMMdd_HHmmss}";
|
||||
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDir, baseName, null, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(firstSource, "기존 파일이 있어 건너뜁니다.");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var collection = new MagickImageCollection();
|
||||
for (var i = 0; i < sources.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
progress?.Report(new ConvertProgress(i, sources.Count, sources[i], 0.5));
|
||||
var image = LoadImageForCombine(sources[i], outExt, options);
|
||||
collection.Add(image);
|
||||
progress?.Report(new ConvertProgress(i, sources.Count, sources[i], 1));
|
||||
}
|
||||
|
||||
ApplyCombineEncoding(collection, outExt, options);
|
||||
collection.Write(path);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress?.Report(new ConvertProgress(sources.Count, sources.Count, path, 1));
|
||||
return ConvertResult.Ok(firstSource, new[] { path });
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConvertResult.Fail(firstSource, ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static MagickImage LoadImageForCombine(string sourcePath, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
var image = new MagickImage(sourcePath);
|
||||
try { image.AutoOrient(); } catch { }
|
||||
|
||||
var alphaCapable = outputExtension is ".tif" or ".tiff";
|
||||
if ((!alphaCapable || 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
|
||||
&& (image.Width > (uint)maxLong || image.Height > (uint)maxLong))
|
||||
{
|
||||
image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
private static void ApplyCombineEncoding(MagickImageCollection collection, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
foreach (var image in collection)
|
||||
{
|
||||
switch (outputExtension)
|
||||
{
|
||||
case ".pdf":
|
||||
image.Format = MagickFormat.Pdf;
|
||||
break;
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
image.Format = MagickFormat.Tiff;
|
||||
if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
|
||||
image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression);
|
||||
break;
|
||||
case ".gif":
|
||||
image.Format = MagickFormat.Gif;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveOutputDirectory(string sourcePath, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath))
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
// Everything2Everything shell extension — IExplorerCommand handlers
|
||||
// Two verbs:
|
||||
// - QuickCommandHandler → "Everything2Everything.exe quick "<paths>""
|
||||
// - DialogCommandHandler → "Everything2Everything.exe dialog "<paths>""
|
||||
// Everything2Everything shell extension — IExplorerCommand cascade
|
||||
//
|
||||
// Root verb (cascade) → "Everything2Everything으로 변환"
|
||||
// ├─ ToJpg / ToPng / ... → "exe to <ext> <paths>"
|
||||
// └─ Dialog → "exe dialog <paths>"
|
||||
//
|
||||
// Legacy CLSIDs (Quick, Dialog) are kept for backward compat with any
|
||||
// external registrations that may still reference them.
|
||||
|
||||
#include "pch.h"
|
||||
|
||||
|
|
@ -10,6 +14,8 @@
|
|||
using Microsoft::WRL::ClassicCom;
|
||||
using Microsoft::WRL::ComPtr;
|
||||
using Microsoft::WRL::InhibitRoOriginateError;
|
||||
using Microsoft::WRL::Make;
|
||||
using Microsoft::WRL::MakeAndInitialize;
|
||||
using Microsoft::WRL::Module;
|
||||
using Microsoft::WRL::ModuleType;
|
||||
using Microsoft::WRL::RuntimeClass;
|
||||
|
|
@ -101,6 +107,201 @@ HRESULT LaunchAppWithItems(const wchar_t* verb, IShellItemArray* items) {
|
|||
return S_OK;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Sub-command — leaf in the cascade (e.g., "JPEG (.jpg)" → "to jpg")
|
||||
// ---------------------------------------------------------------------
|
||||
class SubVerbCommand : public RuntimeClass<
|
||||
RuntimeClassFlags<ClassicCom | InhibitRoOriginateError>,
|
||||
IExplorerCommand>
|
||||
{
|
||||
public:
|
||||
HRESULT RuntimeClassInitialize() { return S_OK; }
|
||||
|
||||
void Configure(std::wstring title, std::wstring verb) {
|
||||
title_ = std::move(title);
|
||||
verb_ = std::move(verb);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override {
|
||||
return SHStrDupW(title_.c_str(), name);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override {
|
||||
auto exe = ResolveExePath();
|
||||
return SHStrDupW(exe.c_str(), icon);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* infoTip) override {
|
||||
*infoTip = nullptr;
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetCanonicalName(GUID* guidCommandName) override {
|
||||
*guidCommandName = GUID_NULL;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* cmdState) override {
|
||||
*cmdState = ECS_ENABLED;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override {
|
||||
*flags = ECF_DEFAULT;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumCommands) override {
|
||||
*enumCommands = nullptr;
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Invoke(IShellItemArray* items, IBindCtx*) override {
|
||||
return LaunchAppWithItems(verb_.c_str(), items);
|
||||
}
|
||||
|
||||
private:
|
||||
std::wstring title_;
|
||||
std::wstring verb_;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// IEnumExplorerCommand — feeds children to Explorer
|
||||
// ---------------------------------------------------------------------
|
||||
class SubCommandEnumerator : public RuntimeClass<
|
||||
RuntimeClassFlags<ClassicCom | InhibitRoOriginateError>,
|
||||
IEnumExplorerCommand>
|
||||
{
|
||||
public:
|
||||
HRESULT RuntimeClassInitialize() { return S_OK; }
|
||||
|
||||
void Configure(std::vector<ComPtr<IExplorerCommand>> commands) {
|
||||
commands_ = std::move(commands);
|
||||
index_ = 0;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Next(ULONG celt, IExplorerCommand** apUICommand, ULONG* pceltFetched) override {
|
||||
if (!apUICommand) return E_POINTER;
|
||||
ULONG fetched = 0;
|
||||
for (; fetched < celt && index_ < commands_.size(); ++fetched, ++index_) {
|
||||
apUICommand[fetched] = commands_[index_].Get();
|
||||
apUICommand[fetched]->AddRef();
|
||||
}
|
||||
if (pceltFetched) *pceltFetched = fetched;
|
||||
return (fetched == celt) ? S_OK : S_FALSE;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Skip(ULONG celt) override {
|
||||
size_t remaining = commands_.size() - index_;
|
||||
index_ += static_cast<size_t>(std::min<ULONG>(celt, static_cast<ULONG>(remaining)));
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Reset() override {
|
||||
index_ = 0;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Clone(IEnumExplorerCommand** ppenum) override {
|
||||
if (!ppenum) return E_POINTER;
|
||||
ComPtr<SubCommandEnumerator> clone;
|
||||
RETURN_IF_FAILED(MakeAndInitialize<SubCommandEnumerator>(&clone));
|
||||
clone->Configure(commands_);
|
||||
clone->index_ = index_;
|
||||
return clone.CopyTo(ppenum);
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<ComPtr<IExplorerCommand>> commands_;
|
||||
size_t index_ = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Root cascade — "Everything2Everything으로 변환" with 11 sub-items
|
||||
// ---------------------------------------------------------------------
|
||||
struct SubItem {
|
||||
const wchar_t* Title;
|
||||
const wchar_t* Verb;
|
||||
};
|
||||
|
||||
inline ComPtr<SubVerbCommand> MakeSub(const wchar_t* title, const wchar_t* verb) {
|
||||
ComPtr<SubVerbCommand> cmd;
|
||||
MakeAndInitialize<SubVerbCommand>(&cmd);
|
||||
cmd->Configure(title, verb);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class __declspec(uuid("F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70"))
|
||||
RootCascadeCommand final
|
||||
: public RuntimeClass<
|
||||
RuntimeClassFlags<ClassicCom | InhibitRoOriginateError>,
|
||||
IExplorerCommand>
|
||||
{
|
||||
public:
|
||||
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override {
|
||||
return SHStrDupW(L"Everything2Everything으로 변환", name);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override {
|
||||
auto exe = ResolveExePath();
|
||||
return SHStrDupW(exe.c_str(), icon);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* infoTip) override {
|
||||
*infoTip = nullptr;
|
||||
return E_NOTIMPL;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetCanonicalName(GUID* guidCommandName) override {
|
||||
*guidCommandName = __uuidof(RootCascadeCommand);
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* cmdState) override {
|
||||
*cmdState = ECS_ENABLED;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override {
|
||||
*flags = ECF_HASSUBCOMMANDS;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumCommands) override {
|
||||
if (!enumCommands) return E_POINTER;
|
||||
*enumCommands = nullptr;
|
||||
|
||||
std::vector<ComPtr<IExplorerCommand>> cmds;
|
||||
cmds.reserve(11);
|
||||
cmds.push_back(MakeSub(L"JPEG (.jpg)", L"to jpg"));
|
||||
cmds.push_back(MakeSub(L"PNG (.png)", L"to png"));
|
||||
cmds.push_back(MakeSub(L"WebP (.webp)", L"to webp"));
|
||||
cmds.push_back(MakeSub(L"PDF (.pdf)", L"to pdf"));
|
||||
cmds.push_back(MakeSub(L"텍스트 (.txt) — OCR", L"to txt"));
|
||||
cmds.push_back(MakeSub(L"Word (.docx) — OCR", L"to docx"));
|
||||
cmds.push_back(MakeSub(L"AVIF (.avif)", L"to avif"));
|
||||
cmds.push_back(MakeSub(L"GIF (.gif)", L"to gif"));
|
||||
cmds.push_back(MakeSub(L"TIFF (.tif)", L"to tif"));
|
||||
cmds.push_back(MakeSub(L"BMP (.bmp)", L"to bmp"));
|
||||
cmds.push_back(MakeSub(L"변환… (옵션 선택)", L"dialog"));
|
||||
|
||||
ComPtr<SubCommandEnumerator> enumerator;
|
||||
RETURN_IF_FAILED(MakeAndInitialize<SubCommandEnumerator>(&enumerator));
|
||||
enumerator->Configure(std::move(cmds));
|
||||
return enumerator.CopyTo(enumCommands);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Invoke(IShellItemArray*, IBindCtx*) override {
|
||||
// Root verb itself does nothing — children carry the action.
|
||||
return S_OK;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Legacy non-cascade verbs — kept so any older registrations still work.
|
||||
// ---------------------------------------------------------------------
|
||||
template <typename Derived>
|
||||
class CommandHandlerBase : public RuntimeClass<
|
||||
RuntimeClassFlags<ClassicCom | InhibitRoOriginateError>,
|
||||
|
|
@ -146,8 +347,6 @@ public:
|
|||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class __declspec(uuid("801B2DD3-632C-4731-9510-AEAE09345264"))
|
||||
QuickCommandHandler final
|
||||
: public CommandHandlerBase<QuickCommandHandler>
|
||||
|
|
@ -166,8 +365,10 @@ public:
|
|||
static constexpr const wchar_t* Verb() { return L"dialog"; }
|
||||
};
|
||||
|
||||
CoCreatableClass(RootCascadeCommand)
|
||||
CoCreatableClass(QuickCommandHandler)
|
||||
CoCreatableClass(DialogCommandHandler)
|
||||
CoCreatableClassWrlCreatorMapInclude(RootCascadeCommand)
|
||||
CoCreatableClassWrlCreatorMapInclude(QuickCommandHandler)
|
||||
CoCreatableClassWrlCreatorMapInclude(DialogCommandHandler)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue