1
0
Fork 0

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:
Yun Chan 2026-05-07 15:46:38 +09:00
parent 9b7e5f0d0c
commit 2633a928c1
7 changed files with 452 additions and 68 deletions

View file

@ -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))