feat: Everything2Everything 양방향 변환 매트릭스 피보팅
EverythingToJpeg(N→JPEG 단방향)에서 Everything2Everything(N×M 매트릭스)으로 앱을 피보팅. 7개 Provider, 200+ 변환 쌍 지원. 리네이밍 - 디렉토리/솔루션/csproj/namespace/AssemblyName/MSIX Identity 일괄 치환 - 임시파일 prefix e2j_ → e2e_, 로고 라벨 E2J → E2E 아키텍처 - ConversionPair(input, output) 신규 + ProviderCapability.SupportedConversions - IConverterProvider.ConvertAsync에 outputExtension 파라미터 추가 - ProviderRegistry를 Dictionary<(input, output), Provider> 매트릭스로 재작성 + OutputsForInput / OutputsForFile 쿼리 API - ConversionEngine: 출력 ext 라우팅, 동일 입출력 자동 skip, 서브폴더 suffix를 출력 ext에서 자동 도출 - ConvertOptions를 형식별 sub-record로 분리 (Jpeg/Png/Webp/Avif/Tiff/Pdf*/Html*/Ocr) Provider 매트릭스 - MagickProvider: PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF/PDF 양방향 + RAW/PSD 디코딩 (단일이미지→1페이지 PDF, GIF/TIFF→다페이지 PDF) - HeicProvider: HEIC/HEIF → 이미지 7종 - PdfProvider: PDF → 이미지 6종 (PNG임베드 후 ImageMagick 인코딩) - HtmlProvider: HTML/HTM → 이미지 + PDF (CDP printToPDF) - DocxProvider/HwpxProvider: PDF + 이미지 7종 (LibreOffice 활용) - OcrProvider 신규: 이미지/PDF → TXT/DOCX (Windows.Media.Ocr + DocumentFormat.OpenXml, PDF는 페이지별 OCR 후 결합) UI - 사이드바 TARGET FORMAT을 동적 ComboBox로 (큐 파일 매트릭스의 교집합만 표시) - 출력 형식별 색상 배지 + Quality 패널 라벨 동적 - OUTPUT DESTINATION hint도 출력 ext 기반 동적 CLI - 신규 'to <ext> <files...>' verb (예: to png photo.heic doc.docx) - help 텍스트 양방향 컨셉으로 갱신 셸 통합 - ContextMenuRegistrar 카스케이드로 재설계 (ExtendedSubCommandsKey + Everything2Everything.SubMenu.<ext>) - 입력별 매트릭스에 따라 인기 출력 10종(JPEG/PNG/WebP/PDF/TXT/DOCX/AVIF/GIF/TIFF/BMP) 동적 노출, 마지막에 '변환…' 옵션 - 메뉴 라벨 'JPEG로 변환' → 'Everything2Everything으로 변환' 기타 - Core.csproj TFM net9.0-windows → net9.0-windows10.0.19041.0 (WinRT API용) - DocumentFormat.OpenXml 3.1.0 NuGet 추가 - README 양방향 매트릭스 기준 전면 개편 - MSIX appxmanifest Description / IExplorerCommand DLL 라벨 갱신 빌드: 0 errors, 0 warnings
This commit is contained in:
parent
96861e627d
commit
9b7e5f0d0c
63 changed files with 1788 additions and 733 deletions
122
src/Everything2Everything.Core/ConversionEngine.cs
Normal file
122
src/Everything2Everything.Core/ConversionEngine.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
|
||||
namespace Everything2Everything.Core;
|
||||
|
||||
public sealed class ConversionEngine
|
||||
{
|
||||
private readonly ProviderRegistry _registry;
|
||||
|
||||
public ConversionEngine(ProviderRegistry registry)
|
||||
{
|
||||
_registry = registry;
|
||||
}
|
||||
|
||||
public ProviderRegistry Providers => _registry;
|
||||
|
||||
public async Task<IReadOnlyList<ConvertResult>> ConvertManyAsync(
|
||||
IEnumerable<string> sources,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<ConvertProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sourceList = sources.ToList();
|
||||
var results = new List<ConvertResult>(sourceList.Count);
|
||||
|
||||
for (var i = 0; i < sourceList.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var source = sourceList[i];
|
||||
progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0));
|
||||
|
||||
var result = await ConvertOneAsync(source, outputExtension, options,
|
||||
new Progress<double>(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results.Add(result);
|
||||
progress?.Report(new ConvertProgress(i + 1, sourceList.Count, source, 1));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertOneAsync(
|
||||
string sourcePath,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(sourcePath))
|
||||
return ConvertResult.Fail(sourcePath, "파일을 찾을 수 없습니다.");
|
||||
|
||||
var output = ConversionPair.Normalize(outputExtension);
|
||||
var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
|
||||
|
||||
if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
|
||||
return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
|
||||
|
||||
if (!_registry.TryGet(sourcePath, output, out var provider) || provider is null)
|
||||
{
|
||||
var available = _registry.OutputsForFile(sourcePath);
|
||||
var hint = available.Count > 0
|
||||
? $" 가능한 출력: {string.Join(", ", available)}"
|
||||
: string.Empty;
|
||||
return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환을 지원하지 않습니다.{hint}");
|
||||
}
|
||||
|
||||
var availability = await 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);
|
||||
|
||||
try
|
||||
{
|
||||
return await provider.ConvertAsync(sourcePath, outputDir, output, options, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ConvertResult.Fail(sourcePath, ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveOutputDirectory(string sourcePath, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath))
|
||||
?? throw new InvalidOperationException("소스 경로에서 폴더를 결정할 수 없습니다.");
|
||||
|
||||
return options.OutputLocation switch
|
||||
{
|
||||
OutputLocation.SameFolderAsSource => sourceDir,
|
||||
OutputLocation.Custom => string.IsNullOrWhiteSpace(options.CustomOutputDirectory)
|
||||
? sourceDir
|
||||
: options.CustomOutputDirectory!,
|
||||
_ => Path.Combine(sourceDir,
|
||||
Path.GetFileNameWithoutExtension(sourcePath) + ResolveSubfolderSuffix(outputExtension, options)),
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveSubfolderSuffix(string outputExtension, ConvertOptions options)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.SubfolderSuffix) && options.SubfolderSuffix != "_converted")
|
||||
return options.SubfolderSuffix;
|
||||
|
||||
var ext = outputExtension.TrimStart('.').ToLowerInvariant();
|
||||
return string.IsNullOrEmpty(ext) ? "_converted" : "_" + ext;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ConvertProgress(int Index, int Total, string CurrentPath, double FileProgress);
|
||||
124
src/Everything2Everything.Core/ConvertOptions.cs
Normal file
124
src/Everything2Everything.Core/ConvertOptions.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
namespace Everything2Everything.Core;
|
||||
|
||||
public enum OutputLocation
|
||||
{
|
||||
SubfolderBesideSource,
|
||||
SameFolderAsSource,
|
||||
Custom
|
||||
}
|
||||
|
||||
public enum NameCollision
|
||||
{
|
||||
AppendNumber,
|
||||
Overwrite,
|
||||
Skip
|
||||
}
|
||||
|
||||
public sealed class ConvertOptions
|
||||
{
|
||||
public OutputLocation OutputLocation { get; set; } = OutputLocation.SubfolderBesideSource;
|
||||
|
||||
public string SubfolderSuffix { get; set; } = "_converted";
|
||||
|
||||
public string? CustomOutputDirectory { get; set; }
|
||||
|
||||
public NameCollision OnCollision { get; set; } = NameCollision.AppendNumber;
|
||||
|
||||
public int? MaxLongEdgePixels { get; set; }
|
||||
|
||||
public bool KeepExifWhenPossible { get; set; } = true;
|
||||
|
||||
public bool FlattenTransparency { get; set; } = false;
|
||||
|
||||
public string TransparencyBackground { get; set; } = "#FFFFFF";
|
||||
|
||||
public JpegEncodingOptions Jpeg { get; set; } = new();
|
||||
|
||||
public PngEncodingOptions Png { get; set; } = new();
|
||||
|
||||
public WebpEncodingOptions Webp { get; set; } = new();
|
||||
|
||||
public AvifEncodingOptions Avif { get; set; } = new();
|
||||
|
||||
public TiffEncodingOptions Tiff { get; set; } = new();
|
||||
|
||||
public BmpEncodingOptions Bmp { get; set; } = new();
|
||||
|
||||
public GifEncodingOptions Gif { get; set; } = new();
|
||||
|
||||
public PdfRenderOptions PdfRender { get; set; } = new();
|
||||
|
||||
public PdfBuildOptions PdfBuild { get; set; } = new();
|
||||
|
||||
public HtmlRenderOptions HtmlRender { get; set; } = new();
|
||||
|
||||
public OcrOptions Ocr { get; set; } = new();
|
||||
|
||||
public static ConvertOptions Quick() => new();
|
||||
}
|
||||
|
||||
public sealed class JpegEncodingOptions
|
||||
{
|
||||
public int Quality { get; set; } = 92;
|
||||
public bool Progressive { get; set; } = false;
|
||||
}
|
||||
|
||||
public sealed class PngEncodingOptions
|
||||
{
|
||||
public int Compression { get; set; } = 7;
|
||||
public bool Interlace { get; set; } = false;
|
||||
}
|
||||
|
||||
public sealed class WebpEncodingOptions
|
||||
{
|
||||
public int Quality { get; set; } = 90;
|
||||
public bool Lossless { get; set; } = false;
|
||||
}
|
||||
|
||||
public sealed class AvifEncodingOptions
|
||||
{
|
||||
public int Quality { get; set; } = 60;
|
||||
public int Speed { get; set; } = 6;
|
||||
}
|
||||
|
||||
public sealed class TiffEncodingOptions
|
||||
{
|
||||
public string Compression { get; set; } = "lzw";
|
||||
}
|
||||
|
||||
public sealed class BmpEncodingOptions
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class GifEncodingOptions
|
||||
{
|
||||
}
|
||||
|
||||
public sealed class PdfRenderOptions
|
||||
{
|
||||
public int Dpi { get; set; } = 200;
|
||||
public bool WithAnnotations { get; set; } = true;
|
||||
public bool WithFormFill { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class PdfBuildOptions
|
||||
{
|
||||
public string PageSize { get; set; } = "Auto";
|
||||
public int MarginPoints { get; set; } = 24;
|
||||
public bool FitToPage { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class HtmlRenderOptions
|
||||
{
|
||||
public int ViewportWidth { get; set; } = 1280;
|
||||
public int? ViewportHeight { get; set; }
|
||||
public int WaitMilliseconds { get; set; } = 2000;
|
||||
public bool FullPage { get; set; } = true;
|
||||
}
|
||||
|
||||
public sealed class OcrOptions
|
||||
{
|
||||
public string Language { get; set; } = "ko+en";
|
||||
public bool PreserveLayout { get; set; } = true;
|
||||
public string Backend { get; set; } = "auto";
|
||||
}
|
||||
25
src/Everything2Everything.Core/ConvertResult.cs
Normal file
25
src/Everything2Everything.Core/ConvertResult.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
namespace Everything2Everything.Core;
|
||||
|
||||
public enum ConvertStatus
|
||||
{
|
||||
Success,
|
||||
Skipped,
|
||||
Failed
|
||||
}
|
||||
|
||||
public sealed record ConvertResult(
|
||||
string SourcePath,
|
||||
IReadOnlyList<string> OutputPaths,
|
||||
ConvertStatus Status,
|
||||
string? Message = null,
|
||||
Exception? Error = null)
|
||||
{
|
||||
public static ConvertResult Ok(string source, IReadOnlyList<string> outputs)
|
||||
=> new(source, outputs, ConvertStatus.Success);
|
||||
|
||||
public static ConvertResult Fail(string source, string message, Exception? ex = null)
|
||||
=> new(source, Array.Empty<string>(), ConvertStatus.Failed, message, ex);
|
||||
|
||||
public static ConvertResult Skip(string source, string message)
|
||||
=> new(source, Array.Empty<string>(), ConvertStatus.Skipped, message);
|
||||
}
|
||||
187
src/Everything2Everything.Core/Converters/DocxProvider.cs
Normal file
187
src/Everything2Everything.Core/Converters/DocxProvider.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
using System.Diagnostics;
|
||||
using Everything2Everything.Core.Providers;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
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 readonly PdfProvider _pdfProvider;
|
||||
|
||||
public DocxProvider(PdfProvider pdfProvider)
|
||||
{
|
||||
_pdfProvider = pdfProvider;
|
||||
}
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "docx",
|
||||
DisplayName: "Word 문서 (DOCX)",
|
||||
SupportedConversions: ProviderCapability.PairsFromMatrix(DocxInputs, DocxOutputs),
|
||||
Status: ProviderStatus.RequiresExternal,
|
||||
Summary: "DOCX/DOC을 PDF로 변환하거나 페이지별 이미지로 렌더링합니다.",
|
||||
ExternalDependencies: new[]
|
||||
{
|
||||
new ExternalDependency(
|
||||
Name: "Microsoft Word 또는 LibreOffice",
|
||||
Description: "DOCX → PDF 변환에 둘 중 하나가 필요합니다. 둘 다 없으면 LibreOffice 설치를 권장합니다.",
|
||||
DownloadUrl: "https://www.libreoffice.org/download/",
|
||||
IsRequired: true),
|
||||
},
|
||||
RoadmapNote: "향후 OpenXML 기반 자체 렌더링 검토.");
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (ExternalToolDetector.IsWordComAvailable())
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
if (ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"Microsoft Word 또는 LibreOffice가 설치되어 있어야 합니다.",
|
||||
Capability.ExternalDependencies));
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var tempPdf = Path.Combine(Path.GetTempPath(),
|
||||
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
|
||||
|
||||
try
|
||||
{
|
||||
progress?.Report(0.05);
|
||||
|
||||
var converted = false;
|
||||
string? failureReason = null;
|
||||
|
||||
if (ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
|
||||
{
|
||||
converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!converted) failureReason = "LibreOffice 변환에 실패했습니다.";
|
||||
}
|
||||
|
||||
if (!converted && ExternalToolDetector.IsWordComAvailable())
|
||||
{
|
||||
try
|
||||
{
|
||||
converted = ConvertWithWordCom(sourcePath, tempPdf);
|
||||
if (!converted) failureReason = "Microsoft Word 변환에 실패했습니다.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failureReason = $"Microsoft Word 변환 오류: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
if (!converted)
|
||||
return ConvertResult.Fail(sourcePath, failureReason ?? "DOCX → PDF 외부 변환 도구가 필요합니다.");
|
||||
|
||||
progress?.Report(0.55);
|
||||
|
||||
if (outExt == ".pdf")
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { finalPath });
|
||||
}
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
|
||||
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
|
||||
with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
|
||||
{
|
||||
var outDir = Path.GetDirectoryName(targetPdf)!;
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add("pdf");
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0) return false;
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
|
||||
if (!File.Exists(produced)) return false;
|
||||
|
||||
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(targetPdf)) File.Delete(targetPdf);
|
||||
File.Move(produced, targetPdf);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
|
||||
private static bool ConvertWithWordCom(string sourcePath, string targetPdf)
|
||||
{
|
||||
const int wdFormatPDF = 17;
|
||||
var wordType = Type.GetTypeFromProgID("Word.Application");
|
||||
if (wordType is null) return false;
|
||||
|
||||
dynamic? word = Activator.CreateInstance(wordType);
|
||||
if (word is null) return false;
|
||||
try
|
||||
{
|
||||
word.Visible = false;
|
||||
word.DisplayAlerts = 0;
|
||||
dynamic doc = word.Documents.Open(sourcePath, ReadOnly: true, Visible: false);
|
||||
try
|
||||
{
|
||||
doc.SaveAs2(targetPdf, wdFormatPDF);
|
||||
}
|
||||
finally
|
||||
{
|
||||
doc.Close(false);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { word.Quit(); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using Microsoft.Win32;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
internal static class ExternalToolDetector
|
||||
{
|
||||
public static bool TryFindLibreOfficeSoffice(out string sofficePath)
|
||||
{
|
||||
sofficePath = "";
|
||||
var candidates = new List<string>();
|
||||
|
||||
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
||||
foreach (var root in new[] { pf, pfx86 })
|
||||
{
|
||||
if (string.IsNullOrEmpty(root)) continue;
|
||||
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.com"));
|
||||
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.exe"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\LibreOffice\UNO\InstallPath");
|
||||
if (key?.GetValue(null) is string installPath)
|
||||
{
|
||||
candidates.Add(Path.Combine(installPath, "soffice.com"));
|
||||
candidates.Add(Path.Combine(installPath, "soffice.exe"));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
foreach (var path in candidates.Distinct())
|
||||
{
|
||||
if (File.Exists(path)) { sofficePath = path; return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsWordComAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.ClassesRoot.OpenSubKey("Word.Application");
|
||||
return key is not null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsH2OrestartInstalled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var roots = new[]
|
||||
{
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
};
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (string.IsNullOrEmpty(root)) continue;
|
||||
var loDir = Path.Combine(root, "LibreOffice", "4", "user", "uno_packages", "cache", "uno_packages");
|
||||
if (Directory.Exists(loDir))
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(loDir, "*H2Orestart*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (Directory.Exists(dir)) return true;
|
||||
}
|
||||
}
|
||||
var extDir = Path.Combine(root, "LibreOffice", "4", "user", "extensions", "bundled");
|
||||
if (Directory.Exists(extDir))
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(extDir, "*H2O*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (Directory.Exists(dir)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
79
src/Everything2Everything.Core/Converters/HeicProvider.cs
Normal file
79
src/Everything2Everything.Core/Converters/HeicProvider.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
using PhotoSauce.MagicScaler;
|
||||
using PhotoSauce.NativeCodecs.Libheif;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
public sealed class HeicProvider : IConverterProvider
|
||||
{
|
||||
private static readonly string[] HeicInputs = { ".heic", ".heif" };
|
||||
|
||||
private static readonly string[] PassThroughOutputs =
|
||||
{ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".gif" };
|
||||
|
||||
private static int _codecConfigured;
|
||||
private readonly MagickProvider _magickProvider;
|
||||
|
||||
public HeicProvider() : this(new MagickProvider()) { }
|
||||
|
||||
public HeicProvider(MagickProvider magickProvider)
|
||||
{
|
||||
_magickProvider = magickProvider;
|
||||
}
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "heic",
|
||||
DisplayName: "HEIC / HEIF",
|
||||
SupportedConversions: ProviderCapability.PairsFromMatrix(HeicInputs, PassThroughOutputs),
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF로 변환합니다.",
|
||||
ExternalDependencies: Array.Empty<ExternalDependency>(),
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureCodec();
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureCodec();
|
||||
|
||||
var tempPng = Path.Combine(Path.GetTempPath(),
|
||||
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.png");
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Run(() =>
|
||||
{
|
||||
MagicImageProcessor.ProcessImage(sourcePath, tempPng, ProcessImageSettings.Default);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress?.Report(0.5);
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.5 + p * 0.5));
|
||||
var result = await _magickProvider
|
||||
.ConvertAsync(tempPng, outputDirectory, outputExtension, options, inner, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return result with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPng)) File.Delete(tempPng); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureCodec()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _codecConfigured, 1) == 1) return;
|
||||
CodecManager.Configure(codecs => codecs.UseLibheif());
|
||||
}
|
||||
}
|
||||
281
src/Everything2Everything.Core/Converters/HtmlProvider.cs
Normal file
281
src/Everything2Everything.Core/Converters/HtmlProvider.cs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using ImageMagick;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
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" };
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "html",
|
||||
DisplayName: "HTML / 웹 페이지",
|
||||
SupportedConversions: ProviderCapability.PairsFromMatrix(HtmlInputs, HtmlOutputs),
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "HTML/HTM을 WebView2로 헤드리스 렌더링하여 이미지 또는 PDF로 저장합니다.",
|
||||
ExternalDependencies: new[]
|
||||
{
|
||||
new ExternalDependency(
|
||||
Name: "Microsoft Edge WebView2 Runtime",
|
||||
Description: "Windows 11에는 기본 포함되어 있습니다.",
|
||||
DownloadUrl: "https://developer.microsoft.com/microsoft-edge/webview2/",
|
||||
IsRequired: true),
|
||||
},
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var version = CoreWebView2Environment.GetAvailableBrowserVersionString();
|
||||
if (string.IsNullOrEmpty(version))
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"WebView2 Runtime이 설치되어 있지 않습니다.",
|
||||
Capability.ExternalDependencies));
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"WebView2 감지 실패: " + ex.Message,
|
||||
Capability.ExternalDependencies));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
|
||||
if (outExt == ".pdf")
|
||||
{
|
||||
var pdfBytes = await CapturePdfAsync(sourcePath, options, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await File.WriteAllBytesAsync(path, pdfBytes, cancellationToken).ConfigureAwait(false);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
|
||||
var pngBytes = await CapturePngAsync(sourcePath, options, progress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
progress?.Report(0.85);
|
||||
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var image = new MagickImage(pngBytes);
|
||||
var alphaCapable = outExt is ".png" or ".webp" or ".avif" or ".tif" or ".tiff";
|
||||
if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha)
|
||||
{
|
||||
image.BackgroundColor = new MagickColor(options.TransparencyBackground);
|
||||
image.Alpha(AlphaOption.Remove);
|
||||
image.Alpha(AlphaOption.Off);
|
||||
}
|
||||
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);
|
||||
image.Write(path);
|
||||
}, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
|
||||
private static void ApplyEncoding(IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
switch (outputExtension)
|
||||
{
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
break;
|
||||
case ".png":
|
||||
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 ".avif":
|
||||
image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
|
||||
image.Settings.SetDefine(MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
|
||||
image.Format = MagickFormat.Avif;
|
||||
break;
|
||||
case ".bmp":
|
||||
image.Format = MagickFormat.Bmp;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<byte[]> CapturePngAsync(
|
||||
string sourcePath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
=> RunOnDispatcherThreadAsync((web, p) => CaptureScreenshotAsync(web, options, p), sourcePath, options, progress, cancellationToken);
|
||||
|
||||
private static Task<byte[]> CapturePdfAsync(
|
||||
string sourcePath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
=> RunOnDispatcherThreadAsync((web, p) => PrintToPdfAsync(web, options, p), sourcePath, options, progress, cancellationToken);
|
||||
|
||||
private static Task<byte[]> RunOnDispatcherThreadAsync(
|
||||
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
|
||||
string sourcePath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var dispatcher = Dispatcher.CurrentDispatcher;
|
||||
_ = RunCaptureOnDispatcher(capture, dispatcher, sourcePath, options, progress, cancellationToken, tcs);
|
||||
Dispatcher.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.IsBackground = true;
|
||||
thread.Name = "Everything2Everything.HtmlCapture";
|
||||
thread.Start();
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
private static async Task RunCaptureOnDispatcher(
|
||||
Func<CoreWebView2, IProgress<double>?, Task<byte[]>> capture,
|
||||
Dispatcher dispatcher,
|
||||
string sourcePath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken,
|
||||
TaskCompletionSource<byte[]> tcs)
|
||||
{
|
||||
CoreWebView2Controller? controller = null;
|
||||
try
|
||||
{
|
||||
var userDataFolder = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Everything2Everything", "WebView2");
|
||||
Directory.CreateDirectory(userDataFolder);
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true);
|
||||
progress?.Report(0.15);
|
||||
|
||||
controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true);
|
||||
|
||||
int width = options.HtmlRender.ViewportWidth > 0 ? options.HtmlRender.ViewportWidth : 1280;
|
||||
int height = options.HtmlRender.ViewportHeight ?? 720;
|
||||
controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height);
|
||||
controller.IsVisible = false;
|
||||
|
||||
var web = controller.CoreWebView2;
|
||||
progress?.Report(0.3);
|
||||
|
||||
var navTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
EventHandler<CoreWebView2NavigationCompletedEventArgs>? navHandler = null;
|
||||
navHandler = (_, e) =>
|
||||
{
|
||||
web.NavigationCompleted -= navHandler!;
|
||||
if (e.IsSuccess) navTcs.TrySetResult(true);
|
||||
else navTcs.TrySetException(new InvalidOperationException(
|
||||
$"내비게이션 실패: {e.WebErrorStatus}"));
|
||||
};
|
||||
web.NavigationCompleted += navHandler;
|
||||
|
||||
var fileUri = new Uri(sourcePath).AbsoluteUri;
|
||||
web.Navigate(fileUri);
|
||||
|
||||
using (cancellationToken.Register(() => navTcs.TrySetCanceled()))
|
||||
{
|
||||
await navTcs.Task.ConfigureAwait(true);
|
||||
}
|
||||
progress?.Report(0.5);
|
||||
|
||||
if (options.HtmlRender.WaitMilliseconds > 0)
|
||||
await Task.Delay(options.HtmlRender.WaitMilliseconds, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
progress?.Report(0.65);
|
||||
|
||||
var bytes = await capture(web, progress).ConfigureAwait(true);
|
||||
tcs.TrySetResult(bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { controller?.Close(); } catch { }
|
||||
dispatcher.BeginInvokeShutdown(DispatcherPriority.Background);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> CaptureScreenshotAsync(CoreWebView2 web, ConvertOptions options, IProgress<double>? progress)
|
||||
{
|
||||
var captureParams = options.HtmlRender.FullPage
|
||||
? "{\"captureBeyondViewport\":true,\"format\":\"png\"}"
|
||||
: "{\"format\":\"png\"}";
|
||||
|
||||
var resultJson = await web
|
||||
.CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams)
|
||||
.ConfigureAwait(true);
|
||||
progress?.Report(0.8);
|
||||
|
||||
using var doc = JsonDocument.Parse(resultJson);
|
||||
var b64 = doc.RootElement.GetProperty("data").GetString()
|
||||
?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다.");
|
||||
return Convert.FromBase64String(b64);
|
||||
}
|
||||
|
||||
private static async Task<byte[]> PrintToPdfAsync(CoreWebView2 web, ConvertOptions options, IProgress<double>? progress)
|
||||
{
|
||||
var resultJson = await web
|
||||
.CallDevToolsProtocolMethodAsync("Page.printToPDF", "{\"printBackground\":true,\"preferCSSPageSize\":true}")
|
||||
.ConfigureAwait(true);
|
||||
progress?.Report(0.8);
|
||||
|
||||
using var doc = JsonDocument.Parse(resultJson);
|
||||
var b64 = doc.RootElement.GetProperty("data").GetString()
|
||||
?? throw new InvalidOperationException("CDP printToPDF가 빈 결과를 반환했습니다.");
|
||||
return Convert.FromBase64String(b64);
|
||||
}
|
||||
}
|
||||
152
src/Everything2Everything.Core/Converters/HwpxProvider.cs
Normal file
152
src/Everything2Everything.Core/Converters/HwpxProvider.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System.Diagnostics;
|
||||
using Everything2Everything.Core.Providers;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
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 readonly PdfProvider _pdfProvider;
|
||||
|
||||
public HwpxProvider() : this(new PdfProvider()) { }
|
||||
|
||||
public HwpxProvider(PdfProvider pdfProvider)
|
||||
{
|
||||
_pdfProvider = pdfProvider;
|
||||
}
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "hwpx",
|
||||
DisplayName: "한글 문서 (HWP / HWPX)",
|
||||
SupportedConversions: ProviderCapability.PairsFromMatrix(HwpInputs, HwpOutputs),
|
||||
Status: ProviderStatus.RequiresExternal,
|
||||
Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 PDF 또는 페이지별 이미지로 저장합니다.",
|
||||
ExternalDependencies: new[]
|
||||
{
|
||||
new ExternalDependency(
|
||||
Name: "LibreOffice",
|
||||
Description: "한글 변환에 필요한 헤드리스 오피스 엔진.",
|
||||
DownloadUrl: "https://www.libreoffice.org/download/",
|
||||
IsRequired: true),
|
||||
new ExternalDependency(
|
||||
Name: "H2Orestart 확장",
|
||||
Description: "LibreOffice가 한글 파일을 읽도록 하는 오픈소스 확장. 다운로드한 oxt 파일을 LibreOffice에서 더블클릭해 설치.",
|
||||
DownloadUrl: "https://github.com/ebandal/H2Orestart/releases",
|
||||
IsRequired: true),
|
||||
},
|
||||
RoadmapNote: null);
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"LibreOffice가 설치되어 있지 않습니다.",
|
||||
Capability.ExternalDependencies));
|
||||
|
||||
if (!ExternalToolDetector.IsH2OrestartInstalled())
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"H2Orestart 확장이 설치되어 있지 않습니다. https://github.com/ebandal/H2Orestart/releases 에서 .oxt 다운로드 후 LibreOffice에서 설치하세요.",
|
||||
Capability.ExternalDependencies));
|
||||
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice))
|
||||
return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다.");
|
||||
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var tempPdf = Path.Combine(Path.GetTempPath(),
|
||||
$"e2e_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf");
|
||||
|
||||
try
|
||||
{
|
||||
progress?.Report(0.05);
|
||||
|
||||
var converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!converted)
|
||||
return ConvertResult.Fail(sourcePath,
|
||||
"LibreOffice 변환에 실패했습니다. H2Orestart 확장이 정상 설치되어 있는지 확인하세요.");
|
||||
|
||||
progress?.Report(0.55);
|
||||
|
||||
if (outExt == ".pdf")
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
var finalPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, ".pdf", options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(finalPath, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
File.Copy(tempPdf, finalPath, overwrite: options.OnCollision == NameCollision.Overwrite);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { finalPath });
|
||||
}
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(0.55 + p * 0.45));
|
||||
return _pdfProvider.ConvertCore(tempPdf, outputDirectory, outExt, options, inner, cancellationToken)
|
||||
with { SourcePath = sourcePath };
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
|
||||
{
|
||||
var outDir = Path.GetDirectoryName(targetPdf)!;
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = sofficePath,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--headless");
|
||||
psi.ArgumentList.Add("--norestore");
|
||||
psi.ArgumentList.Add("--nofirststartwizard");
|
||||
psi.ArgumentList.Add("--convert-to");
|
||||
psi.ArgumentList.Add("pdf");
|
||||
psi.ArgumentList.Add("--outdir");
|
||||
psi.ArgumentList.Add(outDir);
|
||||
psi.ArgumentList.Add(sourcePath);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
try { proc.Kill(true); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
if (proc.ExitCode != 0) return false;
|
||||
|
||||
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
|
||||
if (!File.Exists(produced)) return false;
|
||||
|
||||
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (File.Exists(targetPdf)) File.Delete(targetPdf);
|
||||
File.Move(produced, targetPdf);
|
||||
}
|
||||
return File.Exists(targetPdf);
|
||||
}
|
||||
}
|
||||
254
src/Everything2Everything.Core/Converters/MagickProvider.cs
Normal file
254
src/Everything2Everything.Core/Converters/MagickProvider.cs
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
using ImageMagick;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
public sealed class MagickProvider : IConverterProvider
|
||||
{
|
||||
private static readonly string[] SingleFrameInputs =
|
||||
{
|
||||
".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd",
|
||||
".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2", ".srw", ".pef", ".raw",
|
||||
};
|
||||
|
||||
private static readonly string[] MultiFrameInputs = { ".gif", ".tif", ".tiff" };
|
||||
|
||||
private static readonly string[] AllInputs = SingleFrameInputs.Concat(MultiFrameInputs).ToArray();
|
||||
|
||||
private static readonly string[] WritableOutputs =
|
||||
{ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff", ".gif", ".pdf" };
|
||||
|
||||
private static readonly HashSet<string> AlphaCapableOutputs = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".png", ".webp", ".avif", ".tif", ".tiff", ".gif",
|
||||
};
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "magick",
|
||||
DisplayName: "이미지·RAW·애니메이션",
|
||||
SupportedConversions: ProviderCapability.PairsFromMatrix(AllInputs, WritableOutputs),
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "PNG/JPEG/WebP/AVIF/BMP/TIFF/GIF/PDF 사이의 양방향 변환 + RAW(NEF/CR2/ARW/DNG…)·PSD 디코딩.",
|
||||
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)
|
||||
{
|
||||
return Task.Run(
|
||||
() => ConvertCore(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static ConvertResult ConvertCore(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputExt = Path.GetExtension(sourcePath).ToLowerInvariant();
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
var isMultiFrameInput = MultiFrameInputs.Contains(inputExt);
|
||||
var format = ResolveFormat(outExt);
|
||||
var isMultiFrameOutput = outExt is ".gif" or ".tif" or ".tiff" or ".pdf";
|
||||
|
||||
if (isMultiFrameInput)
|
||||
{
|
||||
using var collection = new MagickImageCollection(sourcePath);
|
||||
if (collection.Count == 0)
|
||||
return ConvertResult.Fail(sourcePath, "이미지 프레임을 읽지 못했습니다.");
|
||||
|
||||
if (collection.Count == 1)
|
||||
{
|
||||
var single = collection[0];
|
||||
ApplyCommonTransforms(single, outExt, options);
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
WriteSingle(single, path, format, outExt, options);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
|
||||
collection.Coalesce();
|
||||
|
||||
if (isMultiFrameOutput)
|
||||
{
|
||||
foreach (var frame in collection)
|
||||
ApplyCommonTransforms(frame, outExt, options);
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
ApplyCollectionEncoding(collection, format, outExt, options);
|
||||
collection.Write(path);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
|
||||
var outputs = new List<string>();
|
||||
var width = (int)Math.Ceiling(Math.Log10(collection.Count + 1));
|
||||
for (var i = 0; i < collection.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var frame = collection[i];
|
||||
ApplyCommonTransforms(frame, outExt, options);
|
||||
var suffix = $"_{(i + 1).ToString().PadLeft(width, '0')}";
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
|
||||
WriteSingle(frame, path, format, outExt, options);
|
||||
outputs.Add(path);
|
||||
progress?.Report((i + 1.0) / collection.Count);
|
||||
}
|
||||
|
||||
return outputs.Count > 0
|
||||
? ConvertResult.Ok(sourcePath, outputs)
|
||||
: ConvertResult.Skip(sourcePath, "모든 프레임이 이미 존재해 건너뜁니다.");
|
||||
}
|
||||
else
|
||||
{
|
||||
using var image = new MagickImage(sourcePath);
|
||||
ApplyCommonTransforms(image, outExt, options);
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
WriteSingle(image, path, format, outExt, options);
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
}
|
||||
|
||||
private static MagickFormat ResolveFormat(string outputExtension) => outputExtension switch
|
||||
{
|
||||
".png" => MagickFormat.Png,
|
||||
".jpg" or ".jpeg" => MagickFormat.Jpeg,
|
||||
".webp" => MagickFormat.WebP,
|
||||
".avif" => MagickFormat.Avif,
|
||||
".bmp" => MagickFormat.Bmp,
|
||||
".tif" or ".tiff" => MagickFormat.Tiff,
|
||||
".gif" => MagickFormat.Gif,
|
||||
".pdf" => MagickFormat.Pdf,
|
||||
_ => throw new NotSupportedException($"지원하지 않는 출력 형식: {outputExtension}"),
|
||||
};
|
||||
|
||||
private static void ApplyCommonTransforms(IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
try { image.AutoOrient(); } catch { }
|
||||
|
||||
var flattenForOutput = !AlphaCapableOutputs.Contains(outputExtension);
|
||||
if ((flattenForOutput || options.FlattenTransparency) && image.HasAlpha)
|
||||
{
|
||||
image.BackgroundColor = new MagickColor(options.TransparencyBackground);
|
||||
image.Alpha(AlphaOption.Remove);
|
||||
image.Alpha(AlphaOption.Off);
|
||||
}
|
||||
|
||||
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0)
|
||||
{
|
||||
var w = (int)image.Width;
|
||||
var h = (int)image.Height;
|
||||
if (w > maxLong || h > maxLong)
|
||||
{
|
||||
var geom = new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteSingle(IMagickImage<ushort> image, string path, MagickFormat format, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
ApplySingleEncoding(image, format, outputExtension, options);
|
||||
image.Write(path);
|
||||
}
|
||||
|
||||
private static void ApplySingleEncoding(IMagickImage<ushort> image, MagickFormat format, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
image.Format = format;
|
||||
switch (outputExtension)
|
||||
{
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
|
||||
if (options.Jpeg.Progressive)
|
||||
image.Settings.Interlace = Interlace.Jpeg;
|
||||
break;
|
||||
case ".png":
|
||||
image.Quality = (uint)Math.Clamp((options.Png.Compression * 10) + 5, 1, 100);
|
||||
if (options.Png.Interlace)
|
||||
image.Settings.Interlace = Interlace.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");
|
||||
break;
|
||||
case ".avif":
|
||||
image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
|
||||
image.Settings.SetDefine(MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
|
||||
break;
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
|
||||
image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression);
|
||||
break;
|
||||
case ".pdf":
|
||||
ApplyPdfPageSettings(image, options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyPdfPageSettings(IMagickImage<ushort> image, ConvertOptions options)
|
||||
{
|
||||
var pageSize = options.PdfBuild.PageSize;
|
||||
if (string.Equals(pageSize, "Auto", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(pageSize))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryGetPagePoints(pageSize, out var widthPt, out var heightPt))
|
||||
{
|
||||
var marginPt = Math.Max(0, options.PdfBuild.MarginPoints);
|
||||
var contentW = (uint)Math.Max(1, widthPt - marginPt * 2);
|
||||
var contentH = (uint)Math.Max(1, heightPt - marginPt * 2);
|
||||
|
||||
if (options.PdfBuild.FitToPage)
|
||||
{
|
||||
var geom = new MagickGeometry(contentW, contentH) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
}
|
||||
|
||||
image.Page = new MagickGeometry(
|
||||
(int)marginPt, (int)marginPt,
|
||||
(uint)widthPt, (uint)heightPt);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetPagePoints(string pageSize, out int widthPt, out int heightPt)
|
||||
{
|
||||
switch (pageSize.ToUpperInvariant())
|
||||
{
|
||||
case "A4": widthPt = 595; heightPt = 842; return true;
|
||||
case "A3": widthPt = 842; heightPt = 1191; return true;
|
||||
case "A5": widthPt = 420; heightPt = 595; return true;
|
||||
case "LETTER": widthPt = 612; heightPt = 792; return true;
|
||||
case "LEGAL": widthPt = 612; heightPt = 1008; return true;
|
||||
default: widthPt = 0; heightPt = 0; return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCollectionEncoding(MagickImageCollection collection, MagickFormat format, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
foreach (var img in collection)
|
||||
ApplySingleEncoding(img, format, outputExtension, options);
|
||||
}
|
||||
}
|
||||
220
src/Everything2Everything.Core/Converters/OcrProvider.cs
Normal file
220
src/Everything2Everything.Core/Converters/OcrProvider.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
using Everything2Everything.Core.Providers;
|
||||
using Windows.Globalization;
|
||||
using Windows.Graphics.Imaging;
|
||||
using Windows.Media.Ocr;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
public sealed class OcrProvider : IConverterProvider
|
||||
{
|
||||
private static readonly string[] OcrInputs =
|
||||
{ ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp", ".gif", ".heic", ".heif", ".pdf" };
|
||||
|
||||
private static readonly string[] OcrOutputs = { ".txt", ".docx" };
|
||||
|
||||
private readonly PdfProvider _pdfProvider;
|
||||
|
||||
public OcrProvider() : this(new PdfProvider()) { }
|
||||
|
||||
public OcrProvider(PdfProvider pdfProvider)
|
||||
{
|
||||
_pdfProvider = pdfProvider;
|
||||
}
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "ocr",
|
||||
DisplayName: "OCR (이미지/PDF → 텍스트·DOCX)",
|
||||
SupportedConversions: ProviderCapability.PairsFromMatrix(OcrInputs, OcrOutputs),
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "Windows OCR 엔진으로 이미지 또는 PDF 페이지에서 텍스트를 추출해 .txt 또는 .docx로 저장합니다.",
|
||||
ExternalDependencies: Array.Empty<ExternalDependency>(),
|
||||
RoadmapNote: "Windows에 설치된 OCR 언어 팩을 사용 — 한국어/영어는 Windows 11 기본 포함.");
|
||||
|
||||
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var langs = OcrEngine.AvailableRecognizerLanguages;
|
||||
if (langs is null || langs.Count == 0)
|
||||
return Task.FromResult(ProviderAvailability.NotReady(
|
||||
"Windows OCR 언어 팩이 설치되어 있지 않습니다. 설정 → 시간 및 언어 → 언어에서 OCR 기능을 추가하세요."));
|
||||
return Task.FromResult(ProviderAvailability.Ready);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ProviderAvailability.NotReady("Windows OCR 엔진 초기화 실패: " + ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var inputExt = Path.GetExtension(sourcePath).ToLowerInvariant();
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
|
||||
var pages = inputExt == ".pdf"
|
||||
? await ExtractPdfPagesAsync(sourcePath, options, progress, cancellationToken).ConfigureAwait(false)
|
||||
: new List<string> { sourcePath };
|
||||
|
||||
if (pages.Count == 0)
|
||||
return ConvertResult.Fail(sourcePath, "OCR 입력 페이지를 추출하지 못했습니다.");
|
||||
|
||||
try
|
||||
{
|
||||
var engine = ResolveEngine(options.Ocr.Language);
|
||||
if (engine is null)
|
||||
return ConvertResult.Fail(sourcePath,
|
||||
$"요청한 언어({options.Ocr.Language})에 맞는 OCR 엔진을 찾을 수 없습니다. 사용 가능: {string.Join(", ", OcrEngine.AvailableRecognizerLanguages.Select(l => l.LanguageTag))}");
|
||||
|
||||
var pageTexts = new List<string>(pages.Count);
|
||||
for (var i = 0; i < pages.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var text = await RecognizeAsync(engine, pages[i], cancellationToken).ConfigureAwait(false);
|
||||
pageTexts.Add(text);
|
||||
progress?.Report((i + 1.0) / pages.Count * 0.9);
|
||||
}
|
||||
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision))
|
||||
return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
|
||||
|
||||
if (outExt == ".txt")
|
||||
{
|
||||
var combined = pages.Count == 1
|
||||
? pageTexts[0]
|
||||
: string.Join(Environment.NewLine + Environment.NewLine + "---" + Environment.NewLine + Environment.NewLine, pageTexts);
|
||||
await File.WriteAllTextAsync(path, combined, System.Text.Encoding.UTF8, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else if (outExt == ".docx")
|
||||
{
|
||||
WriteDocx(path, pageTexts);
|
||||
}
|
||||
else
|
||||
{
|
||||
return ConvertResult.Fail(sourcePath, $"지원하지 않는 출력 형식: {outExt}");
|
||||
}
|
||||
|
||||
progress?.Report(1.0);
|
||||
return ConvertResult.Ok(sourcePath, new[] { path });
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (inputExt == ".pdf")
|
||||
{
|
||||
foreach (var p in pages)
|
||||
{
|
||||
try { if (File.Exists(p)) File.Delete(p); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static OcrEngine? ResolveEngine(string requestedLanguage)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(requestedLanguage)
|
||||
|| string.Equals(requestedLanguage, "auto", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return OcrEngine.TryCreateFromUserProfileLanguages();
|
||||
}
|
||||
|
||||
var preferences = requestedLanguage.Split(new[] { '+', ',', ';' },
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var available = OcrEngine.AvailableRecognizerLanguages;
|
||||
foreach (var pref in preferences)
|
||||
{
|
||||
var matched = available.FirstOrDefault(l =>
|
||||
l.LanguageTag.StartsWith(pref, StringComparison.OrdinalIgnoreCase));
|
||||
if (matched is not null)
|
||||
return OcrEngine.TryCreateFromLanguage(matched);
|
||||
}
|
||||
|
||||
return OcrEngine.TryCreateFromUserProfileLanguages();
|
||||
}
|
||||
|
||||
private static async Task<string> RecognizeAsync(OcrEngine engine, string imagePath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var fileStream = File.OpenRead(imagePath);
|
||||
using var memory = new MemoryStream();
|
||||
await fileStream.CopyToAsync(memory, cancellationToken).ConfigureAwait(false);
|
||||
memory.Position = 0;
|
||||
|
||||
using var randomAccess = new InMemoryRandomAccessStream();
|
||||
using (var writer = new DataWriter(randomAccess.GetOutputStreamAt(0)))
|
||||
{
|
||||
writer.WriteBytes(memory.ToArray());
|
||||
await writer.StoreAsync();
|
||||
}
|
||||
randomAccess.Seek(0);
|
||||
|
||||
var decoder = await BitmapDecoder.CreateAsync(randomAccess);
|
||||
using var bitmap = await decoder.GetSoftwareBitmapAsync();
|
||||
|
||||
var ocrResult = await engine.RecognizeAsync(bitmap);
|
||||
return ocrResult?.Text ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task<List<string>> ExtractPdfPagesAsync(
|
||||
string pdfPath,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tempDir = Path.Combine(Path.GetTempPath(), $"e2e_ocr_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempDir);
|
||||
|
||||
var renderOptions = new ConvertOptions
|
||||
{
|
||||
OutputLocation = OutputLocation.Custom,
|
||||
CustomOutputDirectory = tempDir,
|
||||
OnCollision = NameCollision.Overwrite,
|
||||
};
|
||||
renderOptions.PdfRender.Dpi = Math.Max(150, options.PdfRender.Dpi);
|
||||
|
||||
var inner = new Progress<double>(p => progress?.Report(p * 0.4));
|
||||
var result = _pdfProvider.ConvertCore(pdfPath, tempDir, ".png", renderOptions, inner, cancellationToken);
|
||||
if (result.Status != ConvertStatus.Success)
|
||||
return new List<string>();
|
||||
|
||||
return result.OutputPaths.ToList();
|
||||
}
|
||||
|
||||
private static void WriteDocx(string path, IReadOnlyList<string> pageTexts)
|
||||
{
|
||||
using var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document);
|
||||
var mainPart = doc.AddMainDocumentPart();
|
||||
mainPart.Document = new Document();
|
||||
var body = mainPart.Document.AppendChild(new Body());
|
||||
|
||||
for (var pageIndex = 0; pageIndex < pageTexts.Count; pageIndex++)
|
||||
{
|
||||
var pageText = pageTexts[pageIndex] ?? string.Empty;
|
||||
foreach (var line in pageText.Split('\n', StringSplitOptions.None))
|
||||
{
|
||||
var paragraph = body.AppendChild(new Paragraph());
|
||||
var run = paragraph.AppendChild(new Run());
|
||||
run.AppendChild(new Text(line.TrimEnd('\r')) { Space = SpaceProcessingModeValues.Preserve });
|
||||
}
|
||||
|
||||
if (pageIndex < pageTexts.Count - 1)
|
||||
{
|
||||
var pageBreakPara = body.AppendChild(new Paragraph());
|
||||
var pageBreakRun = pageBreakPara.AppendChild(new Run());
|
||||
pageBreakRun.AppendChild(new Break { Type = BreakValues.Page });
|
||||
}
|
||||
}
|
||||
|
||||
mainPart.Document.Save();
|
||||
}
|
||||
}
|
||||
150
src/Everything2Everything.Core/Converters/PdfProvider.cs
Normal file
150
src/Everything2Everything.Core/Converters/PdfProvider.cs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
using PDFtoImage;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Everything2Everything.Core.Converters;
|
||||
|
||||
public sealed class PdfProvider : IConverterProvider
|
||||
{
|
||||
private static readonly string[] PdfInputs = { ".pdf" };
|
||||
|
||||
private static readonly string[] PdfRenderOutputs =
|
||||
{ ".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff" };
|
||||
|
||||
public ProviderCapability Capability { get; } = new(
|
||||
Id: "pdf",
|
||||
DisplayName: "PDF",
|
||||
SupportedConversions: ProviderCapability.PairsFromMatrix(PdfInputs, PdfRenderOutputs),
|
||||
Status: ProviderStatus.Available,
|
||||
Summary: "PDF 각 페이지를 PNG/JPEG/WebP/AVIF/BMP/TIFF로 렌더링합니다.",
|
||||
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)
|
||||
{
|
||||
return Task.Run(
|
||||
() => ConvertCore(sourcePath, outputDirectory, outputExtension, options, progress, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
internal ConvertResult ConvertCore(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var outExt = ConversionPair.Normalize(outputExtension);
|
||||
var baseName = Path.GetFileNameWithoutExtension(sourcePath);
|
||||
|
||||
int pageCount;
|
||||
using (var probe = File.OpenRead(sourcePath))
|
||||
{
|
||||
pageCount = Conversion.GetPageCount(probe);
|
||||
}
|
||||
if (pageCount <= 0)
|
||||
return ConvertResult.Fail(sourcePath, "PDF에 페이지가 없습니다.");
|
||||
|
||||
var renderOptions = new RenderOptions
|
||||
{
|
||||
Dpi = options.PdfRender.Dpi,
|
||||
BackgroundColor = SKColors.White,
|
||||
WithAnnotations = options.PdfRender.WithAnnotations,
|
||||
WithFormFill = options.PdfRender.WithFormFill,
|
||||
UseTiling = true,
|
||||
};
|
||||
|
||||
var width = (int)Math.Ceiling(Math.Log10(pageCount + 1));
|
||||
var outputs = new List<string>();
|
||||
|
||||
for (var i = 0; i < pageCount; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var suffix = pageCount == 1 ? null : $"_p{(i + 1).ToString().PadLeft(width, '0')}";
|
||||
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, outExt, options.OnCollision);
|
||||
if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue;
|
||||
|
||||
using (var input = File.OpenRead(sourcePath))
|
||||
using (var pngStream = new MemoryStream())
|
||||
{
|
||||
Conversion.SavePng(pngStream, input, page: i, leaveOpen: false, password: null, options: renderOptions);
|
||||
pngStream.Position = 0;
|
||||
|
||||
using var image = new ImageMagick.MagickImage(pngStream);
|
||||
ApplyTransforms(image, outExt, options);
|
||||
ApplyEncoding(image, outExt, options);
|
||||
image.Write(path);
|
||||
}
|
||||
|
||||
outputs.Add(path);
|
||||
progress?.Report((i + 1.0) / pageCount);
|
||||
}
|
||||
|
||||
return outputs.Count > 0
|
||||
? ConvertResult.Ok(sourcePath, outputs)
|
||||
: ConvertResult.Skip(sourcePath, "모든 페이지가 이미 존재해 건너뜁니다.");
|
||||
}
|
||||
|
||||
private static void ApplyTransforms(ImageMagick.IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
var alphaCapable = outputExtension is ".png" or ".webp" or ".avif" or ".tif" or ".tiff";
|
||||
if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha)
|
||||
{
|
||||
image.BackgroundColor = new ImageMagick.MagickColor(options.TransparencyBackground);
|
||||
image.Alpha(ImageMagick.AlphaOption.Remove);
|
||||
image.Alpha(ImageMagick.AlphaOption.Off);
|
||||
}
|
||||
|
||||
if (options.MaxLongEdgePixels is int maxLong && maxLong > 0
|
||||
&& (image.Width > (uint)maxLong || image.Height > (uint)maxLong))
|
||||
{
|
||||
image.Resize(new ImageMagick.MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false });
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyEncoding(ImageMagick.IMagickImage<ushort> image, string outputExtension, ConvertOptions options)
|
||||
{
|
||||
switch (outputExtension)
|
||||
{
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
image.Quality = (uint)Math.Clamp(options.Jpeg.Quality, 1, 100);
|
||||
image.Format = ImageMagick.MagickFormat.Jpeg;
|
||||
break;
|
||||
case ".png":
|
||||
image.Format = ImageMagick.MagickFormat.Png;
|
||||
break;
|
||||
case ".webp":
|
||||
image.Quality = (uint)Math.Clamp(options.Webp.Quality, 1, 100);
|
||||
if (options.Webp.Lossless)
|
||||
image.Settings.SetDefine(ImageMagick.MagickFormat.WebP, "lossless", "true");
|
||||
image.Format = ImageMagick.MagickFormat.WebP;
|
||||
break;
|
||||
case ".avif":
|
||||
image.Quality = (uint)Math.Clamp(options.Avif.Quality, 1, 100);
|
||||
image.Settings.SetDefine(ImageMagick.MagickFormat.Avif, "speed", Math.Clamp(options.Avif.Speed, 0, 10).ToString());
|
||||
image.Format = ImageMagick.MagickFormat.Avif;
|
||||
break;
|
||||
case ".bmp":
|
||||
image.Format = ImageMagick.MagickFormat.Bmp;
|
||||
break;
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
if (!string.IsNullOrWhiteSpace(options.Tiff.Compression))
|
||||
image.Settings.SetDefine(ImageMagick.MagickFormat.Tiff, "compression", options.Tiff.Compression);
|
||||
image.Format = ImageMagick.MagickFormat.Tiff;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<UseWindowsForms>false</UseWindowsForms>
|
||||
<UseWPF>true</UseWPF>
|
||||
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System.IO" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
using Everything2Everything.Core.Providers;
|
||||
|
||||
namespace Everything2Everything.Core;
|
||||
|
||||
public static class Everything2EverythingBootstrap
|
||||
{
|
||||
public static ConversionEngine CreateDefault()
|
||||
{
|
||||
var magick = new Converters.MagickProvider();
|
||||
var pdf = new Converters.PdfProvider();
|
||||
var providers = new IConverterProvider[]
|
||||
{
|
||||
magick,
|
||||
new Converters.HeicProvider(magick),
|
||||
pdf,
|
||||
new Converters.DocxProvider(pdf),
|
||||
new Converters.HtmlProvider(),
|
||||
new Converters.HwpxProvider(),
|
||||
new Converters.OcrProvider(pdf),
|
||||
};
|
||||
return new ConversionEngine(new ProviderRegistry(providers));
|
||||
}
|
||||
}
|
||||
68
src/Everything2Everything.Core/HistoryStorage.cs
Normal file
68
src/Everything2Everything.Core/HistoryStorage.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Everything2Everything.Core;
|
||||
|
||||
public static class HistoryStorage
|
||||
{
|
||||
private static readonly string Dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Everything2Everything");
|
||||
|
||||
private static readonly string FilePath = Path.Combine(Dir, "history.jsonl");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public static IReadOnlyList<HistoryEntry> Load()
|
||||
{
|
||||
if (!File.Exists(FilePath)) return Array.Empty<HistoryEntry>();
|
||||
|
||||
var list = new List<HistoryEntry>();
|
||||
try
|
||||
{
|
||||
foreach (var line in File.ReadAllLines(FilePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<HistoryEntry>(line, JsonOptions);
|
||||
if (entry is not null) list.Add(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 손상된 줄은 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<HistoryEntry>();
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static void Append(HistoryEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
var json = JsonSerializer.Serialize(entry, JsonOptions);
|
||||
File.AppendAllText(FilePath, json + Environment.NewLine);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 영구 저장 실패는 메모리 동작에 영향 없음
|
||||
}
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
try { if (File.Exists(FilePath)) File.Delete(FilePath); } catch { }
|
||||
}
|
||||
|
||||
public static string LocationHint => FilePath;
|
||||
}
|
||||
37
src/Everything2Everything.Core/HistoryStore.cs
Normal file
37
src/Everything2Everything.Core/HistoryStore.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Everything2Everything.Core;
|
||||
|
||||
public sealed record HistoryEntry(
|
||||
DateTime Timestamp,
|
||||
string SourcePath,
|
||||
string SourceFormat,
|
||||
long SourceSizeBytes,
|
||||
long OutputSizeBytes,
|
||||
int OutputCount,
|
||||
string? MetaLine,
|
||||
ConvertStatus Status,
|
||||
string? Message)
|
||||
{
|
||||
public long SavingsBytes => SourceSizeBytes - OutputSizeBytes;
|
||||
|
||||
public DateOnly Date => DateOnly.FromDateTime(Timestamp);
|
||||
}
|
||||
|
||||
public sealed class HistoryStore
|
||||
{
|
||||
private readonly ObservableCollection<HistoryEntry> _entries = new();
|
||||
|
||||
public ReadOnlyObservableCollection<HistoryEntry> Entries { get; }
|
||||
|
||||
public HistoryStore()
|
||||
{
|
||||
Entries = new ReadOnlyObservableCollection<HistoryEntry>(_entries);
|
||||
}
|
||||
|
||||
public void Add(HistoryEntry entry) => _entries.Insert(0, entry);
|
||||
|
||||
public void Clear() => _entries.Clear();
|
||||
|
||||
public int Count => _entries.Count;
|
||||
}
|
||||
57
src/Everything2Everything.Core/OutputPathHelper.cs
Normal file
57
src/Everything2Everything.Core/OutputPathHelper.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
namespace Everything2Everything.Core;
|
||||
|
||||
internal static class OutputPathHelper
|
||||
{
|
||||
public static string ResolveOutputPath(
|
||||
string outputDirectory,
|
||||
string baseName,
|
||||
string? pageSuffix,
|
||||
string outputExtension,
|
||||
NameCollision collision)
|
||||
{
|
||||
var safe = SanitizeFileName(baseName);
|
||||
var ext = NormalizeExtension(outputExtension);
|
||||
var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}{ext}" : $"{safe}{pageSuffix}{ext}";
|
||||
var fullPath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
if (!File.Exists(fullPath)) return fullPath;
|
||||
|
||||
switch (collision)
|
||||
{
|
||||
case NameCollision.Overwrite:
|
||||
return fullPath;
|
||||
case NameCollision.Skip:
|
||||
return fullPath;
|
||||
case NameCollision.AppendNumber:
|
||||
default:
|
||||
for (var i = 1; i < 10000; i++)
|
||||
{
|
||||
var candidate = string.IsNullOrEmpty(pageSuffix)
|
||||
? Path.Combine(outputDirectory, $"{safe} ({i}){ext}")
|
||||
: Path.Combine(outputDirectory, $"{safe}{pageSuffix} ({i}){ext}");
|
||||
if (!File.Exists(candidate)) return candidate;
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool ShouldSkip(string finalPath, NameCollision collision)
|
||||
=> collision == NameCollision.Skip && File.Exists(finalPath);
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
Span<char> buffer = stackalloc char[name.Length];
|
||||
for (var i = 0; i < name.Length; i++)
|
||||
{
|
||||
buffer[i] = Array.IndexOf(invalid, name[i]) >= 0 ? '_' : name[i];
|
||||
}
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string ext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ext)) return ".jpg";
|
||||
return ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
128
src/Everything2Everything.Core/PreviewService.cs
Normal file
128
src/Everything2Everything.Core/PreviewService.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
using System.Windows.Media.Imaging;
|
||||
using ImageMagick;
|
||||
using PDFtoImage;
|
||||
using PhotoSauce.MagicScaler;
|
||||
using PhotoSauce.NativeCodecs.Libheif;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace Everything2Everything.Core;
|
||||
|
||||
public sealed record PreviewResult(BitmapSource? Image, string? Reason, string? Dimensions, int? PageCount);
|
||||
|
||||
public static class PreviewService
|
||||
{
|
||||
private static int _heifConfigured;
|
||||
|
||||
public static async Task<PreviewResult> CreateAsync(string path, int maxLongEdge = 720, CancellationToken ct = default)
|
||||
{
|
||||
if (!File.Exists(path)) return new PreviewResult(null, "파일을 찾을 수 없습니다.", null, null);
|
||||
|
||||
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||
try
|
||||
{
|
||||
return ext switch
|
||||
{
|
||||
".pdf" => await Task.Run(() => RenderPdf(path, maxLongEdge), ct).ConfigureAwait(false),
|
||||
".heic" or ".heif" => await Task.Run(() => RenderHeic(path, maxLongEdge), ct).ConfigureAwait(false),
|
||||
".html" or ".htm" => new PreviewResult(null, "HTML 미리보기는 변환 시점에 렌더됩니다.", null, null),
|
||||
".doc" or ".docx" or ".hwp" or ".hwpx" =>
|
||||
new PreviewResult(null, "문서 미리보기는 다음 업데이트에서 지원합니다.", null, null),
|
||||
_ => await Task.Run(() => RenderViaMagick(path, maxLongEdge), ct).ConfigureAwait(false),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PreviewResult(null, "미리보기 생성 실패: " + ex.Message, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreviewResult RenderViaMagick(string path, int maxLongEdge)
|
||||
{
|
||||
using var image = new MagickImage(path);
|
||||
var w = (int)image.Width;
|
||||
var h = (int)image.Height;
|
||||
try { image.AutoOrient(); } catch { }
|
||||
if (image.HasAlpha)
|
||||
{
|
||||
image.BackgroundColor = MagickColors.White;
|
||||
image.Alpha(AlphaOption.Remove);
|
||||
image.Alpha(AlphaOption.Off);
|
||||
}
|
||||
if (w > maxLongEdge || h > maxLongEdge)
|
||||
{
|
||||
var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
}
|
||||
image.Quality = 88;
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
var bytes = image.ToByteArray();
|
||||
return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", null);
|
||||
}
|
||||
|
||||
private static PreviewResult RenderHeic(string path, int maxLongEdge)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _heifConfigured, 1) == 0)
|
||||
CodecManager.Configure(c => c.UseLibheif());
|
||||
|
||||
var tempPng = Path.Combine(Path.GetTempPath(), $"e2e_pv_{Guid.NewGuid():N}.png");
|
||||
try
|
||||
{
|
||||
MagicImageProcessor.ProcessImage(path, tempPng, ProcessImageSettings.Default);
|
||||
return RenderViaMagick(tempPng, maxLongEdge);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (File.Exists(tempPng)) File.Delete(tempPng); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static PreviewResult RenderPdf(string path, int maxLongEdge)
|
||||
{
|
||||
int pageCount;
|
||||
using (var probe = File.OpenRead(path))
|
||||
{
|
||||
pageCount = Conversion.GetPageCount(probe);
|
||||
}
|
||||
if (pageCount <= 0) return new PreviewResult(null, "PDF 페이지가 없습니다.", null, 0);
|
||||
|
||||
var ms = new MemoryStream();
|
||||
using (var input = File.OpenRead(path))
|
||||
{
|
||||
var renderOptions = new RenderOptions
|
||||
{
|
||||
Dpi = 144,
|
||||
BackgroundColor = SKColors.White,
|
||||
WithAnnotations = true,
|
||||
WithFormFill = true,
|
||||
};
|
||||
Conversion.SaveJpeg(ms, input, page: 0, leaveOpen: false, password: null, options: renderOptions);
|
||||
}
|
||||
ms.Position = 0;
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
// optional resize via Magick
|
||||
using var image = new MagickImage(bytes);
|
||||
var w = (int)image.Width;
|
||||
var h = (int)image.Height;
|
||||
if (w > maxLongEdge || h > maxLongEdge)
|
||||
{
|
||||
var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false };
|
||||
image.Resize(geom);
|
||||
image.Quality = 88;
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
bytes = image.ToByteArray();
|
||||
}
|
||||
return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", pageCount);
|
||||
}
|
||||
|
||||
private static BitmapSource BytesToBitmap(byte[] bytes)
|
||||
{
|
||||
var bmp = new BitmapImage();
|
||||
bmp.BeginInit();
|
||||
bmp.CacheOption = BitmapCacheOption.OnLoad;
|
||||
bmp.StreamSource = new MemoryStream(bytes);
|
||||
bmp.EndInit();
|
||||
bmp.Freeze();
|
||||
return bmp;
|
||||
}
|
||||
}
|
||||
15
src/Everything2Everything.Core/Providers/ConversionPair.cs
Normal file
15
src/Everything2Everything.Core/Providers/ConversionPair.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
namespace Everything2Everything.Core.Providers;
|
||||
|
||||
public sealed record ConversionPair(string InputExtension, string OutputExtension)
|
||||
{
|
||||
public static ConversionPair Of(string input, string output)
|
||||
=> new(Normalize(input), Normalize(output));
|
||||
|
||||
public static string Normalize(string ext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ext))
|
||||
throw new ArgumentException("확장자가 비어 있습니다.", nameof(ext));
|
||||
var trimmed = ext.Trim().ToLowerInvariant();
|
||||
return trimmed.StartsWith('.') ? trimmed : "." + trimmed;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
namespace Everything2Everything.Core.Providers;
|
||||
|
||||
public interface IConverterProvider
|
||||
{
|
||||
ProviderCapability Capability { get; }
|
||||
|
||||
Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ConvertResult> ConvertAsync(
|
||||
string sourcePath,
|
||||
string outputDirectory,
|
||||
string outputExtension,
|
||||
ConvertOptions options,
|
||||
IProgress<double>? progress,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record ProviderAvailability(
|
||||
bool IsReady,
|
||||
string? Reason = null,
|
||||
IReadOnlyList<ExternalDependency>? MissingDependencies = null)
|
||||
{
|
||||
public static ProviderAvailability Ready { get; } = new(true);
|
||||
|
||||
public static ProviderAvailability NotReady(string reason, IReadOnlyList<ExternalDependency>? missing = null)
|
||||
=> new(false, reason, missing);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
namespace Everything2Everything.Core.Providers;
|
||||
|
||||
public enum ProviderStatus
|
||||
{
|
||||
Available,
|
||||
Preview,
|
||||
RequiresExternal,
|
||||
ComingSoon,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
public sealed record ExternalDependency(
|
||||
string Name,
|
||||
string Description,
|
||||
string? DownloadUrl = null,
|
||||
bool IsRequired = true);
|
||||
|
||||
public sealed record ProviderCapability(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
IReadOnlyList<ConversionPair> SupportedConversions,
|
||||
ProviderStatus Status,
|
||||
string Summary,
|
||||
IReadOnlyList<ExternalDependency> ExternalDependencies,
|
||||
string? RoadmapNote = null)
|
||||
{
|
||||
public bool CanRegisterContextMenu => Status is ProviderStatus.Available or ProviderStatus.Preview or ProviderStatus.RequiresExternal;
|
||||
public bool IsImplemented => Status is not ProviderStatus.ComingSoon and not ProviderStatus.Disabled;
|
||||
|
||||
public IReadOnlyList<string> InputExtensions
|
||||
=> SupportedConversions
|
||||
.Select(p => p.InputExtension)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
public IReadOnlyList<string> OutputExtensions
|
||||
=> SupportedConversions
|
||||
.Select(p => p.OutputExtension)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(e => e, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
public bool Supports(string inputExtension, string outputExtension)
|
||||
{
|
||||
var input = ConversionPair.Normalize(inputExtension);
|
||||
var output = ConversionPair.Normalize(outputExtension);
|
||||
return SupportedConversions.Any(p =>
|
||||
string.Equals(p.InputExtension, input, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(p.OutputExtension, output, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public IEnumerable<string> OutputsFor(string inputExtension)
|
||||
{
|
||||
var input = ConversionPair.Normalize(inputExtension);
|
||||
return SupportedConversions
|
||||
.Where(p => string.Equals(p.InputExtension, input, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p.OutputExtension)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ConversionPair> PairsFromMatrix(IEnumerable<string> inputs, IEnumerable<string> outputs)
|
||||
{
|
||||
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));
|
||||
return pairs;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ConversionPair> PairsToSingleOutput(IEnumerable<string> inputs, string output)
|
||||
=> inputs.Select(i => ConversionPair.Of(i, output)).ToList();
|
||||
}
|
||||
80
src/Everything2Everything.Core/Providers/ProviderRegistry.cs
Normal file
80
src/Everything2Everything.Core/Providers/ProviderRegistry.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
namespace Everything2Everything.Core.Providers;
|
||||
|
||||
public sealed class ProviderRegistry
|
||||
{
|
||||
private readonly List<IConverterProvider> _providers;
|
||||
private readonly Dictionary<(string Input, string Output), IConverterProvider> _byPair
|
||||
= new(PairComparer.Instance);
|
||||
private readonly Dictionary<string, List<string>> _outputsByInput
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _allInputs = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _allOutputs = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ProviderRegistry(IEnumerable<IConverterProvider> providers)
|
||||
{
|
||||
_providers = providers.ToList();
|
||||
foreach (var provider in _providers)
|
||||
{
|
||||
if (!provider.Capability.IsImplemented) continue;
|
||||
foreach (var pair in provider.Capability.SupportedConversions)
|
||||
{
|
||||
var key = (pair.InputExtension, pair.OutputExtension);
|
||||
_byPair.TryAdd(key, provider);
|
||||
if (!_outputsByInput.TryGetValue(pair.InputExtension, out var list))
|
||||
_outputsByInput[pair.InputExtension] = list = new List<string>();
|
||||
if (!list.Contains(pair.OutputExtension, StringComparer.OrdinalIgnoreCase))
|
||||
list.Add(pair.OutputExtension);
|
||||
_allInputs.Add(pair.InputExtension);
|
||||
_allOutputs.Add(pair.OutputExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<IConverterProvider> All => _providers;
|
||||
|
||||
public IEnumerable<IConverterProvider> Implemented => _providers.Where(p => p.Capability.IsImplemented);
|
||||
|
||||
public IEnumerable<IConverterProvider> ComingSoon => _providers.Where(p => p.Capability.Status == ProviderStatus.ComingSoon);
|
||||
|
||||
public bool TryGet(string sourcePath, string outputExtension, out IConverterProvider? provider)
|
||||
{
|
||||
var input = ConversionPair.Normalize(Path.GetExtension(sourcePath));
|
||||
var output = ConversionPair.Normalize(outputExtension);
|
||||
return _byPair.TryGetValue((input, output), out provider);
|
||||
}
|
||||
|
||||
public IConverterProvider? FindByPair(string inputExtension, string outputExtension)
|
||||
{
|
||||
var key = (ConversionPair.Normalize(inputExtension), ConversionPair.Normalize(outputExtension));
|
||||
return _byPair.TryGetValue(key, out var p) ? p : null;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> OutputsForInput(string inputExtension)
|
||||
{
|
||||
var input = ConversionPair.Normalize(inputExtension);
|
||||
return _outputsByInput.TryGetValue(input, out var list)
|
||||
? list.OrderBy(e => e, StringComparer.OrdinalIgnoreCase).ToList()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> OutputsForFile(string sourcePath)
|
||||
=> OutputsForInput(Path.GetExtension(sourcePath));
|
||||
|
||||
public IReadOnlyCollection<string> AllInputExtensions => _allInputs;
|
||||
|
||||
public IReadOnlyCollection<string> AllOutputExtensions => _allOutputs;
|
||||
|
||||
private sealed class PairComparer : IEqualityComparer<(string, string)>
|
||||
{
|
||||
public static readonly PairComparer Instance = new();
|
||||
|
||||
public bool Equals((string, string) x, (string, string) y)
|
||||
=> StringComparer.OrdinalIgnoreCase.Equals(x.Item1, y.Item1)
|
||||
&& StringComparer.OrdinalIgnoreCase.Equals(x.Item2, y.Item2);
|
||||
|
||||
public int GetHashCode((string, string) obj)
|
||||
=> HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item1 ?? ""),
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Item2 ?? ""));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue