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)