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
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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue