diff --git a/Everything2Everything.slnx b/Everything2Everything.slnx index 1224e0b..30ba018 100644 --- a/Everything2Everything.slnx +++ b/Everything2Everything.slnx @@ -1,4 +1,5 @@ + diff --git a/docs/ssot/PLAN.md b/docs/ssot/PLAN.md index ec46bf2..4afc659 100644 --- a/docs/ssot/PLAN.md +++ b/docs/ssot/PLAN.md @@ -100,7 +100,7 @@ Everything2Everything의 북극성은 "세상의 모든 변환을 원자(atomic) ## 실행 로드맵 -### P1 · 그래프 엔진 도입 + 즉시 체감 가치(PDF 압축) `effort:L` `risk:medium` `status:planned` +### P1 · 그래프 엔진 도입 + 즉시 체감 가치(PDF 압축) `effort:L` `risk:medium` `status:done` **목표:** ProviderRegistry를 ConversionGraph로 승격하고 멀티홉 경로 탐색을 엔진에 내장한다. 동시에 PDF 압축이라는 즉시 체감 신기능을 출시해 '보이지 않는 리팩터링의 함정'을 회피한다. **산출물:** diff --git a/docs/ssot/_data/status.json b/docs/ssot/_data/status.json index 784e757..fe38ce5 100644 --- a/docs/ssot/_data/status.json +++ b/docs/ssot/_data/status.json @@ -1,6 +1,6 @@ { "_comment": "로드맵 단계별 진행 상태. 값: planned | in_progress | done. 갱신 후 `python build.py`로 index.html/PLAN.md 재생성.", - "P1": "planned", + "P1": "done", "P2": "planned", "P3": "planned", "P4": "planned", diff --git a/docs/ssot/index.html b/docs/ssot/index.html index afea5cd..7c1f018 100644 --- a/docs/ssot/index.html +++ b/docs/ssot/index.html @@ -395,7 +395,7 @@ footer{border-top:1px solid var(--line2);margin-top:60px;padding:30px 0;color:va
P1
그래프 엔진 도입 + 즉시 체감 가치(PDF 압축)
ProviderRegistry를 ConversionGraph로 승격하고 멀티홉 경로 탐색을 엔진에 내장한다. 동시에 PDF 압축이라는 즉시 체감 신기능을 출시해 '보이지 않는 리팩터링의 함정'을 회피한다.
-
예정LRISK medium
+
완료LRISK medium

산출물

  • 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 + + + + + + + + + + + + +