diff --git a/src/Everything2Everything.Core/Converters/DataProvider.cs b/src/Everything2Everything.Core/Converters/DataProvider.cs
new file mode 100644
index 0000000..4ad7736
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/DataProvider.cs
@@ -0,0 +1,215 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using ClosedXML.Excel;
+using CsvHelper;
+using Everything2Everything.Core.Providers;
+
+namespace Everything2Everything.Core.Converters;
+
+///
+/// 표(tabular) 데이터 양방향 변환기. 원자 엣지 csv↔json, csv↔xlsx만 선언하고
+/// json↔xlsx는 그래프가 csv 경유로 자동 합성한다(도그푸딩). 순수 .NET, 외부 도구 불필요.
+///
+public sealed class DataProvider : IConverterProvider
+{
+ public ProviderCapability Capability { get; } = new(
+ Id: "data",
+ DisplayName: "표 데이터 (CSV/JSON/XLSX)",
+ SupportedConversions: new[]
+ {
+ new ConversionPair(".csv", ".json", LossClass.Container),
+ new ConversionPair(".json", ".csv", LossClass.Container),
+ new ConversionPair(".csv", ".xlsx", LossClass.Container),
+ new ConversionPair(".xlsx", ".csv", LossClass.Container),
+ },
+ Status: ProviderStatus.Available,
+ Summary: "CSV·JSON·XLSX 표 데이터를 양방향 변환합니다 (json↔xlsx는 csv를 거쳐 자동 합성). 평면 표 데이터 기준.",
+ ExternalDependencies: Array.Empty(),
+ RoadmapNote: "중첩 JSON·다중 시트·Parquet는 후속 확장.");
+
+ 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(() => Convert(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken), cancellationToken);
+
+ private static ConvertResult Convert(
+ string sourcePath, string outputDirectory, string outputExtension,
+ ConvertOptions options, IProgress? progress, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ var inExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
+ var outExt = ConversionPair.Normalize(outputExtension);
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+
+ progress?.Report(0.1);
+ var tmp = outPath + ".tmp";
+ try
+ {
+ switch (inExt, outExt)
+ {
+ case (".csv", ".json"): CsvToJson(sourcePath, tmp, ct); break;
+ case (".json", ".csv"): JsonToCsv(sourcePath, tmp, ct); break;
+ case (".csv", ".xlsx"): CsvToXlsx(sourcePath, tmp, ct); break;
+ case (".xlsx", ".csv"): XlsxToCsv(sourcePath, tmp, ct); break;
+ default:
+ return ConvertResult.Fail(sourcePath, $"{inExt} → {outExt} 변환을 지원하지 않습니다.");
+ }
+
+ if (File.Exists(outPath)) File.Delete(outPath);
+ File.Move(tmp, outPath);
+ }
+ catch (Exception)
+ {
+ try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* 정리 실패 무시 */ }
+ throw;
+ }
+
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { outPath });
+ }
+
+ private static void CsvToJson(string source, string target, CancellationToken ct)
+ {
+ var rows = new List>();
+ using (var reader = new StreamReader(source))
+ using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
+ {
+ csv.Read();
+ csv.ReadHeader();
+ var headers = csv.HeaderRecord ?? Array.Empty();
+ while (csv.Read())
+ {
+ ct.ThrowIfCancellationRequested();
+ var row = new Dictionary(headers.Length);
+ foreach (var h in headers)
+ row[h] = InferValue(csv.GetField(h));
+ rows.Add(row);
+ }
+ }
+ var json = JsonSerializer.Serialize(rows, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ });
+ File.WriteAllText(target, json, new UTF8Encoding(false));
+ }
+
+ private static void JsonToCsv(string source, string target, CancellationToken ct)
+ {
+ var json = File.ReadAllText(source);
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.ValueKind != JsonValueKind.Array)
+ throw new NotSupportedException("JSON 최상위가 객체 배열이어야 CSV로 변환할 수 있습니다.");
+
+ // 헤더 = 모든 객체 키의 합집합(첫 등장 순서 보존)
+ var headers = new List();
+ var seen = new HashSet();
+ foreach (var el in doc.RootElement.EnumerateArray())
+ {
+ if (el.ValueKind != JsonValueKind.Object)
+ throw new NotSupportedException("JSON 배열 원소는 모두 객체여야 합니다.");
+ foreach (var prop in el.EnumerateObject())
+ if (seen.Add(prop.Name)) headers.Add(prop.Name);
+ }
+
+ using var writer = new StreamWriter(target, false, new UTF8Encoding(false));
+ using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
+ foreach (var h in headers) csv.WriteField(h);
+ csv.NextRecord();
+ foreach (var el in doc.RootElement.EnumerateArray())
+ {
+ ct.ThrowIfCancellationRequested();
+ foreach (var h in headers)
+ csv.WriteField(el.TryGetProperty(h, out var v) ? JsonToField(v) : "");
+ csv.NextRecord();
+ }
+ }
+
+ private static void CsvToXlsx(string source, string target, CancellationToken ct)
+ {
+ using var workbook = new XLWorkbook();
+ var ws = workbook.AddWorksheet("Sheet1");
+ using (var reader = new StreamReader(source))
+ using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
+ {
+ csv.Read();
+ csv.ReadHeader();
+ var headers = csv.HeaderRecord ?? Array.Empty();
+ for (var c = 0; c < headers.Length; c++)
+ ws.Cell(1, c + 1).Value = headers[c];
+
+ var row = 2;
+ while (csv.Read())
+ {
+ ct.ThrowIfCancellationRequested();
+ for (var c = 0; c < headers.Length; c++)
+ {
+ var field = csv.GetField(headers[c]);
+ var cell = ws.Cell(row, c + 1);
+ // 숫자/불리언은 형식 유지, 나머지는 문자열
+ if (InferValue(field) is { } val && val is not string)
+ cell.Value = val switch
+ {
+ bool b => b,
+ long l => l,
+ double d => d,
+ _ => field ?? string.Empty,
+ };
+ else
+ cell.Value = field ?? string.Empty;
+ }
+ row++;
+ }
+ }
+ using (var fs = File.Create(target))
+ workbook.SaveAs(fs);
+ }
+
+ private static void XlsxToCsv(string source, string target, CancellationToken ct)
+ {
+ using var workbook = new XLWorkbook(source);
+ var ws = workbook.Worksheets.First();
+ var range = ws.RangeUsed();
+
+ using var writer = new StreamWriter(target, false, new UTF8Encoding(false));
+ using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
+ if (range is not null)
+ {
+ foreach (var r in range.Rows())
+ {
+ ct.ThrowIfCancellationRequested();
+ foreach (var cell in r.Cells())
+ csv.WriteField(cell.GetString());
+ csv.NextRecord();
+ }
+ }
+ }
+
+ private static object? InferValue(string? s)
+ {
+ if (string.IsNullOrEmpty(s)) return null;
+ if (bool.TryParse(s, out var b)) return b;
+ // 선행 0이 있는 값(우편번호 등)은 문자열로 보존
+ if (s.Length > 1 && s[0] == '0' && char.IsDigit(s[1])) return s;
+ if (long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) return l;
+ if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) return d;
+ return s;
+ }
+
+ private static string JsonToField(JsonElement el) => el.ValueKind switch
+ {
+ JsonValueKind.String => el.GetString() ?? "",
+ JsonValueKind.Null => "",
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ JsonValueKind.Number => el.GetRawText(),
+ _ => el.GetRawText(),
+ };
+}
diff --git a/src/Everything2Everything.Core/Converters/DocxProvider.cs b/src/Everything2Everything.Core/Converters/DocxProvider.cs
index c3c2959..6fd7793 100644
--- a/src/Everything2Everything.Core/Converters/DocxProvider.cs
+++ b/src/Everything2Everything.Core/Converters/DocxProvider.cs
@@ -7,8 +7,8 @@ public sealed class DocxProvider : IConverterProvider
{
private static readonly string[] DocxInputs = { ".docx", ".doc" };
- private static readonly string[] DocxOutputs =
- { ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
+ private static readonly string[] DocxImageOutputs =
+ { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
private readonly PdfProvider _pdfProvider;
@@ -20,7 +20,8 @@ public sealed class DocxProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "docx",
DisplayName: "Word 문서 (DOCX)",
- SupportedConversions: ProviderCapability.PairsFromMatrix(DocxInputs, DocxOutputs),
+ SupportedConversions: ProviderCapability.PairsFromMatrix(DocxInputs, new[] { ".pdf" }, LossClass.Recode)
+ .Concat(ProviderCapability.PairsFromMatrix(DocxInputs, DocxImageOutputs, LossClass.Rasterize)).ToList(),
Status: ProviderStatus.RequiresExternal,
Summary: "DOCX/DOC을 PDF로 변환하거나 페이지별 이미지로 렌더링합니다.",
ExternalDependencies: new[]
diff --git a/src/Everything2Everything.Core/Converters/HtmlProvider.cs b/src/Everything2Everything.Core/Converters/HtmlProvider.cs
index eb9abc7..9db4759 100644
--- a/src/Everything2Everything.Core/Converters/HtmlProvider.cs
+++ b/src/Everything2Everything.Core/Converters/HtmlProvider.cs
@@ -12,13 +12,14 @@ public sealed class HtmlProvider : IConverterProvider
{
private static readonly string[] HtmlInputs = { ".html", ".htm" };
- private static readonly string[] HtmlOutputs =
- { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".pdf" };
+ private static readonly string[] HtmlImageOutputs =
+ { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
public ProviderCapability Capability { get; } = new(
Id: "html",
DisplayName: "HTML / 웹 페이지",
- SupportedConversions: ProviderCapability.PairsFromMatrix(HtmlInputs, HtmlOutputs),
+ SupportedConversions: ProviderCapability.PairsFromMatrix(HtmlInputs, new[] { ".pdf" }, LossClass.Recode)
+ .Concat(ProviderCapability.PairsFromMatrix(HtmlInputs, HtmlImageOutputs, LossClass.Rasterize)).ToList(),
Status: ProviderStatus.Available,
Summary: "HTML/HTM을 WebView2로 헤드리스 렌더링하여 이미지 또는 PDF로 저장합니다.",
ExternalDependencies: new[]
diff --git a/src/Everything2Everything.Core/Converters/HwpxProvider.cs b/src/Everything2Everything.Core/Converters/HwpxProvider.cs
index 2cd2fb4..fbcfe36 100644
--- a/src/Everything2Everything.Core/Converters/HwpxProvider.cs
+++ b/src/Everything2Everything.Core/Converters/HwpxProvider.cs
@@ -7,8 +7,8 @@ public sealed class HwpxProvider : IConverterProvider
{
private static readonly string[] HwpInputs = { ".hwp", ".hwpx" };
- private static readonly string[] HwpOutputs =
- { ".pdf", ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
+ private static readonly string[] HwpImageOutputs =
+ { ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
private readonly PdfProvider _pdfProvider;
@@ -22,7 +22,8 @@ public sealed class HwpxProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "hwpx",
DisplayName: "한글 문서 (HWP / HWPX)",
- SupportedConversions: ProviderCapability.PairsFromMatrix(HwpInputs, HwpOutputs),
+ SupportedConversions: ProviderCapability.PairsFromMatrix(HwpInputs, new[] { ".pdf" }, LossClass.Recode)
+ .Concat(ProviderCapability.PairsFromMatrix(HwpInputs, HwpImageOutputs, LossClass.Rasterize)).ToList(),
Status: ProviderStatus.RequiresExternal,
Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 PDF 또는 페이지별 이미지로 저장합니다.",
ExternalDependencies: new[]
diff --git a/src/Everything2Everything.Core/Converters/ImageOptimProvider.cs b/src/Everything2Everything.Core/Converters/ImageOptimProvider.cs
new file mode 100644
index 0000000..de9d587
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/ImageOptimProvider.cs
@@ -0,0 +1,125 @@
+using Everything2Everything.Core.Providers;
+using ImageMagick;
+
+namespace Everything2Everything.Core.Converters;
+
+///
+/// 동일 포맷 이미지를 재인코딩해 용량을 최적화한다(PdfToolProvider의 이미지 버전).
+/// 명시적 self-edge(jpg→jpg 등)로 그래프에 등록되며, 출력이 입력보다 커지면 원본을 보존한다.
+///
+public sealed class ImageOptimProvider : IConverterProvider
+{
+ public ProviderCapability Capability { get; } = new(
+ Id: "image-optim",
+ DisplayName: "이미지 최적화 (재압축)",
+ SupportedConversions: new[]
+ {
+ new ConversionPair(".jpg", ".jpg", LossClass.Recode),
+ new ConversionPair(".jpeg", ".jpeg", LossClass.Recode),
+ new ConversionPair(".png", ".png", LossClass.Container),
+ new ConversionPair(".webp", ".webp", LossClass.Recode),
+ new ConversionPair(".tif", ".tif", LossClass.Container),
+ new ConversionPair(".tiff", ".tiff", LossClass.Container),
+ new ConversionPair(".bmp", ".bmp", LossClass.Container),
+ },
+ Status: ProviderStatus.Available,
+ Summary: "JPEG/PNG/WebP/TIFF/BMP를 같은 포맷으로 재압축해 용량을 줄입니다 (품질 옵션 적용).",
+ ExternalDependencies: Array.Empty(),
+ RoadmapNote: null);
+
+ 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(() => Optimize(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken), cancellationToken);
+
+ private static ConvertResult Optimize(
+ string sourcePath, string outputDirectory, string outputExtension,
+ ConvertOptions options, IProgress? progress, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ var outExt = ConversionPair.Normalize(outputExtension);
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, "_optimized", outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+
+ progress?.Report(0.1);
+ var tmp = outPath + ".tmp";
+ try
+ {
+ using (var image = new MagickImage(sourcePath))
+ {
+ try { image.AutoOrient(); } catch { /* orient 실패 무시 */ }
+
+ 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 });
+ }
+
+ ApplyEncoding(image, outExt, options);
+ ct.ThrowIfCancellationRequested();
+ image.Write(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 (Exception)
+ {
+ try { if (File.Exists(tmp)) File.Delete(tmp); } catch { /* 정리 실패 무시 */ }
+ throw;
+ }
+
+ progress?.Report(1.0);
+ return ConvertResult.Ok(sourcePath, new[] { outPath });
+ }
+
+ private static void ApplyEncoding(IMagickImage image, string outExt, ConvertOptions options)
+ {
+ switch (outExt)
+ {
+ case ".jpg":
+ case ".jpeg":
+ image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
+ image.Format = MagickFormat.Jpeg;
+ break;
+ case ".png":
+ image.Settings.SetDefine(MagickFormat.Png, "compression-level",
+ Math.Clamp(options.Png.Compression, 0, 9).ToString());
+ image.Format = MagickFormat.Png;
+ break;
+ case ".webp":
+ image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
+ if (options.Webp.Lossless)
+ image.Settings.SetDefine(MagickFormat.WebP, "lossless", "true");
+ image.Format = MagickFormat.WebP;
+ break;
+ case ".tif":
+ case ".tiff":
+ if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
+ image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression);
+ image.Format = MagickFormat.Tiff;
+ break;
+ case ".bmp":
+ image.Format = MagickFormat.Bmp;
+ break;
+ }
+ }
+}
diff --git a/src/Everything2Everything.Core/Converters/OcrProvider.cs b/src/Everything2Everything.Core/Converters/OcrProvider.cs
index 6c0459f..ae6b259 100644
--- a/src/Everything2Everything.Core/Converters/OcrProvider.cs
+++ b/src/Everything2Everything.Core/Converters/OcrProvider.cs
@@ -28,7 +28,7 @@ public sealed class OcrProvider : IConverterProvider
public ProviderCapability Capability { get; } = new(
Id: "ocr",
DisplayName: "OCR (이미지/PDF → 텍스트·DOCX)",
- SupportedConversions: ProviderCapability.PairsFromMatrix(OcrInputs, OcrOutputs),
+ SupportedConversions: ProviderCapability.PairsFromMatrix(OcrInputs, OcrOutputs, LossClass.Rasterize),
Status: ProviderStatus.Available,
Summary: "Windows OCR 엔진으로 이미지 또는 PDF 페이지에서 텍스트를 추출해 .txt 또는 .docx로 저장합니다.",
ExternalDependencies: Array.Empty(),
diff --git a/src/Everything2Everything.Core/Converters/VectorProvider.cs b/src/Everything2Everything.Core/Converters/VectorProvider.cs
new file mode 100644
index 0000000..5088d63
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/VectorProvider.cs
@@ -0,0 +1,111 @@
+using Everything2Everything.Core.Providers;
+using SkiaSharp;
+using Svg.Skia;
+
+namespace Everything2Everything.Core.Converters;
+
+///
+/// SVG 벡터를 PNG 래스터 또는 PDF로 렌더링한다. svg→jpg/webp/bmp/tiff 등은
+/// 그래프가 svg→png→X 로 자동 합성하므로 여기선 png·pdf 원자 엣지만 둔다. 순수 .NET(SkiaSharp).
+///
+public sealed class VectorProvider : IConverterProvider
+{
+ public ProviderCapability Capability { get; } = new(
+ Id: "vector",
+ DisplayName: "벡터 (SVG)",
+ SupportedConversions: new[]
+ {
+ new ConversionPair(".svg", ".png", LossClass.Rasterize),
+ new ConversionPair(".svg", ".pdf", LossClass.Recode),
+ },
+ Status: ProviderStatus.Available,
+ Summary: "SVG를 PNG로 래스터화하거나 PDF로 렌더링합니다 (다른 이미지 포맷은 PNG를 거쳐 자동 변환).",
+ ExternalDependencies: Array.Empty(),
+ RoadmapNote: null);
+
+ 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(() => Convert(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken), cancellationToken);
+
+ private static ConvertResult Convert(
+ string sourcePath, string outputDirectory, string outputExtension,
+ ConvertOptions options, IProgress? progress, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ var outExt = ConversionPair.Normalize(outputExtension);
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+
+ progress?.Report(0.1);
+
+ using var svg = new SKSvg();
+ var picture = svg.Load(sourcePath);
+ if (picture is null)
+ return ConvertResult.Fail(sourcePath, "SVG를 읽지 못했습니다.");
+
+ var rect = picture.CullRect;
+ var srcW = rect.Width > 0 ? rect.Width : 512f;
+ var srcH = rect.Height > 0 ? rect.Height : 512f;
+
+ // MaxLongEdgePixels에 맞춰 스케일 (png만 의미 — pdf는 벡터 보존)
+ var scale = 1f;
+ if (outExt == ".png" && options.MaxLongEdgePixels is int maxLong && maxLong > 0)
+ {
+ var longEdge = Math.Max(srcW, srcH);
+ if (longEdge > maxLong) scale = maxLong / longEdge;
+ }
+
+ var width = Math.Max(1, (int)Math.Ceiling(srcW * scale));
+ var height = Math.Max(1, (int)Math.Ceiling(srcH * scale));
+ ct.ThrowIfCancellationRequested();
+
+ var tmp = outPath + ".tmp";
+ try
+ {
+ if (outExt == ".pdf")
+ {
+ using (var stream = new SKFileWStream(tmp))
+ using (var document = SKDocument.CreatePdf(stream))
+ {
+ var canvas = document.BeginPage(srcW, srcH);
+ canvas.Clear(SKColors.White);
+ canvas.DrawPicture(picture);
+ document.EndPage();
+ document.Close();
+ }
+ }
+ else // .png
+ {
+ using var bitmap = new SKBitmap(width, height, SKColorType.Rgba8888, SKAlphaType.Premul);
+ using (var canvas = new SKCanvas(bitmap))
+ {
+ canvas.Clear(SKColors.Transparent);
+ if (scale != 1f) canvas.Scale(scale);
+ canvas.DrawPicture(picture);
+ canvas.Flush();
+ }
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Png, 100);
+ using var fs = File.Create(tmp);
+ data.SaveTo(fs);
+ }
+
+ if (File.Exists(outPath)) File.Delete(outPath);
+ File.Move(tmp, outPath);
+ }
+ catch (Exception)
+ {
+ 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 4fe1d94..dc1e9ad 100644
--- a/src/Everything2Everything.Core/Everything2Everything.Core.csproj
+++ b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
@@ -12,15 +12,18 @@
+
+
-
+
+
diff --git a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
index c26a46d..d873341 100644
--- a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
+++ b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
@@ -19,6 +19,9 @@ public static class Everything2EverythingBootstrap
new Converters.HwpxProvider(),
new Converters.OcrProvider(pdf),
new Converters.DocumentProvider(),
+ new Converters.DataProvider(),
+ new Converters.VectorProvider(),
+ new Converters.ImageOptimProvider(),
};
return new ConversionEngine(new ProviderRegistry(providers));
}
diff --git a/src/Everything2Everything.Tests/DataProviderTests.cs b/src/Everything2Everything.Tests/DataProviderTests.cs
new file mode 100644
index 0000000..e468302
--- /dev/null
+++ b/src/Everything2Everything.Tests/DataProviderTests.cs
@@ -0,0 +1,68 @@
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Everything2Everything.Core;
+using Everything2Everything.Core.Converters;
+using Everything2Everything.Core.Providers;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+public class DataProviderTests
+{
+ private static string TempDir()
+ {
+ var d = Path.Combine(Path.GetTempPath(), "e2e_test_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(d);
+ return d;
+ }
+
+ [Fact]
+ public async Task Csv_To_Json_To_Csv_Roundtrip()
+ {
+ var dir = TempDir();
+ var csv = Path.Combine(dir, "t.csv");
+ await File.WriteAllTextAsync(csv, "name,age\nAlice,30\nBob,25\n");
+
+ var p = new DataProvider();
+ var r1 = await p.ConvertAsync(csv, dir, ".json", new ConvertOptions(), null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r1.Status);
+ var json = await File.ReadAllTextAsync(r1.OutputPaths[0]);
+ Assert.Contains("Alice", json);
+ Assert.Contains("30", json);
+
+ var r2 = await p.ConvertAsync(r1.OutputPaths[0], dir, ".csv", new ConvertOptions(), null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r2.Status);
+ var csv2 = await File.ReadAllTextAsync(r2.OutputPaths[0]);
+ Assert.Contains("Alice", csv2);
+ Assert.Contains("age", csv2);
+ }
+
+ [Fact]
+ public async Task Csv_To_Xlsx_To_Csv_Roundtrip()
+ {
+ var dir = TempDir();
+ var csv = Path.Combine(dir, "t.csv");
+ await File.WriteAllTextAsync(csv, "a,b\n1,x\n2,y\n");
+
+ var p = new DataProvider();
+ var r1 = await p.ConvertAsync(csv, dir, ".xlsx", new ConvertOptions(), null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r1.Status);
+ Assert.True(new FileInfo(r1.OutputPaths[0]).Length > 0);
+
+ var r2 = await p.ConvertAsync(r1.OutputPaths[0], dir, ".csv", new ConvertOptions(), null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r2.Status);
+ var csv2 = await File.ReadAllTextAsync(r2.OutputPaths[0]);
+ Assert.Contains("a", csv2);
+ Assert.Contains("x", csv2);
+ }
+
+ [Fact]
+ public void DefaultGraph_JsonToXlsx_ComposedViaCsv()
+ {
+ var graph = Everything2EverythingBootstrap.CreateDefault().Providers.Graph;
+ var path = graph.FindBestPath(".json", ".xlsx", maxHops: 3);
+ Assert.NotNull(path);
+ Assert.Equal(2, path!.Count); // json→csv→xlsx 자동 합성
+ }
+}
diff --git a/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj b/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj
index 27c108e..5ded2f5 100644
--- a/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj
+++ b/src/Everything2Everything.Tests/Everything2Everything.Tests.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/Everything2Everything.Tests/ImageOptimProviderTests.cs b/src/Everything2Everything.Tests/ImageOptimProviderTests.cs
new file mode 100644
index 0000000..793a92a
--- /dev/null
+++ b/src/Everything2Everything.Tests/ImageOptimProviderTests.cs
@@ -0,0 +1,57 @@
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Everything2Everything.Core;
+using Everything2Everything.Core.Converters;
+using Everything2Everything.Core.Providers;
+using ImageMagick;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+public class ImageOptimProviderTests
+{
+ private static string TempDir()
+ {
+ var d = Path.Combine(Path.GetTempPath(), "e2e_test_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(d);
+ return d;
+ }
+
+ [Fact]
+ public async Task Png_Optimize_ProducesOutput()
+ {
+ var dir = TempDir();
+ var png = Path.Combine(dir, "t.png");
+ using (var img = new MagickImage(MagickColors.Red, 64, 64)) img.Write(png);
+
+ var p = new ImageOptimProvider();
+ var r = await p.ConvertAsync(png, dir, ".png", new ConvertOptions(), null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r.Status);
+ Assert.True(File.Exists(r.OutputPaths[0]));
+ }
+
+ [Fact]
+ public async Task Jpg_Optimize_LowerQuality_ProducesOutput()
+ {
+ var dir = TempDir();
+ var jpg = Path.Combine(dir, "t.jpg");
+ using (var img = new MagickImage(MagickColors.SteelBlue, 256, 256)) img.Write(jpg);
+
+ var p = new ImageOptimProvider();
+ var options = new ConvertOptions();
+ options.Jpeg.Quality = 30;
+ var r = await p.ConvertAsync(jpg, dir, ".jpg", options, null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r.Status);
+ Assert.True(File.Exists(r.OutputPaths[0]));
+ }
+
+ [Fact]
+ public void DefaultGraph_PngSelfEdge_ExistsForOptimization()
+ {
+ var graph = Everything2EverythingBootstrap.CreateDefault().Providers.Graph;
+ // 이미지 최적화 self-edge가 그래프에 등록되어 png→png가 가능 (압축 의도)
+ Assert.NotNull(graph.FindBestPath(".png", ".png"));
+ Assert.NotNull(graph.FindBestPath(".jpg", ".jpg"));
+ }
+}
diff --git a/src/Everything2Everything.Tests/VectorProviderTests.cs b/src/Everything2Everything.Tests/VectorProviderTests.cs
new file mode 100644
index 0000000..eb2a9e4
--- /dev/null
+++ b/src/Everything2Everything.Tests/VectorProviderTests.cs
@@ -0,0 +1,58 @@
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Everything2Everything.Core;
+using Everything2Everything.Core.Converters;
+using Everything2Everything.Core.Providers;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+public class VectorProviderTests
+{
+ private const string Svg =
+ "";
+
+ private static string TempDir()
+ {
+ var d = Path.Combine(Path.GetTempPath(), "e2e_test_" + System.Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(d);
+ return d;
+ }
+
+ [Fact]
+ public async Task Svg_To_Png_ProducesValidFile()
+ {
+ var dir = TempDir();
+ var svg = Path.Combine(dir, "t.svg");
+ await File.WriteAllTextAsync(svg, Svg);
+
+ var p = new VectorProvider();
+ var r = await p.ConvertAsync(svg, dir, ".png", new ConvertOptions(), null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r.Status);
+ Assert.True(new FileInfo(r.OutputPaths[0]).Length > 100);
+ }
+
+ [Fact]
+ public async Task Svg_To_Pdf_ProducesValidFile()
+ {
+ var dir = TempDir();
+ var svg = Path.Combine(dir, "t.svg");
+ await File.WriteAllTextAsync(svg, Svg);
+
+ var p = new VectorProvider();
+ var r = await p.ConvertAsync(svg, dir, ".pdf", new ConvertOptions(), null, CancellationToken.None);
+ Assert.Equal(ConvertStatus.Success, r.Status);
+ Assert.True(new FileInfo(r.OutputPaths[0]).Length > 100);
+ }
+
+ [Fact]
+ public void DefaultGraph_SvgToJpg_ComposedViaPng()
+ {
+ var graph = Everything2EverythingBootstrap.CreateDefault().Providers.Graph;
+ var path = graph.FindBestPath(".svg", ".jpg", maxHops: 3);
+ Assert.NotNull(path); // svg→png→jpg 자동 합성
+ var route = string.Join(" -> ", path!.Select(e => e.From)) + " -> " + path[^1].To;
+ Assert.True(path.Count == 2, "예상 svg→png→jpg(2홉), 실제: " + route);
+ }
+}