From d49ee6365f9d89a5cf04f447821aa2c2b4d26902 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 1 Jun 2026 13:09:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=9C=EC=88=98=20.NET=20Provider=20?= =?UTF-8?q?3=EC=A2=85=20(Data/Vector/ImageOptim)=20+=20Loss=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EC=A0=95=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검증 가능한 순수 .NET 변환기를 우선 추가. 외부 도구 불필요, 실제 동작까지 테스트로 검증. - DataProvider: csv<->json, csv<->xlsx 원자 엣지 (json<->xlsx는 그래프가 csv 경유 자동 합성). CsvHelper/ClosedXML - VectorProvider: svg->png/pdf (svg->기타이미지는 png 경유 합성). Svg.Skia - ImageOptimProvider: 동일포맷 재압축 self-edge (jpg/png/webp/tiff/bmp) - Loss 분류 정확화: OCR·문서/HTML->이미지=Rasterize, ->pdf=Recode (멀티홉 이상경로 svg->pdf->docx->jpg 차단) - 그래프 합성/실동작 테스트 9개 추가 (총 23개 통과) - DocumentFormat.OpenXml 3.1.0->최신 (ClosedXML 호환) --- .../Converters/DataProvider.cs | 215 ++++++++++++++++++ .../Converters/DocxProvider.cs | 7 +- .../Converters/HtmlProvider.cs | 7 +- .../Converters/HwpxProvider.cs | 7 +- .../Converters/ImageOptimProvider.cs | 125 ++++++++++ .../Converters/OcrProvider.cs | 2 +- .../Converters/VectorProvider.cs | 111 +++++++++ .../Everything2Everything.Core.csproj | 5 +- .../Everything2EverythingBootstrap.cs | 3 + .../DataProviderTests.cs | 68 ++++++ .../Everything2Everything.Tests.csproj | 1 + .../ImageOptimProviderTests.cs | 57 +++++ .../VectorProviderTests.cs | 58 +++++ 13 files changed, 655 insertions(+), 11 deletions(-) create mode 100644 src/Everything2Everything.Core/Converters/DataProvider.cs create mode 100644 src/Everything2Everything.Core/Converters/ImageOptimProvider.cs create mode 100644 src/Everything2Everything.Core/Converters/VectorProvider.cs create mode 100644 src/Everything2Everything.Tests/DataProviderTests.cs create mode 100644 src/Everything2Everything.Tests/ImageOptimProviderTests.cs create mode 100644 src/Everything2Everything.Tests/VectorProviderTests.cs 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); + } +}