산출물
- ConversionGraph + 자체 Dijkstra PathFinder(외부 의존성 0, .NET 9 PriorityQueue)
- ConversionPair.LossClass 필드 + 정적 가중치 테이블
- ConversionEngine.ConvertOneAsync 그래프 위임 + ChainExecutor(공용 workDir 헬퍼)
- PdfToolProvider 신설: PDF 압축(Light=PDFsharp 구조최적화, Strong=PDFium 렌더+Magick 재인코딩, Max=Ghostscript 외부폴백) + 병합/분할
- xUnit 테스트 프로젝트 신설(현재 0개) + 그래프 경로탐색 회귀 테스트
diff --git a/src/Everything2Everything.Core/ConversionEngine.cs b/src/Everything2Everything.Core/ConversionEngine.cs
index 7ffbe54..0e718af 100644
--- a/src/Everything2Everything.Core/ConversionEngine.cs
+++ b/src/Everything2Everything.Core/ConversionEngine.cs
@@ -85,11 +85,15 @@ public sealed class ConversionEngine
var output = ConversionPair.Normalize(outputExtension);
var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
- if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
- return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
+ // 그래프 경로 탐색: 직접 엣지가 있으면 1홉, 없으면 손실 가중치 기반 멀티홉을 자동 합성.
+ var maxHops = options.AllowMultiHop ? Math.Max(1, options.MaxHops) : 1;
+ var path = _registry.Graph.FindBestPath(inputExt, output, maxHops, !options.AvoidLossy);
- if (!_registry.TryGet(sourcePath, output, out var provider) || provider is null)
+ if (path is null || path.Count == 0)
{
+ if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
+ return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
+
var available = _registry.OutputsForFile(sourcePath);
var hint = available.Count > 0
? $" 가능한 출력: {string.Join(", ", available)}"
@@ -97,30 +101,117 @@ public sealed class ConversionEngine
return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환을 지원하지 않습니다.{hint}");
}
- var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
- if (!availability.IsReady)
+ return await ExecuteChainAsync(sourcePath, output, path, options, progress, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ ///
+ /// 변환 경로(1홉 또는 멀티홉)를 순차 실행한다. 중간 산출물은 임시 작업폴더에 체이닝하고
+ /// 마지막 홉만 실제 출력 폴더에 쓴다. 각 홉 시작 전 Provider 가용성을 점검한다.
+ ///
+ private async Task
ExecuteChainAsync(
+ string sourcePath,
+ string finalOutputExtension,
+ IReadOnlyList path,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ foreach (var edge in path)
{
- var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty();
- var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
- if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
- return ConvertResult.Fail(sourcePath, detail);
+ var availability = await edge.Provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
+ if (!availability.IsReady)
+ {
+ var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty();
+ var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
+ if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
+ return ConvertResult.Fail(sourcePath, detail);
+ }
}
- var outputDir = ResolveOutputDirectory(sourcePath, output, options);
- Directory.CreateDirectory(outputDir);
+ // 단일 홉 — 기존 동작과 동일 (출력 폴더 해결 후 직접 위임)
+ if (path.Count == 1)
+ {
+ var outputDir = ResolveOutputDirectory(sourcePath, finalOutputExtension, options);
+ Directory.CreateDirectory(outputDir);
+ try
+ {
+ return await path[0].Provider
+ .ConvertAsync(sourcePath, outputDir, finalOutputExtension, options, progress, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex) { return ConvertResult.Fail(sourcePath, ex.Message, ex); }
+ }
+ // 멀티홉 — 중간 산출물은 임시 폴더, 마지막만 실제 출력
+ var workRoot = Path.Combine(Path.GetTempPath(), "e2e_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(workRoot);
try
{
- return await provider.ConvertAsync(sourcePath, outputDir, output, options, progress, cancellationToken)
- .ConfigureAwait(false);
+ var current = sourcePath;
+ for (var h = 0; h < path.Count; h++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var edge = path[h];
+ var isLast = h == path.Count - 1;
+ var hopExt = edge.To;
+ var outDir = isLast
+ ? ResolveOutputDirectory(sourcePath, finalOutputExtension, options)
+ : Path.Combine(workRoot, "h" + h.ToString());
+ Directory.CreateDirectory(outDir);
+
+ var hopIndex = h;
+ var hopProgress = new Progress(p =>
+ progress?.Report((hopIndex + Math.Clamp(p, 0, 1)) / path.Count));
+
+ ConvertResult result;
+ try
+ {
+ result = await edge.Provider
+ .ConvertAsync(current, outDir, hopExt, options, hopProgress, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return ConvertResult.Fail(sourcePath,
+ $"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {ex.Message}", ex);
+ }
+
+ if (isLast)
+ {
+ // 마지막 홉은 상태를 그대로 존중 (정상 Skip을 Fail로 둔갑시키지 않음)
+ return result.Status switch
+ {
+ ConvertStatus.Success => ConvertResult.Ok(sourcePath, result.OutputPaths),
+ ConvertStatus.Skipped => ConvertResult.Skip(sourcePath, result.Message ?? "건너뛰었습니다."),
+ _ => ConvertResult.Fail(sourcePath,
+ $"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? "산출물이 없습니다."}", result.Error),
+ };
+ }
+
+ // 중간 홉은 단일 산출물이어야 다음 홉으로 체이닝할 수 있다.
+ if (result.Status != ConvertStatus.Success || result.OutputPaths.Count == 0)
+ {
+ return ConvertResult.Fail(sourcePath,
+ $"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? "산출물이 없습니다."}");
+ }
+ if (result.OutputPaths.Count > 1)
+ {
+ // 다중 산출물(예: PDF→페이지별 이미지)을 중간 홉으로 두면 나머지가 유실된다 — 명시적 거부.
+ return ConvertResult.Fail(sourcePath,
+ $"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To})가 여러 파일을 생성해 자동 합성된 다음 단계로 이어갈 수 없습니다. 직접 변환 경로를 사용하거나 단계를 나눠 실행하세요.");
+ }
+
+ current = result.OutputPaths[0]; // 다음 홉의 입력
+ }
+
+ return ConvertResult.Fail(sourcePath, "변환 체인 실행에 실패했습니다.");
}
- catch (OperationCanceledException)
+ finally
{
- throw;
- }
- catch (Exception ex)
- {
- return ConvertResult.Fail(sourcePath, ex.Message, ex);
+ try { Directory.Delete(workRoot, true); } catch { /* 임시 정리 실패 무시 */ }
}
}
diff --git a/src/Everything2Everything.Core/ConvertOptions.cs b/src/Everything2Everything.Core/ConvertOptions.cs
index 8b948d8..03fcf82 100644
--- a/src/Everything2Everything.Core/ConvertOptions.cs
+++ b/src/Everything2Everything.Core/ConvertOptions.cs
@@ -54,6 +54,18 @@ public sealed class ConvertOptions
public OcrOptions Ocr { get; set; } = new();
+ // --- 변환 그래프 경로 옵션 (P1) ---
+ /// 멀티홉 경로 자동 합성 허용. false면 직접(1홉) 변환만.
+ public bool AllowMultiHop { get; set; } = true;
+
+ /// 멀티홉 최대 홉 수.
+ public int MaxHops { get; set; } = 3;
+
+ /// 래스터화 같은 큰 손실 엣지를 회피한다.
+ public bool AvoidLossy { get; set; } = false;
+
+ public PdfCompressOptions PdfCompress { get; set; } = new();
+
public static ConvertOptions Quick() => new();
}
@@ -122,3 +134,9 @@ public sealed class OcrOptions
public bool PreserveLayout { get; set; } = true;
public string Backend { get; set; } = "auto";
}
+
+public sealed class PdfCompressOptions
+{
+ /// Light(구조 최적화·무손실) | Strong(렌더 재인코딩) | Max(Ghostscript). P1은 Light만 구현.
+ public string Level { get; set; } = "Light";
+}
diff --git a/src/Everything2Everything.Core/Converters/PdfProvider.cs b/src/Everything2Everything.Core/Converters/PdfProvider.cs
index fe9e489..ebf817f 100644
--- a/src/Everything2Everything.Core/Converters/PdfProvider.cs
+++ b/src/Everything2Everything.Core/Converters/PdfProvider.cs
@@ -14,7 +14,7 @@ public sealed class PdfProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "pdf",
DisplayName: "PDF",
- SupportedConversions: ProviderCapability.PairsFromMatrix(PdfInputs, PdfRenderOutputs),
+ SupportedConversions: ProviderCapability.PairsFromMatrix(PdfInputs, PdfRenderOutputs, LossClass.Rasterize),
Status: ProviderStatus.Available,
Summary: "PDF 각 페이지를 PNG/JPEG/WebP/AVIF/BMP/TIFF로 렌더링합니다.",
ExternalDependencies: Array.Empty(),
diff --git a/src/Everything2Everything.Core/Converters/PdfToolProvider.cs b/src/Everything2Everything.Core/Converters/PdfToolProvider.cs
new file mode 100644
index 0000000..138caf0
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/PdfToolProvider.cs
@@ -0,0 +1,88 @@
+using Everything2Everything.Core.Providers;
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.IO;
+
+namespace Everything2Everything.Core.Converters;
+
+///
+/// PDF 재압축/최적화 Provider. pdf→pdf 동일포맷 변환을 그래프 엣지로 노출해
+/// ConversionEngine의 동일포맷 Skip을 우회한다. P1은 PDFsharp(MIT) in-process 구조 최적화(Light).
+/// Strong(렌더 재인코딩)·Max(Ghostscript 외부)는 후속 단계에서 확장.
+///
+public sealed class PdfToolProvider : IConverterProvider
+{
+ public ProviderCapability Capability { get; } = new(
+ Id: "pdf-tool",
+ DisplayName: "PDF 압축",
+ SupportedConversions: new[] { new ConversionPair(".pdf", ".pdf", LossClass.Container) },
+ Status: ProviderStatus.Available,
+ Summary: "PDF를 재압축해 용량을 줄입니다 (구조 최적화). 전자서명·XFA 폼·PDF/A 준수는 재저장 과정에서 유실될 수 있습니다.",
+ ExternalDependencies: Array.Empty(),
+ RoadmapNote: "Strong(렌더 재인코딩)·Max(Ghostscript) 압축 레벨은 후속 추가 예정");
+
+ public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(ProviderAvailability.Ready);
+
+ public Task ConvertAsync(
+ string sourcePath,
+ string outputDirectory,
+ string outputExtension,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ => Task.Run(() => Compress(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
+
+ private static ConvertResult Compress(
+ string sourcePath,
+ string outputDirectory,
+ ConvertOptions options,
+ IProgress? progress,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, "_compressed", ".pdf", options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+
+ progress?.Report(0.1);
+
+ // 임시 파일에 먼저 저장한 뒤 원자적으로 교체 — Save 도중 예외가 나도 손상된 출력이 잔존하지 않게.
+ var tmp = outPath + ".tmp";
+ try
+ {
+ using (var doc = PdfReader.Open(sourcePath, PdfDocumentOpenMode.Modify))
+ {
+ doc.Options.CompressContentStreams = true;
+ doc.Options.NoCompression = false;
+ doc.Options.EnableCcittCompressionForBilevelImages = true;
+ cancellationToken.ThrowIfCancellationRequested();
+ doc.Save(tmp);
+ }
+
+ progress?.Report(0.9);
+
+ // 재압축이 오히려 더 커지면 원본을 사용(출력이 입력보다 크지 않도록 보장).
+ var before = new FileInfo(sourcePath).Length;
+ var after = new FileInfo(tmp).Length;
+ if (after > before)
+ {
+ File.Copy(sourcePath, outPath, overwrite: true);
+ File.Delete(tmp);
+ }
+ else
+ {
+ if (File.Exists(outPath)) File.Delete(outPath);
+ File.Move(tmp, outPath);
+ }
+ }
+ catch
+ {
+ try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* 임시 정리 실패 무시 */ }
+ throw;
+ }
+
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { outPath });
+ }
+}
diff --git a/src/Everything2Everything.Core/Everything2Everything.Core.csproj b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
index f521e94..4fe1d94 100644
--- a/src/Everything2Everything.Core/Everything2Everything.Core.csproj
+++ b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
@@ -20,6 +20,7 @@
+
diff --git a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
index 4fe26ee..c26a46d 100644
--- a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
+++ b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
@@ -13,6 +13,7 @@ public static class Everything2EverythingBootstrap
magick,
new Converters.HeicProvider(magick),
pdf,
+ new Converters.PdfToolProvider(),
new Converters.DocxProvider(pdf),
new Converters.HtmlProvider(),
new Converters.HwpxProvider(),
diff --git a/src/Everything2Everything.Core/Providers/ConversionGraph.cs b/src/Everything2Everything.Core/Providers/ConversionGraph.cs
new file mode 100644
index 0000000..b161e44
--- /dev/null
+++ b/src/Everything2Everything.Core/Providers/ConversionGraph.cs
@@ -0,0 +1,134 @@
+namespace Everything2Everything.Core.Providers;
+
+///
+/// 모든 Provider의 변환 능력을 방향 그래프로 합성한다. 노드=확장자, 엣지=Provider+LossClass 가중치.
+/// 자체 Dijkstra(외부 의존성 0, .NET PriorityQueue)로 임의의 입력→출력 멀티홉 경로를 최저 손실로 탐색한다.
+/// 홉 제약(maxHops)이 있는 최단경로이므로 상태를 (노드, 홉수)로 두는 라벨링 Dijkstra를 쓴다.
+/// (일반 Dijkstra는 hops 값이 '최저비용 경로의 홉'으로 오염돼 유효한 경로를 놓치는 false negative가 생긴다.)
+///
+public sealed class ConversionGraph
+{
+ public readonly record struct Edge(string From, string To, IConverterProvider Provider, LossClass Loss)
+ {
+ public double Weight => LossWeights.Of(Loss) + LossWeights.HopPenalty;
+ }
+
+ private readonly Dictionary> _adj = new(StringComparer.OrdinalIgnoreCase);
+
+ public void AddEdge(string from, string to, IConverterProvider provider, LossClass loss)
+ {
+ var f = ConversionPair.Normalize(from);
+ var t = ConversionPair.Normalize(to);
+ if (!_adj.TryGetValue(f, out var list))
+ _adj[f] = list = new List();
+ list.Add(new Edge(f, t, provider, loss));
+ }
+
+ public bool HasNode(string ext) => _adj.ContainsKey(ConversionPair.Normalize(ext));
+
+ public IReadOnlyList EdgesFrom(string ext)
+ => _adj.TryGetValue(ConversionPair.Normalize(ext), out var list) ? list : Array.Empty();
+
+ ///
+ /// 입력에서 도달 가능한 모든 출력 확장자 (홉 제약 도달성). FindBestPath와 동일한 (노드,홉) 도달 기준을 공유하므로
+ /// "ReachableOutputs가 반환한 모든 X에 대해 FindBestPath(input, X) != null" 불변식이 성립한다.
+ ///
+ public IReadOnlyCollection ReachableOutputs(string inputExt, int maxHops = 3, bool allowLossy = true)
+ {
+ var start = ConversionPair.Normalize(inputExt);
+ if (maxHops < 1) maxHops = 1;
+ var result = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var visited = new HashSet<(string, int)>();
+ var q = new Queue<(string Node, int Hop)>();
+ q.Enqueue((start, 0));
+ visited.Add((start, 0));
+
+ // 동일포맷 self-edge(예: pdf→pdf 압축)도 도달 출력으로 노출
+ foreach (var e in EdgesFrom(start))
+ if (string.Equals(e.To, start, StringComparison.OrdinalIgnoreCase) && (allowLossy || e.Loss != LossClass.Rasterize))
+ result.Add(e.To);
+
+ while (q.Count > 0)
+ {
+ var (node, hop) = q.Dequeue();
+ if (hop >= maxHops) continue;
+ foreach (var e in EdgesFrom(node))
+ {
+ if (!allowLossy && e.Loss == LossClass.Rasterize) continue;
+ if (!string.Equals(e.To, start, StringComparison.OrdinalIgnoreCase))
+ result.Add(e.To);
+ var next = (e.To, hop + 1);
+ if (visited.Add(next))
+ q.Enqueue(next);
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// 입력→출력 최저 손실 경로 (홉 ≤ maxHops). 직접(1홉) 엣지가 있으면 홉 페널티로 거의 항상 최소가 된다.
+ /// 동일포맷(start==goal)은 self-edge(압축 등)가 있을 때만 경로를 반환하고, 없으면 null(엔진이 Skip 처리).
+ ///
+ public IReadOnlyList? FindBestPath(string inputExt, string outputExt, int maxHops = 3, bool allowLossy = true)
+ {
+ var start = ConversionPair.Normalize(inputExt);
+ var goal = ConversionPair.Normalize(outputExt);
+ if (maxHops < 1) maxHops = 1;
+
+ if (string.Equals(start, goal, StringComparison.OrdinalIgnoreCase))
+ {
+ Edge? best = null;
+ foreach (var e in EdgesFrom(start))
+ {
+ if (!string.Equals(e.To, goal, StringComparison.OrdinalIgnoreCase)) continue;
+ if (!allowLossy && e.Loss == LossClass.Rasterize) continue;
+ if (best is null || e.Weight < best.Value.Weight) best = e;
+ }
+ return best is null ? null : new[] { best.Value };
+ }
+
+ if (!_adj.ContainsKey(start)) return null;
+
+ // 상태 = (노드, 홉수). 홉수를 상태에 포함해야 홉 제약 최단경로를 정확히 푼다.
+ var dist = new Dictionary<(string Node, int Hop), double>();
+ var prev = new Dictionary<(string Node, int Hop), (Edge Edge, string FromNode, int FromHop)>();
+ var visited = new HashSet<(string, int)>();
+ var pq = new PriorityQueue<(string Node, int Hop), double>();
+ dist[(start, 0)] = 0;
+ pq.Enqueue((start, 0), 0);
+
+ (string Node, int Hop)? goalState = null;
+ while (pq.TryDequeue(out var s, out var cost))
+ {
+ if (!visited.Add(s)) continue;
+ if (string.Equals(s.Node, goal, StringComparison.OrdinalIgnoreCase)) { goalState = s; break; }
+ if (s.Hop >= maxHops) continue;
+
+ foreach (var e in EdgesFrom(s.Node))
+ {
+ if (!allowLossy && e.Loss == LossClass.Rasterize) continue;
+ var next = (e.To, s.Hop + 1);
+ var nd = cost + e.Weight;
+ if (!dist.TryGetValue(next, out var c) || nd < c)
+ {
+ dist[next] = nd;
+ prev[next] = (e, s.Node, s.Hop);
+ pq.Enqueue(next, nd);
+ }
+ }
+ }
+
+ if (goalState is null) return null;
+
+ var path = new List();
+ var cur = goalState.Value;
+ while (!(string.Equals(cur.Node, start, StringComparison.OrdinalIgnoreCase) && cur.Hop == 0))
+ {
+ var p = prev[cur];
+ path.Add(p.Edge);
+ cur = (p.FromNode, p.FromHop);
+ }
+ path.Reverse();
+ return path;
+ }
+}
diff --git a/src/Everything2Everything.Core/Providers/ConversionPair.cs b/src/Everything2Everything.Core/Providers/ConversionPair.cs
index 07fe8dc..ec994ac 100644
--- a/src/Everything2Everything.Core/Providers/ConversionPair.cs
+++ b/src/Everything2Everything.Core/Providers/ConversionPair.cs
@@ -1,9 +1,42 @@
namespace Everything2Everything.Core.Providers;
-public sealed record ConversionPair(string InputExtension, string OutputExtension)
+///
+/// 변환 1홉의 품질 손실 등급. 멀티홉 경로 선택(Dijkstra 가중치)과 UI '손실 변환' 배지의 단일 출처(SSOT).
+/// 손실은 곱셈적이므로 가중치를 -log(보존율) 근사로 두면 덧셈 최단경로가 곧 최대 품질보존 경로가 된다.
+///
+public enum LossClass
{
- public static ConversionPair Of(string input, string output)
- => new(Normalize(input), Normalize(output));
+ /// 무손실 — 픽셀/내용 완전 보존 (예: png→png 메타 정리, 컨테이너 무손실 재포장).
+ Lossless,
+ /// 컨테이너/구조 변경, 내용 보존 (예: pdf 구조 재압축, tiff 압축 방식 변경).
+ Container,
+ /// 재인코딩/손실 압축 (예: jpg 품질 인코딩, docx→pdf 렌더).
+ Recode,
+ /// 래스터화 — 벡터/텍스트의 편집성 상실 (예: pdf/svg→png). 단방향 손실 절벽.
+ Rasterize,
+}
+
+/// LossClass → 그래프 엣지 가중치. 클수록 회피된다.
+public static class LossWeights
+{
+ /// 홉마다 가산되는 페널티. 동일 품질이면 홉이 적은 경로를 선호하게 한다.
+ public const double HopPenalty = 0.1;
+
+ public static double Of(LossClass loss) => loss switch
+ {
+ LossClass.Lossless => 0.05,
+ LossClass.Container => 0.15,
+ LossClass.Recode => 0.50,
+ LossClass.Rasterize => 1.20,
+ _ => 0.50,
+ };
+}
+
+/// 단일 변환 쌍 (입력 확장자 → 출력 확장자) + 품질 손실 등급.
+public sealed record ConversionPair(string InputExtension, string OutputExtension, LossClass Loss = LossClass.Recode)
+{
+ public static ConversionPair Of(string input, string output, LossClass loss = LossClass.Recode)
+ => new(Normalize(input), Normalize(output), loss);
public static string Normalize(string ext)
{
diff --git a/src/Everything2Everything.Core/Providers/ProviderCapability.cs b/src/Everything2Everything.Core/Providers/ProviderCapability.cs
index e8688df..5fa0714 100644
--- a/src/Everything2Everything.Core/Providers/ProviderCapability.cs
+++ b/src/Everything2Everything.Core/Providers/ProviderCapability.cs
@@ -59,17 +59,24 @@ public sealed record ProviderCapability(
.Distinct(StringComparer.OrdinalIgnoreCase);
}
- public static IReadOnlyList PairsFromMatrix(IEnumerable inputs, IEnumerable outputs)
+ public static IReadOnlyList PairsFromMatrix(IEnumerable inputs, IEnumerable outputs, LossClass loss = LossClass.Recode)
{
var inputList = inputs.Select(ConversionPair.Normalize).ToList();
var outputList = outputs.Select(ConversionPair.Normalize).ToList();
var pairs = new List(inputList.Count * outputList.Count);
foreach (var i in inputList)
foreach (var o in outputList)
- pairs.Add(new ConversionPair(i, o));
+ if (!string.Equals(i, o, StringComparison.OrdinalIgnoreCase)) // 동일포맷 자기쌍 제외 (png→png 재인코딩 회귀 방지)
+ pairs.Add(new ConversionPair(i, o, loss));
return pairs;
}
- public static IReadOnlyList PairsToSingleOutput(IEnumerable inputs, string output)
- => inputs.Select(i => ConversionPair.Of(i, output)).ToList();
+ public static IReadOnlyList PairsToSingleOutput(IEnumerable inputs, string output, LossClass loss = LossClass.Recode)
+ {
+ var o = ConversionPair.Normalize(output);
+ return inputs.Select(ConversionPair.Normalize)
+ .Where(i => !string.Equals(i, o, StringComparison.OrdinalIgnoreCase))
+ .Select(i => new ConversionPair(i, o, loss))
+ .ToList();
+ }
}
diff --git a/src/Everything2Everything.Core/Providers/ProviderRegistry.cs b/src/Everything2Everything.Core/Providers/ProviderRegistry.cs
index 5d538b5..09dcd68 100644
--- a/src/Everything2Everything.Core/Providers/ProviderRegistry.cs
+++ b/src/Everything2Everything.Core/Providers/ProviderRegistry.cs
@@ -9,6 +9,7 @@ public sealed class ProviderRegistry
= new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet _allInputs = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet _allOutputs = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConversionGraph _graph = new();
public ProviderRegistry(IEnumerable providers)
{
@@ -26,10 +27,14 @@ public sealed class ProviderRegistry
list.Add(pair.OutputExtension);
_allInputs.Add(pair.InputExtension);
_allOutputs.Add(pair.OutputExtension);
+ _graph.AddEdge(pair.InputExtension, pair.OutputExtension, provider, pair.Loss);
}
}
}
+ /// 모든 Provider 능력으로부터 빌드된 변환 그래프. 엔진의 멀티홉 경로 탐색에 사용.
+ public ConversionGraph Graph => _graph;
+
public IReadOnlyList All => _providers;
public IEnumerable Implemented => _providers.Where(p => p.Capability.IsImplemented);
diff --git a/src/Everything2Everything.Tests/ConversionGraphTests.cs b/src/Everything2Everything.Tests/ConversionGraphTests.cs
new file mode 100644
index 0000000..7843a78
--- /dev/null
+++ b/src/Everything2Everything.Tests/ConversionGraphTests.cs
@@ -0,0 +1,210 @@
+using System.IO;
+using Everything2Everything.Core;
+using Everything2Everything.Core.Providers;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+/// 가짜 Provider — 그래프 경로 탐색 단위 테스트용. 실제 변환은 더미 파일 경로를 반환한다.
+internal sealed class FakeProvider : IConverterProvider
+{
+ public ProviderCapability Capability { get; }
+
+ public FakeProvider(string id, params ConversionPair[] pairs)
+ {
+ Capability = new ProviderCapability(
+ Id: id,
+ DisplayName: id,
+ SupportedConversions: pairs,
+ Status: ProviderStatus.Available,
+ Summary: id,
+ ExternalDependencies: Array.Empty());
+ }
+
+ public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
+ => Task.FromResult(ProviderAvailability.Ready);
+
+ public Task ConvertAsync(string sourcePath, string outputDirectory, string outputExtension,
+ ConvertOptions options, IProgress? progress, CancellationToken cancellationToken)
+ => Task.FromResult(ConvertResult.Ok(sourcePath,
+ new[] { Path.Combine(outputDirectory, Path.GetFileNameWithoutExtension(sourcePath) + outputExtension) }));
+}
+
+public class ConversionGraphTests
+{
+ private static ConversionGraph Build(params (string from, string to, LossClass loss)[] edges)
+ {
+ var g = new ConversionGraph();
+ var p = new FakeProvider("fake");
+ foreach (var (from, to, loss) in edges)
+ g.AddEdge(from, to, p, loss);
+ return g;
+ }
+
+ [Fact]
+ public void DirectEdge_IsSingleHop()
+ {
+ var g = Build((".a", ".b", LossClass.Recode));
+ var path = g.FindBestPath(".a", ".b");
+ Assert.NotNull(path);
+ Assert.Single(path);
+ Assert.Equal(".a", path![0].From);
+ Assert.Equal(".b", path[0].To);
+ }
+
+ [Fact]
+ public void MultiHop_IsAutoComposed()
+ {
+ var g = Build((".a", ".b", LossClass.Recode), (".b", ".c", LossClass.Recode));
+ var path = g.FindBestPath(".a", ".c");
+ Assert.NotNull(path);
+ Assert.Equal(2, path!.Count);
+ Assert.Equal(".a", path[0].From);
+ Assert.Equal(".b", path[0].To);
+ Assert.Equal(".c", path[1].To);
+ }
+
+ [Fact]
+ public void NoPath_ReturnsNull()
+ {
+ var g = Build((".a", ".b", LossClass.Recode));
+ Assert.Null(g.FindBestPath(".a", ".z"));
+ Assert.Null(g.FindBestPath(".x", ".b"));
+ }
+
+ [Fact]
+ public void SameFormat_SelfEdge_IsReturned()
+ {
+ // pdf→pdf 압축 같은 동일포맷 self-edge는 경로로 반환된다.
+ var g = Build((".pdf", ".pdf", LossClass.Container));
+ var path = g.FindBestPath(".pdf", ".pdf");
+ Assert.NotNull(path);
+ Assert.Single(path);
+ Assert.Equal(".pdf", path![0].To);
+ }
+
+ [Fact]
+ public void SameFormat_WithoutSelfEdge_ReturnsNull()
+ {
+ // self-edge가 없으면 동일포맷은 경로 없음 → 엔진이 Skip 처리한다.
+ var g = Build((".a", ".b", LossClass.Recode));
+ Assert.Null(g.FindBestPath(".a", ".a"));
+ }
+
+ [Fact]
+ public void MaxHops_LimitsPathLength()
+ {
+ var g = Build((".a", ".b", LossClass.Recode), (".b", ".c", LossClass.Recode), (".c", ".d", LossClass.Recode));
+ Assert.NotNull(g.FindBestPath(".a", ".d", maxHops: 3));
+ Assert.Null(g.FindBestPath(".a", ".d", maxHops: 2));
+ }
+
+ [Fact]
+ public void AvoidLossy_SkipsRasterizeEdges()
+ {
+ // a→z 직접은 Rasterize, a→b→z는 무손실 경로. allowLossy=false면 후자만.
+ var g = Build(
+ (".a", ".z", LossClass.Rasterize),
+ (".a", ".b", LossClass.Lossless),
+ (".b", ".z", LossClass.Lossless));
+
+ var lossy = g.FindBestPath(".a", ".z", allowLossy: true);
+ Assert.NotNull(lossy);
+
+ var safe = g.FindBestPath(".a", ".z", maxHops: 3, allowLossy: false);
+ Assert.NotNull(safe);
+ Assert.Equal(2, safe!.Count); // 래스터 직접 엣지를 피해 우회
+ }
+
+ [Fact]
+ public void LowestLoss_PathIsPreferred()
+ {
+ // a→c 직접(Rasterize, 큰 손실) vs a→b→c(둘 다 Lossless). Dijkstra는 무손실 우회를 택한다.
+ var g = Build(
+ (".a", ".c", LossClass.Rasterize),
+ (".a", ".b", LossClass.Lossless),
+ (".b", ".c", LossClass.Lossless));
+ var path = g.FindBestPath(".a", ".c");
+ Assert.NotNull(path);
+ Assert.Equal(2, path!.Count); // 손실 적은 멀티홉 선호
+ }
+
+ [Fact]
+ public void ReachableOutputs_ComputesTransitiveClosure()
+ {
+ var g = Build((".a", ".b", LossClass.Recode), (".b", ".c", LossClass.Recode), (".c", ".d", LossClass.Recode));
+ var reach = g.ReachableOutputs(".a", maxHops: 3);
+ Assert.Contains(".b", reach);
+ Assert.Contains(".c", reach);
+ Assert.Contains(".d", reach);
+ }
+
+ [Fact]
+ public void HopConstrained_NotPoisonedByCheaperLongerRoute()
+ {
+ // 회귀: .a→.x→.b(무손실 2홉, 저비용)가 .b의 홉을 2로 오염시켜
+ // .a→.b→.goal(2홉) 유효 경로를 막던 false negative 방지. (적대적 리뷰 confirmed #1)
+ var g = Build(
+ (".a", ".x", LossClass.Lossless),
+ (".x", ".b", LossClass.Lossless),
+ (".a", ".b", LossClass.Recode),
+ (".b", ".goal", LossClass.Recode));
+ var path = g.FindBestPath(".a", ".goal", maxHops: 2);
+ Assert.NotNull(path);
+ Assert.Equal(2, path!.Count);
+ Assert.Equal(".a", path[0].From);
+ Assert.Equal(".b", path[0].To);
+ Assert.Equal(".goal", path[1].To);
+ }
+
+ [Fact]
+ public void ReachableOutputs_AllHaveFindablePath()
+ {
+ // 불변식: ReachableOutputs가 반환한 모든 출력은 FindBestPath로 실제 경로가 나와야 한다. (confirmed #2)
+ var g = Build(
+ (".a", ".b", LossClass.Recode),
+ (".b", ".c", LossClass.Recode),
+ (".a", ".x", LossClass.Lossless),
+ (".x", ".c", LossClass.Lossless),
+ (".c", ".d", LossClass.Recode));
+ foreach (var outExt in g.ReachableOutputs(".a", maxHops: 3))
+ Assert.NotNull(g.FindBestPath(".a", outExt, maxHops: 3));
+ }
+
+ [Fact]
+ public void PairsFromMatrix_ExcludesSelfPairs()
+ {
+ // 회귀: 자기쌍(png→png)이 제외되어 동일포맷 재인코딩이 발생하지 않는다. (confirmed #8)
+ var pairs = ProviderCapability.PairsFromMatrix(
+ new[] { ".png", ".jpg" }, new[] { ".png", ".jpg", ".webp" });
+ Assert.DoesNotContain(pairs, p => p.InputExtension == p.OutputExtension);
+ Assert.Contains(pairs, p => p.InputExtension == ".png" && p.OutputExtension == ".webp");
+ }
+}
+
+public class RegistryGraphIntegrationTests
+{
+ [Fact]
+ public void DefaultEngine_BuildsGraph_WithKnownEdges()
+ {
+ var engine = Everything2EverythingBootstrap.CreateDefault();
+ var graph = engine.Providers.Graph;
+
+ // PdfToolProvider가 pdf→pdf 압축 엣지를 등록했는지 (P1 신규)
+ var pdfCompress = graph.FindBestPath(".pdf", ".pdf");
+ Assert.NotNull(pdfCompress);
+
+ // 기존 변환 능력이 그래프에 반영되는지 (png은 입력 노드로 존재)
+ Assert.True(graph.HasNode(".png"));
+ }
+
+ [Fact]
+ public void DefaultEngine_PngReachesPdf()
+ {
+ var engine = Everything2EverythingBootstrap.CreateDefault();
+ var graph = engine.Providers.Graph;
+ // png→pdf 경로(직접 또는 멀티홉)가 존재해야 한다.
+ var path = graph.FindBestPath(".png", ".pdf", maxHops: 3);
+ Assert.NotNull(path);
+ }
+}
diff --git a/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj b/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj
new file mode 100644
index 0000000..27c108e
--- /dev/null
+++ b/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net9.0-windows10.0.19041.0
+ enable
+ enable
+ false
+ true
+ $(NoWarn);NU1901;NU1902;NU1903;NU1904
+
+
+
+
+
+
+
+
+
+
+
+
+