feat: 순수 .NET Provider 3종 (Data/Vector/ImageOptim) + Loss 분류 정확화
검증 가능한 순수 .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 호환)
This commit is contained in:
parent
e3ef59e009
commit
d49ee6365f
13 changed files with 655 additions and 11 deletions
215
src/Everything2Everything.Core/Converters/DataProvider.cs
Normal file
215
src/Everything2Everything.Core/Converters/DataProvider.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 표(tabular) 데이터 양방향 변환기. 원자 엣지 csv↔json, csv↔xlsx만 선언하고
|
||||
/// json↔xlsx는 그래프가 csv 경유로 자동 합성한다(도그푸딩). 순수 .NET, 외부 도구 불필요.
|
||||
/// </summary>
|
||||
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<ExternalDependency>(),
|
||||
RoadmapNote: "중첩 JSON·다중 시트·Parquet는 후속 확장.");
|
||||
|
||||
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(() => Convert(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static ConvertResult Convert(
|
||||
string sourcePath, string outputDirectory, string outputExtension,
|
||||
ConvertOptions options, IProgress<double>? 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<Dictionary<string, object?>>();
|
||||
using (var reader = new StreamReader(source))
|
||||
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
|
||||
{
|
||||
csv.Read();
|
||||
csv.ReadHeader();
|
||||
var headers = csv.HeaderRecord ?? Array.Empty<string>();
|
||||
while (csv.Read())
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var row = new Dictionary<string, object?>(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<string>();
|
||||
var seen = new HashSet<string>();
|
||||
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<string>();
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
125
src/Everything2Everything.Core/Converters/ImageOptimProvider.cs
Normal file
125
src/Everything2Everything.Core/Converters/ImageOptimProvider.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
using ImageMagick;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// 동일 포맷 이미지를 재인코딩해 용량을 최적화한다(PdfToolProvider의 이미지 버전).
|
||||
/// 명시적 self-edge(jpg→jpg 등)로 그래프에 등록되며, 출력이 입력보다 커지면 원본을 보존한다.
|
||||
/// </summary>
|
||||
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<ExternalDependency>(),
|
||||
RoadmapNote: null);
|
||||
|
||||
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(() => Optimize(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static ConvertResult Optimize(
|
||||
string sourcePath, string outputDirectory, string outputExtension,
|
||||
ConvertOptions options, IProgress<double>? 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<ushort> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ExternalDependency>(),
|
||||
|
|
|
|||
111
src/Everything2Everything.Core/Converters/VectorProvider.cs
Normal file
111
src/Everything2Everything.Core/Converters/VectorProvider.cs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
using SkiaSharp;
|
||||
using Svg.Skia;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// SVG 벡터를 PNG 래스터 또는 PDF로 렌더링한다. svg→jpg/webp/bmp/tiff 등은
|
||||
/// 그래프가 svg→png→X 로 자동 합성하므로 여기선 png·pdf 원자 엣지만 둔다. 순수 .NET(SkiaSharp).
|
||||
/// </summary>
|
||||
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<ExternalDependency>(),
|
||||
RoadmapNote: null);
|
||||
|
||||
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(() => Convert(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken), cancellationToken);
|
||||
|
||||
private static ConvertResult Convert(
|
||||
string sourcePath, string outputDirectory, string outputExtension,
|
||||
ConvertOptions options, IProgress<double>? 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -12,15 +12,18 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.13.0" />
|
||||
<PackageReference Include="PDFtoImage" Version="5.2.1" />
|
||||
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
|
||||
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.1.0" />
|
||||
<PackageReference Include="DocumentFormat.OpenXml" Version="3.5.1" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="ReverseMarkdown" Version="4.6.0" />
|
||||
<PackageReference Include="PDFsharp" Version="6.2.0" />
|
||||
<PackageReference Include="Svg.Skia" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue