From 2633a928c1a3601d5da45c00d38c53b3e26f8c3e Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Thu, 7 May 2026 15:46:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B0=B0=EC=B9=98=20=EA=B2=B0=ED=95=A9?= =?UTF-8?q?=20=EB=AA=A8=EB=93=9C=20+=20IExplorerCommand=20DLL=20=EC=B9=B4?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- README.md | 2 + packaging/Package.appxmanifest | 94 +++----- src/Everything2Everything.App/App.xaml.cs | 2 +- .../Views/MainWindow.xaml | 9 + .../Views/MainWindow.xaml.cs | 35 ++- .../ConversionEngine.cs | 165 +++++++++++++- src/Everything2Everything.Shell/dllmain.cpp | 213 +++++++++++++++++- 7 files changed, 452 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index d6a68a6..a0781d2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest index 67e4b52..60726b8 100644 --- a/packaging/Package.appxmanifest +++ b/packaging/Package.appxmanifest @@ -44,10 +44,15 @@ - + + + + @@ -63,132 +68,103 @@ - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + - - + diff --git a/src/Everything2Everything.App/App.xaml.cs b/src/Everything2Everything.App/App.xaml.cs index 6fb04b5..6c03b33 100644 --- a/src/Everything2Everything.App/App.xaml.cs +++ b/src/Everything2Everything.App/App.xaml.cs @@ -93,7 +93,7 @@ public partial class App : Application { var options = ConvertOptions.Quick(); var reporter = new Progress(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) { diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml b/src/Everything2Everything.App/Views/MainWindow.xaml index 48f6a48..1a26455 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml +++ b/src/Everything2Everything.App/Views/MainWindow.xaml @@ -102,6 +102,15 @@ + + diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index 26b8f7a..6a68afb 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -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; diff --git a/src/Everything2Everything.Core/ConversionEngine.cs b/src/Everything2Everything.Core/ConversionEngine.cs index 8d2a885..7ffbe54 100644 --- a/src/Everything2Everything.Core/ConversionEngine.cs +++ b/src/Everything2Everything.Core/ConversionEngine.cs @@ -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 CombinableInputs = new(StringComparer.OrdinalIgnoreCase) + { + ".png", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".bmp", + ".tif", ".tiff", ".gif", ".heic", ".heif", ".psd", + }; + + private static readonly HashSet 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? progress = null, + BatchMode batchMode = BatchMode.Independent, CancellationToken cancellationToken = default) { var sourceList = sources.ToList(); - var results = new List(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(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 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 CombineAsync( + IReadOnlyList sources, + string outputExtension, + ConvertOptions options, + IProgress? 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)) diff --git a/src/Everything2Everything.Shell/dllmain.cpp b/src/Everything2Everything.Shell/dllmain.cpp index 74a1bb5..2c4e300 100644 --- a/src/Everything2Everything.Shell/dllmain.cpp +++ b/src/Everything2Everything.Shell/dllmain.cpp @@ -1,7 +1,11 @@ -// Everything2Everything shell extension — IExplorerCommand handlers -// Two verbs: -// - QuickCommandHandler → "Everything2Everything.exe quick """ -// - DialogCommandHandler → "Everything2Everything.exe dialog """ +// Everything2Everything shell extension — IExplorerCommand cascade +// +// Root verb (cascade) → "Everything2Everything으로 변환" +// ├─ ToJpg / ToPng / ... → "exe to " +// └─ Dialog → "exe dialog " +// +// 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, + 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, + IEnumExplorerCommand> +{ +public: + HRESULT RuntimeClassInitialize() { return S_OK; } + + void Configure(std::vector> 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(std::min(celt, static_cast(remaining))); + return S_OK; + } + + IFACEMETHODIMP Reset() override { + index_ = 0; + return S_OK; + } + + IFACEMETHODIMP Clone(IEnumExplorerCommand** ppenum) override { + if (!ppenum) return E_POINTER; + ComPtr clone; + RETURN_IF_FAILED(MakeAndInitialize(&clone)); + clone->Configure(commands_); + clone->index_ = index_; + return clone.CopyTo(ppenum); + } + +private: + std::vector> commands_; + size_t index_ = 0; +}; + +// --------------------------------------------------------------------- +// Root cascade — "Everything2Everything으로 변환" with 11 sub-items +// --------------------------------------------------------------------- +struct SubItem { + const wchar_t* Title; + const wchar_t* Verb; +}; + +inline ComPtr MakeSub(const wchar_t* title, const wchar_t* verb) { + ComPtr cmd; + MakeAndInitialize(&cmd); + cmd->Configure(title, verb); + return cmd; +} + +} // namespace + +class __declspec(uuid("F1A2B3C4-D5E6-4789-9A01-2B3C4D5E6F70")) + RootCascadeCommand final + : public RuntimeClass< + RuntimeClassFlags, + 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> 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 enumerator; + RETURN_IF_FAILED(MakeAndInitialize(&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 class CommandHandlerBase : public RuntimeClass< RuntimeClassFlags, @@ -146,8 +347,6 @@ public: } }; -} // namespace - class __declspec(uuid("801B2DD3-632C-4731-9510-AEAE09345264")) QuickCommandHandler final : public CommandHandlerBase @@ -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)