초기 커밋: Phase 1 — 레지스트리 기반 컨텍스트 메뉴 + 핵심 변환 파이프라인
- 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) <noreply@anthropic.com>
This commit is contained in:
commit
8fa4a613d7
37 changed files with 2727 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||||
4
EverythingToJpeg.slnx
Normal file
4
EverythingToJpeg.slnx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<Solution>
|
||||||
|
<Project Path="src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj" />
|
||||||
|
<Project Path="src/EverythingToJpeg.App/EverythingToJpeg.App.csproj" />
|
||||||
|
</Solution>
|
||||||
119
README.md
Normal file
119
README.md
Normal file
|
|
@ -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 (예정).
|
||||||
25
packaging/README.md
Normal file
25
packaging/README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Phase 2 — MSIX 패키징 (placeholder)
|
||||||
|
|
||||||
|
이 폴더는 향후 IExplorerCommand 셸 익스텐션 + MSIX Sparse Package 작업을 위한 자리입니다. 현재 구현되지 않았습니다.
|
||||||
|
|
||||||
|
## 다음 단계 체크리스트
|
||||||
|
|
||||||
|
- [ ] `EverythingToJpeg.Shell` C++/WinRT 또는 C#(WinRT projection) 프로젝트 생성 → `IExplorerCommand` 구현
|
||||||
|
- [ ] `Package.appxmanifest` 작성 — `<uap3:Extension Category="windows.fileExplorerContextMenus">` 사용
|
||||||
|
- [ ] 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)
|
||||||
59
src/EverythingToJpeg.App/App.xaml
Normal file
59
src/EverythingToJpeg.App/App.xaml
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<Application x:Class="EverythingToJpeg.App.App"
|
||||||
|
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"
|
||||||
|
xmlns:local="clr-namespace:EverythingToJpeg.App"
|
||||||
|
ShutdownMode="OnExplicitShutdown">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<ResourceDictionary.MergedDictionaries>
|
||||||
|
<ui:ThemesDictionary Theme="Dark"/>
|
||||||
|
<ui:ControlsDictionary/>
|
||||||
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
|
<!-- Spacing tokens (Fluent 8pt grid) -->
|
||||||
|
<Thickness x:Key="SpacingXS">4</Thickness>
|
||||||
|
<Thickness x:Key="SpacingS">8</Thickness>
|
||||||
|
<Thickness x:Key="SpacingM">16</Thickness>
|
||||||
|
<Thickness x:Key="SpacingL">24</Thickness>
|
||||||
|
<Thickness x:Key="SpacingXL">32</Thickness>
|
||||||
|
|
||||||
|
<!-- Typography (Fluent 2 type ramp, Segoe UI Variable) -->
|
||||||
|
<Style x:Key="TextTitleLarge" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI Variable Display, Segoe UI"/>
|
||||||
|
<Setter Property="FontSize" Value="28"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="TextTitle" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI Variable Display, Segoe UI"/>
|
||||||
|
<Setter Property="FontSize" Value="20"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="TextSubtitle" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
|
||||||
|
<Setter Property="FontSize" Value="16"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="TextBodyStrong" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="TextBody" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI Variable Text, Segoe UI"/>
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
</Style>
|
||||||
|
<Style x:Key="TextCaption" TargetType="TextBlock">
|
||||||
|
<Setter Property="FontFamily" Value="Segoe UI Variable Small, Segoe UI"/>
|
||||||
|
<Setter Property="FontSize" Value="12"/>
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<!-- Status badge brushes -->
|
||||||
|
<SolidColorBrush x:Key="BadgeReadyBrush" Color="#10B981"/>
|
||||||
|
<SolidColorBrush x:Key="BadgeWarnBrush" Color="#F59E0B"/>
|
||||||
|
<SolidColorBrush x:Key="BadgeInfoBrush" Color="#3B82F6"/>
|
||||||
|
<SolidColorBrush x:Key="BadgeMutedBrush" Color="#6B7280"/>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
100
src/EverythingToJpeg.App/App.xaml.cs
Normal file
100
src/EverythingToJpeg.App/App.xaml.cs
Normal file
|
|
@ -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<string> 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<string> files)
|
||||||
|
{
|
||||||
|
var progress = new QuickProgressWindow(files.Count);
|
||||||
|
progress.Show();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var options = ConvertOptions.Quick();
|
||||||
|
var reporter = new Progress<ConvertProgress>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/EverythingToJpeg.App/AssemblyInfo.cs
Normal file
10
src/EverythingToJpeg.App/AssemblyInfo.cs
Normal file
|
|
@ -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)
|
||||||
|
)]
|
||||||
120
src/EverythingToJpeg.App/Cli/CliRouter.cs
Normal file
120
src/EverythingToJpeg.App/Cli/CliRouter.cs
Normal file
|
|
@ -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<string> Files);
|
||||||
|
|
||||||
|
public static ParsedArgs Parse(string[] args)
|
||||||
|
{
|
||||||
|
if (args is null || args.Length == 0)
|
||||||
|
return new ParsedArgs(Mode.ShowMain, Array.Empty<string>());
|
||||||
|
|
||||||
|
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<string>()),
|
||||||
|
"unregister" => new ParsedArgs(Mode.Unregister, Array.Empty<string>()),
|
||||||
|
"diagnose" or "doctor" => new ParsedArgs(Mode.Diagnose, Array.Empty<string>()),
|
||||||
|
"help" or "--help" or "-h" or "/?" => new ParsedArgs(Mode.Help, Array.Empty<string>()),
|
||||||
|
_ when File.Exists(args[0]) => new ParsedArgs(Mode.Dialog, ExpandFiles(args)),
|
||||||
|
_ => new ParsedArgs(Mode.ShowMain, Array.Empty<string>()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ExpandFiles(IEnumerable<string> raw)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
Normal file
24
src/EverythingToJpeg.App/Cli/ConsoleHelper.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
29
src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
Normal file
29
src/EverythingToJpeg.App/EverythingToJpeg.App.csproj
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<AssemblyName>EverythingToJpeg</AssemblyName>
|
||||||
|
<RootNamespace>EverythingToJpeg.App</RootNamespace>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
|
||||||
|
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||||
|
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EverythingToJpeg.Core\EverythingToJpeg.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="WPF-UI" Version="4.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="System.IO" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
5
src/EverythingToJpeg.App/GlobalUsings.cs
Normal file
5
src/EverythingToJpeg.App/GlobalUsings.cs
Normal file
|
|
@ -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;
|
||||||
96
src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
Normal file
96
src/EverythingToJpeg.App/Shell/ContextMenuRegistrar.cs
Normal file
|
|
@ -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<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/EverythingToJpeg.App/Views/ConvertWindow.xaml
Normal file
141
src/EverythingToJpeg.App/Views/ConvertWindow.xaml
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.ConvertWindow"
|
||||||
|
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="JPEG로 변환"
|
||||||
|
Width="940" Height="700"
|
||||||
|
MinWidth="720" MinHeight="540"
|
||||||
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
WindowBackdropType="Mica"
|
||||||
|
WindowCornerPreference="Round"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
AllowDrop="True"
|
||||||
|
Drop="OnFilesDropped"
|
||||||
|
DragOver="OnDragOver">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ui:TitleBar Grid.Row="0" Title="JPEG로 변환"/>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="32,8,32,16">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" MinWidth="320"/>
|
||||||
|
<ColumnDefinition Width="320"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- File list panel -->
|
||||||
|
<ui:Card Grid.Column="0" Padding="0" Margin="0,0,16,0">
|
||||||
|
<DockPanel>
|
||||||
|
<Grid DockPanel.Dock="Top" Margin="20,16,20,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="대상 파일" Style="{StaticResource TextSubtitle}"/>
|
||||||
|
<TextBlock x:Name="FilesSummaryText" Style="{StaticResource TextCaption}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||||
|
<ui:Button Content="추가" Icon="{ui:SymbolIcon Add24}"
|
||||||
|
Click="OnAddFilesClick" Margin="0,0,8,0"/>
|
||||||
|
<ui:Button Content="비우기" Icon="{ui:SymbolIcon Delete24}"
|
||||||
|
Click="OnClearFilesClick"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,0,20,16">
|
||||||
|
<ItemsControl x:Name="FilesList"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</ui:Card>
|
||||||
|
|
||||||
|
<!-- Options panel -->
|
||||||
|
<ui:Card Grid.Column="1" Padding="0">
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,16,20,16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="옵션" Style="{StaticResource TextSubtitle}" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<TextBlock Text="JPEG 품질" Style="{StaticResource TextBodyStrong}" Margin="0,0,0,4"/>
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="48"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Slider x:Name="QualitySlider" Minimum="50" Maximum="100" Value="92"
|
||||||
|
SmallChange="1" LargeChange="5" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding ElementName=QualitySlider, Path=Value, StringFormat={}{0:0}}"
|
||||||
|
VerticalAlignment="Center" HorizontalAlignment="Right"
|
||||||
|
FontWeight="SemiBold"/>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Style="{StaticResource TextCaption}"
|
||||||
|
Text="92 권장. 90+에서 시각적으로 거의 무손실."/>
|
||||||
|
|
||||||
|
<TextBlock Text="출력 위치" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
|
||||||
|
<ComboBox x:Name="OutputModeCombo" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="원본 폴더 안에 _jpeg 하위 폴더"/>
|
||||||
|
<ComboBoxItem Content="원본과 같은 폴더"/>
|
||||||
|
<ComboBoxItem Content="사용자 지정 폴더…"/>
|
||||||
|
</ComboBox>
|
||||||
|
<Grid Margin="0,8,0,0" x:Name="CustomFolderRow" Visibility="Collapsed">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBox x:Name="CustomFolderTextBox" />
|
||||||
|
<ui:Button Grid.Column="1" Content="찾아보기" Margin="6,0,0,0"
|
||||||
|
Click="OnBrowseClick"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="이름 충돌" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
|
||||||
|
<ComboBox x:Name="CollisionCombo" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="번호 붙여 새 파일"/>
|
||||||
|
<ComboBoxItem Content="덮어쓰기"/>
|
||||||
|
<ComboBoxItem Content="건너뛰기"/>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Text="긴 변 최대 픽셀 (선택)" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
|
||||||
|
<TextBox x:Name="MaxLongEdgeTextBox" ToolTip="비워 두면 원본 크기 유지"/>
|
||||||
|
|
||||||
|
<TextBlock Text="PDF 렌더 DPI" Style="{StaticResource TextBodyStrong}" Margin="0,16,0,4"/>
|
||||||
|
<Slider x:Name="DpiSlider" Minimum="72" Maximum="450" Value="200"/>
|
||||||
|
<TextBlock Style="{StaticResource TextCaption}"
|
||||||
|
Text="{Binding ElementName=DpiSlider, Path=Value, StringFormat=현재 {0:0} DPI}"/>
|
||||||
|
|
||||||
|
<CheckBox x:Name="FlattenCheckBox" Content="투명 영역을 흰색으로 채우기"
|
||||||
|
IsChecked="True" Margin="0,16,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ui:Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Bottom action bar -->
|
||||||
|
<Border Grid.Row="2" Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}"
|
||||||
|
Padding="32,16">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<StackPanel VerticalAlignment="Center">
|
||||||
|
<ProgressBar x:Name="OverallProgress" Height="6" Minimum="0" Maximum="1"
|
||||||
|
IsIndeterminate="False"/>
|
||||||
|
<TextBlock x:Name="ProgressStatusText" Style="{StaticResource TextCaption}"
|
||||||
|
Margin="0,6,0,0"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="20,0,0,0">
|
||||||
|
<ui:Button x:Name="CancelButton" Content="닫기" Click="OnCancelClick"
|
||||||
|
Margin="0,0,8,0" Padding="20,6"/>
|
||||||
|
<ui:Button x:Name="ConvertButton" Content="변환 시작"
|
||||||
|
Icon="{ui:SymbolIcon ArrowRight24}"
|
||||||
|
Appearance="Primary" Click="OnConvertClick"
|
||||||
|
Padding="24,6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
360
src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
Normal file
360
src/EverythingToJpeg.App/Views/ConvertWindow.xaml.cs
Normal file
|
|
@ -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<FileEntry> _entries = new();
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public ConvertWindow(ConversionEngine engine, IReadOnlyList<string> 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<string> paths)
|
||||||
|
{
|
||||||
|
var existing = new HashSet<string>(_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<string> ExpandPaths(IEnumerable<string> 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<ConvertProgress>(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<ConvertResult> 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<ConvertResult> 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 = """
|
||||||
|
<DataTemplate 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">
|
||||||
|
<Border Margin="0,0,0,6" Padding="12,8" CornerRadius="6"
|
||||||
|
Background="{DynamicResource SubtleFillColorTransparentBrush}"
|
||||||
|
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}"
|
||||||
|
BorderThickness="1">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="44"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Border Width="40" Height="40" CornerRadius="4"
|
||||||
|
Background="{DynamicResource ControlFillColorDefaultBrush}">
|
||||||
|
<Image Source="{Binding Thumbnail}" Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
|
<StackPanel Grid.Column="1" Margin="12,0,8,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding FileName}" FontWeight="SemiBold"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding SubText}" FontSize="11"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||||
|
TextTrimming="CharacterEllipsis"/>
|
||||||
|
<TextBlock Text="{Binding State}" FontSize="11" Margin="0,2,0,0">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextFillColorTertiaryBrush}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsFailed}" Value="True">
|
||||||
|
<Setter Property="Foreground" Value="#F87171"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
<ui:Button Grid.Column="2" Icon="{ui:SymbolIcon Dismiss20}"
|
||||||
|
Appearance="Transparent" Click="OnRemoveEntry"
|
||||||
|
ToolTip="목록에서 제거"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
""";
|
||||||
|
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));
|
||||||
|
}
|
||||||
32
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
Normal file
32
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.DiagnoseWindow"
|
||||||
|
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="진단"
|
||||||
|
Width="640" Height="540"
|
||||||
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
WindowBackdropType="Mica"
|
||||||
|
WindowCornerPreference="Round"
|
||||||
|
WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<ui:TitleBar Grid.Row="0" Title="진단"/>
|
||||||
|
<ScrollViewer Grid.Row="1" Margin="32,8,32,16" VerticalScrollBarVisibility="Auto">
|
||||||
|
<StackPanel x:Name="ItemsPanel"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
<Border Grid.Row="2" Padding="32,16"
|
||||||
|
Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}">
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
|
<ui:Button Content="새로고침" Icon="{ui:SymbolIcon ArrowClockwise24}"
|
||||||
|
Click="OnRefreshClick" Margin="0,0,8,0"/>
|
||||||
|
<ui:Button Content="닫기" Click="OnCloseClick" Appearance="Primary"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
118
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
Normal file
118
src/EverythingToJpeg.App/Views/DiagnoseWindow.xaml.cs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
111
src/EverythingToJpeg.App/Views/MainWindow.xaml
Normal file
111
src/EverythingToJpeg.App/Views/MainWindow.xaml
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.MainWindow"
|
||||||
|
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"
|
||||||
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
WindowBackdropType="Mica"
|
||||||
|
WindowCornerPreference="Round"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
AllowDrop="True"
|
||||||
|
Drop="OnFilesDropped"
|
||||||
|
DragOver="OnDragOver"
|
||||||
|
DragLeave="OnDragLeave">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<ui:TitleBar Grid.Row="0" Title="EverythingToJpeg">
|
||||||
|
<ui:TitleBar.Icon>
|
||||||
|
<ui:SymbolIcon Symbol="ImageMultiple24"/>
|
||||||
|
</ui:TitleBar.Icon>
|
||||||
|
</ui:TitleBar>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" Margin="32,16,32,32">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<StackPanel Grid.Row="0" Margin="0,0,0,24">
|
||||||
|
<TextBlock Text="모든 것을 JPEG로." Style="{StaticResource TextTitleLarge}"/>
|
||||||
|
<TextBlock Margin="0,4,0,0" Style="{StaticResource TextBody}"
|
||||||
|
Foreground="{DynamicResource TextFillColorSecondaryBrush}">
|
||||||
|
파일을 우클릭하면 끝. PNG · GIF · HEIC · RAW · PDF · DOCX 가 모두 한 번에 변환됩니다.
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Drop zone -->
|
||||||
|
<Border x:Name="DropZone" Grid.Row="1"
|
||||||
|
BorderBrush="{DynamicResource ControlStrokeColorDefaultBrush}"
|
||||||
|
BorderThickness="2"
|
||||||
|
Background="{DynamicResource ControlFillColorDefaultBrush}"
|
||||||
|
CornerRadius="12"
|
||||||
|
Padding="32,24"
|
||||||
|
Margin="0,0,0,20">
|
||||||
|
<Border.Style>
|
||||||
|
<Style TargetType="Border">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsDraggingOver, RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource SystemFillColorAttentionBrush}"/>
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</Border.Style>
|
||||||
|
<StackPanel HorizontalAlignment="Center">
|
||||||
|
<ui:SymbolIcon Symbol="ImageArrowCounterclockwise24" FontSize="36"
|
||||||
|
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="여기에 파일이나 폴더를 끌어다 놓으세요"
|
||||||
|
Style="{StaticResource TextSubtitle}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,12,0,4"/>
|
||||||
|
<TextBlock Style="{StaticResource TextCaption}" HorizontalAlignment="Center">
|
||||||
|
또는
|
||||||
|
<Hyperlink Click="OnPickFilesClick">파일 선택</Hyperlink>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Provider list -->
|
||||||
|
<ui:Card Grid.Row="2" Padding="0">
|
||||||
|
<DockPanel>
|
||||||
|
<Grid DockPanel.Dock="Top" Margin="20,16,20,12">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="지원 형식" Style="{StaticResource TextSubtitle}"/>
|
||||||
|
<TextBlock Grid.Column="1" Style="{StaticResource TextCaption}"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
준비된 형식만 우클릭 메뉴에 등록됩니다
|
||||||
|
</TextBlock>
|
||||||
|
</Grid>
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="20,0,20,16">
|
||||||
|
<ItemsControl x:Name="ProvidersList"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</DockPanel>
|
||||||
|
</ui:Card>
|
||||||
|
|
||||||
|
<!-- Action bar -->
|
||||||
|
<StackPanel Grid.Row="3" Orientation="Horizontal"
|
||||||
|
HorizontalAlignment="Right" Margin="0,20,0,0">
|
||||||
|
<ui:Button Content="진단" Icon="{ui:SymbolIcon Stethoscope24}"
|
||||||
|
Click="OnDiagnoseClick" Margin="0,0,8,0"/>
|
||||||
|
<ui:Button x:Name="UnregisterButton" Content="컨텍스트 메뉴 해제"
|
||||||
|
Icon="{ui:SymbolIcon DismissCircle24}"
|
||||||
|
Click="OnUnregisterClick" Margin="0,0,8,0"/>
|
||||||
|
<ui:Button x:Name="RegisterButton" Content="컨텍스트 메뉴 등록"
|
||||||
|
Icon="{ui:SymbolIcon CheckmarkCircle24}"
|
||||||
|
Appearance="Primary" Click="OnRegisterClick"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
273
src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
Normal file
273
src/EverythingToJpeg.App/Views/MainWindow.xaml.cs
Normal file
|
|
@ -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<string> ExpandPaths(IEnumerable<string> paths)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
40
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
Normal file
40
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<ui:FluentWindow x:Class="EverythingToJpeg.App.Views.QuickProgressWindow"
|
||||||
|
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="JPEG로 빠른 변환"
|
||||||
|
Width="560" Height="220"
|
||||||
|
ExtendsContentIntoTitleBar="True"
|
||||||
|
WindowBackdropType="Mica"
|
||||||
|
WindowCornerPreference="Round"
|
||||||
|
WindowStartupLocation="CenterScreen"
|
||||||
|
ResizeMode="NoResize">
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<ui:TitleBar Grid.Row="0" Title="JPEG로 빠른 변환"/>
|
||||||
|
<Grid Grid.Row="1" Margin="32,12,32,24">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<TextBlock Text="변환 중…" Style="{StaticResource TextSubtitle}"/>
|
||||||
|
<TextBlock x:Name="StatusText" Grid.Row="1" Margin="0,4,0,12"
|
||||||
|
Style="{StaticResource TextCaption}" TextTrimming="CharacterEllipsis"/>
|
||||||
|
<ProgressBar x:Name="OverallProgress" Grid.Row="2" Height="6"
|
||||||
|
Minimum="0" Maximum="1"/>
|
||||||
|
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||||
|
<ui:Button x:Name="OpenFolderButton" Content="결과 폴더 열기"
|
||||||
|
Icon="{ui:SymbolIcon Folder24}"
|
||||||
|
Click="OnOpenFolderClick" IsEnabled="False" Margin="0,0,8,0"/>
|
||||||
|
<ui:Button x:Name="CloseButton" Content="닫기" Click="OnCloseClick"
|
||||||
|
Appearance="Primary" IsEnabled="False"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</ui:FluentWindow>
|
||||||
81
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs
Normal file
81
src/EverythingToJpeg.App/Views/QuickProgressWindow.xaml.cs
Normal file
|
|
@ -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<ConvertResult> 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/EverythingToJpeg.App/app.manifest
Normal file
27
src/EverythingToJpeg.App/app.manifest
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="EverythingToJpeg.App"/>
|
||||||
|
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" /> <!-- Win10/11 -->
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||||
|
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
99
src/EverythingToJpeg.Core/ConversionEngine.cs
Normal file
99
src/EverythingToJpeg.Core/ConversionEngine.cs
Normal file
|
|
@ -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<IReadOnlyList<ConvertResult>> ConvertManyAsync(
|
||||||
|
IEnumerable<string> sources,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<ConvertProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var sourceList = sources.ToList();
|
||||||
|
var results = new List<ConvertResult>(sourceList.Count);
|
||||||
|
|
||||||
|
for (var i = 0; i < sourceList.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var source = sourceList[i];
|
||||||
|
progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0));
|
||||||
|
|
||||||
|
var result = await ConvertOneAsync(source, options,
|
||||||
|
new Progress<double>(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
results.Add(result);
|
||||||
|
progress?.Report(new ConvertProgress(i + 1, sourceList.Count, source, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ConvertResult> ConvertOneAsync(
|
||||||
|
string sourcePath,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? 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<string>();
|
||||||
|
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);
|
||||||
40
src/EverythingToJpeg.Core/ConvertOptions.cs
Normal file
40
src/EverythingToJpeg.Core/ConvertOptions.cs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
25
src/EverythingToJpeg.Core/ConvertResult.cs
Normal file
25
src/EverythingToJpeg.Core/ConvertResult.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
namespace EverythingToJpeg.Core;
|
||||||
|
|
||||||
|
public enum ConvertStatus
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
Skipped,
|
||||||
|
Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ConvertResult(
|
||||||
|
string SourcePath,
|
||||||
|
IReadOnlyList<string> OutputPaths,
|
||||||
|
ConvertStatus Status,
|
||||||
|
string? Message = null,
|
||||||
|
Exception? Error = null)
|
||||||
|
{
|
||||||
|
public static ConvertResult Ok(string source, IReadOnlyList<string> outputs)
|
||||||
|
=> new(source, outputs, ConvertStatus.Success);
|
||||||
|
|
||||||
|
public static ConvertResult Fail(string source, string message, Exception? ex = null)
|
||||||
|
=> new(source, Array.Empty<string>(), ConvertStatus.Failed, message, ex);
|
||||||
|
|
||||||
|
public static ConvertResult Skip(string source, string message)
|
||||||
|
=> new(source, Array.Empty<string>(), ConvertStatus.Skipped, message);
|
||||||
|
}
|
||||||
169
src/EverythingToJpeg.Core/Converters/DocxProvider.cs
Normal file
169
src/EverythingToJpeg.Core/Converters/DocxProvider.cs
Normal file
|
|
@ -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<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (ExternalToolDetector.IsWordComAvailable())
|
||||||
|
return Task.FromResult(ProviderAvailability.Ready);
|
||||||
|
if (ExternalToolDetector.TryFindLibreOfficeSoffice(out _))
|
||||||
|
return Task.FromResult(ProviderAvailability.Ready);
|
||||||
|
|
||||||
|
return Task.FromResult(ProviderAvailability.NotReady(
|
||||||
|
"Microsoft Word 또는 LibreOffice가 설치되어 있어야 합니다.",
|
||||||
|
Capability.ExternalDependencies));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? 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<double>(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<bool> ConvertWithLibreOfficeAsync(string sofficePath, string sourcePath, string targetPdf, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var outDir = Path.GetDirectoryName(targetPdf)!;
|
||||||
|
var psi = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = sofficePath,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
};
|
||||||
|
psi.ArgumentList.Add("--headless");
|
||||||
|
psi.ArgumentList.Add("--norestore");
|
||||||
|
psi.ArgumentList.Add("--nofirststartwizard");
|
||||||
|
psi.ArgumentList.Add("--convert-to");
|
||||||
|
psi.ArgumentList.Add("pdf");
|
||||||
|
psi.ArgumentList.Add("--outdir");
|
||||||
|
psi.ArgumentList.Add(outDir);
|
||||||
|
psi.ArgumentList.Add(sourcePath);
|
||||||
|
|
||||||
|
using var proc = Process.Start(psi);
|
||||||
|
if (proc is null) return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await proc.WaitForExitAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
try { proc.Kill(true); } catch { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proc.ExitCode != 0) return false;
|
||||||
|
|
||||||
|
var produced = Path.Combine(outDir, Path.GetFileNameWithoutExtension(sourcePath) + ".pdf");
|
||||||
|
if (!File.Exists(produced)) return false;
|
||||||
|
|
||||||
|
if (!string.Equals(produced, targetPdf, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (File.Exists(targetPdf)) File.Delete(targetPdf);
|
||||||
|
File.Move(produced, targetPdf);
|
||||||
|
}
|
||||||
|
return File.Exists(targetPdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ConvertWithWordCom(string sourcePath, string targetPdf)
|
||||||
|
{
|
||||||
|
const int wdFormatPDF = 17;
|
||||||
|
var wordType = Type.GetTypeFromProgID("Word.Application");
|
||||||
|
if (wordType is null) return false;
|
||||||
|
|
||||||
|
dynamic? word = Activator.CreateInstance(wordType);
|
||||||
|
if (word is null) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
word.Visible = false;
|
||||||
|
word.DisplayAlerts = 0;
|
||||||
|
dynamic doc = word.Documents.Open(sourcePath, ReadOnly: true, Visible: false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
doc.SaveAs2(targetPdf, wdFormatPDF);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
doc.Close(false);
|
||||||
|
}
|
||||||
|
return File.Exists(targetPdf);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { word.Quit(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
Normal file
51
src/EverythingToJpeg.Core/Converters/ExternalToolDetector.cs
Normal file
|
|
@ -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<string>();
|
||||||
|
|
||||||
|
var pf = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||||
|
var pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
|
||||||
|
foreach (var root in new[] { pf, pfx86 })
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(root)) continue;
|
||||||
|
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.com"));
|
||||||
|
candidates.Add(Path.Combine(root, "LibreOffice", "program", "soffice.exe"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\LibreOffice\UNO\InstallPath");
|
||||||
|
if (key?.GetValue(null) is string installPath)
|
||||||
|
{
|
||||||
|
candidates.Add(Path.Combine(installPath, "soffice.com"));
|
||||||
|
candidates.Add(Path.Combine(installPath, "soffice.exe"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
foreach (var path in candidates.Distinct())
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) { sofficePath = path; return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsWordComAvailable()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var key = Registry.ClassesRoot.OpenSubKey("Word.Application");
|
||||||
|
return key is not null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/EverythingToJpeg.Core/Converters/HeicProvider.cs
Normal file
73
src/EverythingToJpeg.Core/Converters/HeicProvider.cs
Normal file
|
|
@ -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<ExternalDependency>(),
|
||||||
|
RoadmapNote: null);
|
||||||
|
|
||||||
|
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureCodec();
|
||||||
|
return Task.FromResult(ProviderAvailability.Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? 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<double>(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
Normal file
30
src/EverythingToJpeg.Core/Converters/HtmlProvider.cs
Normal file
|
|
@ -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<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다."));
|
||||||
|
|
||||||
|
public Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath, string outputDirectory, ConvertOptions options,
|
||||||
|
IProgress<double>? progress, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(ConvertResult.Skip(sourcePath, "HTML 변환은 곧 지원 예정입니다."));
|
||||||
|
}
|
||||||
30
src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
Normal file
30
src/EverythingToJpeg.Core/Converters/HwpxProvider.cs
Normal file
|
|
@ -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<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(ProviderAvailability.NotReady("아직 구현되지 않았습니다. 곧 지원 예정입니다."));
|
||||||
|
|
||||||
|
public Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath, string outputDirectory, ConvertOptions options,
|
||||||
|
IProgress<double>? progress, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult(ConvertResult.Skip(sourcePath, "HWP/HWPX 변환은 곧 지원 예정입니다."));
|
||||||
|
}
|
||||||
131
src/EverythingToJpeg.Core/Converters/MagickProvider.cs
Normal file
131
src/EverythingToJpeg.Core/Converters/MagickProvider.cs
Normal file
|
|
@ -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<ExternalDependency>(),
|
||||||
|
RoadmapNote: null);
|
||||||
|
|
||||||
|
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(ProviderAvailability.Ready);
|
||||||
|
|
||||||
|
public Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ConvertResult ConvertCore(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? 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<string>();
|
||||||
|
var width = (int)Math.Ceiling(Math.Log10(collection.Count + 1));
|
||||||
|
for (var i = 0; i < collection.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var frame = collection[i];
|
||||||
|
ApplyCommonTransforms(frame, 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<ushort> 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<ushort> image, string path, int quality)
|
||||||
|
{
|
||||||
|
image.Quality = (uint)Math.Clamp(quality, 1, 100);
|
||||||
|
image.Format = MagickFormat.Jpeg;
|
||||||
|
image.Write(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/EverythingToJpeg.Core/Converters/PdfProvider.cs
Normal file
98
src/EverythingToJpeg.Core/Converters/PdfProvider.cs
Normal file
|
|
@ -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<ExternalDependency>(),
|
||||||
|
RoadmapNote: null);
|
||||||
|
|
||||||
|
public Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(ProviderAvailability.Ready);
|
||||||
|
|
||||||
|
public Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.Run(() => ConvertCore(sourcePath, outputDirectory, options, progress, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ConvertResult ConvertCore(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? 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<string>();
|
||||||
|
|
||||||
|
for (var i = 0; i < pageCount; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var suffix = pageCount == 1 ? null : $"_p{(i + 1).ToString().PadLeft(width, '0')}";
|
||||||
|
var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, suffix, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
Normal file
20
src/EverythingToJpeg.Core/EverythingToJpeg.Core.csproj
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<UseWindowsForms>false</UseWindowsForms>
|
||||||
|
<UseWPF>false</UseWPF>
|
||||||
|
<NoWarn>$(NoWarn);NU1901;NU1902;NU1903;NU1904</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.13.0" />
|
||||||
|
<PackageReference Include="PDFtoImage" Version="5.2.1" />
|
||||||
|
<PackageReference Include="PhotoSauce.MagicScaler" Version="0.15.0" />
|
||||||
|
<PackageReference Include="PhotoSauce.NativeCodecs.Libheif" Version="1.19.5-preview1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
22
src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
Normal file
22
src/EverythingToJpeg.Core/EverythingToJpegBootstrap.cs
Normal file
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/EverythingToJpeg.Core/OutputPathHelper.cs
Normal file
49
src/EverythingToJpeg.Core/OutputPathHelper.cs
Normal file
|
|
@ -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<char> buffer = stackalloc char[name.Length];
|
||||||
|
for (var i = 0; i < name.Length; i++)
|
||||||
|
{
|
||||||
|
buffer[i] = Array.IndexOf(invalid, name[i]) >= 0 ? '_' : name[i];
|
||||||
|
}
|
||||||
|
return new string(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
Normal file
26
src/EverythingToJpeg.Core/Providers/IConverterProvider.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
namespace EverythingToJpeg.Core.Providers;
|
||||||
|
|
||||||
|
public interface IConverterProvider
|
||||||
|
{
|
||||||
|
ProviderCapability Capability { get; }
|
||||||
|
|
||||||
|
Task<ProviderAvailability> CheckAvailabilityAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<ConvertResult> ConvertAsync(
|
||||||
|
string sourcePath,
|
||||||
|
string outputDirectory,
|
||||||
|
ConvertOptions options,
|
||||||
|
IProgress<double>? progress,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ProviderAvailability(
|
||||||
|
bool IsReady,
|
||||||
|
string? Reason = null,
|
||||||
|
IReadOnlyList<ExternalDependency>? MissingDependencies = null)
|
||||||
|
{
|
||||||
|
public static ProviderAvailability Ready { get; } = new(true);
|
||||||
|
|
||||||
|
public static ProviderAvailability NotReady(string reason, IReadOnlyList<ExternalDependency>? missing = null)
|
||||||
|
=> new(false, reason, missing);
|
||||||
|
}
|
||||||
29
src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
Normal file
29
src/EverythingToJpeg.Core/Providers/ProviderCapability.cs
Normal file
|
|
@ -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<string> Extensions,
|
||||||
|
ProviderStatus Status,
|
||||||
|
string Summary,
|
||||||
|
IReadOnlyList<ExternalDependency> ExternalDependencies,
|
||||||
|
string? RoadmapNote = null)
|
||||||
|
{
|
||||||
|
public bool CanRegisterContextMenu => Status is ProviderStatus.Available or ProviderStatus.Preview or ProviderStatus.RequiresExternal;
|
||||||
|
public bool IsImplemented => Status is not ProviderStatus.ComingSoon and not ProviderStatus.Disabled;
|
||||||
|
}
|
||||||
40
src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
Normal file
40
src/EverythingToJpeg.Core/Providers/ProviderRegistry.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
namespace EverythingToJpeg.Core.Providers;
|
||||||
|
|
||||||
|
public sealed class ProviderRegistry
|
||||||
|
{
|
||||||
|
private readonly List<IConverterProvider> _providers;
|
||||||
|
private readonly Dictionary<string, IConverterProvider> _byExtension = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public ProviderRegistry(IEnumerable<IConverterProvider> 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<IConverterProvider> All => _providers;
|
||||||
|
|
||||||
|
public IEnumerable<IConverterProvider> Implemented => _providers.Where(p => p.Capability.IsImplemented);
|
||||||
|
|
||||||
|
public IEnumerable<IConverterProvider> ComingSoon => _providers.Where(p => p.Capability.Status == ProviderStatus.ComingSoon);
|
||||||
|
|
||||||
|
public bool 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<string> ImplementedExtensions => _byExtension.Keys;
|
||||||
|
|
||||||
|
private static string Normalize(string ext)
|
||||||
|
=> ext.StartsWith('.') ? ext.ToLowerInvariant() : "." + ext.ToLowerInvariant();
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue