1
0
Fork 0

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:
Yun Chan 2026-05-07 13:40:08 +09:00
parent 96861e627d
commit 9b7e5f0d0c
63 changed files with 1788 additions and 733 deletions

View 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();
}
}