From 8fa4a613d7790c0b2d6eca3b388ba9154fc6a6e4 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 12:50:35 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=BB=A4=EB=B0=8B:=20Phas?= =?UTF-8?q?e=201=20=E2=80=94=20=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=B0=98=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EB=89=B4=20+=20=ED=95=B5=EC=8B=AC=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Provider 추상화(IConverterProvider + ProviderCapability) — Available/RequiresExternal/ComingSoon 1급 시민 - 변환 구현: Magick(이미지·RAW·GIF·TIFF), HEIC/HEIF(libheif decode→Magick encode), PDF(PDFium), DOCX(Word COM 또는 LibreOffice 자동 감지) - ComingSoon 스텁: HTML, HWP/HWPX (UI 노출, 컨텍스트 메뉴 등록 자동 제외) - WPF + WPF-UI 4.3 Fluent 2 UI: 메인 창(드래그&드롭, Provider 카드), 변환 창(썸네일·옵션·진행률), 빠른 변환 진행 창, 진단 창 - 단일 EXE에 verb 라우팅: quick / dialog / register / unregister / diagnose / help - HKCU SystemFileAssociations 기반 컨텍스트 메뉴 등록 (Win11 "추가 옵션 표시"에 노출) - README, packaging/ Phase 2 placeholder 포함 Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 21 + EverythingToJpeg.slnx | 4 + README.md | 119 ++++++ packaging/README.md | 25 ++ src/EverythingToJpeg.App/App.xaml | 59 +++ src/EverythingToJpeg.App/App.xaml.cs | 100 +++++ src/EverythingToJpeg.App/AssemblyInfo.cs | 10 + src/EverythingToJpeg.App/Cli/CliRouter.cs | 120 ++++++ src/EverythingToJpeg.App/Cli/ConsoleHelper.cs | 24 ++ .../EverythingToJpeg.App.csproj | 29 ++ src/EverythingToJpeg.App/GlobalUsings.cs | 5 + .../Shell/ContextMenuRegistrar.cs | 96 +++++ .../Views/ConvertWindow.xaml | 141 +++++++ .../Views/ConvertWindow.xaml.cs | 360 ++++++++++++++++++ .../Views/DiagnoseWindow.xaml | 32 ++ .../Views/DiagnoseWindow.xaml.cs | 118 ++++++ .../Views/MainWindow.xaml | 111 ++++++ .../Views/MainWindow.xaml.cs | 273 +++++++++++++ .../Views/QuickProgressWindow.xaml | 40 ++ .../Views/QuickProgressWindow.xaml.cs | 81 ++++ src/EverythingToJpeg.App/app.manifest | 27 ++ src/EverythingToJpeg.Core/ConversionEngine.cs | 99 +++++ src/EverythingToJpeg.Core/ConvertOptions.cs | 40 ++ src/EverythingToJpeg.Core/ConvertResult.cs | 25 ++ .../Converters/DocxProvider.cs | 169 ++++++++ .../Converters/ExternalToolDetector.cs | 51 +++ .../Converters/HeicProvider.cs | 73 ++++ .../Converters/HtmlProvider.cs | 30 ++ .../Converters/HwpxProvider.cs | 30 ++ .../Converters/MagickProvider.cs | 131 +++++++ .../Converters/PdfProvider.cs | 98 +++++ .../EverythingToJpeg.Core.csproj | 20 + .../EverythingToJpegBootstrap.cs | 22 ++ src/EverythingToJpeg.Core/OutputPathHelper.cs | 49 +++ .../Providers/IConverterProvider.cs | 26 ++ .../Providers/ProviderCapability.cs | 29 ++ .../Providers/ProviderRegistry.cs | 40 ++ 37 files changed, 2727 insertions(+) create mode 100644 .gitignore create mode 100644 EverythingToJpeg.slnx create mode 100644 README.md create mode 100644 packaging/README.md create mode 100644 src/EverythingToJpeg.App/App.xaml create mode 100644 src/EverythingToJpeg.App/App.xaml.cs create mode 100644 src/EverythingToJpeg.App/AssemblyInfo.cs create mode 100644 src/EverythingToJpeg.App/Cli/CliRouter.cs create mode 100644 src/EverythingToJpeg.App/Cli/ConsoleHelper.cs create mode 100644 src/EverythingToJpeg.App/EverythingToJpeg.App.csproj create mode 100644 src/EverythingToJpeg.App/GlobalUsings.cs create mode 100644 src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs create mode 100644 src/EverythingToJpeg.App/Views/ConvertWindow.xaml create mode 100644 src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs create mode 100644 src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml create mode 100644 src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs create mode 100644 src/EverythingToJpeg.App/Views/MainWindow.xaml create mode 100644 src/EverythingToJpeg.App/Views/MainWindow.xaml.cs create mode 100644 src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml create mode 100644 src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs create mode 100644 src/EverythingToJpeg.App/app.manifest create mode 100644 src/EverythingToJpeg.Core/ConversionEngine.cs create mode 100644 src/EverythingToJpeg.Core/ConvertOptions.cs create mode 100644 src/EverythingToJpeg.Core/ConvertResult.cs create mode 100644 src/EverythingToJpeg.Core/Converters/DocxProvider.cs create mode 100644 src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs create mode 100644 src/EverythingToJpeg.Core/Converters/HeicProvider.cs create mode 100644 src/EverythingToJpeg.Core/Converters/HtmlProvider.cs create mode 100644 src/EverythingToJpeg.Core/Converters/HwpxProvider.cs create mode 100644 src/EverythingToJpeg.Core/Converters/MagickProvider.cs create mode 100644 src/EverythingToJpeg.Core/Converters/PdfProvider.cs create mode 100644 src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj create mode 100644 src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs create mode 100644 src/EverythingToJpeg.Core/OutputPathHelper.cs create mode 100644 src/EverythingToJpeg.Core/Providers/IConverterProvider.cs create mode 100644 src/EverythingToJpeg.Core/Providers/ProviderCapability.cs create mode 100644 src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5451a16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# build outputs +bin/ +obj/ +publish/ +publish-self/ + +# IDE +.vs/ +.vscode/ +*.user +*.suo + +# OS +Thumbs.db +.DS_Store + +# logs +*.log + +# rider +.idea/ diff --git a/EverythingToJpeg.slnx b/EverythingToJpeg.slnx new file mode 100644 index 0000000..b50fab5 --- /dev/null +++ b/EverythingToJpeg.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..89e58fa --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# EverythingToJpeg + +Windows 우클릭 컨텍스트 메뉴에서 한 방에 JPEG로. PNG · GIF · BMP · TIFF · WebP · AVIF · HEIC · RAW · PSD · PDF · DOCX 를 지원합니다. + +- **빠른 변환** — 다이얼로그 없이 원본 폴더의 `<원본명>_jpeg/` 하위에 즉시 저장 +- **변환…** — 옵션 다이얼로그(품질, 출력 위치, 이름 충돌, 크기 제한, PDF DPI) +- 진행 상황 + 썸네일 + 드래그 & 드롭 (메인 창) + +## 빠른 시작 + +### 1) 빌드 + +```powershell +# 솔루션 빌드 +dotnet build EverythingToJpeg.slnx -c Release + +# 단일 폴더 publish (framework-dependent, .NET 9 Desktop Runtime 필요) +dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj ` + -c Release -r win-x64 --self-contained false -o publish + +# .NET 런타임 동봉 (단일 사용자 배포가 편함) +dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj ` + -c Release -r win-x64 --self-contained true ` + -p:PublishSingleFile=false -o publish-self +``` + +산출물: `publish\EverythingToJpeg.exe` + +### 2) 컨텍스트 메뉴 등록 + +`EverythingToJpeg.exe`를 한 번 실행 → "컨텍스트 메뉴 등록" 클릭. 또는 CLI: + +```powershell +.\EverythingToJpeg.exe register +.\EverythingToJpeg.exe unregister +``` + +> Windows 11 메인 우클릭 메뉴가 아니라 "추가 옵션 표시(Shift+우클릭)" 메뉴에 노출됩니다. 메인 메뉴 노출은 Phase 2에서 IExplorerCommand + MSIX 로 추가 예정. + +### 3) 사용 + +- 파일 우클릭 → "추가 옵션 표시" → **JPEG로 빠른 변환** 또는 **JPEG로 변환…** +- 또는 메인 창에 파일/폴더를 끌어다 놓기 + +## 지원 현황 + +| 형식 | 상태 | 참고 | +|---|---|---| +| PNG · BMP · JPEG · WebP · AVIF · PSD · TIFF · GIF · RAW(NEF/CR2/CR3/ARW/DNG/RAF/ORF/RW2/SRW/PEF) | ✅ 준비됨 | Magick.NET 14.x | +| HEIC · HEIF | ✅ 준비됨 | PhotoSauce + libheif 디코드 | +| PDF | ✅ 준비됨 | PDFtoImage(PDFium) | +| DOCX · DOC | ⚙ 외부 도구 필요 | Microsoft Word 또는 LibreOffice 자동 감지 | +| HTML · HTM | 🕐 개발 중 | WebView2 헤드리스 캡처 예정 | +| HWP · HWPX | 🕐 개발 중 | LibreOffice + H2Orestart 파이프라인 예정 | + +## 기술 스택 + +- **.NET 9 + WPF**, Windows 10.0.19041.0+ +- UI: **WPF-UI 4.3** (Win11 Fluent 2 — Mica 백드롭, Segoe UI Variable 타입 램프) +- 변환 엔진: Magick.NET, PDFtoImage, PhotoSauce.MagicScaler + Libheif + +## 로드맵 (Phase 2) + +1. **IExplorerCommand 셸 익스텐션 + MSIX Sparse Package** — Win11 메인 컨텍스트 메뉴 직접 노출 +2. **HTML 변환** — WebView2 헤드리스, viewport 옵션 +3. **HWP/HWPX 변환** — LibreOffice + H2Orestart 자동 설치 가이드 +4. **GitHub Releases CI/CD** — 태그 푸시 시 자동 빌드 + MSIX 패키징 +5. **자체 서명 인증서 자동 생성·배포** — 내부 5대 PC 신뢰 체인 자동화 (현재는 unsigned MSIX → 개발자 모드 필요) + +## 미서명 빌드를 신뢰할 PC에 설치하기 (Phase 2 미리보기) + +본인 PC 5대에만 설치할 계획이므로 정식 코드사이닝 인증서 없이도 사용 가능합니다. + +### 옵션 A — Portable EXE (지금 바로 가능) +1. `publish` 폴더 통째로 PC에 복사 +2. `EverythingToJpeg.exe` 실행 → 한 번만 "컨텍스트 메뉴 등록" +3. 끝. SmartScreen 경고가 뜨면 "추가 정보" → "실행" + +### 옵션 B — MSIX Sparse Package (Phase 2) +1. `EverythingToJpeg.Package` 프로젝트로 unsigned MSIX 빌드 +2. 각 PC에서 **개발자 모드 켜기** (설정 → 개인 정보 및 보안 → 개발자용) +3. PowerShell: + ```powershell + Add-AppxPackage -AllowUnsigned -Path EverythingToJpeg.msix + ``` +4. 또는 Group Policy로 사이드로딩 허용 후 자체 서명 인증서를 Local Machine\Trusted People에 임포트 + +## 프로젝트 구조 + +``` +everythingToJpeg/ +├── EverythingToJpeg.slnx +└── src/ + ├── EverythingToJpeg.Core/ — 변환 엔진, Provider 추상화 + │ ├── Providers/ — IConverterProvider + Capability 메타데이터 + │ ├── Converters/ — Magick / Heic / Pdf / Docx / Html / Hwpx + │ ├── ConversionEngine.cs + │ └── EverythingToJpegBootstrap.cs + └── EverythingToJpeg.App/ — WPF + CLI 통합 진입점 + ├── App.xaml(.cs) — CLI 라우터 + ├── Cli/CliRouter.cs — verb: quick / dialog / register / diagnose + ├── Shell/ContextMenuRegistrar.cs — HKCU 레지스트리 등록 + └── Views/ — Fluent UI 화면 +``` + +## Provider 전략 (확장 포인트) + +새 형식을 지원하려면 `IConverterProvider`를 구현하고 `EverythingToJpegBootstrap.CreateDefault()`에 등록합니다. `ProviderCapability`에 다음을 명시하세요: + +- `Status` — `Available` / `Preview` / `RequiresExternal` / `ComingSoon` / `Disabled` +- `Extensions` — 자동 라우팅 + 컨텍스트 메뉴 등록 키 +- `ExternalDependencies` — UI에 자동 노출되는 외부 도구 +- `RoadmapNote` — 사용자에게 보여줄 향후 계획 + +`ComingSoon` 상태는 메인 창의 "지원 형식" 섹션에 자동 노출되지만 컨텍스트 메뉴 등록에서는 자동 제외됩니다. + +## 라이선스 + +MIT (예정). diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..7b7eb2e --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,25 @@ +# Phase 2 — MSIX 패키징 (placeholder) + +이 폴더는 향후 IExplorerCommand 셸 익스텐션 + MSIX Sparse Package 작업을 위한 자리입니다. 현재 구현되지 않았습니다. + +## 다음 단계 체크리스트 + +- [ ] `EverythingToJpeg.Shell` C++/WinRT 또는 C#(WinRT projection) 프로젝트 생성 → `IExplorerCommand` 구현 +- [ ] `Package.appxmanifest` 작성 — `` 사용 +- [ ] Windows Application Packaging Project (.wapproj) 생성 — App + Shell DLL 묶기 +- [ ] 자체 서명 인증서 생성 스크립트: + ```powershell + New-SelfSignedCertificate -Type CodeSigningCert ` + -Subject "CN=EverythingToJpegDev" ` + -KeyAlgorithm RSA -KeyLength 2048 ` + -CertStoreLocation "Cert:\CurrentUser\My" + ``` +- [ ] MakeAppx + SignTool로 MSIX 빌드 + 서명 +- [ ] 5대 PC에 인증서를 `Cert:\LocalMachine\TrustedPeople`에 임포트 +- [ ] GitHub Actions: 태그 푸시 시 unsigned MSIX 자동 빌드 + Release 첨부 + +## 참고 + +- [PowerToys 컨텍스트 메뉴 개발 문서](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/common/context-menus.md) +- [IExplorerCommand C# 예제](https://github.com/cjee21/IExplorerCommand-Examples) +- [Microsoft: Sparse package 등록](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps) diff --git a/src/EverythingToJpeg.App/App.xaml b/src/EverythingToJpeg.App/App.xaml new file mode 100644 index 0000000..1510506 --- /dev/null +++ b/src/EverythingToJpeg.App/App.xaml @@ -0,0 +1,59 @@ + + + + + + + + + + 4 + 8 + 16 + 24 + 32 + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/App.xaml.cs b/src/EverythingToJpeg.App/App.xaml.cs new file mode 100644 index 0000000..95b0549 --- /dev/null +++ b/src/EverythingToJpeg.App/App.xaml.cs @@ -0,0 +1,100 @@ +using System.Windows; +using EverythingToJpeg.App.Cli; +using EverythingToJpeg.App.Views; +using EverythingToJpeg.Core; + +namespace EverythingToJpeg.App; + +public partial class App : Application +{ + public ConversionEngine Engine { get; } = EverythingToJpegBootstrap.CreateDefault(); + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + var parsed = CliRouter.Parse(e.Args); + + switch (parsed.Mode) + { + case CliRouter.Mode.Help: + ConsoleHelper.WriteLine(CliRouter.HelpText()); + Environment.Exit(0); + return; + + case CliRouter.Mode.Register: + Environment.Exit(CliRouter.RunRegister(register: true)); + return; + + case CliRouter.Mode.Unregister: + Environment.Exit(CliRouter.RunRegister(register: false)); + return; + + case CliRouter.Mode.Diagnose: + ShowDiagnoseWindow(); + return; + + case CliRouter.Mode.Quick: + if (parsed.Files.Count == 0) + { + MessageBox.Show("변환할 파일이 없습니다.", "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Information); + Shutdown(1); + return; + } + await RunQuickAsync(parsed.Files); + return; + + case CliRouter.Mode.Dialog: + ShowConvertDialog(parsed.Files); + return; + + case CliRouter.Mode.ShowMain: + default: + ShowMainWindow(); + return; + } + } + + private void ShowMainWindow() + { + var window = new MainWindow(); + MainWindow = window; + window.Show(); + } + + private void ShowConvertDialog(IReadOnlyList files) + { + var window = new ConvertWindow(Engine, files); + MainWindow = window; + window.Show(); + } + + private void ShowDiagnoseWindow() + { + var window = new DiagnoseWindow(Engine); + MainWindow = window; + window.Show(); + } + + private async Task RunQuickAsync(IReadOnlyList files) + { + var progress = new QuickProgressWindow(files.Count); + progress.Show(); + + try + { + var options = ConvertOptions.Quick(); + var reporter = new Progress(p => progress.Report(p)); + var results = await Engine.ConvertManyAsync(files, options, reporter); + progress.Finish(results); + } + catch (Exception ex) + { + MessageBox.Show($"변환 중 오류: {ex.Message}", "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); + progress.Close(); + Shutdown(1); + } + } +} diff --git a/src/EverythingToJpeg.App/AssemblyInfo.cs b/src/EverythingToJpeg.App/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/EverythingToJpeg.App/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/EverythingToJpeg.App/Cli/CliRouter.cs b/src/EverythingToJpeg.App/Cli/CliRouter.cs new file mode 100644 index 0000000..613be16 --- /dev/null +++ b/src/EverythingToJpeg.App/Cli/CliRouter.cs @@ -0,0 +1,120 @@ +using EverythingToJpeg.App.Shell; +using EverythingToJpeg.Core; + +namespace EverythingToJpeg.App.Cli; + +internal static class CliRouter +{ + public enum Mode + { + ShowMain, + Quick, + Dialog, + Register, + Unregister, + Diagnose, + Help, + } + + public sealed record ParsedArgs(Mode Mode, IReadOnlyList Files); + + public static ParsedArgs Parse(string[] args) + { + if (args is null || args.Length == 0) + return new ParsedArgs(Mode.ShowMain, Array.Empty()); + + var verb = args[0].Trim().ToLowerInvariant(); + var rest = args.Skip(1).Where(a => !string.IsNullOrWhiteSpace(a)).ToList(); + + return verb switch + { + "quick" => new ParsedArgs(Mode.Quick, ExpandFiles(rest)), + "dialog" => new ParsedArgs(Mode.Dialog, ExpandFiles(rest)), + "register" => new ParsedArgs(Mode.Register, Array.Empty()), + "unregister" => new ParsedArgs(Mode.Unregister, Array.Empty()), + "diagnose" or "doctor" => new ParsedArgs(Mode.Diagnose, Array.Empty()), + "help" or "--help" or "-h" or "/?" => new ParsedArgs(Mode.Help, Array.Empty()), + _ when File.Exists(args[0]) => new ParsedArgs(Mode.Dialog, ExpandFiles(args)), + _ => new ParsedArgs(Mode.ShowMain, Array.Empty()), + }; + } + + private static IReadOnlyList ExpandFiles(IEnumerable raw) + { + var list = new List(); + foreach (var arg in raw) + { + if (string.IsNullOrWhiteSpace(arg)) continue; + try + { + if (File.Exists(arg)) { list.Add(Path.GetFullPath(arg)); continue; } + if (Directory.Exists(arg)) + { + foreach (var f in Directory.EnumerateFiles(arg, "*", SearchOption.TopDirectoryOnly)) + list.Add(Path.GetFullPath(f)); + } + } + catch { } + } + return list; + } + + public static string HelpText() + { + var engine = EverythingToJpegBootstrap.CreateDefault(); + var supported = string.Join(", ", + engine.Providers.Implemented.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e)); + var coming = string.Join(", ", + engine.Providers.ComingSoon.SelectMany(p => p.Capability.Extensions).Distinct().OrderBy(e => e)); + + return $""" + EverythingToJpeg — 모든 것을 JPEG로 + + 사용: + EverythingToJpeg.exe quick <파일들...> 빠른 변환 (다이얼로그 없이 즉시) + EverythingToJpeg.exe dialog <파일들...> 상세 옵션 다이얼로그 표시 + EverythingToJpeg.exe register 컨텍스트 메뉴 등록 (현재 사용자) + EverythingToJpeg.exe unregister 컨텍스트 메뉴 해제 + EverythingToJpeg.exe diagnose 지원 형식·외부 도구 진단 + EverythingToJpeg.exe 메인 창 표시 + + 지원 (지금): {supported} + 지원 예정 : {coming} + """; + } + + public static int RunRegister(bool register) + { + var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_register.log"); + var log = new System.Text.StringBuilder(); + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] RunRegister start, register={register}"); + try + { + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Creating engine..."); + var engine = EverythingToJpegBootstrap.CreateDefault(); + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Engine OK. Implemented providers: {string.Join(",", engine.Providers.Implemented.Select(p => p.Capability.Id))}"); + + if (register) + { + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] Calling Register..."); + ContextMenuRegistrar.Register(engine); + } + else + { + ContextMenuRegistrar.Unregister(engine); + } + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] OK"); + File.WriteAllText(logPath, log.ToString()); + Console.Out.WriteLine($"등록 완료. 로그: {logPath}"); + return 0; + } + catch (Exception ex) + { + log.AppendLine($"[{DateTime.Now:HH:mm:ss.fff}] EXCEPTION {ex.GetType().Name}: {ex.Message}"); + log.AppendLine(ex.ToString()); + try { File.WriteAllText(logPath, log.ToString()); } catch { } + Console.Error.WriteLine(ex.Message); + return 1; + } + } +} diff --git a/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs b/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs new file mode 100644 index 0000000..345f187 --- /dev/null +++ b/src/EverythingToJpeg.App/Cli/ConsoleHelper.cs @@ -0,0 +1,24 @@ +using System.Runtime.InteropServices; + +namespace EverythingToJpeg.App.Cli; + +internal static class ConsoleHelper +{ + private static bool _attached; + + public static void WriteLine(string text) + { + EnsureAttached(); + Console.Out.WriteLine(text); + Console.Out.Flush(); + } + + private static void EnsureAttached() + { + if (_attached) return; + _attached = AttachConsole(-1); + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(int dwProcessId); +} diff --git a/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj b/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj new file mode 100644 index 0000000..b68e912 --- /dev/null +++ b/src/EverythingToJpeg.App/EverythingToJpeg.App.csproj @@ -0,0 +1,29 @@ + + + + WinExe + net9.0-windows10.0.19041.0 + enable + enable + true + EverythingToJpeg + EverythingToJpeg.App + app.manifest + 10.0.17763.0 + 10.0.17763.0 + $(NoWarn);NU1901;NU1902;NU1903;NU1904 + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/GlobalUsings.cs b/src/EverythingToJpeg.App/GlobalUsings.cs new file mode 100644 index 0000000..8ba9936 --- /dev/null +++ b/src/EverythingToJpeg.App/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using MessageBox = System.Windows.MessageBox; +global using MessageBoxButton = System.Windows.MessageBoxButton; +global using MessageBoxImage = System.Windows.MessageBoxImage; +global using MessageBoxResult = System.Windows.MessageBoxResult; +global using TextBlock = System.Windows.Controls.TextBlock; diff --git a/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs b/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs new file mode 100644 index 0000000..d752263 --- /dev/null +++ b/src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs @@ -0,0 +1,96 @@ +using EverythingToJpeg.Core; +using EverythingToJpeg.Core.Providers; +using Microsoft.Win32; + +namespace EverythingToJpeg.App.Shell; + +internal static class ContextMenuRegistrar +{ + private const string QuickVerb = "EverythingToJpeg.Quick"; + private const string DialogVerb = "EverythingToJpeg.Dialog"; + + private const string QuickLabel = "JPEG로 빠른 변환"; + private const string DialogLabel = "JPEG로 변환…"; + + public static void Register(ConversionEngine engine) + { + var exe = GetAppExecutablePath(); + var icon = exe + ",0"; + + foreach (var ext in CollectExtensions(engine)) + { + WriteVerb(ext, QuickVerb, QuickLabel, icon, $"\"{exe}\" quick \"%1\""); + WriteVerb(ext, DialogVerb, DialogLabel, icon, $"\"{exe}\" dialog \"%1\""); + } + + NotifyShell(); + } + + public static void Unregister(ConversionEngine engine) + { + foreach (var ext in CollectExtensions(engine)) + { + DeleteVerb(ext, QuickVerb); + DeleteVerb(ext, DialogVerb); + } + NotifyShell(); + } + + private static IEnumerable CollectExtensions(ConversionEngine engine) + { + return engine.Providers.Implemented + .Where(p => p.Capability.CanRegisterContextMenu) + .SelectMany(p => p.Capability.Extensions) + .Select(e => e.StartsWith('.') ? e : "." + e) + .Select(e => e.ToLowerInvariant()) + .Distinct(); + } + + private static void WriteVerb(string ext, string verb, string label, string icon, string command) + { + var keyPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell\{verb}"; + using var verbKey = Registry.CurrentUser.CreateSubKey(keyPath, writable: true) + ?? throw new InvalidOperationException($"레지스트리 키 생성 실패: {keyPath}"); + + verbKey.SetValue(null, label, RegistryValueKind.String); + verbKey.SetValue("Icon", icon, RegistryValueKind.String); + verbKey.SetValue("MUIVerb", label, RegistryValueKind.String); + + using var commandKey = verbKey.CreateSubKey("command", writable: true) + ?? throw new InvalidOperationException("command 하위 키 생성 실패"); + commandKey.SetValue(null, command, RegistryValueKind.String); + } + + private static void DeleteVerb(string ext, string verb) + { + var parentPath = $@"Software\Classes\SystemFileAssociations\{ext}\shell"; + try + { + using var parent = Registry.CurrentUser.OpenSubKey(parentPath, writable: true); + parent?.DeleteSubKeyTree(verb, throwOnMissingSubKey: false); + } + catch + { + } + } + + private static string GetAppExecutablePath() + { + var exe = Environment.ProcessPath; + if (!string.IsNullOrEmpty(exe) && File.Exists(exe)) return exe; + return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar + "EverythingToJpeg.exe"; + } + + private static void NotifyShell() + { + try { NativeMethods.SHChangeNotify(0x08000000, 0x0000, IntPtr.Zero, IntPtr.Zero); } + catch { } + } + + private static class NativeMethods + { + [System.Runtime.InteropServices.DllImport("shell32.dll")] + public static extern void SHChangeNotify(int wEventId, uint uFlags, IntPtr dwItem1, IntPtr dwItem2); + } +} diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml new file mode 100644 index 0000000..1fcfc8a --- /dev/null +++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs new file mode 100644 index 0000000..15dcce1 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs @@ -0,0 +1,360 @@ +using System.Collections.ObjectModel; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using EverythingToJpeg.Core; +using EverythingToJpeg.Core.Providers; +using Wpf.Ui.Controls; + +namespace EverythingToJpeg.App.Views; + +public partial class ConvertWindow : FluentWindow +{ + private readonly ConversionEngine _engine; + private readonly ObservableCollection _entries = new(); + private CancellationTokenSource? _cts; + + public ConvertWindow(ConversionEngine engine, IReadOnlyList initialFiles) + { + _engine = engine; + InitializeComponent(); + + FilesList.ItemsSource = _entries; + FilesList.ItemTemplate = (DataTemplate)CreateFileEntryTemplate(); + + AddFiles(initialFiles); + + OutputModeCombo.SelectionChanged += (_, _) => + CustomFolderRow.Visibility = OutputModeCombo.SelectedIndex == 2 + ? Visibility.Visible : Visibility.Collapsed; + } + + private void AddFiles(IEnumerable paths) + { + var existing = new HashSet(_entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase); + foreach (var p in paths) + { + if (!File.Exists(p)) continue; + if (existing.Contains(p)) continue; + + var entry = new FileEntry(p, _engine); + _entries.Add(entry); + _ = entry.LoadThumbnailAsync(); + } + UpdateSummary(); + } + + private void UpdateSummary() + { + FilesSummaryText.Text = _entries.Count == 0 + ? "비어 있음 — 파일을 끌어다 놓거나 추가하세요" + : $"{_entries.Count}개 파일"; + } + + private void OnDragOver(object sender, DragEventArgs e) + { + e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) + ? DragDropEffects.Copy : DragDropEffects.None; + e.Handled = true; + } + + private void OnFilesDropped(object sender, DragEventArgs e) + { + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; + if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return; + AddFiles(ExpandPaths(paths)); + } + + private void OnAddFilesClick(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Multiselect = true, + Title = "추가할 파일 선택", + Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc|모든 파일|*.*", + }; + if (dlg.ShowDialog(this) == true) AddFiles(dlg.FileNames); + } + + private void OnClearFilesClick(object sender, RoutedEventArgs e) + { + _entries.Clear(); + UpdateSummary(); + } + + private void OnRemoveEntry(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement fe && fe.DataContext is FileEntry entry) + { + _entries.Remove(entry); + UpdateSummary(); + } + } + + private void OnBrowseClick(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "출력 폴더 선택" }; + if (dlg.ShowDialog(this) == true) + CustomFolderTextBox.Text = dlg.FolderName; + } + + private static IEnumerable ExpandPaths(IEnumerable paths) + { + foreach (var p in paths) + { + if (File.Exists(p)) yield return p; + else if (Directory.Exists(p)) + { + foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)) + yield return f; + } + } + } + + private async void OnConvertClick(object sender, RoutedEventArgs e) + { + if (_entries.Count == 0) + { + ShowInfo("변환할 파일이 없습니다."); + return; + } + + ConvertButton.IsEnabled = false; + CancelButton.Content = "취소"; + _cts = new CancellationTokenSource(); + + var options = BuildOptions(); + var reporter = new Progress(p => + { + var overall = p.Total == 0 ? 0 : (p.Index + p.FileProgress) / p.Total; + OverallProgress.Value = Math.Clamp(overall, 0, 1); + ProgressStatusText.Text = $"{Math.Min(p.Index + 1, p.Total)} / {p.Total} — {Path.GetFileName(p.CurrentPath)}"; + UpdateEntryProgress(p); + }); + + try + { + var sources = _entries.Select(en => en.Path).ToList(); + var results = await _engine.ConvertManyAsync(sources, options, reporter, _cts.Token); + ApplyResults(results); + ProgressStatusText.Text = SummarizeResults(results); + } + catch (OperationCanceledException) + { + ProgressStatusText.Text = "변환이 취소되었습니다."; + } + catch (Exception ex) + { + ProgressStatusText.Text = "오류: " + ex.Message; + } + finally + { + ConvertButton.IsEnabled = true; + CancelButton.Content = "닫기"; + _cts = null; + } + } + + private void UpdateEntryProgress(ConvertProgress p) + { + if (p.Index >= _entries.Count) return; + for (var i = 0; i < _entries.Count; i++) + { + if (i < p.Index) _entries[i].State = "완료"; + else if (i == p.Index) _entries[i].State = "변환 중…"; + else _entries[i].State = "대기"; + } + } + + private void ApplyResults(IReadOnlyList results) + { + foreach (var result in results) + { + var entry = _entries.FirstOrDefault(e => string.Equals(e.Path, result.SourcePath, StringComparison.OrdinalIgnoreCase)); + if (entry is null) continue; + entry.State = result.Status switch + { + ConvertStatus.Success => $"성공 ({result.OutputPaths.Count}개)", + ConvertStatus.Skipped => "건너뜀", + ConvertStatus.Failed => "실패: " + result.Message, + _ => entry.State, + }; + entry.IsFailed = result.Status == ConvertStatus.Failed; + } + } + + private static string SummarizeResults(IReadOnlyList results) + { + var success = results.Count(r => r.Status == ConvertStatus.Success); + var skipped = results.Count(r => r.Status == ConvertStatus.Skipped); + var failed = results.Count(r => r.Status == ConvertStatus.Failed); + var outputs = results.Sum(r => r.OutputPaths.Count); + return $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}"; + } + + private void OnCancelClick(object sender, RoutedEventArgs e) + { + if (_cts is { } cts) { cts.Cancel(); return; } + Close(); + } + + private ConvertOptions BuildOptions() + { + var opts = new ConvertOptions + { + Quality = (int)QualitySlider.Value, + PdfDpi = (int)DpiSlider.Value, + FlattenTransparency = FlattenCheckBox.IsChecked == true, + }; + opts.OutputLocation = OutputModeCombo.SelectedIndex switch + { + 1 => OutputLocation.SameFolderAsSource, + 2 => OutputLocation.Custom, + _ => OutputLocation.SubfolderBesideSource, + }; + if (opts.OutputLocation == OutputLocation.Custom) + opts.CustomOutputDirectory = CustomFolderTextBox.Text; + + opts.OnCollision = CollisionCombo.SelectedIndex switch + { + 1 => NameCollision.Overwrite, + 2 => NameCollision.Skip, + _ => NameCollision.AppendNumber, + }; + + if (int.TryParse(MaxLongEdgeTextBox.Text, out var maxEdge) && maxEdge > 0) + opts.MaxLongEdgePixels = maxEdge; + + return opts; + } + + private void ShowInfo(string message) + => MessageBox.Show(this, message, "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Information); + + private object CreateFileEntryTemplate() + { + const string xaml = """ + + + + + + + + + + + + + + + + + + + + + + + + + """; + return System.Windows.Markup.XamlReader.Parse(xaml); + } +} + +public sealed class FileEntry : System.ComponentModel.INotifyPropertyChanged +{ + private readonly ConversionEngine _engine; + private string _state = "대기"; + private bool _isFailed; + private ImageSource? _thumbnail; + + public FileEntry(string path, ConversionEngine engine) + { + Path = path; + _engine = engine; + } + + public string Path { get; } + public string FileName => System.IO.Path.GetFileName(Path); + + public string SubText + { + get + { + var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant(); + string handler; + if (_engine.Providers.TryGetForFile(Path, out var provider) && provider is not null) + handler = provider.Capability.DisplayName; + else + handler = "지원되지 않음"; + try + { + var size = new FileInfo(Path).Length; + return $"{ext} · {handler} · {FormatBytes(size)}"; + } + catch + { + return $"{ext} · {handler}"; + } + } + } + + public string State { get => _state; set { _state = value; Raise(nameof(State)); } } + public bool IsFailed { get => _isFailed; set { _isFailed = value; Raise(nameof(IsFailed)); } } + public ImageSource? Thumbnail { get => _thumbnail; set { _thumbnail = value; Raise(nameof(Thumbnail)); } } + + public Task LoadThumbnailAsync() => Task.Run(() => + { + try + { + var ext = System.IO.Path.GetExtension(Path).ToLowerInvariant(); + if (ext is ".png" or ".jpg" or ".jpeg" or ".bmp" or ".gif") + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.UriSource = new Uri(Path); + bmp.DecodePixelWidth = 80; + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.EndInit(); + bmp.Freeze(); + System.Windows.Application.Current.Dispatcher.Invoke(() => Thumbnail = bmp); + } + } + catch { } + }); + + private static string FormatBytes(long bytes) + { + string[] units = { "B", "KB", "MB", "GB" }; + double size = bytes; + var unit = 0; + while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; } + return $"{size:0.#} {units[unit]}"; + } + + public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; + private void Raise(string n) => PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(n)); +} diff --git a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml new file mode 100644 index 0000000..d5b95ec --- /dev/null +++ b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs new file mode 100644 index 0000000..7e6e2e6 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs @@ -0,0 +1,118 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using EverythingToJpeg.Core; +using EverythingToJpeg.Core.Providers; +using Wpf.Ui.Controls; + +namespace EverythingToJpeg.App.Views; + +public partial class DiagnoseWindow : FluentWindow +{ + private readonly ConversionEngine _engine; + + public DiagnoseWindow(ConversionEngine engine) + { + _engine = engine; + InitializeComponent(); + Loaded += async (_, _) => await PopulateAsync(); + } + + private async Task PopulateAsync() + { + ItemsPanel.Children.Clear(); + + ItemsPanel.Children.Add(BuildSection("환경", new[] + { + ("OS", Environment.OSVersion.VersionString), + (".NET", Environment.Version.ToString()), + ("실행 경로", Environment.ProcessPath ?? AppContext.BaseDirectory), + })); + + foreach (var provider in _engine.Providers.All) + { + var availability = await provider.CheckAvailabilityAsync(); + ItemsPanel.Children.Add(BuildProviderCard(provider, availability)); + } + } + + private static UIElement BuildSection(string title, IEnumerable<(string Key, string Value)> items) + { + var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) }; + var stack = new StackPanel(); + stack.Children.Add(new TextBlock + { + Text = title, + FontSize = 14, + FontWeight = FontWeights.SemiBold, + Margin = new Thickness(0, 0, 0, 8), + }); + foreach (var (k, v) in items) + { + var row = new Grid { Margin = new Thickness(0, 2, 0, 2) }; + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(120) }); + row.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + var keyText = new TextBlock { Text = k, FontSize = 12, Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush") }; + var valueText = new TextBlock { Text = v, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis }; + Grid.SetColumn(valueText, 1); + row.Children.Add(keyText); + row.Children.Add(valueText); + stack.Children.Add(row); + } + card.Content = stack; + return card; + } + + private UIElement BuildProviderCard(IConverterProvider provider, ProviderAvailability availability) + { + var card = new CardControl { Padding = new Thickness(16, 12, 16, 12), Margin = new Thickness(0, 0, 0, 12) }; + var stack = new StackPanel(); + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(new TextBlock + { + Text = provider.Capability.DisplayName, + FontSize = 14, + FontWeight = FontWeights.SemiBold, + }); + var (badgeText, brushKey) = (provider.Capability.Status, availability.IsReady) switch + { + (ProviderStatus.ComingSoon, _) => ("개발 중", "BadgeMutedBrush"), + (ProviderStatus.Disabled, _) => ("비활성", "BadgeMutedBrush"), + (_, true) => ("준비됨", "BadgeReadyBrush"), + _ => ("점검 필요", "BadgeWarnBrush"), + }; + header.Children.Add(new Border + { + Background = (Brush)Application.Current.FindResource(brushKey), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(8, 2, 8, 2), + Margin = new Thickness(8, 0, 0, 0), + Child = new TextBlock { Text = badgeText, FontSize = 11, Foreground = Brushes.White }, + }); + stack.Children.Add(header); + + stack.Children.Add(new TextBlock + { + Text = $"확장자: {string.Join(", ", provider.Capability.Extensions)}", + FontSize = 11, + Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"), + Margin = new Thickness(0, 4, 0, 0), + }); + if (!availability.IsReady && !string.IsNullOrEmpty(availability.Reason)) + { + stack.Children.Add(new TextBlock + { + Text = availability.Reason, + FontSize = 11, + Foreground = (Brush)Application.Current.FindResource("BadgeWarnBrush"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 0), + }); + } + card.Content = stack; + return card; + } + + private async void OnRefreshClick(object sender, RoutedEventArgs e) => await PopulateAsync(); + private void OnCloseClick(object sender, RoutedEventArgs e) => Close(); +} diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml b/src/EverythingToJpeg.App/Views/MainWindow.xaml new file mode 100644 index 0000000..53a8f09 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 파일을 우클릭하면 끝. PNG · GIF · HEIC · RAW · PDF · DOCX 가 모두 한 번에 변환됩니다. + + + + + + + + + + + + + 또는 + 파일 선택 + + + + + + + + + + + + + + + 준비된 형식만 우클릭 메뉴에 등록됩니다 + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs new file mode 100644 index 0000000..aac1bf4 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs @@ -0,0 +1,273 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using EverythingToJpeg.App.Shell; +using EverythingToJpeg.Core.Providers; +using Wpf.Ui.Controls; + +namespace EverythingToJpeg.App.Views; + +public partial class MainWindow : FluentWindow, INotifyPropertyChanged +{ + private bool _isDraggingOver; + public bool IsDraggingOver + { + get => _isDraggingOver; + set { _isDraggingOver = value; OnPropertyChanged(); } + } + + public MainWindow() + { + InitializeComponent(); + DataContext = this; + Loaded += async (_, _) => await PopulateAsync(); + } + + private async Task PopulateAsync() + { + var engine = ((App)Application.Current).Engine; + ProvidersList.Items.Clear(); + foreach (var provider in engine.Providers.All) + { + var availability = await provider.CheckAvailabilityAsync(); + ProvidersList.Items.Add(BuildProviderRow(provider.Capability, availability)); + } + } + + private static UIElement BuildProviderRow(ProviderCapability cap, ProviderAvailability availability) + { + var (badge, badgeKey) = cap.Status switch + { + ProviderStatus.Available => availability.IsReady + ? ("준비됨", "BadgeReadyBrush") + : ("점검 필요", "BadgeWarnBrush"), + ProviderStatus.Preview => ("프리뷰", "BadgeInfoBrush"), + ProviderStatus.RequiresExternal => availability.IsReady + ? ("외부 도구 감지됨", "BadgeReadyBrush") + : ("외부 도구 필요", "BadgeWarnBrush"), + ProviderStatus.ComingSoon => ("개발 중", "BadgeMutedBrush"), + _ => ("비활성", "BadgeMutedBrush"), + }; + + var card = new CardControl + { + Padding = new Thickness(16, 12, 16, 12), + Margin = new Thickness(0, 0, 0, 8), + }; + + var stack = new StackPanel(); + + var headerStack = new StackPanel { Orientation = Orientation.Horizontal }; + headerStack.Children.Add(new TextBlock + { + Text = cap.DisplayName, + FontFamily = new FontFamily("Segoe UI Variable Text, Segoe UI"), + FontSize = 14, + FontWeight = FontWeights.SemiBold, + VerticalAlignment = VerticalAlignment.Center, + }); + headerStack.Children.Add(new Border + { + Background = (Brush)Application.Current.FindResource(badgeKey), + CornerRadius = new CornerRadius(10), + Padding = new Thickness(8, 2, 8, 2), + Margin = new Thickness(8, 0, 0, 0), + Child = new TextBlock { Text = badge, FontSize = 11, Foreground = Brushes.White }, + }); + stack.Children.Add(headerStack); + + stack.Children.Add(new TextBlock + { + Text = cap.Summary, + FontFamily = new FontFamily("Segoe UI Variable Text, Segoe UI"), + FontSize = 12, + Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 0), + }); + + stack.Children.Add(new TextBlock + { + Text = "확장자: " + string.Join(", ", cap.Extensions), + FontSize = 11, + Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"), + Margin = new Thickness(0, 4, 0, 0), + }); + + if (!availability.IsReady && !string.IsNullOrEmpty(availability.Reason)) + { + stack.Children.Add(new TextBlock + { + Text = availability.Reason, + FontSize = 11, + Foreground = (Brush)Application.Current.FindResource("BadgeWarnBrush"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 4, 0, 0), + }); + } + + if (cap.ExternalDependencies.Count > 0) + { + foreach (var dep in cap.ExternalDependencies) + { + var line = new TextBlock + { + FontSize = 11, + Foreground = (Brush)Application.Current.FindResource("TextFillColorSecondaryBrush"), + Margin = new Thickness(0, 2, 0, 0), + TextWrapping = TextWrapping.Wrap, + }; + line.Inlines.Add(new Run($"• {dep.Name} — {dep.Description} ")); + if (!string.IsNullOrEmpty(dep.DownloadUrl)) + { + var hl = new Hyperlink(new Run(dep.DownloadUrl)) { NavigateUri = new Uri(dep.DownloadUrl) }; + hl.RequestNavigate += (_, e) => + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = e.Uri.AbsoluteUri, + UseShellExecute = true + }); + } + catch { } + e.Handled = true; + }; + line.Inlines.Add(hl); + } + stack.Children.Add(line); + } + } + + if (!string.IsNullOrEmpty(cap.RoadmapNote)) + { + stack.Children.Add(new TextBlock + { + Text = "로드맵: " + cap.RoadmapNote, + FontSize = 11, + FontStyle = FontStyles.Italic, + Foreground = (Brush)Application.Current.FindResource("TextFillColorTertiaryBrush"), + Margin = new Thickness(0, 4, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + } + + card.Content = stack; + return card; + } + + private void OnDragOver(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(DataFormats.FileDrop)) + { + e.Effects = DragDropEffects.Copy; + IsDraggingOver = true; + } + else + { + e.Effects = DragDropEffects.None; + } + e.Handled = true; + } + + private void OnDragLeave(object sender, DragEventArgs e) + { + IsDraggingOver = false; + } + + private void OnFilesDropped(object sender, DragEventArgs e) + { + IsDraggingOver = false; + if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; + if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return; + OpenConvertWindow(paths); + } + + private void OnPickFilesClick(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFileDialog + { + Title = "변환할 파일 선택", + Multiselect = true, + Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc|모든 파일|*.*", + }; + if (dlg.ShowDialog(this) == true) + { + OpenConvertWindow(dlg.FileNames); + } + } + + private void OpenConvertWindow(string[] paths) + { + var files = ExpandPaths(paths); + if (files.Count == 0) return; + var window = new ConvertWindow(((App)Application.Current).Engine, files) { Owner = this }; + window.ShowDialog(); + } + + private static List ExpandPaths(IEnumerable paths) + { + var list = new List(); + foreach (var p in paths) + { + try + { + if (File.Exists(p)) list.Add(p); + else if (Directory.Exists(p)) + list.AddRange(Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)); + } + catch { } + } + return list; + } + + private async void OnRegisterClick(object sender, RoutedEventArgs e) + { + try + { + ContextMenuRegistrar.Register(((App)Application.Current).Engine); + ShowToast("컨텍스트 메뉴를 등록했습니다.\n파일 위에서 우클릭 → \"추가 옵션 표시\"에서 보입니다."); + await PopulateAsync(); + } + catch (Exception ex) + { + MessageBox.Show("등록 중 오류: " + ex.Message, "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private async void OnUnregisterClick(object sender, RoutedEventArgs e) + { + try + { + ContextMenuRegistrar.Unregister(((App)Application.Current).Engine); + ShowToast("컨텍스트 메뉴를 해제했습니다."); + await PopulateAsync(); + } + catch (Exception ex) + { + MessageBox.Show("해제 중 오류: " + ex.Message, "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void OnDiagnoseClick(object sender, RoutedEventArgs e) + { + var window = new DiagnoseWindow(((App)Application.Current).Engine) { Owner = this }; + window.ShowDialog(); + } + + private void ShowToast(string message) + { + MessageBox.Show(this, message, "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Information); + } + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml b/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml new file mode 100644 index 0000000..aeee32b --- /dev/null +++ b/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs b/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs new file mode 100644 index 0000000..a1f51db --- /dev/null +++ b/src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs @@ -0,0 +1,81 @@ +using System.Windows; +using EverythingToJpeg.Core; +using Wpf.Ui.Controls; + +namespace EverythingToJpeg.App.Views; + +public partial class QuickProgressWindow : FluentWindow +{ + private readonly int _total; + private string? _firstSuccessOutput; + + public QuickProgressWindow(int total) + { + _total = total; + InitializeComponent(); + StatusText.Text = $"0 / {_total}"; + } + + public void Report(ConvertProgress p) + { + if (!CheckAccess()) { Dispatcher.Invoke(() => Report(p)); return; } + var overall = _total == 0 ? 0 : (p.Index + p.FileProgress) / _total; + OverallProgress.Value = Math.Clamp(overall, 0, 1); + StatusText.Text = $"{Math.Min(p.Index + 1, _total)} / {_total} — {Path.GetFileName(p.CurrentPath)}"; + } + + public void Finish(IReadOnlyList results) + { + if (!CheckAccess()) { Dispatcher.Invoke(() => Finish(results)); return; } + + var success = results.Count(r => r.Status == ConvertStatus.Success); + var skipped = results.Count(r => r.Status == ConvertStatus.Skipped); + var failed = results.Count(r => r.Status == ConvertStatus.Failed); + var outputs = results.Sum(r => r.OutputPaths.Count); + + OverallProgress.Value = 1; + StatusText.Text = $"성공 {success}개 (출력 {outputs}), 건너뜀 {skipped}, 실패 {failed}"; + CloseButton.IsEnabled = true; + + _firstSuccessOutput = results + .FirstOrDefault(r => r.Status == ConvertStatus.Success)? + .OutputPaths.FirstOrDefault(); + OpenFolderButton.IsEnabled = _firstSuccessOutput is not null; + + if (failed > 0) + { + var detail = string.Join("\n", + results.Where(r => r.Status == ConvertStatus.Failed) + .Take(5) + .Select(r => $"• {Path.GetFileName(r.SourcePath)}: {r.Message}")); + MessageBox.Show(this, "일부 파일 변환에 실패했습니다.\n\n" + detail, + "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Warning); + } + else if (failed == 0 && skipped == 0 && _firstSuccessOutput is not null) + { + OpenInExplorer(_firstSuccessOutput); + Close(); + } + } + + private void OnOpenFolderClick(object sender, RoutedEventArgs e) + { + if (_firstSuccessOutput is not null) OpenInExplorer(_firstSuccessOutput); + } + + private void OnCloseClick(object sender, RoutedEventArgs e) => Close(); + + private static void OpenInExplorer(string path) + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/select,\"{path}\"", + UseShellExecute = true, + }); + } + catch { } + } +} diff --git a/src/EverythingToJpeg.App/app.manifest b/src/EverythingToJpeg.App/app.manifest new file mode 100644 index 0000000..9144896 --- /dev/null +++ b/src/EverythingToJpeg.App/app.manifest @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + true/pm + PerMonitorV2 + true + UTF-8 + + + diff --git a/src/EverythingToJpeg.Core/ConversionEngine.cs b/src/EverythingToJpeg.Core/ConversionEngine.cs new file mode 100644 index 0000000..a326251 --- /dev/null +++ b/src/EverythingToJpeg.Core/ConversionEngine.cs @@ -0,0 +1,99 @@ +using EverythingToJpeg.Core.Providers; + +namespace EverythingToJpeg.Core; + +public sealed class ConversionEngine +{ + private readonly ProviderRegistry _registry; + + public ConversionEngine(ProviderRegistry registry) + { + _registry = registry; + } + + public ProviderRegistry Providers => _registry; + + public async Task> ConvertManyAsync( + IEnumerable sources, + ConvertOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var sourceList = sources.ToList(); + var results = new List(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, options, + new Progress(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 ConvertOneAsync( + string sourcePath, + ConvertOptions options, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (!File.Exists(sourcePath)) + return ConvertResult.Fail(sourcePath, "파일을 찾을 수 없습니다."); + + if (!_registry.TryGetForFile(sourcePath, out var provider) || provider is null) + return ConvertResult.Fail(sourcePath, $"지원하지 않는 형식입니다: {Path.GetExtension(sourcePath)}"); + + var availability = await provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false); + if (!availability.IsReady) + { + var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty(); + var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다."; + if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})"; + return ConvertResult.Fail(sourcePath, detail); + } + + var outputDir = ResolveOutputDirectory(sourcePath, options); + Directory.CreateDirectory(outputDir); + + try + { + return await provider.ConvertAsync(sourcePath, outputDir, options, progress, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return ConvertResult.Fail(sourcePath, ex.Message, ex); + } + } + + private static string ResolveOutputDirectory(string sourcePath, 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) + options.SubfolderSuffix), + }; + } +} + +public sealed record ConvertProgress(int Index, int Total, string CurrentPath, double FileProgress); diff --git a/src/EverythingToJpeg.Core/ConvertOptions.cs b/src/EverythingToJpeg.Core/ConvertOptions.cs new file mode 100644 index 0000000..46db7b6 --- /dev/null +++ b/src/EverythingToJpeg.Core/ConvertOptions.cs @@ -0,0 +1,40 @@ +namespace EverythingToJpeg.Core; + +public enum OutputLocation +{ + SubfolderBesideSource, + SameFolderAsSource, + Custom +} + +public enum NameCollision +{ + AppendNumber, + Overwrite, + Skip +} + +public sealed class ConvertOptions +{ + public int Quality { get; set; } = 92; + + public OutputLocation OutputLocation { get; set; } = OutputLocation.SubfolderBesideSource; + + public string SubfolderSuffix { get; set; } = "_jpeg"; + + public string? CustomOutputDirectory { get; set; } + + public NameCollision OnCollision { get; set; } = NameCollision.AppendNumber; + + public int? MaxLongEdgePixels { get; set; } + + public int PdfDpi { get; set; } = 200; + + public bool KeepExifWhenPossible { get; set; } = true; + + public bool FlattenTransparency { get; set; } = true; + + public string TransparencyBackground { get; set; } = "#FFFFFF"; + + public static ConvertOptions Quick() => new(); +} diff --git a/src/EverythingToJpeg.Core/ConvertResult.cs b/src/EverythingToJpeg.Core/ConvertResult.cs new file mode 100644 index 0000000..5a89b29 --- /dev/null +++ b/src/EverythingToJpeg.Core/ConvertResult.cs @@ -0,0 +1,25 @@ +namespace EverythingToJpeg.Core; + +public enum ConvertStatus +{ + Success, + Skipped, + Failed +} + +public sealed record ConvertResult( + string SourcePath, + IReadOnlyList OutputPaths, + ConvertStatus Status, + string? Message = null, + Exception? Error = null) +{ + public static ConvertResult Ok(string source, IReadOnlyList outputs) + => new(source, outputs, ConvertStatus.Success); + + public static ConvertResult Fail(string source, string message, Exception? ex = null) + => new(source, Array.Empty(), ConvertStatus.Failed, message, ex); + + public static ConvertResult Skip(string source, string message) + => new(source, Array.Empty(), ConvertStatus.Skipped, message); +} diff --git a/src/EverythingToJpeg.Core/Converters/DocxProvider.cs b/src/EverythingToJpeg.Core/Converters/DocxProvider.cs new file mode 100644 index 0000000..9716027 --- /dev/null +++ b/src/EverythingToJpeg.Core/Converters/DocxProvider.cs @@ -0,0 +1,169 @@ +using System.Diagnostics; +using EverythingToJpeg.Core.Providers; + +namespace EverythingToJpeg.Core.Converters; + +public sealed class DocxProvider : IConverterProvider +{ + private readonly PdfProvider _pdfProvider; + + public DocxProvider(PdfProvider pdfProvider) + { + _pdfProvider = pdfProvider; + } + + public ProviderCapability Capability { get; } = new( + Id: "docx", + DisplayName: "Word 문서 (DOCX)", + Extensions: new[] { ".docx", ".doc" }, + Status: ProviderStatus.RequiresExternal, + Summary: "DOCX/DOC 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.", + ExternalDependencies: new[] + { + new ExternalDependency( + Name: "Microsoft Word 또는 LibreOffice", + Description: "DOCX → PDF 변환에 둘 중 하나가 필요합니다. 둘 다 없으면 LibreOffice 설치를 권장합니다.", + DownloadUrl: "https://www.libreoffice.org/download/", + IsRequired: true), + }, + RoadmapNote: "향후 OpenXML 기반 자체 렌더링 검토."); + + public Task 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 ConvertAsync( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var tempPdf = Path.Combine(Path.GetTempPath(), + $"e2j_{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); + + var inner = new Progress(p => progress?.Report(0.55 + p * 0.45)); + return _pdfProvider.ConvertCore(tempPdf, outputDirectory, options, inner, cancellationToken) + with { SourcePath = sourcePath }; + } + finally + { + try { if (File.Exists(tempPdf)) File.Delete(tempPdf); } catch { } + } + } + + private static async Task 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 { } + } + } +} diff --git a/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs b/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs new file mode 100644 index 0000000..31f08ad --- /dev/null +++ b/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs @@ -0,0 +1,51 @@ +using Microsoft.Win32; + +namespace EverythingToJpeg.Core.Converters; + +internal static class ExternalToolDetector +{ + public static bool TryFindLibreOfficeSoffice(out string sofficePath) + { + sofficePath = ""; + var candidates = new List(); + + 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; + } + } +} diff --git a/src/EverythingToJpeg.Core/Converters/HeicProvider.cs b/src/EverythingToJpeg.Core/Converters/HeicProvider.cs new file mode 100644 index 0000000..8bb8dbf --- /dev/null +++ b/src/EverythingToJpeg.Core/Converters/HeicProvider.cs @@ -0,0 +1,73 @@ +using EverythingToJpeg.Core.Providers; +using PhotoSauce.MagicScaler; +using PhotoSauce.NativeCodecs.Libheif; + +namespace EverythingToJpeg.Core.Converters; + +public sealed class HeicProvider : IConverterProvider +{ + 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", + Extensions: new[] { ".heic", ".heif" }, + Status: ProviderStatus.Available, + Summary: "iPhone 등에서 만든 HEIC·HEIF 사진을 JPEG로 변환합니다.", + ExternalDependencies: Array.Empty(), + RoadmapNote: null); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + { + EnsureCodec(); + return Task.FromResult(ProviderAvailability.Ready); + } + + public async Task ConvertAsync( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + EnsureCodec(); + + var tempPng = Path.Combine(Path.GetTempPath(), + $"e2j_{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(p => progress?.Report(0.5 + p * 0.5)); + var result = await _magickProvider + .ConvertAsync(tempPng, outputDirectory, 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()); + } +} diff --git a/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs b/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs new file mode 100644 index 0000000..6deaeb0 --- /dev/null +++ b/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs @@ -0,0 +1,30 @@ +using EverythingToJpeg.Core.Providers; + +namespace EverythingToJpeg.Core.Converters; + +public sealed class HtmlProvider : IConverterProvider +{ + public ProviderCapability Capability { get; } = new( + Id: "html", + DisplayName: "HTML / 웹 페이지", + Extensions: new[] { ".html", ".htm" }, + Status: ProviderStatus.ComingSoon, + Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 JPEG로 캡처합니다.", + ExternalDependencies: new[] + { + new ExternalDependency( + Name: "Microsoft Edge WebView2 Runtime", + Description: "Windows 11에는 기본 포함되어 있습니다.", + DownloadUrl: "https://developer.microsoft.com/microsoft-edge/webview2/", + IsRequired: true), + }, + RoadmapNote: "Phase 2 — WebView2 헤드리스 캡처 + 사용자 정의 viewport."); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다.")); + + public Task ConvertAsync( + string sourcePath, string outputDirectory, ConvertOptions options, + IProgress? progress, CancellationToken cancellationToken) + => Task.FromResult(ConvertResult.Skip(sourcePath, "HTML 변환은 곧 지원 예정입니다.")); +} diff --git a/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs b/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs new file mode 100644 index 0000000..acaa746 --- /dev/null +++ b/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs @@ -0,0 +1,30 @@ +using EverythingToJpeg.Core.Providers; + +namespace EverythingToJpeg.Core.Converters; + +public sealed class HwpxProvider : IConverterProvider +{ + public ProviderCapability Capability { get; } = new( + Id: "hwpx", + DisplayName: "한글 문서 (HWP / HWPX)", + Extensions: new[] { ".hwp", ".hwpx" }, + Status: ProviderStatus.ComingSoon, + Summary: "한글(HWP/HWPX) 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.", + ExternalDependencies: new[] + { + new ExternalDependency( + Name: "LibreOffice + H2Orestart 확장", + Description: "한글 파일을 LibreOffice가 읽도록 해 주는 오픈소스 확장입니다.", + DownloadUrl: "https://github.com/ebandal/H2Orestart", + IsRequired: true), + }, + RoadmapNote: "Phase 2 — H2Orestart + soffice headless 파이프라인. 한컴오피스 SDK 연동도 검토."); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다.")); + + public Task ConvertAsync( + string sourcePath, string outputDirectory, ConvertOptions options, + IProgress? progress, CancellationToken cancellationToken) + => Task.FromResult(ConvertResult.Skip(sourcePath, "HWP/HWPX 변환은 곧 지원 예정입니다.")); +} diff --git a/src/EverythingToJpeg.Core/Converters/MagickProvider.cs b/src/EverythingToJpeg.Core/Converters/MagickProvider.cs new file mode 100644 index 0000000..deca1b9 --- /dev/null +++ b/src/EverythingToJpeg.Core/Converters/MagickProvider.cs @@ -0,0 +1,131 @@ +using EverythingToJpeg.Core.Providers; +using ImageMagick; + +namespace EverythingToJpeg.Core.Converters; + +public sealed class MagickProvider : IConverterProvider +{ + private static readonly string[] SingleFrameExtensions = + { + ".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd", + ".dng", ".nef", ".cr2", ".cr3", ".arw", ".raf", ".orf", ".rw2", ".srw", ".pef", ".raw", + }; + + private static readonly string[] MultiFrameExtensions = { ".gif", ".tif", ".tiff" }; + + public ProviderCapability Capability { get; } = new( + Id: "magick", + DisplayName: "이미지·RAW·애니메이션", + Extensions: SingleFrameExtensions.Concat(MultiFrameExtensions).ToList(), + Status: ProviderStatus.Available, + Summary: "PNG, BMP, JPEG, WebP, AVIF, PSD, GIF, TIFF, RAW(NEF/CR2/ARW/DNG/RAF/ORF/RW2 등)을 JPEG로 변환합니다.", + ExternalDependencies: Array.Empty(), + RoadmapNote: null); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(ProviderAvailability.Ready); + + public Task ConvertAsync( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken); + } + + private static ConvertResult ConvertCore( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var ext = Path.GetExtension(sourcePath).ToLowerInvariant(); + var baseName = Path.GetFileNameWithoutExtension(sourcePath); + var isMultiFrame = MultiFrameExtensions.Contains(ext); + + if (isMultiFrame) + { + using var collection = new MagickImageCollection(sourcePath); + if (collection.Count == 0) + return ConvertResult.Fail(sourcePath, "이미지 프레임을 읽지 못했습니다."); + + if (collection.Count == 1) + { + var single = collection[0]; + ApplyCommonTransforms(single, options); + var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision); + if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) + return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); + WriteJpeg(single, path, options.Quality); + progress?.Report(1.0); + return ConvertResult.Ok(sourcePath, new[] { path }); + } + + collection.Coalesce(); + var outputs = new List(); + 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, options); + var suffix = $"_{(i + 1).ToString().PadLeft(width, '0')}"; + var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, options.OnCollision); + if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue; + WriteJpeg(frame, path, options.Quality); + 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, options); + var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision); + if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) + return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); + WriteJpeg(image, path, options.Quality); + progress?.Report(1.0); + return ConvertResult.Ok(sourcePath, new[] { path }); + } + } + + private static void ApplyCommonTransforms(IMagickImage image, ConvertOptions options) + { + try { image.AutoOrient(); } catch { } + + if (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); + } + } + + image.Format = MagickFormat.Jpeg; + } + + private static void WriteJpeg(IMagickImage image, string path, int quality) + { + image.Quality = (uint)Math.Clamp(quality, 1, 100); + image.Format = MagickFormat.Jpeg; + image.Write(path); + } +} diff --git a/src/EverythingToJpeg.Core/Converters/PdfProvider.cs b/src/EverythingToJpeg.Core/Converters/PdfProvider.cs new file mode 100644 index 0000000..2cd40ec --- /dev/null +++ b/src/EverythingToJpeg.Core/Converters/PdfProvider.cs @@ -0,0 +1,98 @@ +using EverythingToJpeg.Core.Providers; +using PDFtoImage; +using SkiaSharp; + +namespace EverythingToJpeg.Core.Converters; + +public sealed class PdfProvider : IConverterProvider +{ + public ProviderCapability Capability { get; } = new( + Id: "pdf", + DisplayName: "PDF", + Extensions: new[] { ".pdf" }, + Status: ProviderStatus.Available, + Summary: "PDF 각 페이지를 JPEG로 변환합니다.", + ExternalDependencies: Array.Empty(), + RoadmapNote: null); + + public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) + => Task.FromResult(ProviderAvailability.Ready); + + public Task ConvertAsync( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken); + } + + internal ConvertResult ConvertCore( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + 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.PdfDpi, + BackgroundColor = SKColors.White, + WithAnnotations = true, + WithFormFill = true, + UseTiling = true, + }; + + var width = (int)Math.Ceiling(Math.Log10(pageCount + 1)); + var outputs = new List(); + + 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, options.OnCollision); + if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) continue; + + using (var input = File.OpenRead(sourcePath)) + { + Conversion.SaveJpeg(path, input, page: i, leaveOpen: false, password: null, options: renderOptions); + } + + if (options.MaxLongEdgePixels is int maxLong && maxLong > 0) + { + ResizeIfNeeded(path, maxLong, options.Quality); + } + + outputs.Add(path); + progress?.Report((i + 1.0) / pageCount); + } + + return outputs.Count > 0 + ? ConvertResult.Ok(sourcePath, outputs) + : ConvertResult.Skip(sourcePath, "모든 페이지가 이미 존재해 건너뜁니다."); + } + + private static void ResizeIfNeeded(string jpegPath, int maxLongEdge, int quality) + { + using var image = new ImageMagick.MagickImage(jpegPath); + if (image.Width <= (uint)maxLongEdge && image.Height <= (uint)maxLongEdge) return; + + var geom = new ImageMagick.MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false }; + image.Resize(geom); + image.Quality = (uint)Math.Clamp(quality, 1, 100); + image.Format = ImageMagick.MagickFormat.Jpeg; + image.Write(jpegPath); + } +} diff --git a/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj b/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj new file mode 100644 index 0000000..62ab7bd --- /dev/null +++ b/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj @@ -0,0 +1,20 @@ + + + + net9.0-windows + enable + enable + latest + false + false + $(NoWarn);NU1901;NU1902;NU1903;NU1904 + + + + + + + + + + diff --git a/src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs b/src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs new file mode 100644 index 0000000..96ef52f --- /dev/null +++ b/src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs @@ -0,0 +1,22 @@ +using EverythingToJpeg.Core.Providers; + +namespace EverythingToJpeg.Core; + +public static class EverythingToJpegBootstrap +{ + 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(), + }; + return new ConversionEngine(new ProviderRegistry(providers)); + } +} diff --git a/src/EverythingToJpeg.Core/OutputPathHelper.cs b/src/EverythingToJpeg.Core/OutputPathHelper.cs new file mode 100644 index 0000000..dad1695 --- /dev/null +++ b/src/EverythingToJpeg.Core/OutputPathHelper.cs @@ -0,0 +1,49 @@ +namespace EverythingToJpeg.Core; + +internal static class OutputPathHelper +{ + public static string ResolveOutputPath( + string outputDirectory, + string baseName, + string? pageSuffix, + NameCollision collision) + { + var safe = SanitizeFileName(baseName); + var fileName = string.IsNullOrEmpty(pageSuffix) ? $"{safe}.jpg" : $"{safe}{pageSuffix}.jpg"; + 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}).jpg") + : Path.Combine(outputDirectory, $"{safe}{pageSuffix} ({i}).jpg"); + 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 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); + } +} diff --git a/src/EverythingToJpeg.Core/Providers/IConverterProvider.cs b/src/EverythingToJpeg.Core/Providers/IConverterProvider.cs new file mode 100644 index 0000000..01b93eb --- /dev/null +++ b/src/EverythingToJpeg.Core/Providers/IConverterProvider.cs @@ -0,0 +1,26 @@ +namespace EverythingToJpeg.Core.Providers; + +public interface IConverterProvider +{ + ProviderCapability Capability { get; } + + Task CheckAvailabilityAsync(CancellationToken cancellationToken = default); + + Task ConvertAsync( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken); +} + +public sealed record ProviderAvailability( + bool IsReady, + string? Reason = null, + IReadOnlyList? MissingDependencies = null) +{ + public static ProviderAvailability Ready { get; } = new(true); + + public static ProviderAvailability NotReady(string reason, IReadOnlyList? missing = null) + => new(false, reason, missing); +} diff --git a/src/EverythingToJpeg.Core/Providers/ProviderCapability.cs b/src/EverythingToJpeg.Core/Providers/ProviderCapability.cs new file mode 100644 index 0000000..8981a5a --- /dev/null +++ b/src/EverythingToJpeg.Core/Providers/ProviderCapability.cs @@ -0,0 +1,29 @@ +namespace EverythingToJpeg.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 Extensions, + ProviderStatus Status, + string Summary, + IReadOnlyList 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; +} diff --git a/src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs b/src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs new file mode 100644 index 0000000..0e85979 --- /dev/null +++ b/src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs @@ -0,0 +1,40 @@ +namespace EverythingToJpeg.Core.Providers; + +public sealed class ProviderRegistry +{ + private readonly List _providers; + private readonly Dictionary _byExtension = new(StringComparer.OrdinalIgnoreCase); + + public ProviderRegistry(IEnumerable providers) + { + _providers = providers.ToList(); + foreach (var provider in _providers) + { + if (!provider.Capability.IsImplemented) continue; + foreach (var ext in provider.Capability.Extensions) + { + _byExtension[Normalize(ext)] = provider; + } + } + } + + public IReadOnlyList All => _providers; + + public IEnumerable Implemented => _providers.Where(p => p.Capability.IsImplemented); + + public IEnumerable ComingSoon => _providers.Where(p => p.Capability.Status == ProviderStatus.ComingSoon); + + public bool TryGetForFile(string sourcePath, out IConverterProvider? provider) + { + var ext = Normalize(Path.GetExtension(sourcePath)); + return _byExtension.TryGetValue(ext, out provider); + } + + public IConverterProvider? FindByExtension(string ext) + => _byExtension.TryGetValue(Normalize(ext), out var p) ? p : null; + + public IReadOnlyCollection ImplementedExtensions => _byExtension.Keys; + + private static string Normalize(string ext) + => ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant(); +}