From 8fa4a613d7790c0b2d6eca3b388ba9154fc6a6e4 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 12:50:35 +0900 Subject: [PATCH 01/12] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=BB=A4=EB=B0=8B:?= =?UTF-8?q?=20Phase=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(); +} From f2c8610ff61ae8231a9b757089abe100d95f5e3f Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 13:03:16 +0900 Subject: [PATCH 02/12] =?UTF-8?q?Phase=202:=20MSIX=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20+=20IExplorerCommand=20=EC=85=B8=20=EC=9D=B5?= =?UTF-8?q?=EC=8A=A4=ED=85=90=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C++ 셸 익스텐션 (src/EverythingToJpeg.Shell/) - WRL RuntimeClass 패턴으로 IExplorerCommand 두 핸들러 구현 - Quick(빠른 변환) / Dialog(설정 창) verb를 다른 CLSID로 분리 - Invoke()에서 EverythingToJpeg.exe로 verb + 파일 경로 전달 - VS 2026 빌드 검증, /utf-8 한글 라벨 지원 MSIX 패키징 (packaging/) - Package.appxmanifest: com:SurrogateServer + desktop4:FileExplorerContextMenus - 26개 확장자 × 2 verb 자동 노출 - BuildMsix.ps1: dotnet publish + msbuild + makeappx 일관 파이프라인 - CreateDevCert.ps1 / Install-EverythingToJpeg.ps1: 자체 서명 인증서 워크플로 - GenerateAssets.ps1: placeholder 로고 자동 생성 CI/CD - .github/workflows/release.yml: 태그 푸시 시 미서명 MSIX 자동 빌드 + 첨부 검증 - 50MB MSIX 산출 확인 (packaging/dist/EverythingToJpeg-x64.msix) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 55 +++++ .gitignore | 27 +++ README.md | 37 ++-- packaging/Assets/Square150x150Logo.png | Bin 0 -> 1565 bytes packaging/Assets/Square44x44Logo.png | Bin 0 -> 670 bytes packaging/Assets/StoreLogo.png | Bin 0 -> 729 bytes packaging/Assets/Wide310x150Logo.png | Bin 0 -> 4901 bytes packaging/BuildMsix.ps1 | 138 +++++++++++++ packaging/CreateDevCert.ps1 | 44 ++++ packaging/GenerateAssets.ps1 | 56 ++++++ packaging/Install-EverythingToJpeg.ps1 | 41 ++++ packaging/Package.appxmanifest | 182 +++++++++++++++++ packaging/README.md | 102 ++++++++-- .../EverythingToJpeg.Shell.vcxproj | 110 ++++++++++ src/EverythingToJpeg.Shell/Source.def | 5 + src/EverythingToJpeg.Shell/dllmain.cpp | 188 ++++++++++++++++++ src/EverythingToJpeg.Shell/framework.h | 4 + src/EverythingToJpeg.Shell/pch.cpp | 1 + src/EverythingToJpeg.Shell/pch.h | 24 +++ 19 files changed, 973 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/Assets/Square150x150Logo.png create mode 100644 packaging/Assets/Square44x44Logo.png create mode 100644 packaging/Assets/StoreLogo.png create mode 100644 packaging/Assets/Wide310x150Logo.png create mode 100644 packaging/BuildMsix.ps1 create mode 100644 packaging/CreateDevCert.ps1 create mode 100644 packaging/GenerateAssets.ps1 create mode 100644 packaging/Install-EverythingToJpeg.ps1 create mode 100644 packaging/Package.appxmanifest create mode 100644 src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj create mode 100644 src/EverythingToJpeg.Shell/Source.def create mode 100644 src/EverythingToJpeg.Shell/dllmain.cpp create mode 100644 src/EverythingToJpeg.Shell/framework.h create mode 100644 src/EverythingToJpeg.Shell/pch.cpp create mode 100644 src/EverythingToJpeg.Shell/pch.h diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..82f0554 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Build MSIX (unsigned) + shell: pwsh + run: | + ./packaging/GenerateAssets.ps1 + ./packaging/BuildMsix.ps1 -Configuration Release -Platform x64 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: EverythingToJpeg-x64-msix + path: packaging/dist/EverythingToJpeg-x64.msix + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + files: packaging/dist/EverythingToJpeg-x64.msix + generate_release_notes: true + body: | + ## 설치 방법 + + 1. 본 릴리즈에서 `EverythingToJpeg-x64.msix` 와 함께 배포된 PFX 인증서를 받습니다. + 2. 관리자 PowerShell: + ```powershell + .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\EverythingToJpeg-x64.msix + ``` + 3. PNG/HEIC/PDF 등 파일을 우클릭 → "JPEG로 빠른 변환" 또는 "JPEG로 변환…" + + > 이 패키지는 자체 서명입니다. 인증서를 `LocalMachine\TrustedPeople`에 신뢰 등록해야 설치됩니다. diff --git a/.gitignore b/.gitignore index 5451a16..a02eac6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,30 @@ Thumbs.db # rider .idea/ + +# Phase 2 packaging artifacts +*.pfx +*.msix +*.msixbundle +*.appx +*.appxbundle +packaging/Layout/ +packaging/dist/ + +# C++ project intermediate +src/EverythingToJpeg.Shell/x64/ +src/EverythingToJpeg.Shell/Win32/ +src/EverythingToJpeg.Shell/Debug/ +src/EverythingToJpeg.Shell/Release/ +src/EverythingToJpeg.Shell/.vs/ +src/EverythingToJpeg.Shell/Everythi*/ +*.tlog +*.obj +*.pch +*.iobj +*.ipdb +*.recipe +*.lastbuildstate + +# .NET artifacts dir from BuildMsix.ps1 +artifacts/ diff --git a/README.md b/README.md index 89e58fa..09f9e37 100644 --- a/README.md +++ b/README.md @@ -59,31 +59,26 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj ` - 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 → 개발자 모드 필요) +| 단계 | 상태 | 내용 | +|---|---|---| +| Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환(이미지·HEIC·RAW·PDF·DOCX), Fluent UI | +| Phase 2 | ✅ 빌드 가능 | C++ IExplorerCommand DLL, MSIX 패키징, 자체 서명 인증서, GitHub Releases 자동화 — `packaging/README.md` 참조 | +| Phase 3 | 🕐 | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 | -## 미서명 빌드를 신뢰할 PC에 설치하기 (Phase 2 미리보기) +## 두 가지 사용 방식 -본인 PC 5대에만 설치할 계획이므로 정식 코드사이닝 인증서 없이도 사용 가능합니다. +### A) Portable EXE — 가장 가벼움 (Phase 1) +- `dotnet publish` 산출물 그대로 사용 +- 우클릭 → **추가 옵션 표시** → "JPEG로 빠른 변환" / "JPEG로 변환…" +- 인증서·서명 불필요 -### 옵션 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에 임포트 +### B) MSIX 패키지 — Win11 메인 메뉴 노출 (Phase 2) +- `packaging/BuildMsix.ps1` 로 MSIX 빌드 +- 자체 서명 인증서를 `LocalMachine\TrustedPeople`에 임포트 후 사이드로드 +- 우클릭 → 바로 메인 메뉴에 항목 노출 +- 자세한 절차는 [packaging/README.md](packaging/README.md) ## 프로젝트 구조 diff --git a/packaging/Assets/Square150x150Logo.png b/packaging/Assets/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bd402c6a4b96c10707ccdc3440742c256ea710a7 GIT binary patch literal 1565 zcmdT^>sQhT82$lasj15n&2Xz0r%cnSsk2OHWy-YdkkF4!5e#G^QqJQAY17QhDknuk zlS0L;lv}_$ni7dh?KCgwR2(L@GqZ~+-a^t+N9XLH=)?0q&-3B^@|^d5E*w7=8AE<7-X`?cLKWXU;QwR1%Um#wPgYT`QQArj4_hmzjmpwX zx=ALh2hU?%CtM@sK07YO3RJFOn>JRAr9M9E-X~ZHqVvM(b zhgvs^swz2baZ7i(2$|CCcnWJuTy@M@lU;9&o|__mPh88u)BHF;epgmh$uBGV>0#h2 zrF$9I#Nbo%RKm{QN0foJNsRhRft<4jTfG>_<_o7}GTgI8(S+;59Ku#K#x-$#DQF+8{xPQkFpP3Sm z6Nh>9309s?UL`S)al`B0=k|K*P^nB9XW`0(ud(50CeBQ%Hj(eviNcPDpv*<0D9q^e zm5QYONCgJaHb>i4Cg(tQIm*2_jiTM!zf zURIejd*Y;=d#zY!OH*`mxd`w43=@bhq$S<-QAJc?D9b60mfMne%8KY|W=GQ+$VB1)Yp3qn+St=`D3nK~HuALDZZ64##7$W@{PC!E^Pr#Ad)A)IbsP z0=PF3@<~a_uHr%8B&R2Zw@vNN1=vv9a#Lu)b{l!E3+_%uvm}2!z~>qnW)JFZ?Zo82 zmij_c66hKzF9%98qXp(eB;CD=DmX%p@ZjUQqOTRWARJ0Z%ngca>H&oO1x&o-txTu3@p`#Uw$V zNXZNpLnnaMrTZHBI*`}B3!F!?{G235oFw*b+2`j4@OIjRj;p;IemVk3tBLbxYCi{m zk0Z-(aQ8e=K|tZ-mc7kKzde6rQ@z4<9&;IGbE&EOlReu1pG@CTV)ivFW8Gd>$G@{y OI)IKmc35_hSok04U9A-W literal 0 HcmV?d00001 diff --git a/packaging/Assets/Square44x44Logo.png b/packaging/Assets/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..997ce9960f655684b6ab4f7609a54e6036830f08 GIT binary patch literal 670 zcmV;P0%84$P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D0y9ZOK~z{r?bY2+ z(qR+_aQ_p+pGbs=L_Z0=$x%5g=d2v1!?I#l4tsG_jMPK@5#H3gf$Lt#jLJC+bB>Zs zlbJ6mNXbws8CqB01~MaNw}IPvwsZ5G=i;-ov+q+-d3WQ0-jN`EcbxQ{KS*bfkei`LsmuXVo&Q9tmhn_6 z&y11M-eq&V#(g>i)B#$l2-43X@_8sPT|AS=D_c zJ=9AQgPSCV$0VtGNClOS1XVZ^sOXME-?fJQx4%PYUxm)LjPhGil;4Ozv@SvH_==%N-s=8b#4NxmT{Dv9Yaa8A4=0G_B6eN zqR|J%sS)fp4ny8BgyNH46d!*LS^Xf2>IP6$+YjN`D+otkps>b+!sH22BCl|4n4Suk;sr?ZAXAV0?lMhxvInQCl;qv^-zD%dY zB%E9Mybh=BzkeJ%?x$lggBi?V1~Zt!3}!Hc8Jr97Z~8MrE~u7tdjJ3c07*qoM6N<$ Eg54S_GXMYp literal 0 HcmV?d00001 diff --git a/packaging/Assets/StoreLogo.png b/packaging/Assets/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..a052a96be22531a746cd52cbbfa85248e80d2d24 GIT binary patch literal 729 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2nC-#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=DjSL74G){)!X^2Bthu7srqc=eO7H`%g*~VK4YE?4;!662jt= zUzC>mbj=w-&1q9Di^I;X*mC9Ynf(X5qe>@U|9jz2=sB+o6Eh9Hm9^E}vR=%Y;TnGb z8`sQ+((5(3&vWk2eqO0}?|j+3=X(lg`ra>>$UU8s6Qh*;S4pxZCQ`xqt&L>X-zB@B zsLw5(R&p)#($yax+DodswmetUT z&MALxxWnR1Db|%5s_x#FrmYic>zrpe^XIi0CO@T{3!-jZq(N^YAYdp6x8KX1(xbx@FOpztJbGKKa!Yr>r{8?0bdhXXwJyc5T+i z6JLK56XbJY4BB)nY~ohABlk8vocwqHPgi7j!@cld;FEC0|2K9bw`|wiO4*Wad^MIR30Kx=OqjX0I~+-}3)$+MRFZX&h{iIE?bx!1 z!Vm_-SnA4%VW^?GW65vmE`9Ise*gKsey`W(oaZ^ubIxZu&-uKc_vifE>XL~dza&2! z8=Ii%#q&07Y!E1`pUTV4I@^ssr?VP}zm16zTUo#K0_%d))6l|@jjiJ0zAY3N>zeQS zMYum3+y0K-1Jdn_bz@^Y*kF3z&@R|{d1@y}G$^~AQ2aNO@ju;ohE354L+7inunKVJ10~{PO7Nz{es|?<1&}xo&4d( zx#qLH$IpMsV&r7h`)kmd;nN!as!ea^rsp@a##E>4Yt>w2`#h&t2KpNHr*uDMSZX`{ za-U*5SSs6_+GsM;x9(r)N6U7MpM@{<Z^bm)f`}0+0fC6O-^;Q0LV|d@_{e-85`y#%c$?T#B z+COA6?KDgJJN+5IsEuvtD2+{v5;6`4g-RsV4{qHg_4gcsDRFOytM|#gSl)7L?O$g8 ztnU{V`6yD*-%HWp%g~8PQNqJZUH%APwL^^0=Tz&9CM7B7A05hi^e~S3)$f?z=ZY1s zjM@1sj}YzB**yg3hPixHGj(n`xS4LRHWthLasW0HIy~TV!GHMBayb3EZm*E22CT1A zdr5g&{f3oCy!}ae+M&-S7QU959ap|le5(&Oaq5Fwdr!uYhz*)y;av%mlHP|s%jRtG(>`DI7{j4y*eKNyZi=teTKvZdNblFpoAhFGQ#=%MGUT2v?l?cp8?hmai5zod#CXypc4J-A3wg1<{2n-m^`NtDDx zSnYQLSF`^9$-TbD?!*60Jo5kB%=hEpoBq=WW#YH)o-b1Pe=@Bk@AY@f9^8LL@u#XN zoEb9A%`Pj&KqX;Dfh2pzH9W9InH`G6=#aWTd}{k`#jU7@5qTR|hSIR!r}PSEkK7!0 z|N88gfMOF0276f&3<#l@oV0Vchajw4?FaBONr@9Pckfmk@B&3#e?_k)Kz{6#{FTu% zJD3rhSn$zZ91lS_b&RSX*P&xv1o0#0bT*O~2biq-G+)V43%_o9hsmL6ce%Bz?#|@u z2QE5HnpiZe-ZIgU_aAYIsr2ms3u1Y(B(w{9 zG_0> zQ6jqe9rmj}JRJgZSt=Pgw9lNQ(Q(J!K`&IMaL}b&$nEcP*N?4Qh@;(i>p_bB*}Vr2ps$Y!Nd!M zJ3d#O%xF_fbzSyiUZ_NKb5s1b=^Z(4vKj-P?Z9ztD7Upx^cWA1YO zDc2WTpXVKwA^{RbH*i`v&nj*X%SGmMmpCKu-BH+fvn}wjI9mE;cAchAWL-NxAh0`= z$JEog{zW4|vd_7>UyVmgIkAG$b|8RH(!LY5X>Hj)7UnFwiFz4wIx^u)nEcUlSx)d? zTj41a)i;z)X}n&1#Q)7KSMN4>=2bHaX zM8VWM0~;rS?Mu%?YY)|0V~&tK3`(0`graOQ+}ll+hYvt+={TB_C_DB#Uu(+NAWl9y zi&_VMB1Wk=&vihL_quEt&}X+MHeZPVG_{9C?jP@Wh{?Pq@RGA4ZOgKmy@Su)Kc<)B z3bT1?ILTj+DZqrhc%ee_!ZkKXDm68vwXg%qXz8AN&b;j`+O2ew+d!cd=t%fZ$H%NCGa?D&F|q9b!eVMbjRL*6TdRjHp@6A1&SV{4d{or z`Q2K63^Z#mfW|Qo7$Rm8Z?jn|GCBJ%jbzh>8v7rnmmE{cV(VBK`wj8~2%<+C6FEwW zR(TH)O767>Zo~9)5hRU8Ly8uUvksD#mWEBA%gaX~z__;!P0gB)lDesC!lODKxfV0; z4xiX2RnK8v;xx3mz)@{o?1ee0VJ-HcQ7#vt%%^R%4wnd$9)otwj(Tg7S?Lrt7+Gn32AIAe@<{ElE2<0sJ(v=#wRs2`s?pI)qneiP2)v{Dha5ddBp(E`G3 zq3y8Z$g4ULS0^HDLP+702m4>yDBW|^!H1Yz327lUlq5h)FGOiOKZZL4x$l}LeDt^O z>W_YgEyn8N9LwR zd+H{E#-@q%zswIPJ<)p>_t|sIEEX5+y&6E<&K+F5b;Mws5VM-W(=Q+GU^txXM&dWU zn#xWl;@Ue8DXDJC&+66!>gy$s4Q-CiUW$f{Z5F=j=0=9S~sw@AmTNHfq1og@Q@ZI zz%2F8)+B9yK=d{a-J<;nBMAP~QNK6$BPdAY^W6aiNlPzX@fNqrx21k+K!%-dsduwV zazBY5hk&6`K4;G~aQ}+;c{{bCPd;rBI-bRLctd@rdERI!S zG~V(f?#@6ES32cb3b2>?fch6-$XEzQhQ(aUf34-_f$poxh$kE(`lJa3@3Kf(tNjdK zPM=Sfq{xo=7cFW3WL@>td%=&~pY|vw_@oSLkc;5OTfhK#m!8os83g_x8Q~vH_Ya+a zam7Doz8@*w<->myOx!nmjFI?8&i;vvcAo#6He4VQVc?;kr;ELP1bwo7^q3T#0x=BG zbvy-3EqaIV}JlT{iZhaP0xdT*G)uFfkoSmdqLAl+`DY$Q!buU7oD`zlON}!ECLV~4^~HIj)>|h`%H`p?)tnAL3FDMcj?LMdCEFt+iSq1z{L0j zR?~Y{R;V!*C+zTBY^NDx5xNWZXY`6)&f3dVBXRj-nC>6*|a9)0R=OIG;EE$&_Aq2NAFn zF*Ww<@fp>GgQ(%O{9X2KPsPhDnU9B972pHDJYUjf`>teS?a=hx4dkpO>zhDqx2Fas zs(3q8XHg8}Emjrxww=WwsQgP-g~0(j-m#E(w@uiOIfL=&ufPp9ykG?(fgEnkV-b_u z{p5DfwQT-9gLJ%TYW^wG!3%mz**ttfpRBlVYP6-E?iPc2C$y(t2PvX^QF8#!b1_pq z#zdkV)}hDQnzgNuVKq;*QdW#!&%0M}VQMYy;15DYe-&#_jm~t~Q>}_mv5L?pav_Gm zV&#I?FG=ySRiHZmUah2J-zkS>CoeshLpv7X3w&aO>>`YIF_*@<>;FW&5Ax#ZwE<}H z`x#^Hdzr%Ey9>z^Q?P?de9)B($#B13Mz2x8B*)$tzz$xYbX_*2cjmJ8vy`2_n8M+r zADRB%Ui4N!@6|iif;3Mzi$&?ZR2mTl>>Y^VkIZ%O1Y*ZkqI~;ZP2)Xe*nRuicr7&J zI(3PnaCt8R8N61|rHkZZ0ACY{50=r|-=IZF96ZW`$?k8-Su721DZqb><_4g9)g-hEA2%zzU>WoD5!lFI z1+Z$&Z?`Vri(ZELzR^PIzV5qv1n(}v;0=`h2VnSy6aOc85G4Ix?fxBXe%JZGEAoH1 z5hMBykpAObW%aFa*WlPIS3Ukhrebk$he!X-^Lf?qbOh_y7MrQ@rSoM*$mst8`lxBI literal 0 HcmV?d00001 diff --git a/packaging/BuildMsix.ps1 b/packaging/BuildMsix.ps1 new file mode 100644 index 0000000..86087dd --- /dev/null +++ b/packaging/BuildMsix.ps1 @@ -0,0 +1,138 @@ +#Requires -Version 5.1 +# MSIX 빌드 파이프라인. +# 1) .NET App publish (framework-dependent) +# 2) C++ Shell DLL 빌드 +# 3) 패키지 Layout 디렉토리 구성 +# 4) makeappx pack +# 5) (선택) signtool sign + +[CmdletBinding()] +param( + [string]$Configuration = 'Release', + [string]$Platform = 'x64', + [switch]$Sign, + [string]$PfxPath, + [securestring]$PfxPassword, + [string]$CertThumbprint, + [switch]$SelfContained +) + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$packagingDir = $PSScriptRoot +$layoutDir = Join-Path $packagingDir 'Layout' +$distDir = Join-Path $packagingDir 'dist' +$manifestPath = Join-Path $packagingDir 'Package.appxmanifest' +$assetsSrc = Join-Path $packagingDir 'Assets' + +$appProj = Join-Path $repoRoot 'src\EverythingToJpeg.App\EverythingToJpeg.App.csproj' +$shellProj = Join-Path $repoRoot 'src\EverythingToJpeg.Shell\EverythingToJpeg.Shell.vcxproj' + +function Find-WindowsSdkTool { + param([string]$ToolName) + $sdkRoots = @( + "${env:ProgramFiles(x86)}\Windows Kits\10\bin", + "$env:ProgramFiles\Windows Kits\10\bin" + ) | Where-Object { Test-Path $_ } + foreach ($root in $sdkRoots) { + $versions = Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object Name -Descending + foreach ($v in $versions) { + $candidate = Join-Path $v.FullName "x64\$ToolName" + if (Test-Path $candidate) { return [string]$candidate } + } + } + return $null +} + +$makeappx = Find-WindowsSdkTool 'makeappx.exe' +if (-not $makeappx) { throw 'makeappx.exe를 찾지 못했습니다. Windows 10 SDK가 필요합니다.' } +Write-Host "makeappx: $makeappx" + +if ($Sign) { + $signtool = Find-WindowsSdkTool 'signtool.exe' + if (-not $signtool) { throw 'signtool.exe를 찾지 못했습니다.' } + Write-Host "signtool: $signtool" +} + +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $vswhere)) { throw 'vswhere.exe를 찾지 못했습니다.' } +$msbuild = (& $vswhere -latest -find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1) +if (-not $msbuild) { throw 'MSBuild를 찾지 못했습니다.' } +Write-Host "msbuild: $msbuild" + +# ---- 1) .NET App publish ---- +Write-Host '' +Write-Host '[1/5] .NET App publish' +$publishOut = Join-Path $repoRoot ('artifacts\publish\app-' + $Platform.ToLower()) +if (Test-Path $publishOut) { Remove-Item $publishOut -Recurse -Force } +$rid = if ($Platform -eq 'ARM64') { 'win-arm64' } else { 'win-x64' } +$selfFlag = if ($SelfContained) { 'true' } else { 'false' } +& dotnet publish $appProj -c $Configuration -r $rid --self-contained $selfFlag -o $publishOut | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'dotnet publish 실패' } + +# ---- 2) C++ Shell DLL ---- +Write-Host '' +Write-Host '[2/5] C++ Shell DLL 빌드' +& $msbuild $shellProj /t:Restore /p:RestorePackagesConfig=true /p:Configuration=$Configuration /p:Platform=$Platform /v:minimal | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'Shell restore 실패' } +& $msbuild $shellProj /p:Configuration=$Configuration /p:Platform=$Platform /m /v:minimal | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'Shell build 실패' } +$shellDll = Join-Path $repoRoot ("src\EverythingToJpeg.Shell\$Platform\$Configuration\EverythingToJpeg.Shell.dll") +if (-not (Test-Path $shellDll)) { throw "Shell DLL 산출물 없음: $shellDll" } + +# ---- 3) Layout 디렉토리 ---- +Write-Host '' +Write-Host '[3/5] Layout 디렉토리 구성' +if (Test-Path $layoutDir) { Remove-Item $layoutDir -Recurse -Force } +New-Item -ItemType Directory -Path $layoutDir | Out-Null + +Copy-Item -Path (Join-Path $publishOut '*') -Destination $layoutDir -Recurse -Force +Copy-Item -Path $shellDll -Destination $layoutDir -Force + +$layoutAssets = Join-Path $layoutDir 'Assets' +New-Item -ItemType Directory -Path $layoutAssets -Force | Out-Null +Copy-Item -Path (Join-Path $assetsSrc '*') -Destination $layoutAssets -Force + +Copy-Item -Path $manifestPath -Destination (Join-Path $layoutDir 'AppxManifest.xml') -Force + +# ---- 4) makeappx pack ---- +Write-Host '' +Write-Host '[4/5] makeappx pack' +if (-not (Test-Path $distDir)) { New-Item -ItemType Directory -Path $distDir | Out-Null } +$msixPath = Join-Path $distDir ("EverythingToJpeg-$($Platform.ToLower()).msix") +if (Test-Path $msixPath) { Remove-Item $msixPath -Force } +& $makeappx pack /d $layoutDir /p $msixPath /o | Out-Host +if ($LASTEXITCODE -ne 0) { throw 'makeappx pack 실패' } +Write-Host "✅ MSIX 산출: $msixPath" + +# ---- 5) (선택) sign ---- +if ($Sign) { + Write-Host '' + Write-Host '[5/5] signtool sign' + if ($CertThumbprint) { + & $signtool sign /fd SHA256 /sha1 $CertThumbprint /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host + } elseif ($PfxPath) { + if (-not $PfxPassword) { + $PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호' + } + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($PfxPassword) + try { + $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + & $signtool sign /fd SHA256 /a /f $PfxPath /p $plain /tr 'http://timestamp.digicert.com' /td SHA256 $msixPath | Out-Host + } finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } else { + throw '서명을 하려면 -CertThumbprint 또는 -PfxPath 가 필요합니다.' + } + if ($LASTEXITCODE -ne 0) { throw 'signtool sign 실패' } + Write-Host '✅ 서명 완료' +} else { + Write-Host '' + Write-Host '[5/5] 서명 건너뜀 (-Sign 미지정). 사이드로드 시 인증서 필요.' +} + +Write-Host '' +Write-Host "최종 산출물: $msixPath" diff --git a/packaging/CreateDevCert.ps1 b/packaging/CreateDevCert.ps1 new file mode 100644 index 0000000..9889cc2 --- /dev/null +++ b/packaging/CreateDevCert.ps1 @@ -0,0 +1,44 @@ +#Requires -Version 5.1 +# 자체 서명 코드 사이닝 인증서 생성 + PFX export. +# Subject가 Package.appxmanifest 의 와 정확히 일치해야 한다. + +[CmdletBinding()] +param( + [string]$Subject = 'CN=EverythingToJpegDev', + [string]$OutputPfx = (Join-Path $PSScriptRoot 'EverythingToJpeg-DevCert.pfx'), + [securestring]$Password +) + +$ErrorActionPreference = 'Stop' + +if (-not $Password) { + Write-Host '인증서 PFX 보호용 비밀번호를 입력하세요. (5대 PC에 설치할 때 필요합니다)' + $Password = Read-Host -AsSecureString -Prompt '비밀번호' +} + +Write-Host "Creating self-signed code-signing certificate: $Subject" +$cert = New-SelfSignedCertificate ` + -Type CodeSigningCert ` + -Subject $Subject ` + -KeyAlgorithm RSA ` + -KeyLength 3072 ` + -Provider 'Microsoft Enhanced RSA and AES Cryptographic Provider' ` + -KeyExportPolicy Exportable ` + -KeyUsage DigitalSignature ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -HashAlgorithm SHA256 ` + -NotAfter (Get-Date).AddYears(5) ` + -FriendlyName 'EverythingToJpeg Dev' + +Write-Host "Thumbprint: $($cert.Thumbprint)" +Write-Host "Exporting PFX: $OutputPfx" +Export-PfxCertificate -Cert $cert -FilePath $OutputPfx -Password $Password | Out-Null + +Write-Host '' +Write-Host '--- 다음 단계 ---' +Write-Host " 1. 이 PFX 파일을 5대 PC 각각에 복사" +Write-Host " 2. 각 PC에서 관리자 PowerShell로:" +Write-Host ' Import-PfxCertificate -CertStoreLocation "Cert:\LocalMachine\TrustedPeople" -FilePath <경로>.pfx -Password (Read-Host -AsSecureString)' +Write-Host ' 3. MSIX 빌드 시 BuildMsix.ps1 -CertThumbprint ' + $cert.Thumbprint +Write-Host '' +Write-Host "PFX는 비밀이므로 절대 git에 커밋하지 마세요. (.gitignore에 *.pfx 추가됨)" diff --git a/packaging/GenerateAssets.ps1 b/packaging/GenerateAssets.ps1 new file mode 100644 index 0000000..6813a8f --- /dev/null +++ b/packaging/GenerateAssets.ps1 @@ -0,0 +1,56 @@ +#Requires -Version 5.1 +# packaging/Assets/ 의 placeholder 아이콘들을 생성한다. +# 추후 진짜 로고로 교체. + +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing + +$assetsDir = Join-Path $PSScriptRoot 'Assets' +if (-not (Test-Path $assetsDir)) { New-Item -ItemType Directory -Path $assetsDir | Out-Null } + +function New-LogoPng { + param( + [int]$Width, + [int]$Height, + [string]$Path, + [string]$Label = '' + ) + $bmp = New-Object System.Drawing.Bitmap($Width, $Height, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias + $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit + + $rect = New-Object System.Drawing.Rectangle(0, 0, $Width, $Height) + $brush = New-Object System.Drawing.Drawing2D.LinearGradientBrush( + $rect, + [System.Drawing.Color]::FromArgb(0xFF, 0x3B, 0x82, 0xF6), + [System.Drawing.Color]::FromArgb(0xFF, 0x1E, 0x40, 0xAF), + [System.Drawing.Drawing2D.LinearGradientMode]::Diagonal) + $g.FillRectangle($brush, $rect) + + if ($Label) { + $fontSize = [Math]::Max(8, [Math]::Min($Width, $Height) / 5) + $font = New-Object System.Drawing.Font('Segoe UI', $fontSize, [System.Drawing.FontStyle]::Bold) + $textBrush = [System.Drawing.Brushes]::White + $sf = New-Object System.Drawing.StringFormat + $sf.Alignment = [System.Drawing.StringAlignment]::Center + $sf.LineAlignment = [System.Drawing.StringAlignment]::Center + $rectF = New-Object System.Drawing.RectangleF(0, 0, [float]$Width, [float]$Height) + $g.DrawString($Label, $font, $textBrush, $rectF, $sf) + $font.Dispose() + $sf.Dispose() + } + + $g.Dispose() + $bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + $brush.Dispose() + Write-Host " [+] $Path ($Width x $Height)" +} + +Write-Host 'Generating placeholder logos…' +New-LogoPng -Width 50 -Height 50 -Path (Join-Path $assetsDir 'StoreLogo.png') -Label 'E2J' +New-LogoPng -Width 44 -Height 44 -Path (Join-Path $assetsDir 'Square44x44Logo.png') -Label 'E2J' +New-LogoPng -Width 150 -Height 150 -Path (Join-Path $assetsDir 'Square150x150Logo.png')-Label 'E2J' +New-LogoPng -Width 310 -Height 150 -Path (Join-Path $assetsDir 'Wide310x150Logo.png') -Label 'EverythingToJpeg' +Write-Host 'Done.' diff --git a/packaging/Install-EverythingToJpeg.ps1 b/packaging/Install-EverythingToJpeg.ps1 new file mode 100644 index 0000000..74518c8 --- /dev/null +++ b/packaging/Install-EverythingToJpeg.ps1 @@ -0,0 +1,41 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator +# 5대 PC에서 MSIX 사이드로드 설치 — 1회 셋업 스크립트. +# 사용법: +# PowerShell (관리자) > .\Install-EverythingToJpeg.ps1 -PfxPath .\EverythingToJpeg-DevCert.pfx -MsixPath .\EverythingToJpeg.msix + +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string]$PfxPath, + [Parameter(Mandatory)] [string]$MsixPath, + [securestring]$PfxPassword +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $PfxPath)) { throw "PFX 파일을 찾을 수 없습니다: $PfxPath" } +if (-not (Test-Path $MsixPath)) { throw "MSIX 파일을 찾을 수 없습니다: $MsixPath" } + +if (-not $PfxPassword) { + $PfxPassword = Read-Host -AsSecureString -Prompt 'PFX 비밀번호' +} + +Write-Host '[1/3] 인증서를 LocalMachine\TrustedPeople에 임포트…' +$importResult = Import-PfxCertificate ` + -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' ` + -FilePath $PfxPath ` + -Password $PfxPassword +Write-Host " Thumbprint: $($importResult.Thumbprint)" + +Write-Host '[2/3] 인증서를 LocalMachine\Root에도 임포트 (체인 신뢰)…' +Import-PfxCertificate ` + -CertStoreLocation 'Cert:\LocalMachine\Root' ` + -FilePath $PfxPath ` + -Password $PfxPassword | Out-Null + +Write-Host '[3/3] MSIX 패키지 설치…' +Add-AppxPackage -Path $MsixPath -ForceApplicationShutdown + +Write-Host '' +Write-Host '✅ 설치 완료. Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…" 항목이 보일 겁니다.' +Write-Host ' (탐색기 재시작이 필요할 수 있음: 작업 관리자 → "Windows 탐색기" 다시 시작)' diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest new file mode 100644 index 0000000..91c4cdb --- /dev/null +++ b/packaging/Package.appxmanifest @@ -0,0 +1,182 @@ + + + + + + + EverythingToJpeg + YunChan + Assets\StoreLogo.png + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/README.md b/packaging/README.md index 7b7eb2e..92b9e26 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,25 +1,87 @@ -# Phase 2 — MSIX 패키징 (placeholder) +# Phase 2 — MSIX 패키징 -이 폴더는 향후 IExplorerCommand 셸 익스텐션 + MSIX Sparse Package 작업을 위한 자리입니다. 현재 구현되지 않았습니다. +Win11 메인 우클릭 메뉴에 "JPEG로 빠른 변환" / "JPEG로 변환…"을 띄우는 정공법. -## 다음 단계 체크리스트 +## 구성 -- [ ] `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 첨부 +``` +packaging/ +├── Package.appxmanifest — IExplorerCommand 등록 (com:Class + desktop4:FileExplorerContextMenus) +├── Assets/ — 앱 아이콘 (placeholder, GenerateAssets.ps1로 생성) +├── GenerateAssets.ps1 — placeholder PNG 일괄 생성 +├── CreateDevCert.ps1 — 자체 서명 코드사이닝 인증서 생성 + PFX export +├── BuildMsix.ps1 — .NET publish + C++ DLL 빌드 + makeappx + (선택) signtool +└── Install-EverythingToJpeg.ps1 — 5대 PC 1회 설치 스크립트 +``` -## 참고 +C++ Shell DLL은 `src/EverythingToJpeg.Shell/` 에 있고 `BuildMsix.ps1` 안에서 자동 빌드됩니다. -- [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) +## 1회: 자체 서명 인증서 만들기 + +```powershell +cd packaging +.\CreateDevCert.ps1 +# Subject 기본값: CN=EverythingToJpegDev (Package.appxmanifest의 Publisher와 일치) +# 비밀번호 입력 → EverythingToJpeg-DevCert.pfx 생성 +``` + +출력된 Thumbprint를 `BuildMsix.ps1 -CertThumbprint <값>` 으로 사용하거나, PFX 파일을 5대 PC에 복사해서 설치 시 사용합니다. + +## 빌드 + +### 미서명 (Phase 1 그대로 사용 가능, 메인 메뉴 노출은 안 됨) +```powershell +.\BuildMsix.ps1 +# 산출: packaging/dist/EverythingToJpeg-x64.msix +``` + +### 서명 +```powershell +# 방법 1: PFX 사용 +.\BuildMsix.ps1 -Sign -PfxPath .\EverythingToJpeg-DevCert.pfx + +# 방법 2: 인증서 저장소의 Thumbprint +.\BuildMsix.ps1 -Sign -CertThumbprint AABBCCDD... +``` + +## 5대 PC 설치 (관리자 PowerShell) + +```powershell +.\Install-EverythingToJpeg.ps1 ` + -PfxPath .\EverythingToJpeg-DevCert.pfx ` + -MsixPath .\EverythingToJpeg-x64.msix +``` + +스크립트가 자동으로: +1. PFX를 `LocalMachine\TrustedPeople` 에 임포트 +2. PFX를 `LocalMachine\Root` 에도 임포트 (체인 신뢰) +3. `Add-AppxPackage` 로 MSIX 사이드로드 + +설치 후 PNG/JPG/HEIC/PDF/DOCX 등을 우클릭하면 **메인 메뉴에 직접** "JPEG로 빠른 변환" / "JPEG로 변환…"이 보입니다. + +## 미서명 사이드로드 (Phase 2 임시 사용) + +자체 서명 만들기조차 귀찮을 때: +```powershell +# 개발자 모드 켜기: 설정 → 개인 정보 및 보안 → 개발자용 → 켜기 +Add-AppxPackage -AllowUnsigned -Path .\EverythingToJpeg-x64.msix +``` +> Win11 24H2부터 `-AllowUnsigned` 지원. 이전 버전은 자체 서명 권장. + +## CI/CD + +`.github/workflows/release.yml` — 태그 푸시(`v1.0.0` 등) 시 자동: +1. .NET / MSBuild 셋업 +2. `BuildMsix.ps1` 실행 (미서명) +3. GitHub Release 생성 + MSIX 첨부 + +서명까지 자동화하려면 GitHub Secrets에 `PFX_BASE64`, `PFX_PASSWORD`를 등록하고 워크플로에 단계 추가 (별도 보안 검토 후). + +## 트러블슈팅 + +| 증상 | 원인 / 해결 | +|---|---| +| `Add-AppxPackage`: "신뢰할 수 없는 인증서" | PFX를 `LocalMachine\TrustedPeople`에 임포트했는지 확인 (Install 스크립트 자동 수행) | +| 메뉴가 안 뜸 | 탐색기 재시작: 작업관리자 → "Windows 탐색기" 다시 시작 | +| Publisher 불일치 오류 | `Package.appxmanifest`의 `Publisher=` 와 인증서 `Subject` 가 정확히 일치해야 함 | +| `App identity required` | MSIX 패키지로 설치된 경우에만 IExplorerCommand 작동. portable EXE는 Phase 1 레지스트리 방식 사용 | diff --git a/src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj b/src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj new file mode 100644 index 0000000..c340920 --- /dev/null +++ b/src/EverythingToJpeg.Shell/EverythingToJpeg.Shell.vcxproj @@ -0,0 +1,110 @@ + + + + + Debug + x64 + + + Release + x64 + + + Release + ARM64 + + + + + 17.0 + Win32Proj + {1A2B3C4D-5E6F-7A8B-9C0D-EF1234567890} + EverythingToJpegShell + 10.0 + + + + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + + + + + + + + + Level4 + true + true + Use + pch.h + stdcpp20 + EVERYTHINGTOJPEG_SHELL_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + /utf-8 %(AdditionalOptions) + + + Windows + true + Source.def + + + + + + _DEBUG;%(PreprocessorDefinitions) + + + + + + true + true + MultiThreaded + Guard + NDEBUG;%(PreprocessorDefinitions) + + + true + true + true + + + + + + + + + + + + Create + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.Shell/Source.def b/src/EverythingToJpeg.Shell/Source.def new file mode 100644 index 0000000..51dbd24 --- /dev/null +++ b/src/EverythingToJpeg.Shell/Source.def @@ -0,0 +1,5 @@ +LIBRARY + +EXPORTS + DllGetClassObject PRIVATE + DllCanUnloadNow PRIVATE diff --git a/src/EverythingToJpeg.Shell/dllmain.cpp b/src/EverythingToJpeg.Shell/dllmain.cpp new file mode 100644 index 0000000..228df55 --- /dev/null +++ b/src/EverythingToJpeg.Shell/dllmain.cpp @@ -0,0 +1,188 @@ +// EverythingToJpeg shell extension — IExplorerCommand handlers +// Two verbs: +// - QuickCommandHandler → "EverythingToJpeg.exe quick """ +// - DialogCommandHandler → "EverythingToJpeg.exe dialog """ + +#include "pch.h" + +#pragma warning(disable : 4324) + +using Microsoft::WRL::ClassicCom; +using Microsoft::WRL::ComPtr; +using Microsoft::WRL::InhibitRoOriginateError; +using Microsoft::WRL::Module; +using Microsoft::WRL::ModuleType; +using Microsoft::WRL::RuntimeClass; +using Microsoft::WRL::RuntimeClassFlags; + +namespace { + +constexpr const wchar_t* kExeFileName = L"EverythingToJpeg.exe"; + +std::wstring QuoteForCommandLineArg(const std::wstring& arg) { + const std::wstring quotable_chars(L" \\\""); + if (arg.find_first_of(quotable_chars) == std::wstring::npos) { + return arg; + } + + std::wstring out; + out.push_back(L'"'); + for (size_t i = 0; i < arg.size(); ++i) { + if (arg[i] == L'\\') { + const size_t start = i; + size_t end = start + 1; + for (; end < arg.size() && arg[end] == L'\\'; ++end) {} + size_t backslash_count = end - start; + if (end == arg.size() || arg[end] == L'"') { + backslash_count *= 2; + } + for (size_t j = 0; j < backslash_count; ++j) + out.push_back(L'\\'); + i = end - 1; + } + else if (arg[i] == L'"') { + out.push_back(L'\\'); + out.push_back(L'"'); + } + else { + out.push_back(arg[i]); + } + } + out.push_back(L'"'); + return out; +} + +std::filesystem::path ResolveExePath() { + std::filesystem::path module_path{ + wil::GetModuleFileNameW(wil::GetModuleInstanceHandle()) }; + module_path = module_path.remove_filename(); + module_path /= kExeFileName; + return module_path; +} + +HRESULT LaunchAppWithItems(const wchar_t* verb, IShellItemArray* items) { + if (!items) return S_OK; + + DWORD count = 0; + RETURN_IF_FAILED(items->GetCount(&count)); + if (count == 0) return S_OK; + + auto exe_path = ResolveExePath(); + + auto command = wil::str_printf(LR"-("%s" %s)-", + exe_path.c_str(), verb); + + for (DWORD i = 0; i < count; ++i) { + ComPtr item; + if (FAILED(items->GetItemAt(i, &item))) continue; + + wil::unique_cotaskmem_string path; + if (FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) continue; + + command = wil::str_printf(LR"-(%s %s)-", + command.c_str(), + QuoteForCommandLineArg(path.get()).c_str()); + } + + wil::unique_process_information process_info; + STARTUPINFOW startup_info = { sizeof(startup_info) }; + RETURN_IF_WIN32_BOOL_FALSE(CreateProcessW( + nullptr, + command.data(), + nullptr, + nullptr, + FALSE, + CREATE_NO_WINDOW, + nullptr, + nullptr, + &startup_info, + &process_info)); + + return S_OK; +} + +template +class CommandHandlerBase : public RuntimeClass< + RuntimeClassFlags, + IExplorerCommand> +{ +public: + IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { + return SHStrDupW(Derived::Title(), name); + } + + IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { + auto exe = ResolveExePath(); + return SHStrDupW(exe.c_str(), icon); + } + + IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* infoTip) override { + *infoTip = nullptr; + return E_NOTIMPL; + } + + IFACEMETHODIMP GetCanonicalName(GUID* guidCommandName) override { + *guidCommandName = GUID_NULL; + return S_OK; + } + + IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* cmdState) override { + *cmdState = ECS_ENABLED; + return S_OK; + } + + IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { + *flags = ECF_DEFAULT; + return S_OK; + } + + IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumCommands) override { + *enumCommands = nullptr; + return E_NOTIMPL; + } + + IFACEMETHODIMP Invoke(IShellItemArray* items, IBindCtx*) override { + return LaunchAppWithItems(Derived::Verb(), items); + } +}; + +} // namespace + +class __declspec(uuid("801B2DD3-632C-4731-9510-AEAE09345264")) + QuickCommandHandler final + : public CommandHandlerBase +{ +public: + static constexpr const wchar_t* Title() { return L"JPEG로 빠른 변환"; } + static constexpr const wchar_t* Verb() { return L"quick"; } +}; + +class __declspec(uuid("CEBA1DB7-9175-4DF6-A362-490DEA49B598")) + DialogCommandHandler final + : public CommandHandlerBase +{ +public: + static constexpr const wchar_t* Title() { return L"JPEG로 변환…"; } + static constexpr const wchar_t* Verb() { return L"dialog"; } +}; + +CoCreatableClass(QuickCommandHandler) +CoCreatableClass(DialogCommandHandler) +CoCreatableClassWrlCreatorMapInclude(QuickCommandHandler) +CoCreatableClassWrlCreatorMapInclude(DialogCommandHandler) + +BOOL APIENTRY DllMain(HMODULE, DWORD, LPVOID) { + return TRUE; +} + +_Check_return_ +STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, LPVOID* ppv) { + if (ppv == nullptr) return E_POINTER; + *ppv = nullptr; + return Module::GetModule().GetClassObject(rclsid, riid, ppv); +} + +__control_entrypoint(DllExport) +STDAPI DllCanUnloadNow(void) { + return Module::GetModule().GetObjectCount() == 0 ? S_OK : S_FALSE; +} diff --git a/src/EverythingToJpeg.Shell/framework.h b/src/EverythingToJpeg.Shell/framework.h new file mode 100644 index 0000000..5cb4cbf --- /dev/null +++ b/src/EverythingToJpeg.Shell/framework.h @@ -0,0 +1,4 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN +#include diff --git a/src/EverythingToJpeg.Shell/pch.cpp b/src/EverythingToJpeg.Shell/pch.cpp new file mode 100644 index 0000000..1d9f38c --- /dev/null +++ b/src/EverythingToJpeg.Shell/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/EverythingToJpeg.Shell/pch.h b/src/EverythingToJpeg.Shell/pch.h new file mode 100644 index 0000000..0eab913 --- /dev/null +++ b/src/EverythingToJpeg.Shell/pch.h @@ -0,0 +1,24 @@ +#ifndef PCH_H +#define PCH_H + +#include "framework.h" + +#include +#include + +#include +#include +#pragma comment(lib, "shlwapi.lib") + +#include +#include +#include +#pragma comment(lib, "runtimeobject.lib") + +#pragma warning(push) +#pragma warning(disable: 28182) +#include +#include +#pragma warning(pop) + +#endif From 5750aed16f000f1d2e47e65e38c9251ed9004156 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 13:23:47 +0900 Subject: [PATCH 03/12] =?UTF-8?q?Phase=203:=20HTML(WebView2)=20+=20HWP/HWP?= =?UTF-8?q?X(LibreOffice)=20=EC=8B=A4=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTML / HTM - HtmlProvider: STA 스레드 + WPF Dispatcher에서 WebView2 헤드리스 컨트롤러(HWND_MESSAGE) 생성 - CDP Page.captureScreenshot(captureBeyondViewport=true)으로 풀페이지 PNG 획득 - Magick.NET으로 JPEG 인코드, 알파/리사이즈/품질 옵션 일관 적용 - ConvertOptions: HtmlViewportWidth/Height, HtmlWaitMilliseconds, HtmlFullPage HWP / HWPX - HwpxProvider: LibreOffice headless --convert-to pdf + H2Orestart 확장 자동 감지 - DocxProvider 패턴 재사용 (소스→PDF→PdfProvider 위임) - ExternalToolDetector.IsH2OrestartInstalled — uno_packages/extensions 검색 기타 - Core csproj: UseWPF=true (WebView2가 WPF 의존), Microsoft.Web.WebView2 1.0.3912.50 추가 - AppX 매니페스트에 .html/.htm/.hwp/.hwpx ItemType 추가 - 파일 다이얼로그 필터 확장 - 두 ComingSoon 상태였던 Provider가 이제 Available/RequiresExternal로 노출됨 검증 - 솔루션 Release 빌드 통과 - MSIX 38개 페이로드 패키징 성공 (WebView2 SDK 포함) Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 8 +- packaging/Package.appxmanifest | 20 ++ .../Views/ConvertWindow.xaml.cs | 2 +- .../Views/MainWindow.xaml.cs | 2 +- src/EverythingToJpeg.Core/ConvertOptions.cs | 8 + .../Converters/ExternalToolDetector.cs | 34 ++++ .../Converters/HtmlProvider.cs | 185 +++++++++++++++++- .../Converters/HwpxProvider.cs | 126 ++++++++++-- .../EverythingToJpeg.Core.csproj | 7 +- 9 files changed, 366 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 09f9e37..f72f957 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj ` | 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) | +| HTML · HTM | ✅ 준비됨 | WebView2 헤드리스 + CDP `Page.captureScreenshot` 풀페이지 | | DOCX · DOC | ⚙ 외부 도구 필요 | Microsoft Word 또는 LibreOffice 자동 감지 | -| HTML · HTM | 🕐 개발 중 | WebView2 헤드리스 캡처 예정 | -| HWP · HWPX | 🕐 개발 중 | LibreOffice + H2Orestart 파이프라인 예정 | +| HWP · HWPX | ⚙ 외부 도구 필요 | LibreOffice + [H2Orestart](https://github.com/ebandal/H2Orestart) 확장 | ## 기술 스택 @@ -64,8 +64,8 @@ dotnet publish src\EverythingToJpeg.App\EverythingToJpeg.App.csproj ` | 단계 | 상태 | 내용 | |---|---|---| | Phase 1 | ✅ | 레지스트리 컨텍스트 메뉴 (Win11 "추가 옵션 표시"), 핵심 변환(이미지·HEIC·RAW·PDF·DOCX), Fluent UI | -| Phase 2 | ✅ 빌드 가능 | C++ IExplorerCommand DLL, MSIX 패키징, 자체 서명 인증서, GitHub Releases 자동화 — `packaging/README.md` 참조 | -| Phase 3 | 🕐 | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 | +| Phase 2 | ✅ | C++ IExplorerCommand DLL, MSIX 패키징, 자체 서명 인증서, GitHub Releases 자동화 — `packaging/README.md` 참조 | +| Phase 3 | ✅ | HTML(WebView2), HWP/HWPX(LibreOffice + H2Orestart) 실구현 | ## 두 가지 사용 방식 diff --git a/packaging/Package.appxmanifest b/packaging/Package.appxmanifest index 91c4cdb..f79706a 100644 --- a/packaging/Package.appxmanifest +++ b/packaging/Package.appxmanifest @@ -170,6 +170,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs index 15dcce1..ad409fd 100644 --- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs +++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs @@ -73,7 +73,7 @@ public partial class ConvertWindow : FluentWindow { 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|모든 파일|*.*", + 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;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*", }; if (dlg.ShowDialog(this) == true) AddFiles(dlg.FileNames); } diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs index aac1bf4..bc72514 100644 --- a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs @@ -193,7 +193,7 @@ public partial class MainWindow : FluentWindow, INotifyPropertyChanged { 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|모든 파일|*.*", + 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;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*", }; if (dlg.ShowDialog(this) == true) { diff --git a/src/EverythingToJpeg.Core/ConvertOptions.cs b/src/EverythingToJpeg.Core/ConvertOptions.cs index 46db7b6..6de34bb 100644 --- a/src/EverythingToJpeg.Core/ConvertOptions.cs +++ b/src/EverythingToJpeg.Core/ConvertOptions.cs @@ -36,5 +36,13 @@ public sealed class ConvertOptions public string TransparencyBackground { get; set; } = "#FFFFFF"; + public int HtmlViewportWidth { get; set; } = 1280; + + public int? HtmlViewportHeight { get; set; } + + public int HtmlWaitMilliseconds { get; set; } = 2000; + + public bool HtmlFullPage { get; set; } = true; + public static ConvertOptions Quick() => new(); } diff --git a/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs b/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs index 31f08ad..6f37a01 100644 --- a/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs +++ b/src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs @@ -48,4 +48,38 @@ internal static class ExternalToolDetector return false; } } + + public static bool IsH2OrestartInstalled() + { + try + { + var roots = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + }; + foreach (var root in roots) + { + if (string.IsNullOrEmpty(root)) continue; + var loDir = Path.Combine(root, "LibreOffice", "4", "user", "uno_packages", "cache", "uno_packages"); + if (Directory.Exists(loDir)) + { + foreach (var dir in Directory.EnumerateDirectories(loDir, "*H2Orestart*", SearchOption.AllDirectories)) + { + if (Directory.Exists(dir)) return true; + } + } + var extDir = Path.Combine(root, "LibreOffice", "4", "user", "extensions", "bundled"); + if (Directory.Exists(extDir)) + { + foreach (var dir in Directory.EnumerateDirectories(extDir, "*H2O*", SearchOption.AllDirectories)) + { + if (Directory.Exists(dir)) return true; + } + } + } + } + catch { } + return false; + } } diff --git a/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs b/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs index 6deaeb0..66edad1 100644 --- a/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs +++ b/src/EverythingToJpeg.Core/Converters/HtmlProvider.cs @@ -1,4 +1,10 @@ +using System.Text.Json; +using System.Threading; +using System.Windows; +using System.Windows.Threading; using EverythingToJpeg.Core.Providers; +using ImageMagick; +using Microsoft.Web.WebView2.Core; namespace EverythingToJpeg.Core.Converters; @@ -8,8 +14,8 @@ public sealed class HtmlProvider : IConverterProvider Id: "html", DisplayName: "HTML / 웹 페이지", Extensions: new[] { ".html", ".htm" }, - Status: ProviderStatus.ComingSoon, - Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 JPEG로 캡처합니다.", + Status: ProviderStatus.Available, + Summary: "HTML/HTM 파일을 WebView2로 헤드리스 렌더링하여 풀페이지 JPEG로 캡처합니다.", ExternalDependencies: new[] { new ExternalDependency( @@ -18,13 +24,176 @@ public sealed class HtmlProvider : IConverterProvider DownloadUrl: "https://developer.microsoft.com/microsoft-edge/webview2/", IsRequired: true), }, - RoadmapNote: "Phase 2 — WebView2 헤드리스 캡처 + 사용자 정의 viewport."); + RoadmapNote: null); public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) - => Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다.")); + { + try + { + var version = CoreWebView2Environment.GetAvailableBrowserVersionString(); + if (string.IsNullOrEmpty(version)) + return Task.FromResult(ProviderAvailability.NotReady( + "WebView2 Runtime이 설치되어 있지 않습니다.", + Capability.ExternalDependencies)); + return Task.FromResult(ProviderAvailability.Ready); + } + catch (Exception ex) + { + return Task.FromResult(ProviderAvailability.NotReady( + "WebView2 감지 실패: " + ex.Message, + Capability.ExternalDependencies)); + } + } - public Task ConvertAsync( - string sourcePath, string outputDirectory, ConvertOptions options, - IProgress? progress, CancellationToken cancellationToken) - => Task.FromResult(ConvertResult.Skip(sourcePath, "HTML 변환은 곧 지원 예정입니다.")); + public async Task ConvertAsync( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var baseName = Path.GetFileNameWithoutExtension(sourcePath); + var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, options.OnCollision); + if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) + return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다."); + + var pngBytes = await CapturePngAsync(sourcePath, options, progress, cancellationToken) + .ConfigureAwait(false); + + progress?.Report(0.85); + + await Task.Run(() => + { + using var image = new MagickImage(pngBytes); + 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 + && (image.Width > (uint)maxLong || image.Height > (uint)maxLong)) + { + image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false }); + } + image.Quality = (uint)Math.Clamp(options.Quality, 1, 100); + image.Format = MagickFormat.Jpeg; + image.Write(path); + }, cancellationToken).ConfigureAwait(false); + + progress?.Report(1.0); + return ConvertResult.Ok(sourcePath, new[] { path }); + } + + private static Task CapturePngAsync( + string sourcePath, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var thread = new Thread(() => + { + try + { + var dispatcher = Dispatcher.CurrentDispatcher; + _ = RunCaptureOnDispatcher(dispatcher, sourcePath, options, progress, cancellationToken, tcs); + Dispatcher.Run(); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.IsBackground = true; + thread.Name = "EverythingToJpeg.HtmlCapture"; + thread.Start(); + + return tcs.Task; + } + + private static async Task RunCaptureOnDispatcher( + Dispatcher dispatcher, + string sourcePath, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken, + TaskCompletionSource tcs) + { + CoreWebView2Controller? controller = null; + try + { + var userDataFolder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "EverythingToJpeg", "WebView2"); + Directory.CreateDirectory(userDataFolder); + + var env = await CoreWebView2Environment.CreateAsync(null, userDataFolder).ConfigureAwait(true); + progress?.Report(0.15); + + // HWND_MESSAGE = (IntPtr)(-3) → headless message-only parent + controller = await env.CreateCoreWebView2ControllerAsync(new IntPtr(-3)).ConfigureAwait(true); + + int width = options.HtmlViewportWidth > 0 ? options.HtmlViewportWidth : 1280; + int height = options.HtmlViewportHeight ?? 720; + controller.Bounds = new System.Drawing.Rectangle(0, 0, width, height); + controller.IsVisible = false; + + var web = controller.CoreWebView2; + progress?.Report(0.3); + + var navTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EventHandler? navHandler = null; + navHandler = (_, e) => + { + web.NavigationCompleted -= navHandler!; + if (e.IsSuccess) navTcs.TrySetResult(true); + else navTcs.TrySetException(new InvalidOperationException( + $"내비게이션 실패: {e.WebErrorStatus}")); + }; + web.NavigationCompleted += navHandler; + + var fileUri = new Uri(sourcePath).AbsoluteUri; + web.Navigate(fileUri); + + using (cancellationToken.Register(() => navTcs.TrySetCanceled())) + { + await navTcs.Task.ConfigureAwait(true); + } + progress?.Report(0.5); + + if (options.HtmlWaitMilliseconds > 0) + await Task.Delay(options.HtmlWaitMilliseconds, cancellationToken).ConfigureAwait(true); + + progress?.Report(0.65); + + // Use CDP for full-page screenshot beyond viewport + var captureParams = options.HtmlFullPage + ? "{\"captureBeyondViewport\":true,\"format\":\"png\"}" + : "{\"format\":\"png\"}"; + + var resultJson = await web + .CallDevToolsProtocolMethodAsync("Page.captureScreenshot", captureParams) + .ConfigureAwait(true); + progress?.Report(0.8); + + using var doc = JsonDocument.Parse(resultJson); + var b64 = doc.RootElement.GetProperty("data").GetString() + ?? throw new InvalidOperationException("CDP captureScreenshot이 빈 결과를 반환했습니다."); + var pngBytes = Convert.FromBase64String(b64); + + tcs.TrySetResult(pngBytes); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + finally + { + try { controller?.Close(); } catch { } + dispatcher.BeginInvokeShutdown(DispatcherPriority.Background); + } + } } diff --git a/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs b/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs index acaa746..954266d 100644 --- a/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs +++ b/src/EverythingToJpeg.Core/Converters/HwpxProvider.cs @@ -1,30 +1,134 @@ +using System.Diagnostics; using EverythingToJpeg.Core.Providers; namespace EverythingToJpeg.Core.Converters; public sealed class HwpxProvider : IConverterProvider { + private readonly PdfProvider _pdfProvider; + + public HwpxProvider() : this(new PdfProvider()) { } + + public HwpxProvider(PdfProvider pdfProvider) + { + _pdfProvider = pdfProvider; + } + public ProviderCapability Capability { get; } = new( Id: "hwpx", DisplayName: "한글 문서 (HWP / HWPX)", Extensions: new[] { ".hwp", ".hwpx" }, - Status: ProviderStatus.ComingSoon, - Summary: "한글(HWP/HWPX) 문서를 PDF로 변환한 뒤 페이지별 JPEG로 저장합니다.", + Status: ProviderStatus.RequiresExternal, + Summary: "한글(HWP/HWPX) 문서를 LibreOffice + H2Orestart로 PDF 변환 후 페이지별 JPEG로 저장합니다.", ExternalDependencies: new[] { new ExternalDependency( - Name: "LibreOffice + H2Orestart 확장", - Description: "한글 파일을 LibreOffice가 읽도록 해 주는 오픈소스 확장입니다.", - DownloadUrl: "https://github.com/ebandal/H2Orestart", + Name: "LibreOffice", + Description: "한글 변환에 필요한 헤드리스 오피스 엔진.", + DownloadUrl: "https://www.libreoffice.org/download/", + IsRequired: true), + new ExternalDependency( + Name: "H2Orestart 확장", + Description: "LibreOffice가 한글 파일을 읽도록 하는 오픈소스 확장. 다운로드한 oxt 파일을 LibreOffice에서 더블클릭해 설치.", + DownloadUrl: "https://github.com/ebandal/H2Orestart/releases", IsRequired: true), }, - RoadmapNote: "Phase 2 — H2Orestart + soffice headless 파이프라인. 한컴오피스 SDK 연동도 검토."); + RoadmapNote: null); public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default) - => Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다.")); + { + if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out _)) + return Task.FromResult(ProviderAvailability.NotReady( + "LibreOffice가 설치되어 있지 않습니다.", + Capability.ExternalDependencies)); - public Task ConvertAsync( - string sourcePath, string outputDirectory, ConvertOptions options, - IProgress? progress, CancellationToken cancellationToken) - => Task.FromResult(ConvertResult.Skip(sourcePath, "HWP/HWPX 변환은 곧 지원 예정입니다.")); + if (!ExternalToolDetector.IsH2OrestartInstalled()) + return Task.FromResult(ProviderAvailability.NotReady( + "H2Orestart 확장이 설치되어 있지 않습니다. https://github.com/ebandal/H2Orestart/releases 에서 .oxt 다운로드 후 LibreOffice에서 설치하세요.", + Capability.ExternalDependencies)); + + return Task.FromResult(ProviderAvailability.Ready); + } + + public async Task ConvertAsync( + string sourcePath, + string outputDirectory, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + if (!ExternalToolDetector.TryFindLibreOfficeSoffice(out var soffice)) + return ConvertResult.Fail(sourcePath, "LibreOffice가 필요합니다."); + + var tempPdf = Path.Combine(Path.GetTempPath(), + $"e2j_{Guid.NewGuid():N}_{Path.GetFileNameWithoutExtension(sourcePath)}.pdf"); + + try + { + progress?.Report(0.05); + + var converted = await ConvertWithLibreOfficeAsync(soffice, sourcePath, tempPdf, cancellationToken) + .ConfigureAwait(false); + + if (!converted) + return ConvertResult.Fail(sourcePath, + "LibreOffice 변환에 실패했습니다. H2Orestart 확장이 정상 설치되어 있는지 확인하세요."); + + progress?.Report(0.55); + + 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); + } } diff --git a/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj b/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj index 62ab7bd..ce3376c 100644 --- a/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj +++ b/src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj @@ -6,7 +6,7 @@ enable latest false - false + true $(NoWarn);NU1901;NU1902;NU1903;NU1904 @@ -15,6 +15,11 @@ + + + + + From 69a6a1360d9462562e387d512087d6a0106f76f6 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 14:50:32 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20=EB=B9=A0=EB=A5=B8=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20=ED=9B=84=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A2=85=EB=A3=8C=20+=20=EC=A7=84?= =?UTF-8?q?=EB=8B=A8=20=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShutdownMode를 OnExplicitShutdown → OnLastWindowClose 로 변경 변환 끝나도 WPF가 안 닫혀 좀비 프로세스로 남던 문제. 사용자 입장에선 "결과가 안 보임"으로 인지됐음. 이제 GUI 닫히면 자동 종료(1초 내) - RunQuickAsync에 %TEMP%\EverythingToJpeg_quick.log 진단 로그 추가 (소스 경로, 결과 상태, 출력 경로, 예외 스택을 모두 기록) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EverythingToJpeg.App/App.xaml | 2 +- src/EverythingToJpeg.App/App.xaml.cs | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/EverythingToJpeg.App/App.xaml b/src/EverythingToJpeg.App/App.xaml index 1510506..430b975 100644 --- a/src/EverythingToJpeg.App/App.xaml +++ b/src/EverythingToJpeg.App/App.xaml @@ -3,7 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:local="clr-namespace:EverythingToJpeg.App" - ShutdownMode="OnExplicitShutdown"> + ShutdownMode="OnLastWindowClose"> diff --git a/src/EverythingToJpeg.App/App.xaml.cs b/src/EverythingToJpeg.App/App.xaml.cs index 95b0549..4796741 100644 --- a/src/EverythingToJpeg.App/App.xaml.cs +++ b/src/EverythingToJpeg.App/App.xaml.cs @@ -79,6 +79,11 @@ public partial class App : Application private async Task RunQuickAsync(IReadOnlyList files) { + var logPath = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_quick.log"); + var log = new System.Text.StringBuilder(); + log.AppendLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] Quick start, {files.Count} file(s)"); + foreach (var f in files) log.AppendLine($" src: {f}"); + var progress = new QuickProgressWindow(files.Count); progress.Show(); @@ -87,14 +92,28 @@ public partial class App : Application var options = ConvertOptions.Quick(); var reporter = new Progress(p => progress.Report(p)); var results = await Engine.ConvertManyAsync(files, options, reporter); + + foreach (var r in results) + { + log.AppendLine($" [{r.Status}] {Path.GetFileName(r.SourcePath)} → {r.OutputPaths.Count} output(s)"); + if (r.Message is { Length: > 0 }) log.AppendLine($" msg: {r.Message}"); + if (r.Error is not null) log.AppendLine($" err: {r.Error}"); + foreach (var o in r.OutputPaths) log.AppendLine($" out: {o}"); + } + progress.Finish(results); } catch (Exception ex) { - MessageBox.Show($"변환 중 오류: {ex.Message}", "EverythingToJpeg", + log.AppendLine($" EXCEPTION {ex.GetType().Name}: {ex.Message}"); + log.AppendLine(ex.ToString()); + try { progress.Close(); } catch { } + MessageBox.Show($"변환 중 오류: {ex.Message}\n\n로그: {logPath}", "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Error); - progress.Close(); - Shutdown(1); + } + finally + { + try { File.WriteAllText(logPath, log.ToString()); } catch { } } } } From e663921460a4bcd39c3fd17e2a21a5209f9d849b Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 14:50:48 +0900 Subject: [PATCH 05/12] =?UTF-8?q?ux:=20ConvertWindow=20=EC=A2=8C=EC=9A=B0?= =?UTF-8?q?=20=E2=86=92=20=EC=83=81=ED=95=98=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이전 좌우 분할은 IDE 패턴이라 변환 도구에 부자연스러웠음. 옵션 패널 320px 고정 때문에 창을 늘리면 좌측만 늘어나 "우측으로 늘어진" 인상을 줬다. 새 레이아웃 (위→아래): 1. 헤더(타이틀+파일 카운트) + 추가/비우기 버튼 2. 옵션 카드 (한 줄: 품질·출력 위치·이름 충돌) + 고급 옵션 expander (긴 변 픽셀·PDF DPI·투명 처리) 3. 파일 리스트 (메인, 세로 스크롤로 끝까지) 4. 하단 액션바 (진행률 + 닫기/변환 시작) 창 비율 940x700 → 640x780 으로 세로 우선. 파일이 많아질수록 자연스럽게 길게 늘어나는 패턴. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/ConvertWindow.xaml | 189 +++++++++++------- 1 file changed, 115 insertions(+), 74 deletions(-) diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml index 1fcfc8a..c97a5f9 100644 --- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml +++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml @@ -3,8 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="JPEG로 변환" - Width="940" Height="700" - MinWidth="720" MinHeight="540" + Width="640" Height="780" + MinWidth="520" MinHeight="560" ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica" WindowCornerPreference="Round" @@ -14,108 +14,149 @@ DragOver="OnDragOver"> + + + - + + - - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - - - - - - - - + + + + - + + FontWeight="SemiBold" VerticalAlignment="Center" HorizontalAlignment="Right"/> - + - - - + + + + + - - - - - - - - + - - + + + + - - - - - - - - - - - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Padding="32,16" Margin="0,16,0,0"> @@ -125,7 +166,7 @@ + Margin="0,6,0,0" TextTrimming="CharacterEllipsis"/> Date: Wed, 6 May 2026 16:47:41 +0900 Subject: [PATCH 06/12] =?UTF-8?q?fix:=20ConvertWindow=20ItemTemplate=20Xam?= =?UTF-8?q?lReader=20=EC=8B=A4=ED=8C=A8=20+=20=EC=A7=84=EB=8B=A8/=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConvertWindow의 ItemTemplate을 코드비하인드 XamlReader.Parse 대신 XAML 안에 으로 인라인 정의. XamlReader는 code-behind 메서드를 wire-up할 수 없어 Click="OnRemoveEntry"가 매번 실패 → ConvertWindow 생성 시 예외 발생, GUI 상태가 깨져 변환 버튼 클릭이 처리되지 않던 근본 원인. - OnConvertClick에 진단 로그(%TEMP%\EverythingToJpeg_dialog.log) 추가 - App에 전역 unhandled exception 핸들러 — DispatcherUnhandled, AppDomain.UnhandledException, TaskScheduler.UnobservedTaskException 모두 %TEMP%\EverythingToJpeg_unhandled.log에 기록 + 메시지박스로 즉시 노출 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EverythingToJpeg.App/App.xaml.cs | 36 +++++++ .../Views/ConvertWindow.xaml | 46 ++++++++- .../Views/ConvertWindow.xaml.cs | 93 +++++++++---------- 3 files changed, 124 insertions(+), 51 deletions(-) diff --git a/src/EverythingToJpeg.App/App.xaml.cs b/src/EverythingToJpeg.App/App.xaml.cs index 4796741..e005df4 100644 --- a/src/EverythingToJpeg.App/App.xaml.cs +++ b/src/EverythingToJpeg.App/App.xaml.cs @@ -13,6 +13,8 @@ public partial class App : Application { base.OnStartup(e); + WireGlobalExceptionLogging(); + var parsed = CliRouter.Parse(e.Args); switch (parsed.Mode) @@ -116,4 +118,38 @@ public partial class App : Application try { File.WriteAllText(logPath, log.ToString()); } catch { } } } + + private static void WireGlobalExceptionLogging() + { + var path = Path.Combine(Path.GetTempPath(), "EverythingToJpeg_unhandled.log"); + + void Append(string source, Exception? ex) + { + try + { + File.AppendAllText(path, + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {source}\n{ex}\n\n"); + } + catch { } + } + + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Append("AppDomain.UnhandledException", e.ExceptionObject as Exception); + + Current.DispatcherUnhandledException += (_, e) => + { + Append("Application.DispatcherUnhandledException", e.Exception); + MessageBox.Show( + "예기치 못한 오류:\n\n" + e.Exception.Message + "\n\n로그: " + path, + "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); + e.Handled = true; + }; + + System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, e) => + { + Append("TaskScheduler.UnobservedTaskException", e.Exception); + e.SetObserved(); + }; + } } diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml index c97a5f9..f896c18 100644 --- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml +++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml @@ -147,7 +147,51 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs index ad409fd..e3ede57 100644 --- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs +++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs @@ -22,7 +22,6 @@ public partial class ConvertWindow : FluentWindow InitializeComponent(); FilesList.ItemsSource = _entries; - FilesList.ItemTemplate = (DataTemplate)CreateFileEntryTemplate(); AddFiles(initialFiles); @@ -113,19 +112,51 @@ public partial class ConvertWindow : FluentWindow } } + private static readonly string DialogLogPath = + Path.Combine(Path.GetTempPath(), "EverythingToJpeg_dialog.log"); + + private static void DiagLog(string line) + { + try + { + File.AppendAllText(DialogLogPath, + $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {line}{Environment.NewLine}"); + } + catch { } + } + private async void OnConvertClick(object sender, RoutedEventArgs e) { + DiagLog($"OnConvertClick: entries={_entries.Count}"); + if (_entries.Count == 0) { + DiagLog(" → no entries, showing info"); ShowInfo("변환할 파일이 없습니다."); return; } ConvertButton.IsEnabled = false; CancelButton.Content = "취소"; + ProgressStatusText.Text = "준비 중…"; _cts = new CancellationTokenSource(); - var options = BuildOptions(); + ConvertOptions options; + try + { + options = BuildOptions(); + DiagLog($" options: Quality={options.Quality} OutputLocation={options.OutputLocation} Custom={options.CustomOutputDirectory} Collision={options.OnCollision} MaxLong={options.MaxLongEdgePixels} PdfDpi={options.PdfDpi}"); + } + catch (Exception ex) + { + DiagLog(" BuildOptions threw: " + ex); + ProgressStatusText.Text = "옵션 처리 오류: " + ex.Message; + ConvertButton.IsEnabled = true; + CancelButton.Content = "닫기"; + _cts = null; + return; + } + var reporter = new Progress(p => { var overall = p.Total == 0 ? 0 : (p.Index + p.FileProgress) / p.Total; @@ -137,17 +168,27 @@ public partial class ConvertWindow : FluentWindow try { var sources = _entries.Select(en => en.Path).ToList(); + DiagLog($" starting ConvertManyAsync, {sources.Count} files"); var results = await _engine.ConvertManyAsync(sources, options, reporter, _cts.Token); + DiagLog($" finished, {results.Count} results"); + foreach (var r in results) + DiagLog($" [{r.Status}] {Path.GetFileName(r.SourcePath)} msg={r.Message}"); ApplyResults(results); ProgressStatusText.Text = SummarizeResults(results); } catch (OperationCanceledException) { + DiagLog(" canceled"); ProgressStatusText.Text = "변환이 취소되었습니다."; } catch (Exception ex) { + DiagLog(" EXCEPTION: " + ex); ProgressStatusText.Text = "오류: " + ex.Message; + MessageBox.Show(this, + "변환 중 오류가 발생했습니다:\n\n" + ex.Message + "\n\n로그: " + DialogLogPath, + "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); } finally { @@ -234,54 +275,6 @@ public partial class ConvertWindow : FluentWindow => 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 From 01467a4d2785bb95113e9042c0ea73486bd739e5 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 16:57:49 +0900 Subject: [PATCH 07/12] =?UTF-8?q?ux:=20FormatShift=20Utility=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=EC=9D=84=20=EB=A9=94=EC=9D=B8=20=EC=9C=88?= =?UTF-8?q?=EB=8F=84=EC=9A=B0=EC=97=90=20=EC=B6=A9=EC=8B=A4=ED=9E=88=20?= =?UTF-8?q?=EC=9E=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 메인 윈도우 (1280x960, dark minimal): - 320px 사이드바 — Target Format(JPG), Encoding Quality 슬라이더(파란색 fill + 흰 thumb), Output Destination, Skip/Rename/Replace 세그먼트, Stats Box, Processing Queue 버튼(disabled→enabled 상태 전이) - 메인 영역 — pill 탭 3개(Active Queue / Preview / Past Results), 액션 버튼 (Register Menu / Diagnose / Export Log / Clear All) - Past Results — 날짜별 그룹화, "Today/Yesterday" 헤더 + Session Savings 뱃지, 파일 아이콘(PDF/PNG/HEIC/JPG/...), 사이즈/savings/status 컬럼 - Active Queue — 빈 상태 드롭존 + 파일 행, 행 클릭 시 Preview 자동 전환 - Drop hint overlay — 파일 끌어올 때 흐릿한 가이드 표시 핵심 신규 인프라: - FormatShiftTheme.xaml — 디자인 토큰(색/타이포/슬라이더 스타일/탭 pill 스타일/ 세그먼트 스타일/primary·secondary 버튼) 전부 - PreviewService — 이미지/RAW(Magick), HEIC(libheif decode), PDF(PDFium)을 720px 긴변 BitmapSource로 렌더. DOCX/HWP/HTML은 안내 문구 fallback - HistoryStore — 변환 결과 누적(메모리), 날짜별 group + session savings 합산 - Export Log — Past Results를 CSV/JSON으로 저장 기존 기능 통합: - 사이드바 옵션이 ConvertOptions에 매핑 (Quality, Output Path, Conflict) - Process Queue 버튼이 ConvertManyAsync 호출 → 결과를 History에 누적 - Register Menu / Diagnose 액션 유지 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Views/FormatShiftTheme.xaml | 293 +++++++ .../Views/MainWindow.xaml | 694 +++++++++++++-- .../Views/MainWindow.xaml.cs | 827 ++++++++++++++---- src/EverythingToJpeg.Core/HistoryStore.cs | 37 + src/EverythingToJpeg.Core/PreviewService.cs | 128 +++ 5 files changed, 1701 insertions(+), 278 deletions(-) create mode 100644 src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml create mode 100644 src/EverythingToJpeg.Core/HistoryStore.cs create mode 100644 src/EverythingToJpeg.Core/PreviewService.cs diff --git a/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml b/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml new file mode 100644 index 0000000..ef512f6 --- /dev/null +++ b/src/EverythingToJpeg.App/Views/FormatShiftTheme.xaml @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inter, Segoe UI Variable Text, Segoe UI + JetBrains Mono, Cascadia Mono, Consolas + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml b/src/EverythingToJpeg.App/Views/MainWindow.xaml index 53a8f09..6816237 100644 --- a/src/EverythingToJpeg.App/Views/MainWindow.xaml +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml @@ -2,110 +2,636 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" - Title="EverythingToJpeg" - Width="880" Height="640" - MinWidth="640" MinHeight="480" + Title="FormatShift Utility" + Width="1280" Height="960" + MinWidth="1080" MinHeight="640" ExtendsContentIntoTitleBar="True" - WindowBackdropType="Mica" + WindowBackdropType="None" WindowCornerPreference="Round" WindowStartupLocation="CenterScreen" AllowDrop="True" Drop="OnFilesDropped" DragOver="OnDragOver" - DragLeave="OnDragLeave"> - + DragLeave="OnDragLeave" + TextOptions.TextFormattingMode="Display" + UseLayoutRounding="True"> + + + + + + + + + - + - - - - - + + - - - - - - - + + + + + + - - - - - 파일을 우클릭하면 끝. PNG · GIF · HEIC · RAW · PDF · DOCX 가 모두 한 번에 변환됩니다. - - + + + + + + + + - - - - - - - - - - 또는 - 파일 선택 - - + + + + + + FormatShift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs index bc72514..3cd3ee6 100644 --- a/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs +++ b/src/EverythingToJpeg.App/Views/MainWindow.xaml.cs @@ -1,256 +1,532 @@ +using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; -using System.Windows.Documents; +using System.Windows.Controls.Primitives; +using System.Windows.Input; using System.Windows.Media; +using System.Windows.Media.Imaging; using EverythingToJpeg.App.Shell; -using EverythingToJpeg.Core.Providers; -using Wpf.Ui.Controls; +using EverythingToJpeg.Core; namespace EverythingToJpeg.App.Views; -public partial class MainWindow : FluentWindow, INotifyPropertyChanged +public partial class MainWindow : Wpf.Ui.Controls.FluentWindow { - private bool _isDraggingOver; - public bool IsDraggingOver - { - get => _isDraggingOver; - set { _isDraggingOver = value; OnPropertyChanged(); } - } + private readonly ObservableCollection _activeQueue = new(); + private readonly ObservableCollection _pastResults = new(); + private CancellationTokenSource? _cts; + private NameCollision _conflictRule = NameCollision.AppendNumber; public MainWindow() { InitializeComponent(); - DataContext = this; - Loaded += async (_, _) => await PopulateAsync(); + + ActiveQueueList.ItemsSource = _activeQueue; + PastResultsList.ItemsSource = _pastResults; + + SeedDemoHistory(); + UpdateBadges(); + UpdateProcessQueueButton(); + ShowTab("Past"); + UpdateActiveQueueVisibility(); + + ApplyAppDataStats(); } - private async Task PopulateAsync() + // ============== Tabs ============== + + private void OnTabClick(object sender, RoutedEventArgs e) { - var engine = ((App)Application.Current).Engine; - ProvidersList.Items.Clear(); - foreach (var provider in engine.Providers.All) + if (sender is ToggleButton tb && tb.Tag is string tag) { - var availability = await provider.CheckAvailabilityAsync(); - ProvidersList.Items.Add(BuildProviderRow(provider.Capability, availability)); + ShowTab(tag); } } - private static UIElement BuildProviderRow(ProviderCapability cap, ProviderAvailability availability) + private void ShowTab(string tag) { - 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"), - }; + TabActiveBtn.IsChecked = tag == "Active"; + TabPreviewBtn.IsChecked = tag == "Preview"; + TabPastBtn.IsChecked = tag == "Past"; - 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; + ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed; + PreviewView.Visibility = tag == "Preview" ? Visibility.Visible : Visibility.Collapsed; + PastResultsView.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed; } + // ============== Drag & Drop ============== + 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.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) + ? DragDropEffects.Copy : DragDropEffects.None; + DropHintOverlay.Visibility = e.Effects == DragDropEffects.Copy + ? Visibility.Visible : Visibility.Collapsed; e.Handled = true; } private void OnDragLeave(object sender, DragEventArgs e) { - IsDraggingOver = false; + DropHintOverlay.Visibility = Visibility.Collapsed; } private void OnFilesDropped(object sender, DragEventArgs e) { - IsDraggingOver = false; + DropHintOverlay.Visibility = Visibility.Collapsed; if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return; if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return; - OpenConvertWindow(paths); + + AddToQueue(ExpandPaths(paths)); + ShowTab("Active"); } - private void OnPickFilesClick(object sender, RoutedEventArgs e) + private static IEnumerable ExpandPaths(IEnumerable paths) { - 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;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*", - }; - 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)) yield return p; + else if (Directory.Exists(p)) { - if (File.Exists(p)) list.Add(p); - else if (Directory.Exists(p)) - list.AddRange(Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)); + foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)) + yield return f; } - catch { } } - return list; } - private async void OnRegisterClick(object sender, RoutedEventArgs e) + private void AddToQueue(IEnumerable paths) { + var existing = new HashSet(_activeQueue.Select(q => q.SourcePath), StringComparer.OrdinalIgnoreCase); + foreach (var path in paths) + { + if (!File.Exists(path) || existing.Contains(path)) continue; + _activeQueue.Add(QueueItem.FromPath(path)); + } + UpdateBadges(); + UpdateProcessQueueButton(); + UpdateActiveQueueVisibility(); + } + + private void OnRemoveQueueItem(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement fe && fe.Tag is QueueItem item) + { + _activeQueue.Remove(item); + UpdateBadges(); + UpdateProcessQueueButton(); + UpdateActiveQueueVisibility(); + } + } + + private void UpdateActiveQueueVisibility() + { + var hasItems = _activeQueue.Count > 0; + DropZoneEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible; + ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed; + } + + private void UpdateBadges() + { + TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture); + TabPreviewBadge.Text = "0"; + var count = _pastResults.Sum(g => g.Entries.Count); + TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture); + } + + private void UpdateProcessQueueButton() + { + var count = _activeQueue.Count; + if (_cts is not null) + { + ProcessQueueButton.Content = $"Processing… ({count} files)"; + ProcessQueueButton.IsEnabled = false; + } + else if (count == 0) + { + ProcessQueueButton.Content = "Idle — drop files to begin"; + ProcessQueueButton.IsEnabled = false; + } + else + { + ProcessQueueButton.Content = $"Process Queue ({count})"; + ProcessQueueButton.IsEnabled = true; + } + } + + // ============== Sidebar inputs ============== + + private void OnQualityChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (QualityValueText is null) return; + QualityValueText.Text = $"{(int)e.NewValue}%"; + } + + private void OnConflictSegmentClick(object sender, RoutedEventArgs e) + { + if (sender is not ToggleButton clicked) return; + ConflictSkipBtn.IsChecked = clicked == ConflictSkipBtn; + ConflictRenameBtn.IsChecked = clicked == ConflictRenameBtn; + ConflictReplaceBtn.IsChecked = clicked == ConflictReplaceBtn; + _conflictRule = (clicked.Tag as string) switch + { + "Skip" => NameCollision.Skip, + "Replace" => NameCollision.Overwrite, + _ => NameCollision.AppendNumber, + }; + } + + private void OnPickOutputFolderClick(object sender, RoutedEventArgs e) + { + var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "출력 폴더 선택" }; + if (dlg.ShowDialog(this) == true) + OutputPathTextBox.Text = dlg.FolderName; + } + + private ConvertOptions BuildOptions() + { + var opts = new ConvertOptions + { + Quality = (int)QualitySlider.Value, + OnCollision = _conflictRule, + }; + + var custom = OutputPathTextBox.Text?.Trim(); + if (!string.IsNullOrEmpty(custom)) + { + opts.OutputLocation = OutputLocation.Custom; + opts.CustomOutputDirectory = custom; + } + else + { + opts.OutputLocation = OutputLocation.SubfolderBesideSource; + } + + return opts; + } + + // ============== Process queue ============== + + // ============== Preview ============== + + private QueueItem? _selectedPreviewItem; + private CancellationTokenSource? _previewCts; + + private async void OnQueueRowClick(object sender, MouseButtonEventArgs e) + { + if (sender is not FrameworkElement fe || fe.Tag is not QueueItem item) return; + _selectedPreviewItem = item; + ShowTab("Preview"); + await LoadPreviewAsync(item); + } + + private async Task LoadPreviewAsync(QueueItem item) + { + _previewCts?.Cancel(); + _previewCts = new CancellationTokenSource(); + var token = _previewCts.Token; + + PreviewEmpty.Visibility = Visibility.Collapsed; + PreviewImage.Visibility = Visibility.Collapsed; + PreviewReason.Visibility = Visibility.Collapsed; + PreviewLoading.Visibility = Visibility.Visible; + + PreviewFileName.Text = item.FileName; + PreviewFilePath.Text = item.SourcePath; + PreviewFormatText.Text = item.FormatLabel; + PreviewSizeText.Text = item.SizeText; + PreviewDimText.Text = "—"; + PreviewPageText.Text = "—"; + try { - ContextMenuRegistrar.Register(((App)Application.Current).Engine); - ShowToast("컨텍스트 메뉴를 등록했습니다.\n파일 위에서 우클릭 → \"추가 옵션 표시\"에서 보입니다."); - await PopulateAsync(); + var result = await PreviewService.CreateAsync(item.SourcePath, 720, token); + if (token.IsCancellationRequested) return; + + PreviewLoading.Visibility = Visibility.Collapsed; + + if (result.Image is not null) + { + PreviewImage.Source = result.Image; + PreviewImage.Visibility = Visibility.Visible; + } + else + { + PreviewReasonText.Text = result.Reason ?? "미리보기를 생성하지 못했습니다."; + PreviewReason.Visibility = Visibility.Visible; + } + + PreviewDimText.Text = result.Dimensions ?? "—"; + PreviewPageText.Text = result.PageCount?.ToString() ?? "—"; + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + PreviewLoading.Visibility = Visibility.Collapsed; + PreviewReasonText.Text = "미리보기 오류: " + ex.Message; + PreviewReason.Visibility = Visibility.Visible; + } + } + + private void OnPreviewOpenFolder(object sender, RoutedEventArgs e) + { + if (_selectedPreviewItem is null) return; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/select,\"{_selectedPreviewItem.SourcePath}\"", + UseShellExecute = true, + }); + } + catch { } + } + + // ============== Export Log ============== + + private void OnExportLogClick(object sender, RoutedEventArgs e) + { + if (_pastResults.Count == 0) + { + MessageBox.Show(this, "저장할 이력이 없습니다.", "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var dlg = new Microsoft.Win32.SaveFileDialog + { + Title = "Export Log", + FileName = $"EverythingToJpeg-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv", + DefaultExt = ".csv", + Filter = "CSV (*.csv)|*.csv|JSON (*.json)|*.json", + }; + if (dlg.ShowDialog(this) != true) return; + + try + { + if (dlg.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + ExportJson(dlg.FileName); + else + ExportCsv(dlg.FileName); + + MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Information); } catch (Exception ex) { - MessageBox.Show("등록 중 오류: " + ex.Message, "EverythingToJpeg", + MessageBox.Show(this, "저장 중 오류: " + ex.Message, "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Error); } } - private async void OnUnregisterClick(object sender, RoutedEventArgs e) + private void ExportCsv(string path) + { + var sb = new System.Text.StringBuilder(); + sb.AppendLine("Date,Format,FileName,SourcePath,Size,Savings,Meta"); + foreach (var group in _pastResults) + foreach (var row in group.Entries) + sb.AppendLine(string.Join(",", + EscapeCsv(group.DateTitle), + EscapeCsv(row.FormatLabel), + EscapeCsv(row.FileName), + EscapeCsv(row.SourcePath), + EscapeCsv(row.SizeText), + EscapeCsv(row.SavingsText), + EscapeCsv(row.MetaLine))); + File.WriteAllText(path, sb.ToString(), System.Text.Encoding.UTF8); + } + + private void ExportJson(string path) + { + var data = _pastResults.Select(g => new + { + date = g.DateTitle, + sessionSavings = HumanizeBytes(g.SessionSavingsBytes), + entries = g.Entries.Select(r => new + { + format = r.FormatLabel, + fileName = r.FileName, + sourcePath = r.SourcePath, + size = r.SizeText, + savings = r.SavingsText, + meta = r.MetaLine, + }), + }); + File.WriteAllText(path, + System.Text.Json.JsonSerializer.Serialize(data, + new System.Text.Json.JsonSerializerOptions { WriteIndented = true }), + System.Text.Encoding.UTF8); + } + + private static string EscapeCsv(string? s) + { + s ??= ""; + if (s.Contains('"') || s.Contains(',') || s.Contains('\n')) + return "\"" + s.Replace("\"", "\"\"") + "\""; + return s; + } + + private async void OnProcessQueueClick(object sender, RoutedEventArgs e) + { + if (_activeQueue.Count == 0) return; + + var snapshot = _activeQueue.ToList(); + foreach (var item in snapshot) item.SetPending(); + + _cts = new CancellationTokenSource(); + UpdateProcessQueueButton(); + + var engine = ((App)Application.Current).Engine; + var options = BuildOptions(); + + var reporter = new Progress(p => + { + for (var i = 0; i < snapshot.Count; i++) + { + if (i < p.Index) snapshot[i].SetState("done"); + else if (i == p.Index) snapshot[i].SetState($"{(int)(p.FileProgress * 100)}%"); + else snapshot[i].SetState("queued"); + } + }); + + try + { + var sources = snapshot.Select(s => s.SourcePath).ToList(); + var results = await engine.ConvertManyAsync(sources, options, reporter, _cts.Token); + + foreach (var (item, result) in snapshot.Zip(results)) + { + long outputSize = 0; + foreach (var p in result.OutputPaths) + { + try { outputSize += new FileInfo(p).Length; } catch { } + } + + AddToHistory(new HistoryEntry( + Timestamp: DateTime.Now, + SourcePath: item.SourcePath, + SourceFormat: item.FormatLabel, + SourceSizeBytes: item.SourceSizeBytes, + OutputSizeBytes: outputSize, + OutputCount: result.OutputPaths.Count, + MetaLine: item.MetaLine, + Status: result.Status, + Message: result.Message)); + + _activeQueue.Remove(item); + } + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + MessageBox.Show(this, "변환 중 오류: " + ex.Message, "EverythingToJpeg", + MessageBoxButton.OK, MessageBoxImage.Error); + } + finally + { + _cts = null; + UpdateBadges(); + UpdateProcessQueueButton(); + UpdateActiveQueueVisibility(); + ApplyAppDataStats(); + if (_activeQueue.Count == 0) ShowTab("Past"); + } + } + + // ============== History ============== + + private void AddToHistory(HistoryEntry entry) + { + var label = FormatDateLabel(entry.Date); + var group = _pastResults.FirstOrDefault(g => g.DateTitle == label); + if (group is null) + { + group = new DateGroup(label); + _pastResults.Insert(0, group); + } + group.Add(HistoryRow.From(entry)); + } + + private void SeedDemoHistory() + { + var today = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today)); + var todayGroup = new DateGroup(today); + todayGroup.Add(new HistoryRow( + FormatLabel: "PNG", FormatBrush: (Brush)FindResource("FsFmtPng"), + FileName: "hero_background_final_v2.png", + MetaLine: "08:42:12 • 3200x1800", + SizeText: "14.2 MB", + SavingsText: "↓ 1.1 MB", + SourcePath: "")); + todayGroup.Add(new HistoryRow( + FormatLabel: "HEIC", FormatBrush: (Brush)FindResource("FsFmtHeic"), + FileName: "portrait_session_04.heic", + MetaLine: "08:35:45 • 4032x3024", + SizeText: "6.8 MB", + SavingsText: "↓ 2.4 MB", + SourcePath: "")); + todayGroup.SessionSavingsBytes = (long)(842.4 * 1024 * 1024); + _pastResults.Add(todayGroup); + + var yesterday = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today.AddDays(-1))); + var yGroup = new DateGroup(yesterday); + yGroup.Add(new HistoryRow( + FormatLabel: "PDF", FormatBrush: (Brush)FindResource("FsFmtPdf"), + FileName: "Q3_Full_Marketing_Deck_v12.pdf", + MetaLine: "17:22:10 • 124 Pages", + SizeText: "245.4 MB", + SavingsText: "↓ 12.8 MB", + SourcePath: "")); + yGroup.Add(new HistoryRow( + FormatLabel: "PNG", FormatBrush: (Brush)FindResource("FsFmtPng"), + FileName: "asset_bundle_archive_raw.png", + MetaLine: "16:45:33 • 8000x8000", + SizeText: "82.1 MB", + SavingsText: "↓ 4.5 MB", + SourcePath: "")); + yGroup.SessionSavingsBytes = (long)(3.1 * 1024 * 1024 * 1024); + _pastResults.Add(yGroup); + + UpdateBadges(); + } + + private static string FormatDateLabel(DateOnly date) + { + var today = DateOnly.FromDateTime(DateTime.Today); + var label = date == today ? "Today" + : date == today.AddDays(-1) ? "Yesterday" + : date.ToString("dddd", CultureInfo.GetCultureInfo("en-US")); + return $"{label}, {date:MMM d}"; + } + + private void ApplyAppDataStats() + { + var todayLabel = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today)); + var todayGroup = _pastResults.FirstOrDefault(g => g.DateTitle == todayLabel); + + var processedToday = todayGroup?.Entries.Count ?? 0; + var allSavings = _pastResults.Sum(g => g.SessionSavingsBytes); + + ProcessedTodayText.Text = processedToday.ToString("N0", CultureInfo.InvariantCulture); + SpaceSavedText.Text = HumanizeBytes(allSavings); + } + + // ============== Top-bar actions ============== + + private void OnRegisterClick(object sender, RoutedEventArgs e) { try { - ContextMenuRegistrar.Unregister(((App)Application.Current).Engine); - ShowToast("컨텍스트 메뉴를 해제했습니다."); - await PopulateAsync(); + ContextMenuRegistrar.Register(((App)Application.Current).Engine); + MessageBox.Show(this, + "컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".", + "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Information); } catch (Exception ex) { - MessageBox.Show("해제 중 오류: " + ex.Message, "EverythingToJpeg", + MessageBox.Show(this, "등록 중 오류: " + ex.Message, "EverythingToJpeg", MessageBoxButton.OK, MessageBoxImage.Error); } } @@ -261,13 +537,176 @@ public partial class MainWindow : FluentWindow, INotifyPropertyChanged window.ShowDialog(); } - private void ShowToast(string message) + private void OnClearAllClick(object sender, RoutedEventArgs e) { - MessageBox.Show(this, message, "EverythingToJpeg", - MessageBoxButton.OK, MessageBoxImage.Information); + if (TabActiveBtn.IsChecked == true) + { + _activeQueue.Clear(); + } + else if (TabPastBtn.IsChecked == true) + { + _pastResults.Clear(); + } + UpdateBadges(); + UpdateProcessQueueButton(); + UpdateActiveQueueVisibility(); + ApplyAppDataStats(); + } + + private void OnOpenFolderClick(object sender, RoutedEventArgs e) + { + if (sender is FrameworkElement fe && fe.Tag is string path && File.Exists(path)) + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = "explorer.exe", + Arguments = $"/select,\"{path}\"", + UseShellExecute = true, + }); + } + catch { } + } + } + + public static string HumanizeBytes(long bytes) + { + if (bytes <= 0) return "0 B"; + string[] units = { "B", "KB", "MB", "GB", "TB" }; + double size = bytes; + var unit = 0; + while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; } + return $"{size:0.#} {units[unit]}"; + } +} + +// ============================================================ +// View models +// ============================================================ + +public sealed class QueueItem : INotifyPropertyChanged +{ + private string _state = "queued"; + + public required string SourcePath { get; init; } + public required string FileName { get; init; } + public required string FormatLabel { get; init; } + public required Brush FormatBrush { get; init; } + public required string SizeText { get; init; } + public required string MetaLine { get; init; } + public required long SourceSizeBytes { get; init; } + + public string StateText + { + get => _state; + set { _state = value; Raise(nameof(StateText)); } + } + + public Brush StateBrush => _state switch + { + "queued" => (Brush)Application.Current.FindResource("FsTextTertiary"), + "done" => (Brush)Application.Current.FindResource("FsAccentGreen"), + _ => (Brush)Application.Current.FindResource("FsAccentBlue"), + }; + + public void SetPending() => StateText = "queued"; + public void SetState(string s) + { + StateText = s; + Raise(nameof(StateBrush)); + } + + public static QueueItem FromPath(string path) + { + var ext = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(); + var (label, brushKey) = FormatPalette.For(ext); + long size = 0; + try { size = new FileInfo(path).Length; } catch { } + + return new QueueItem + { + SourcePath = path, + FileName = Path.GetFileName(path), + FormatLabel = label, + FormatBrush = (Brush)Application.Current.FindResource(brushKey), + SizeText = MainWindow.HumanizeBytes(size), + MetaLine = $"{ext.ToUpperInvariant()} • {MainWindow.HumanizeBytes(size)}", + SourceSizeBytes = size, + }; } public event PropertyChangedEventHandler? PropertyChanged; - private void OnPropertyChanged([CallerMemberName] string? name = null) - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + private void Raise([CallerMemberName] string? n = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); +} + +public sealed class DateGroup : INotifyPropertyChanged +{ + public string DateTitle { get; } + public ObservableCollection Entries { get; } = new(); + public long SessionSavingsBytes { get; set; } + public string SessionSavingsText => $"Session Savings: {MainWindow.HumanizeBytes(SessionSavingsBytes)}"; + + public DateGroup(string dateTitle) { DateTitle = dateTitle; } + + public void Add(HistoryRow row) + { + Entries.Insert(0, row); + SessionSavingsBytes += row.SavingsBytes; + Raise(nameof(SessionSavingsText)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + private void Raise([CallerMemberName] string? n = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); +} + +public sealed record HistoryRow( + string FormatLabel, + Brush FormatBrush, + string FileName, + string MetaLine, + string SizeText, + string SavingsText, + string SourcePath, + long SavingsBytes = 0) +{ + public static HistoryRow From(HistoryEntry e) + { + var ext = Path.GetExtension(e.SourcePath).TrimStart('.').ToLowerInvariant(); + var (label, brushKey) = FormatPalette.For(ext); + var saved = e.SavingsBytes; + var arrow = saved >= 0 ? "↓" : "↑"; + return new HistoryRow( + FormatLabel: label, + FormatBrush: (Brush)Application.Current.FindResource(brushKey), + FileName: Path.GetFileName(e.SourcePath), + MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount} output(s)", + SizeText: MainWindow.HumanizeBytes(e.SourceSizeBytes), + SavingsText: $"{arrow} {MainWindow.HumanizeBytes(Math.Abs(saved))}", + SourcePath: e.SourcePath, + SavingsBytes: saved); + } +} + +internal static class FormatPalette +{ + public static (string Label, string BrushKey) For(string ext) => ext switch + { + "pdf" => ("PDF", "FsFmtPdf"), + "png" => ("PNG", "FsFmtPng"), + "heic" or "heif" => ("HEIC", "FsFmtHeic"), + "jpg" or "jpeg" or "jpe" => ("JPG", "FsFmtJpg"), + "doc" or "docx" => ("DOCX", "FsFmtDocx"), + "html" or "htm" => ("HTML", "FsFmtHtml"), + "hwp" or "hwpx" => ("HWP", "FsFmtHwp"), + "gif" => ("GIF", "FsFmtGif"), + "tif" or "tiff" => ("TIFF", "FsFmtTiff"), + "webp" => ("WEBP", "FsFmtWebp"), + "bmp" => ("BMP", "FsFmtBmp"), + "raw" or "dng" or "nef" or "cr2" or "cr3" or "arw" or "raf" or "orf" or "rw2" or "srw" or "pef" + => ("RAW", "FsFmtRaw"), + _ => (ext.ToUpperInvariant(), "FsFmtOther"), + }; } diff --git a/src/EverythingToJpeg.Core/HistoryStore.cs b/src/EverythingToJpeg.Core/HistoryStore.cs new file mode 100644 index 0000000..3f4515c --- /dev/null +++ b/src/EverythingToJpeg.Core/HistoryStore.cs @@ -0,0 +1,37 @@ +using System.Collections.ObjectModel; + +namespace EverythingToJpeg.Core; + +public sealed record HistoryEntry( + DateTime Timestamp, + string SourcePath, + string SourceFormat, + long SourceSizeBytes, + long OutputSizeBytes, + int OutputCount, + string? MetaLine, + ConvertStatus Status, + string? Message) +{ + public long SavingsBytes => SourceSizeBytes - OutputSizeBytes; + + public DateOnly Date => DateOnly.FromDateTime(Timestamp); +} + +public sealed class HistoryStore +{ + private readonly ObservableCollection _entries = new(); + + public ReadOnlyObservableCollection Entries { get; } + + public HistoryStore() + { + Entries = new ReadOnlyObservableCollection(_entries); + } + + public void Add(HistoryEntry entry) => _entries.Insert(0, entry); + + public void Clear() => _entries.Clear(); + + public int Count => _entries.Count; +} diff --git a/src/EverythingToJpeg.Core/PreviewService.cs b/src/EverythingToJpeg.Core/PreviewService.cs new file mode 100644 index 0000000..eb28fd9 --- /dev/null +++ b/src/EverythingToJpeg.Core/PreviewService.cs @@ -0,0 +1,128 @@ +using System.Windows.Media.Imaging; +using ImageMagick; +using PDFtoImage; +using PhotoSauce.MagicScaler; +using PhotoSauce.NativeCodecs.Libheif; +using SkiaSharp; + +namespace EverythingToJpeg.Core; + +public sealed record PreviewResult(BitmapSource? Image, string? Reason, string? Dimensions, int? PageCount); + +public static class PreviewService +{ + private static int _heifConfigured; + + public static async Task CreateAsync(string path, int maxLongEdge = 720, CancellationToken ct = default) + { + if (!File.Exists(path)) return new PreviewResult(null, "파일을 찾을 수 없습니다.", null, null); + + var ext = Path.GetExtension(path).ToLowerInvariant(); + try + { + return ext switch + { + ".pdf" => await Task.Run(() => RenderPdf(path, maxLongEdge), ct).ConfigureAwait(false), + ".heic" or ".heif" => await Task.Run(() => RenderHeic(path, maxLongEdge), ct).ConfigureAwait(false), + ".html" or ".htm" => new PreviewResult(null, "HTML 미리보기는 변환 시점에 렌더됩니다.", null, null), + ".doc" or ".docx" or ".hwp" or ".hwpx" => + new PreviewResult(null, "문서 미리보기는 다음 업데이트에서 지원합니다.", null, null), + _ => await Task.Run(() => RenderViaMagick(path, maxLongEdge), ct).ConfigureAwait(false), + }; + } + catch (Exception ex) + { + return new PreviewResult(null, "미리보기 생성 실패: " + ex.Message, null, null); + } + } + + private static PreviewResult RenderViaMagick(string path, int maxLongEdge) + { + using var image = new MagickImage(path); + var w = (int)image.Width; + var h = (int)image.Height; + try { image.AutoOrient(); } catch { } + if (image.HasAlpha) + { + image.BackgroundColor = MagickColors.White; + image.Alpha(AlphaOption.Remove); + image.Alpha(AlphaOption.Off); + } + if (w > maxLongEdge || h > maxLongEdge) + { + var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false }; + image.Resize(geom); + } + image.Quality = 88; + image.Format = MagickFormat.Jpeg; + var bytes = image.ToByteArray(); + return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", null); + } + + private static PreviewResult RenderHeic(string path, int maxLongEdge) + { + if (Interlocked.Exchange(ref _heifConfigured, 1) == 0) + CodecManager.Configure(c => c.UseLibheif()); + + var tempPng = Path.Combine(Path.GetTempPath(), $"e2j_pv_{Guid.NewGuid():N}.png"); + try + { + MagicImageProcessor.ProcessImage(path, tempPng, ProcessImageSettings.Default); + return RenderViaMagick(tempPng, maxLongEdge); + } + finally + { + try { if (File.Exists(tempPng)) File.Delete(tempPng); } catch { } + } + } + + private static PreviewResult RenderPdf(string path, int maxLongEdge) + { + int pageCount; + using (var probe = File.OpenRead(path)) + { + pageCount = Conversion.GetPageCount(probe); + } + if (pageCount <= 0) return new PreviewResult(null, "PDF 페이지가 없습니다.", null, 0); + + var ms = new MemoryStream(); + using (var input = File.OpenRead(path)) + { + var renderOptions = new RenderOptions + { + Dpi = 144, + BackgroundColor = SKColors.White, + WithAnnotations = true, + WithFormFill = true, + }; + Conversion.SaveJpeg(ms, input, page: 0, leaveOpen: false, password: null, options: renderOptions); + } + ms.Position = 0; + var bytes = ms.ToArray(); + + // optional resize via Magick + using var image = new MagickImage(bytes); + var w = (int)image.Width; + var h = (int)image.Height; + if (w > maxLongEdge || h > maxLongEdge) + { + var geom = new MagickGeometry((uint)maxLongEdge, (uint)maxLongEdge) { IgnoreAspectRatio = false }; + image.Resize(geom); + image.Quality = 88; + image.Format = MagickFormat.Jpeg; + bytes = image.ToByteArray(); + } + return new PreviewResult(BytesToBitmap(bytes), null, $"{w} × {h}", pageCount); + } + + private static BitmapSource BytesToBitmap(byte[] bytes) + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.CacheOption = BitmapCacheOption.OnLoad; + bmp.StreamSource = new MemoryStream(bytes); + bmp.EndInit(); + bmp.Freeze(); + return bmp; + } +} From 00d1eeff966451170c703db1e0e035393c7104b8 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 6 May 2026 19:26:27 +0900 Subject: [PATCH 08/12] =?UTF-8?q?ux:=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20?= =?UTF-8?q?=E2=86=92=20=EB=A9=94=EC=9D=B8=20=EC=9C=88=EB=8F=84=EC=9A=B0=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20+=20Preview=EB=A5=BC=20=EC=9A=B0=EC=B8=A1?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=EC=9C=BC=EB=A1=9C=20=EC=8A=B9=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 우클릭 진입 통합 - App.OnStartup의 dialog 분기에서 ConvertWindow 대신 MainWindow를 띄우면서 파일을 Active Queue에 자동 추가, 첫 항목 자동 Preview 로드. - 사용자 시점에서 우클릭 → "JPEG로 변환…" 클릭 시 작은 다이얼로그가 아니라 풀스크린 FormatShift 메인 화면이 그대로 노출. - ConvertWindow 자체는 FormatShift 토큰으로 통일했지만 더 이상 호출되지 않음 (코드는 향후 활용 위해 보존). Preview를 탭 → 우측 메인 컬럼으로 승격 - 메인 영역 그리드를 1 column → 2 column 으로 분할: 좌측 *(min 360), 우측 380px 고정 Preview 컬럼. - 탭은 Active Queue / Past Results 두 개만 (Preview 탭과 무의미한 0 뱃지 제거). Preview는 항상 보임. - Active Queue 행 클릭 → 우측 Preview에 즉시 표시 (탭 전환 없음). - Past Results 행도 클릭 가능: 실제 파일이면 미리보기, 데모/소실 파일이면 안내 문구. - Drop hint overlay는 ColumnSpan=2로 양쪽 컬럼 모두 덮음. 이벤트 처리 개선 - 행 Border에 PreviewMouseLeftButtonUp(tunnel) 사용 → 자식 컨트롤이 클릭을 가로채는 이슈 해소. - IsInsideButton 헬퍼로 Remove/Open 버튼이 발화시킨 클릭은 행 핸들러에서 무시하여 의도 충돌 방지. App.xaml 정리 - FormatShiftTheme.xaml 을 App-level resources에 머지 → 모든 윈도우에서 Application.Current.FindResource("Fs*") 조회 가능 (이전엔 Window-level 머지만 해서 ResourceReferenceKeyNotFoundException 발생). LoadPreview 통합 - LoadPreviewByPathAsync(path, fileName, format, size) 단일 메서드로 통합. - _selectedPreviewPath 로 Active/Past 두 케이스 모두 Open in Explorer 일관 처리. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/EverythingToJpeg.App/App.xaml | 1 + src/EverythingToJpeg.App/App.xaml.cs | 2 +- .../Views/ConvertWindow.xaml | 305 ++++++++++++------ .../Views/ConvertWindow.xaml.cs | 231 +++++++------ .../Views/MainWindow.xaml | 302 +++++++++-------- .../Views/MainWindow.xaml.cs | 127 ++++++-- 6 files changed, 595 insertions(+), 373 deletions(-) diff --git a/src/EverythingToJpeg.App/App.xaml b/src/EverythingToJpeg.App/App.xaml index 430b975..7516c02 100644 --- a/src/EverythingToJpeg.App/App.xaml +++ b/src/EverythingToJpeg.App/App.xaml @@ -9,6 +9,7 @@ + diff --git a/src/EverythingToJpeg.App/App.xaml.cs b/src/EverythingToJpeg.App/App.xaml.cs index e005df4..6ed2c32 100644 --- a/src/EverythingToJpeg.App/App.xaml.cs +++ b/src/EverythingToJpeg.App/App.xaml.cs @@ -67,7 +67,7 @@ public partial class App : Application private void ShowConvertDialog(IReadOnlyList files) { - var window = new ConvertWindow(Engine, files); + var window = new Views.MainWindow(files); MainWindow = window; window.Show(); } diff --git a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml index f896c18..8e776b4 100644 --- a/src/EverythingToJpeg.App/Views/ConvertWindow.xaml +++ b/src/EverythingToJpeg.App/Views/ConvertWindow.xaml @@ -3,47 +3,66 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" Title="JPEG로 변환" - Width="640" Height="780" - MinWidth="520" MinHeight="560" + Width="720" Height="780" + MinWidth="560" MinHeight="560" ExtendsContentIntoTitleBar="True" - WindowBackdropType="Mica" + WindowBackdropType="None" WindowCornerPreference="Round" WindowStartupLocation="CenterOwner" AllowDrop="True" Drop="OnFilesDropped" - DragOver="OnDragOver"> - + DragOver="OnDragOver" + TextOptions.TextFormattingMode="Display" + UseLayoutRounding="True"> + + + + + + + + + - + - - + + - - + + - - + + - - + - - - - - - - - - - - - - - - - - - - - - -