feat(P1): 변환 그래프 엔진 + 멀티홉 경로탐색 + PDF 압축
ProviderRegistry를 ConversionGraph로 승격하고 ConversionEngine이 자체 Dijkstra로 멀티홉 경로를 자동 합성하도록 전환. 손실을 LossClass 가중치로 SSOT화. - ConversionGraph: (node,hop) 라벨링 Dijkstra로 홉 제약 최단경로 (일반 Dijkstra의 hops 오염 false negative 회피) - ConversionEngine.ExecuteChainAsync: 멀티홉 체이닝, 중간 다중출력 거부, 마지막 홉 Status 존중 - PdfToolProvider: pdf->pdf 구조 압축 (PDFsharp, Save 원자화) - PairsFromMatrix 자기쌍 제외 (png->png 재인코딩 회귀 방지), PDF->이미지 Rasterize 표시 - xUnit 테스트 프로젝트 신설 + 그래프/회귀 14개 통과 - 적대적 병렬 리뷰(27 agents)로 확정된 13건 중 정확성 핵심 수정 SSOT: P1 done
This commit is contained in:
parent
232f453e15
commit
e3ef59e009
16 changed files with 641 additions and 30 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 변환 경로(1홉 또는 멀티홉)를 순차 실행한다. 중간 산출물은 임시 작업폴더에 체이닝하고
|
||||
/// 마지막 홉만 실제 출력 폴더에 쓴다. 각 홉 시작 전 Provider 가용성을 점검한다.
|
||||
/// </summary>
|
||||
private async Task<ConvertResult> ExecuteChainAsync(
|
||||
string sourcePath,
|
||||
string finalOutputExtension,
|
||||
IReadOnlyList<ConversionGraph.Edge> path,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var edge in path)
|
||||
{
|
||||
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
|
||||
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<string>();
|
||||
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<double>(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 { /* 임시 정리 실패 무시 */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,18 @@ public sealed class ConvertOptions
|
|||
|
||||
public OcrOptions Ocr { get; set; } = new();
|
||||
|
||||
// --- 변환 그래프 경로 옵션 (P1) ---
|
||||
/// <summary>멀티홉 경로 자동 합성 허용. false면 직접(1홉) 변환만.</summary>
|
||||
public bool AllowMultiHop { get; set; } = true;
|
||||
|
||||
/// <summary>멀티홉 최대 홉 수.</summary>
|
||||
public int MaxHops { get; set; } = 3;
|
||||
|
||||
/// <summary>래스터화 같은 큰 손실 엣지를 회피한다.</summary>
|
||||
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
|
||||
{
|
||||
/// <summary>Light(구조 최적화·무손실) | Strong(렌더 재인코딩) | Max(Ghostscript). P1은 Light만 구현.</summary>
|
||||
public string Level { get; set; } = "Light";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ExternalDependency>(),
|
||||
|
|
|
|||
88
src/Everything2Everything.Core/Converters/PdfToolProvider.cs
Normal file
88
src/Everything2Everything.Core/Converters/PdfToolProvider.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
using PdfSharp.Pdf;
|
||||
using PdfSharp.Pdf.IO;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// PDF 재압축/최적화 Provider. pdf→pdf 동일포맷 변환을 그래프 엣지로 노출해
|
||||
/// ConversionEngine의 동일포맷 Skip을 우회한다. P1은 PDFsharp(MIT) in-process 구조 최적화(Light).
|
||||
/// Strong(렌더 재인코딩)·Max(Ghostscript 외부)는 후속 단계에서 확장.
|
||||
/// </summary>
|
||||
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<ExternalDependency>(),
|
||||
RoadmapNote: "Strong(렌더 재인코딩)·Max(Ghostscript) 압축 레벨은 후속 추가 예정");
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(ProviderAvailability.Ready);
|
||||
|
||||
public Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.Run(() => Compress(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static ConvertResult Compress(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
134
src/Everything2Everything.Core/Providers/ConversionGraph.cs
Normal file
134
src/Everything2Everything.Core/Providers/ConversionGraph.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
namespace Everything2Everything.Core.Providers;
|
||||
|
||||
/// <summary>
|
||||
/// 모든 Provider의 변환 능력을 방향 그래프로 합성한다. 노드=확장자, 엣지=Provider+LossClass 가중치.
|
||||
/// 자체 Dijkstra(외부 의존성 0, .NET PriorityQueue)로 임의의 입력→출력 멀티홉 경로를 최저 손실로 탐색한다.
|
||||
/// 홉 제약(maxHops)이 있는 최단경로이므로 상태를 (노드, 홉수)로 두는 라벨링 Dijkstra를 쓴다.
|
||||
/// (일반 Dijkstra는 hops 값이 '최저비용 경로의 홉'으로 오염돼 유효한 경로를 놓치는 false negative가 생긴다.)
|
||||
/// </summary>
|
||||
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<string, List<Edge>> _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<Edge>();
|
||||
list.Add(new Edge(f, t, provider, loss));
|
||||
}
|
||||
|
||||
public bool HasNode(string ext) => _adj.ContainsKey(ConversionPair.Normalize(ext));
|
||||
|
||||
public IReadOnlyList<Edge> EdgesFrom(string ext)
|
||||
=> _adj.TryGetValue(ConversionPair.Normalize(ext), out var list) ? list : Array.Empty<Edge>();
|
||||
|
||||
/// <summary>
|
||||
/// 입력에서 도달 가능한 모든 출력 확장자 (홉 제약 도달성). FindBestPath와 동일한 (노드,홉) 도달 기준을 공유하므로
|
||||
/// "ReachableOutputs가 반환한 모든 X에 대해 FindBestPath(input, X) != null" 불변식이 성립한다.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> ReachableOutputs(string inputExt, int maxHops = 3, bool allowLossy = true)
|
||||
{
|
||||
var start = ConversionPair.Normalize(inputExt);
|
||||
if (maxHops < 1) maxHops = 1;
|
||||
var result = new HashSet<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입력→출력 최저 손실 경로 (홉 ≤ maxHops). 직접(1홉) 엣지가 있으면 홉 페널티로 거의 항상 최소가 된다.
|
||||
/// 동일포맷(start==goal)은 self-edge(압축 등)가 있을 때만 경로를 반환하고, 없으면 null(엔진이 Skip 처리).
|
||||
/// </summary>
|
||||
public IReadOnlyList<Edge>? 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<Edge>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,42 @@
|
|||
namespace Everything2Everything.Core.Providers;
|
||||
|
||||
public sealed record ConversionPair(string InputExtension, string OutputExtension)
|
||||
/// <summary>
|
||||
/// 변환 1홉의 품질 손실 등급. 멀티홉 경로 선택(Dijkstra 가중치)과 UI '손실 변환' 배지의 단일 출처(SSOT).
|
||||
/// 손실은 곱셈적이므로 가중치를 -log(보존율) 근사로 두면 덧셈 최단경로가 곧 최대 품질보존 경로가 된다.
|
||||
/// </summary>
|
||||
public enum LossClass
|
||||
{
|
||||
public static ConversionPair Of(string input, string output)
|
||||
=> new(Normalize(input), Normalize(output));
|
||||
/// <summary>무손실 — 픽셀/내용 완전 보존 (예: png→png 메타 정리, 컨테이너 무손실 재포장).</summary>
|
||||
Lossless,
|
||||
/// <summary>컨테이너/구조 변경, 내용 보존 (예: pdf 구조 재압축, tiff 압축 방식 변경).</summary>
|
||||
Container,
|
||||
/// <summary>재인코딩/손실 압축 (예: jpg 품질 인코딩, docx→pdf 렌더).</summary>
|
||||
Recode,
|
||||
/// <summary>래스터화 — 벡터/텍스트의 편집성 상실 (예: pdf/svg→png). 단방향 손실 절벽.</summary>
|
||||
Rasterize,
|
||||
}
|
||||
|
||||
/// <summary>LossClass → 그래프 엣지 가중치. 클수록 회피된다.</summary>
|
||||
public static class LossWeights
|
||||
{
|
||||
/// <summary>홉마다 가산되는 페널티. 동일 품질이면 홉이 적은 경로를 선호하게 한다.</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>단일 변환 쌍 (입력 확장자 → 출력 확장자) + 품질 손실 등급.</summary>
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,17 +59,24 @@ public sealed record ProviderCapability(
|
|||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ConversionPair> PairsFromMatrix(IEnumerable<string> inputs, IEnumerable<string> outputs)
|
||||
public static IReadOnlyList<ConversionPair> PairsFromMatrix(IEnumerable<string> inputs, IEnumerable<string> outputs, LossClass loss = LossClass.Recode)
|
||||
{
|
||||
var inputList = inputs.Select(ConversionPair.Normalize).ToList();
|
||||
var outputList = outputs.Select(ConversionPair.Normalize).ToList();
|
||||
var pairs = new List<ConversionPair>(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<ConversionPair> PairsToSingleOutput(IEnumerable<string> inputs, string output)
|
||||
=> inputs.Select(i => ConversionPair.Of(i, output)).ToList();
|
||||
public static IReadOnlyList<ConversionPair> PairsToSingleOutput(IEnumerable<string> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ public sealed class ProviderRegistry
|
|||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _allInputs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _allOutputs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConversionGraph _graph = new();
|
||||
|
||||
public ProviderRegistry(IEnumerable<IConverterProvider> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>모든 Provider 능력으로부터 빌드된 변환 그래프. 엔진의 멀티홉 경로 탐색에 사용.</summary>
|
||||
public ConversionGraph Graph => _graph;
|
||||
|
||||
public IReadOnlyList<IConverterProvider> All => _providers;
|
||||
|
||||
public IEnumerable<IConverterProvider> Implemented => _providers.Where(p => p.Capability.IsImplemented);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue